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.
| Network | Entries | Entry points | Tokens and infrastructure |
|---|---|---|---|
| mainnet | 64 | Soroswap router; 7 Blend pools and 2 Blend backstops; Aquarius router and 43 concentrated pools | Soroswap factory; 4 Blend tokens and 5 Blend infrastructure contracts |
| testnet | 50 | Soroswap router; 1 Blend pool and the Blend backstop; Aquarius router and 37 concentrated pools | Soroswap factory; 4 Blend tokens and 3 Blend infrastructure contracts; the Aquarius batcher |
Sources, recorded per entry:
- Soroswap: the protocol's own
mainnet.contracts.jsonandtestnet.contracts.jsoninsoroswap/core. - Blend:
blend-capital/blend-utilsat 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 kind | At signing |
|---|---|
| Blend, Aquarius and Soroswap exits, and their trustline steps | Allow-list, authorization tree, fee cap. A refusal stops the close |
ConvertSorobanToXLM | The 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 |
TransferAsIs | assertSafeTransferInvocation and the untrusted-transfer fee cap. A refusal skips the transfer, and the result names the token as left behind |
RevokeAllowance | assertSafeRevokeInvocation and the untrusted-token fee cap. A refusal skips the revoke |
FinalClassicTx, MediatorForward | Not 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:
TransferAsIsto exactlytransfer(user, destination, amount).RevokeAllowanceto exactlyapprove(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 function | Outcome |
|---|---|
invokeContract on an entry point for this network | Allowed |
invokeContract on any other address | Refused, naming the network's list |
invokeContract whose target is not a C... address | Refused |
createContract, createContractV2 | Always refused |
uploadContractWasm | Always refused |
| An unknown or unreadable discriminant | Refused |
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:
| Call | The only nested call allowed |
|---|---|
Soroswap swap_exact_tokens_for_tokens | path[0].transfer(user, pair, amount_in) |
Soroswap remove_liquidity | pair.transfer(user, pair, liquidity) |
Aquarius router withdraw | pool.withdraw(user, shares, mins), then share_token.burn(user, shares) |
Aquarius router claim | pool.claim(user) |
Blend pool submit with a repay | asset.transfer(user, pool, amount) |
Blend pool submit withdraw, pool claim | Nothing nested |
Blend backstop queue_withdrawal, withdraw, claim | Nothing nested |
Aquarius concentrated withdraw_position, claim_all_position_fees | Nothing nested |
Aquarius pool gauges_claim | Nothing 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:
| Class | Steps | Resource fee cap | Write bound |
|---|---|---|---|
untrusted-token | Allowance revoke | 0.2 XLM | 8,192 bytes, 16 entries |
untrusted-transfer | Standalone-token transfer | 0.75 XLM | 16,384 bytes, 24 entries |
untrusted-convert | Standalone-token conversion, Soroswap LP exit | 1.4 XLM | 8,192 bytes, 16 entries |
pool-exit | Aquarius exits and their reward claims | 3.3 XLM | 32,768 bytes, 40 entries |
defi-exit | Blend steps | 3.3 XLM | None |
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.
Security model
What the deployment can and cannot do, the trust boundaries, the adversaries the design assumes, what each party can see, and the code that defends each boundary.
Audit and review status
The state of the external audit, the internal pre-audit review and its remediation by severity and status, the FxDAO removal, and the dependency posture.