Pool token accounts and tokens
Where deposited tokens are held, which program-derived address controls them, which tokens the pool accepts and refuses, and how SOL moves in and out.
Deposited tokens sit in ordinary token accounts owned by one program-derived address of the shielded pool program, one account per token. No private key exists for that address. Only the program can sign for it, and the program signs only for a withdrawal (an escape hatch included), a fee collection or a tip collection that has passed its checks. SOL is held differently: as plain lamports in one account the program itself owns, which it pays out only for the same operations. What controls the tokens is therefore the program, and the powers governance has over it, described on the governance page.
The pool token authority and pool token accounts
The chart below shows who controls the pool token accounts and the account that holds SOL. The program signs as one program-derived address, that address owns one token account per token, the program owns the SOL account directly, and every pool index shares the same authority and accounts.
flowchart TD prog["Shielded pool program"] -- "signs as" --> auth["Pool token authority"] prog -- "owns" --> l["Lamport account (SOL)"] pools["Every pool index"] -. "shares" .-> auth pools -. "shares" .-> l auth -- "owns" --> a["Token A account"] auth -- "owns" --> b["Token B account"]
The pool token authority is one program-derived address for the whole program, not one per pool index. The code calls it VaultAuthority. Each pool token account is the associated token account of that authority for one mint and its token program, so there is exactly one per token, and every pool index draws on the same one. Anyone can create a token's pool token account with vault_initialize, which only funds the account's rent; creating it does not let the token be deposited. SOL has no pool token account; the SOL section below describes the account that holds it.
Every value transaction names the pool token accounts it touches, and the program checks each one is the authority's associated token account for its mint before moving anything.
The token allowlist
A token can be deposited only while its MintAllowance exists: a 5-byte account, header only, at an address derived from the mint. Its existence is its whole meaning. The program checks it for every mint an active deposit lane names, and nowhere else, so transfers and withdrawals of a token never depend on it.
Governance grants and revokes an allowance through a timelocked proposal: the authority for that operation queues it, and after its delay anyone may execute it. Revoking closes the account and stops new deposits of that token at once. Notes of that token already in the pool are untouched: they can still be transferred and withdrawn, and a later grant reopens deposits. A deposit of a token without a live allowance fails with error 64. The instructions that create a pool token account or the lamport account, and that grant or revoke an allowance, are listed in the reference section at the bottom of this page.
Which tokens the pool accepts
The pool moves tokens of two token programs, SPL Token and Token-2022, and refuses any other. For each token a transaction names, the program also checks:
- Mint accounts are real mints of the named token program.
- The Token-2022 native mint is refused. The pool names SOL by SPL Token's native mint and holds it in the lamport account. Token-2022's native mint would otherwise pass as an ordinary token, with a pool token account of its own holding wrapped SOL that the lamport account never sees, so the program refuses it with error 32.
- The asset table is canonical. A transaction lists up to three tokens, in strictly ascending order of mint address, each with its pool token account.
The program checks nothing else about a mint: not its Token-2022 extensions and not its authorities. A token keeps every power its issuer holds over it, and those powers reach the pool token account like any other account of the mint. A Token-2022 transfer fee is handled by measurement, as described below. A freeze authority can freeze the pool token account for its token, which stops every withdrawal of that token until it is thawed. A Token-2022 permanent delegate can move or burn tokens out of the pool token account without the pool token authority signing. The pool cannot tell which notes those tokens backed, so the shortfall falls on whoever withdraws that token last: each withdrawal the account can no longer cover fails with error 31. Either power reaches only its own token, because every token has its own pool token account.
The errors these checks return are in the reference section.
Deposits are credited at what arrives
For each active deposit lane, the depositor signs a transfer_checked from its token account into the pool token account. The program reads the pool token account's balance before and after, and the difference is the amount the deposit is credited with: that measured amount, not the amount requested, goes into the transaction proof's public input. A token that takes a transfer fee is therefore credited with what the pool actually received, and the proof, made for that amount, has to match it.
A withdrawal sends its stated amount out of the pool token account and debits exactly that. The program first checks that each pool token account holds enough for all its withdrawal lanes, and refuses the transaction with error 31 before anything moves. A token's own transfer fee on the way out is taken from what the destination receives.
For any token other than SOL, the withdrawal destination is a token account of that mint, and the address the lane records, which is the one the screener checks, is that account's owner.
SOL
The pool names SOL by the wrapped SOL mint, like any other token, but it holds SOL as plain lamports rather than as wrapped SOL. The lamports behind every SOL note sit in one account owned by the shielded pool program, the pool's lamport account, which every pool index shares. Because the program owns it, the program pays SOL out by moving lamports directly: no authority signs and no token program is involved.
The lamport account always keeps enough lamports to stay alive on Solana, its reserve, and pays out only what sits above that.
Depositing SOL
A deposit can come from plain SOL, from wrapped SOL or from a mix of both: the program takes what it can from the depositor's wrapped SOL and the rest from their own lamports. As with every token, the amount credited is the increase the lamport account actually received, and lamports sent to it outside a deposit are never credited to anyone.
Withdrawing SOL
A SOL withdrawal pays a plain wallet address, with no token account involved, and that address is the one the screener checks. Before it verifies anything else, the program checks that the lamport account holds enough above its reserve for the withdrawal, and refuses the transaction if it does not.
The other ways out of the pool pay SOL the same way. The escape hatch holds its payout in escrow for 48 hours and then pays the destination wallet, keeping its fee in the lamport account, tip collection pays whatever account a relayer names, and fee collection pays the SOL treasury wallet governance set.
Up to three tokens per transaction
A transaction can involve at most three distinct tokens: across its real input notes, its real output notes, its active deposit and withdrawal lanes and its relayer tip. The circuit has three token slots, and every live note and lane must match exactly one of them. The asset table the program checks has the same three entries. Each token is balanced separately; see value conservation.
Reference
| Tag | Instruction | Version | Payload bytes | Accounts |
|---|---|---|---|---|
| 1 | vault_initialize | v0 | 0 | 7 |
| 28 | mint_allowance_grant | v0 | 32 | 6 |
| 29 | mint_allowance_revoke | v0 | 32 | 6 |
| 35 | lamport_vault_initialize | v0 | 0 | 3 |
contracts/solana/tools/umbra-manifest/manifest/shielded-pool.json.| Code | Error | Meaning |
|---|---|---|
| 16 | DepositAmountMismatch | Returned when a deposit lane's vault balance fell across its own transfer, which no supported token program allows. A deposit that delivers a different amount from the one its proof commits to fails as [`Self::InvalidProof`] instead, because the measured amount is a public input. |
| 30 | AssetTableNotCanonical | Returned when `process`'s asset table is not strictly ascending by mint address. Ascending implies distinct, and distinctness is the property that matters: a table naming one mint twice would let a prover treat one asset as two priced slots and collect a fee-tree leaf for each. |
| 31 | VaultBalanceInsufficient | Returned when a pool token account, or for SOL what the lamport vault holds above its reserve, cannot cover a payout. Value transactions and escapes check it ahead of the proof, so a pool short of backing costs a read rather than a pairing. |
| 32 | UnsupportedNativeMint | Returned when an asset-table entry names Token-2022's native mint. SOL in this protocol is named by SPL Token's WSOL mint and held in the lamport vault, so a second native asset is refused at the point it would enter rather than silently handled as an ordinary token, whose vault would hold wrapped SOL the lamport vault never sees. |
| 47 | UnsupportedTokenProgram | Returned when an account presented as a token program is neither SPL Token nor Token-2022. Two call sites used to let the underlying `ProgramError::InvalidArgument` escape instead, so the same mistake surfaced as two different codes depending on which handler saw it. |
| 48 | MintAccountInvalid | Returned when an account presented as a mint is not one, is not owned by the token program named beside it, or is too short to read its decimals. Distinct from [`Self::TokenAccountMalformed`]: a mint and a token account are different layouts, and confusing which one a caller got wrong is the whole reason these were split. |
| 49 | TokenAccountMintMismatch | Returned when a token account is well-formed but holds a different mint than the asset it was presented for. Not a malformed account and not an unsupported program — the right kind of account for the wrong asset, which no other code could express. |
| 64 | MintNotAllowed | The pool does not accept deposits of this mint, because governance has not granted it a `MintAllowance` or has revoked it. |
| 82 | LamportVaultInvalid | The account named as the lamport vault is not the pool's lamport vault: the vault's fixed address, owned by the pool. |
| 83 | LamportPayoutToVault | A SOL payout named the lamport vault itself as its destination. |
| 84 | LamportPayoutOverflow | A SOL payout would overflow the destination's lamport balance. |
libs/typescript/errors/error-manifest.json.Related
Last updated
The forester
The permissionless service that moves staged keys from the three staging buffers into their indexed trees, the proof it submits, what it can and cannot do, and what stops when no forester runs.
Value conservation
The per-token equation every value transaction proves, the range checks that make it mean what it says, and what the Lean proofs establish about it and under which assumptions.