Account Demolisher
Security

Contract allow-list

The compiled-in set of contracts a signed DeFi call can target, the authorization-tree and fee checks that run with it, and the two token paths held to their own exact shapes.

Every DeFi-protocol Soroban transaction is checked against a network-specific allow-list before it is signed: the adapter that builds it checks it, and the executor checks it again right before signing. Allowance revokes and standalone-token transfers target token contracts found by scanning your account, which anyone can create, and are held to an exact call shape by their own guards instead.

The list is a compile-time constant in src/lib/config/contracts.ts. There is no environment variable, no fetch, and no runtime mutation path. A position discovered on chain cannot extend it. Only editing the source file changes it.

Contents

Each entry has a role. Only an entry point can be the root of a call the app asks you to sign. Tokens and infrastructure are kept for labelling (the spender registry, the scam heuristics) and are refused as a root target, so a bug that builds, say, a direct call on a token is refused at signing.

NetworkEntriesEntry pointsTokens and infrastructure
mainnet64Soroswap router; 7 Blend pools and 2 Blend backstops; Aquarius router and 43 concentrated poolsSoroswap factory; 4 Blend tokens and 5 Blend infrastructure contracts
testnet50Soroswap router; 1 Blend pool and the Blend backstop; Aquarius router and 37 concentrated poolsSoroswap factory; 4 Blend tokens and 3 Blend infrastructure contracts; the Aquarius batcher

Sources, recorded per entry:

  • Soroswap: the protocol's own mainnet.contracts.json and testnet.contracts.json in soroswap/core.
  • Blend: blend-capital/blend-utils at a pinned commit, plus three V1 pools read from the V1 backstop's on-chain reward zone.
  • Aquarius router: the Aquarius developer documentation, cross-checked against the wasm the router runs on each network.
  • Aquarius concentrated pools: enumerated on chain through the pinned router, keeping every pool whose instance runs the concentrated-pool wasm, and cross-checked against the Aquarius API's concentrated listing (mainnet 2026-10-16, testnet 2026-10-08). A concentrated pool deployed later is not on the list, and discovery reports it as an error so the merge stays blocked.

Standard and stable Aquarius pools are not on the list: the app reaches them through the router.

FxDAO has no entries. The protocol shut down on chain, so there is nothing left to exit; see Audit and review status for the evidence.

Entry shape

interface AllowedContract {
  readonly id: string; // C... contract id
  readonly name: string; // e.g. "SoroswapRouter", "BlendPool::FixedV2"
  readonly protocol: string; // "soroswap" | "blend" | "aquarius"
  readonly role: "entrypoint" | "token" | "infrastructure";
  readonly verified_at: string; // ISO date the id was checked
  readonly source: string; // the citation
}

verified_at and source make every id auditable against the protocol's published deployment or the chain.

Where the check runs

assertTransactionAllowed(tx, network) requires the network explicitly, so no caller can fall back to the other network's list. It runs:

  • at build time in each adapter (soroswap/lp.ts, soroswap/swap.ts, aquarius/client.ts, blend/exit.ts) and in the testnet demo-account builder, and
  • at signing time in checkRequiredSorobanStep (src/lib/orchestrator/executor.ts), together with the authorization-tree guard and the fee cap. The preview runs the same function on every simulated step, so a step that would be refused is marked failed before you confirm, instead of stopping the close after independent exits already ran.
Node kindAt signing
Blend, Aquarius and Soroswap exits, and their trustline stepsAllow-list, authorization tree, fee cap. A refusal stops the close
ConvertSorobanToXLMThe same checks. A refusal falls back to sending the token as-is on a direct close, under the transfer guard, and stops the close on an exchange close or when that fails too
TransferAsIsassertSafeTransferInvocation and the untrusted-transfer fee cap. A refusal skips the transfer, and the result names the token as left behind
RevokeAllowanceassertSafeRevokeInvocation and the untrusted-token fee cap. A refusal skips the revoke
FinalClassicTx, MediatorForwardNot applicable: classic transactions

Why two kinds are exempt

TransferAsIs and RevokeAllowance target a token contract found by scanning your account, so the protocol list does not apply to them. Anyone can create such a contract and make it show up in that scan, so each is held to one exact call instead:

  • TransferAsIs to exactly transfer(user, destination, amount).
  • RevokeAllowance to exactly approve(user, spender, 0, ledger).

Both require one invokeHostFunction operation and authorization entries that use source-account credentials, have no sub-invocations, and authorize that same call. A hostile token cannot widen what your signature covers by adding a nested call.

What the allow-list refuses

inspectOperation passes every classic operation and inspects every invokeHostFunction:

Host functionOutcome
invokeContract on an entry point for this networkAllowed
invokeContract on any other addressRefused, naming the network's list
invokeContract whose target is not a C... addressRefused
createContract, createContractV2Always refused
uploadContractWasmAlways refused
An unknown or unreadable discriminantRefused

Refusal reasons name the list, for example Contract is not on TESTNET_ALLOWLIST. A fee-bump wrapper is unwrapped, so it cannot hide an invocation.

Before the host-function check, every authorization entry's invocation tree is walked, and an entry that authorizes contract creation or wasm upload anywhere in the tree is refused. An entry that cannot be read is refused too: signing blind is worse than stopping.

The Aquarius gauge reward claim

A gauge reward claim calls gauges_claim(user) on the Aquarius pool itself, and pools are permissionless, so no standard pool is on the list. checkRequiredSorobanStep passes the pool as the one extra allowed root only when the build read that same address from the pinned router's get_pools for the position's tokens and pool index (routerPool on the node equals poolAddress). The extra root lives for that one check and never joins the list.

The authorization-tree guard

The allow-list checks the contract an operation invokes. The authorization tree the simulation records is copied into the operation, and with source-account credentials one signature authorizes all of it. A token running inside a swap or a withdrawal can call another token with you as from, and a hostile RPC can add entries outright.

assertDefiAuthTree (src/lib/soroban/defi-auth-guard.ts) therefore requires the exact tree each call needs:

CallThe only nested call allowed
Soroswap swap_exact_tokens_for_tokenspath[0].transfer(user, pair, amount_in)
Soroswap remove_liquiditypair.transfer(user, pair, liquidity)
Aquarius router withdrawpool.withdraw(user, shares, mins), then share_token.burn(user, shares)
Aquarius router claimpool.claim(user)
Blend pool submit with a repayasset.transfer(user, pool, amount)
Blend pool submit withdraw, pool claimNothing nested
Blend backstop queue_withdrawal, withdraw, claimNothing nested
Aquarius concentrated withdraw_position, claim_all_position_feesNothing nested
Aquarius pool gauges_claimNothing nested

The BLND trustline a Blend emissions claim needs, and the trustline a pool exit pays a classic asset into, are held to exactly one change_trust for that asset with a nonzero limit; an exit trustline's asset has to be the one whose contract id is the payout token.

A second authorization entry, address credentials, an extra or deeper node, or different arguments is refused as UnsafeDefiAuthError, and so is a tree that cannot be parsed.

The fee cap

assertSorobanFeeWithinCap (src/lib/soroban/fee-cap.ts) refuses a prepared transaction whose resource fee, or declared writes, are above what its class of step legitimately costs. The caps are two to three times the largest fee measured for each class on testnet and mainnet:

ClassStepsResource fee capWrite bound
untrusted-tokenAllowance revoke0.2 XLM8,192 bytes, 16 entries
untrusted-transferStandalone-token transfer0.75 XLM16,384 bytes, 24 entries
untrusted-convertStandalone-token conversion, Soroswap LP exit1.4 XLM8,192 bytes, 16 entries
pool-exitAquarius exits and their reward claims3.3 XLM32,768 bytes, 40 entries
defi-exitBlend steps3.3 XLMNone

A transfer gets a class of its own because it can create the destination's balance entry and pay rent for it, which a revoke never does. The largest legitimate transfer measured, a SEP-41 token creating a new balance on testnet, cost about 0.26 XLM.

Restoring an archived contract code entry (a token's or an old pair's wasm, 17 to 24 KB each) puts the code's rent on the signer. In a sweep of 70 archived testnet Soroswap pairs, the 17 removals that needed such a restore cost 36,812,907 to 174,084,607 stroops (3.7 to 17.4 XLM) in resource fees and declared 14,700 to 53,204 write bytes, all above the largest cap. That is the same shape a hostile token would use to push rent onto the signer, so the caps refuse it instead of paying for it, and the error states the fee the step would cost. No mainnet pair in the sweep needed a code restore.

A one-operation trustline step carries no Soroban resources, and its bid is capped at 1,000,000 stroops. The review counts each Soroban step at most at the inclusion fee plus its class's cap, because the step is simulated again before it is signed and is never signed above that. Before each Soroban signature the progress line shows the fee that signature allows.

The error

A violation raises AllowlistViolation, carrying every violation rather than only the first:

Allow-list violation: 2 contract invocation(s) blocked.
  1. C...: Contract is not on MAINNET_ALLOWLIST.
  2. <auth-entry>: A Soroban authorization entry authorizes contract creation or wasm upload.

getViolations returns the same list without throwing.

For a required step, such as a DeFi exit, this is a hard stop by design. When closing a position needs a contract that is not on the list, the safe move is to add it to the source after checking it, not to sign blindly. Only an optional conversion has a fallback, listed in the table above.

On this page