Solana Foundation: DvP Program
Cantina Security Report
Organization
- @solana-foundation
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
4 findings
4 fixed
0 acknowledged
Low Risk
6 findings
3 fixed
3 acknowledged
Informational
11 findings
5 fixed
6 acknowledged
Medium Risk4 findings
Closed unfunded mint can block funded-leg recovery
Severity
- Severity: Medium
Submitted by
r0bert
Description
CancelDvpandRejectDvprequire both mint accounts to still be owned by their recorded token programs before they refund either leg. This check runs even when one leg has a zero escrow balance and no transfer for that mint will be attempted.Token-2022 mints can include
MintCloseAuthority. If a DvP is created with a closeable mint on leg A, leg A remains unfunded and leg B is funded, the leg A mint can be closed while the DvP is open as long as its supply is zero. After that,CancelDvpandRejectDvpfail at the mint-owner check for leg A before refunding leg B. If the DvP has expired,ReclaimDvpis also unavailable, so the funded leg can remain locked until the mint account is restored at the same address.The same issue exists symmetrically for leg B. The root cause is that terminal refund instructions require live mint accounts for unfunded legs even though closing an empty escrow ATA does not need the mint account.
Recommendation
Store each leg's token program in
SwapDvpat creation and relax terminal refund validation for zero-balance legs.CancelDvpandRejectDvpshould be able to close an empty escrow ATA and refund the opposite funded leg even if the empty leg's mint account was closed. Alternatively, reject mints withMintCloseAuthorityatCreateDvp.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified.
SwapDvpnow storestoken_program_aandtoken_program_bat creation and the sharedCancelDvp/RejectDvprefund path validates the supplied token programs against those stored values rather than requiring the mint accounts to still be owned by those programs. Transfers are skipped for zero-balance legs, so a closed mint on the empty side no longer prevents refunding and closing the funded side. Regression tests cover both directions with a closed unfunded mint.Memo-required ATAs can block DvP recovery
Severity
- Severity: Medium
Submitted by
r0bert
Description
CancelDvpverifies only that each refund destination is the canonical ATA, then sendsTransferCheckedto that account when the escrow balance is non-zero.RejectDvp,ReclaimDvpand the settlement/surplus transfers use the same pattern.This misses a Token-2022 account-level condition: an ATA can enable the
MemoTransferextension and require a memo before incoming transfers. The swap program does not invoke the Memo program before its token CPIs and a caller cannot insert a memo inside the program's CPI sequence. Therefore a party can make their own canonical ATA reject incoming refunds or settlement proceeds.The failure is easiest to see as a two-box escrow. User A funds one escrow box and user B funds the other. If the trade is aborted, the program must return each box to its original party. If user B enables "memo required" on their refund ATA, the DvP's attempt to return user B's tokens fails because the internal token transfer is not immediately preceded by a Memo CPI. Since the refund instruction is atomic, the entire instruction reverts and user A's refund is rolled back too.
This allows locking of funds and griefing. A counterparty can make its own destination ATA reject incoming refunds or settlement proceeds, causing
CancelDvp,RejectDvp,ReclaimDvporSettleDvpto fail. The worst case is after expiry:ReclaimDvpis unavailable, so users must rely onCancelDvporRejectDvp, which refund both sides together. If one side's memo-required ATA rejects its refund, the other side's otherwise healthy funded leg can remain locked until the memo-requiring party disables the extension or the program supports Memo CPIs.Recommendation
Support memo-required Token-2022 destinations in the program. Before each
TransferChecked, inspect the destination token account forMemoTransfer. When a memo is required, invoke the Memo program immediately before the token CPI. Add the Memo program as an explicit account for terminal instructions.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified. All terminal transfer paths now pass a
memo_programaccount intotransfer_checked_cpi, which inspects Token-2022 destinations forMemoTransferand invokes the canonical Memo program immediately before the token CPI when required. The regression suite covers memo-required destinations forCancelDvp,RejectDvp,ReclaimDvpandSettleDvp, including surplus refunds.Post-expiry recovery is all-or-nothing
Severity
- Severity: Medium
Submitted by
r0bert
Description
ReclaimDvpis the only instruction that drains one leg without touching the other leg, but it rejects oncenow > expiry_timestamp. After expiry, users must rely onCancelDvporRejectDvp.Those terminal instructions are coupled across both legs. They validate both mints, verify both escrow and refund ATAs, snapshot both balances, attempt every nonzero refund, then close both escrows and the DvP PDA. If either leg cannot be transferred, the entire instruction reverts. Consequently, a problem with one mint, destination account or TransferHook can lock the other leg after expiry even when the other leg is healthy and independently transferable.
Several concrete instances are tracked separately, including non-transferable mints, memo-required refund ATAs, closed unfunded mints and hook-program vetoes. This issue is the shared architectural root cause: after expiry there is no independent per-leg recovery instruction.
Recommendation
Allow each party to reclaim their own leg after expiry without touching the other leg. Consider removing the expiry restriction from
ReclaimDvpor add a new post-expiry per-leg reclaim instruction. If a per-leg recovery can happen after expiry, add a explicit terminal state so settlement cannot later become possible after one party has recovered their leg. Close the DvP PDA only once both escrows are empty.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified.
ReclaimDvpno longer has an expiry gate and only validates the signer-selected leg, so a healthy leg can be drained independently after expiry without touching the other mint, destination, or hook path.SettleDvpremains expiry-gated, so post-expiry reclaim does not enable one-sided settlement. The integration testtest_reclaim_dvp_post_expiry_recovers_legexercises the fixed path.Nonce-reusable PDA address after close enables stale-deposit capture across DvP instances
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
Sujith S
Description
The
SwapDvpPDA is derived from an identity-only seed set [b"dvp", settlement_authority, user_a, user_b, mint_a, mint_b, nonce.to_le_bytes(), bump]. Economic terms (amount_a, amount_b, expiry_timestamp, earliest_settlement_timestamp) are deliberately not seeds, and the on-chain record carries no per-instantiation discriminator (no creation slot, no version, no init timestamp).SettleDvp/CancelDvp/RejectDvpfully close the PDA: lamports are zeroed andswap_dvp_info.close()reassigns the account to the system program. After close,verify_system_account(swap_dvp_info, true)succeeds again at the same address.Because
CreateDvpis permissionless only the payer signs;user_a,user_b, andsettlement_authorityare never required to sign. Hence any party can re-instantiate a closed DvP at the same address with arbitrarily different amount_a/amount_b/expiry by reusing the original nonce.The escrow ATAs, derived from (PDA, mint, token_program), also land at the same addresses they had under the previous instance.
Combined with funding being a raw, unauthenticated SPL Transfer to the deterministic escrow address with no link to a specific instance (only the escrow's balance matters at Settle/Cancel/Reject time), this address-recycling primitive produces several possible attacks:
-
Stale-deposit capture (primary impact). An adversarial counterparty or settlement authority closes a DvP (via Reject/Cancel) and immediately recreates the same (identity, nonce) PDA with predatory terms (e.g. amount_b = 1). Any funding transfer that the victim prepared, queued, retried, or batched against the public escrow address now lands in the new instance, which Settle then drains under the attacker's amounts. The TOCTOU window is the gap between "validate terms" and "send raw SPL transfer" is fundamental to the raw-transfer funding model.
-
Indexer / custodian state confusion. Off-chain systems that key trades by PDA address see two distinct logical trades collapsed into one identifier, distinguishable only by slot ordering. Any UI/integration that tells a depositor "trade X lives at address P, send tokens there" off stale state directly enables attack (1).
The existing test
test_two_dvps_same_parties_different_nonces_are_isolated(tests/integration-tests/src/test_settle_dvp/mod.rs:471) only covers different nonces in parallel and there is no test or on-chain check that prevents same-nonce reuse after close.The comment in
instructions.rs:83-84frames nonce as "disambiguates DvPs sharing all other seeds," which implicitly assumes nonces are monotonic and unique-for-all-time, but the program does not enforce this and provides no mechanism for clients to discover that an address has been previously used.Recommendation
The optimal fix is to ensure that an (identity, nonce) PDA address corresponds to exactly one trade instance, forever.
Apply one of:
-
Tombstone used nonces. Maintain a small NonceTombstone PDA per (identity-tuple, nonce) that is never closed by
Settle/Cancel/Reject. Create rejects when the tombstone exists. -
Or Add a per-instance discriminator to the seeds. Include the creation slot (from Clock::get()?.slot) or a program-wide monotonic counter (held in a singleton PDA bumped atomically inside Create) in the PDA seed array and store it on the record. After this change, "same nonce" no longer means "same address" and a recreated DvP lives at a fresh PDA.
Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified.
CreateDvpnow requires and creates a per-DvP nonce tombstone PDA derived from theSwapDvpaddress, and terminal instructions close the trade without closing that tombstone. Re-creating the same(settlement_authority, users, mints, nonce)PDA after close fails withNonceAlreadyUsed, which blocks stale-deposit capture at the recycled escrow address. The regression testtest_create_dvp_rejects_reused_nonce_after_closecovers the bypass attempt with changed terms.
Low Risk6 findings
NonTransferable mints can brick funded DvPs
Severity
- Severity: Low
Submitted by
r0bert
Description
validate_mint_extensionsblocks amount-mutating Token-2022 mint extensions but it explicitly allowsNonTransferable. All terminal instructions recover funds by transferring tokens out of the escrow ATA before closing it.Token-2022 rejects transfers from accounts associated with a non-transferable mint. If a positive balance reaches a DvP escrow for such a mint, the swap program cannot settle, refund, reclaim or close that leg. This can happen when a mint authority mints directly to the escrow ATA after
CreateDvp. If the opposite leg is funded, that counterparty's funds are also stuck becauseCancelDvpandRejectDvpattempt to drain both escrows atomically.The comment says unsupported extensions should fail and remain recoverable once the blocking condition lifts. That does not hold for
NonTransferable, since the transfer restriction is the permanent purpose of the mint extension.Recommendation
Reject Token-2022 mints with the
NonTransferableextension atCreateDvp. If non-transferable assets must be supported, the design needs a separate issuer-mediated unwind mechanism because the current PDA-owned escrow cannot transfer or burn those tokens.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified.
validate_mint_extensionsnow rejects Token-2022 mints withNonTransferableduringCreateDvpand the integration testtest_create_rejects_non_transferable_on_mint_averifies the rejection. Under the current create-time path, a non-transferable mint cannot be accepted into a new DvP.Unsynced WSOL lamports bypass refunds
Severity
- Severity: Low
Submitted by
r0bert
Description
get_token_account_balancereads only the SPL token accountamountfield. For native SOL token accounts, lamports can be transferred directly into the account without first callingSyncNative, so those lamports are not reflected inamount.ReclaimDvp,CancelDvp,RejectDvpandSettleDvpdecide how much to refund or deliver from this token amount. Terminal instructions then close the escrow token account to the settlement authority or rejecting signer. Consequently, unsynced WSOL lamports can skip the depositor refund transfer and be paid to the close recipient instead.This does not affect normal SPL token deposits or correctly synced WSOL transfers. It affects native SOL legs when a depositor or integration funds the escrow with a system transfer and forgets to sync the token account before attempting recovery or settlement.
Recommendation
Either reject the native mint at
CreateDvpor explicitly support native SOL accounts by detecting native token accounts and callingSyncNativebefore reading balances.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified.
get_token_account_balancenow detects native token accounts and invokesSyncNativebefore re-reading the tokenamountand all terminal paths use this helper before deciding transfer/refund amounts. The regression testtest_reject_dvp_syncs_native_escrow_before_refundconfirms unsynced WSOL lamports are refunded to the depositor rather than swept to the close recipient.Dust surplus can block settlement when refund ATAs are absent
State
Severity
- Severity: Low
Submitted by
r0bert
Description
SettleDvpsends each agreed leg amount to the counterparty. If either escrow holds more than the agreed amount, it treats the extra balance as a surplus and sends that surplus back to the original depositor's canonical ATA for that mint.The program only checks that the supplied surplus refund address is the right canonical ATA. It does not create the ATA if it is missing. This is fragile because the depositor may have funded the escrow from a custodian or another non-canonical token account and may never have created their own canonical ATA.
Any holder of the leg mint can send a tiny extra amount into the escrow. That turns an otherwise exact settlement into a settlement that must also execute a surplus refund. If the depositor's canonical refund ATA is missing, the surplus
TransferCheckedCPI fails because the destination account is uninitialized. SinceSettleDvpis atomic, that failed surplus refund reverts the whole settlement, even though both agreed trade amounts were funded.This is not a permanent fund loss because the missing ATA can usually be created and the settlement retried. The practical impact is a timing grief. If the dust is added close to expiry, the parties may lose the ability to settle the trade before the deadline and may be forced to cancel or reject instead.
Recommendation
Treat surplus refund ATAs as required settlement accounts, not optional edge-case accounts. Clients should create all four user-side ATAs before calling
SettleDvp: the two ATAs that receive the agreed trade amounts and the two ATAs that may receive surplus refunds.For a stronger on-chain fix,
SettleDvpcan create missing surplus refund ATAs before transferring, using the ATA and system program accounts. Another option is to separate exact-amount settlement from surplus recovery so a missing surplus refund account cannot block delivery of the agreed trade amounts.Solana Foundation: Acknowledged.
Destination-dependent TransferHook extras can block surplus settlement
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
SettleDvpuses one transfer-hook extras slice per leg. That same slice is first used for the main delivery transfer and then reused for the surplus refund transfer when the escrow balance is above the agreed amount.For Token-2022 hooks,
ExtraAccountMetaListentries can resolve required accounts from the transfer instruction's destination account or amount. The main delivery transfer and the surplus refund transfer have different destinations and, usually, different amounts. Therefore hook extras that are correct for the main transfer can be incorrect for the surplus refund.Any holder of the leg mint can over-deposit dust into the escrow. If the hook's extra accounts depend on destination or amount,
SettleDvpthen reverts on the surplus refund CPI even though the agreed trade amount is fully funded. The affected party can usually recover by reclaiming before expiry and re-funding exactly the agreed amount or by canceling/rejecting the DvP, but settlement can be blocked while the surplus remains. If the dust is added close to expiry, the intended DvP may no longer be settleable before the deadline.Recommendation
Do not reuse one hook extras slice for both delivery and surplus refund transfers. Either accept separate hook extras for each transfer destination, split settlement into an exact-balance path plus a separate refund path or reject surplus settlement for hook-bearing mints whose
ExtraAccountMetaListdepends on destination or amount. Client-side builders should warn when hook-bearing escrows have surplus balances and should avoid funding above the exact agreed amount.Solana Foundation: Acknowledged.
Permissionless first‑come CreateDvp permits slot squatting with attacker‑chosen terms
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Sujith S
Description
The SwapDvp PDA is derived from an identity‑only seed set:
["dvp", settlement_authority, user_a, user_b, mint_a, mint_b, nonce]The economic terms of the trade:
amount_a,amount_b,expiry_timestamp, andearliest_settlement_timestampare persisted in the account's data but are not part of the seeds, so they do not influence the trade's address.CreateDvpis permissionless: the only signature check isverify_signer(payer_info, true), and the payer is not required to beuser_a,user_b, orsettlement_authority.Consequently, any third party can pre‑create ("squat") the exact slot a pair of counterparties intends to use and populate it with arbitrary terms (any non‑zero amounts, any future expiry, any
earliest <= expiry). Becauseuser_a/user_bdo not signCreateDvp, the on‑chain record is not evidence that the named parties agreed to those terms.This behavior could lead to the following issues:
-
Denial of service. The squat occupies the intended (settlement_authority, user_a, user_b, mint_a, mint_b, nonce) address, so the legitimate CreateDvp fails. If the nonce is predictable, the attacker can re‑squat immediately after each recovery, producing a persistent DoS on that counterparty/mint pair. Submission to the channel is not access‑controlled at the application layer, so the attacker set is broad.
-
Terms‑mismatch footgun. Escrow ATA addresses derive only from (PDA, mint) and never from the terms. So a client that funds the derivable escrow without reading the stored
SwapDvpcould deposit against terms it never agreed to.
Recommendation
-
Unpredictable nonce. Require clients to generate a cryptographically random 64‑bit nonce per trade so slots cannot be pre‑squatted or re‑squatted in a loop.
-
Validate stored terms before acting. Mandate that funders read and verify the on‑chain SwapDvp (amounts, expiry, earliest, parties, authority) against the agreed deal before depositing, and that the
settlement_authorityre‑validate before Settle. Document explicitly that a SwapDvp record may have been created by any third party and its existence is not proof of mutual agreement. Surface this in the client SDK (e.g., a createAndVerify helper) rather than leaving it to integrators.
Optional on‑chain hardening (each with a tradeoff; adopt only if stronger guarantees are wanted):
-
Bind terms to the address by including a commitment (e.g., hash(
amount_a‖amount_b‖expiry‖earliest)) in the PDA seeds, so a record at the expected address provably carries the expected terms. Tradeoff: the client must know all terms to derive the address (minor). -
Require
user_aanduser_bto co‑signCreateDvpso the stored terms are authenticated as agreed. Tradeoff: breaks the "permissionless create / fund via raw SPL transfer / no custom custodian integration" property - likely undesirable for this product.
Solana Foundation: Acknowledged.
On-chain Clock.unix_timestamp drift breaks wall-clock assumptions in expiry, settlement-window, and reclaim gating
Severity
- Severity: Low
Submitted by
Sujith S
Description
The DvP swap program enforces all time-based invariants against Solana's
Clock::get()?.unix_timestamp, which is a stake-weighted median of validator vote timestamps not real wall-clock time.In normal cluster operation this value lags real time by ~10–30 seconds, and during validator catch-up it can also step forward in larger jumps as the median advances.
The README and instruction docs present
expiry_timestampandearliest_settlement_timestampas straightforward time cutoffs, with no disclosure that the cutoff is measured against cluster time rather than the wall-clock seconds that off-chain participants reason in. The mismatch surfaces four issues across the four time-gated code paths.-
Settlement-authority free option during clock lag.
settle_dvp.rsaccepts Settle whilecluster_now <= dvp.expiry_timestamp. Because the cluster clock trails real time, the on-chain Settle window remains open for tens of seconds after the real wall-clockexpiry_timestamphas passed. -
Reclaim can be bricked by a forward clock jump.
reclaim_dvp.rsrejects Reclaim oncecluster_now > dvp.expiry_timestamp. When the cluster median catches up in a step, a depositor whose tx lands in a slot where the clock has just jumped pastexpiry_timestampwhile real wall-clock time is still pre-expiry gets DvpExpired. -
earliest_settlement_timestamp is not a hard time-lock.
settle_dvp.rsgatessettlement on cluster_now >= earliest. A forward cluster-clock jump can flip this predicate from false to true seconds before real wall-clock earliest, allowing settlement before the wall-clock-defined window opens. -
Create-time expiry_timestamp > now check has the same lag hole.
create_dvp.rsrejects Create whenexpiry_timestamp <= cluster_now. With cluster lag of N seconds, a permissionless payer (Create is permissionless by design) can successfully create a DvP whoseexpiry_timestampis already in the past in wall-clock terms and the trade is born wall-clock-expired.
In all four cases the code does exactly what its comments claim, there is no logic bug. The exposure is that the protocol treats
Clock.unix_timestampas a synonym for "now," while every off-chain participant, integrator, UI, and policy engine reasons in wall-clock seconds.Recommendation
This is best treated as a documentation-and-integration issue first, with an optional protocol-level mitigation for the settlement-authority option problem if the threat model demands it.
- Document the clock model explicitly in dvp-swap-program/README.md. Add a section describing that
expiry_timestampandearliest_settlement_timestampare evaluated againstClock.unix_timestamp(cluster-vote median, lagging real time by ~10–30s in normal operation and capable of forward jumps), not wall-clock time. - Establish a recommended drift budget (e.g. 60s) and make the protocol be tolerant to the drift budget.
Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified. The README now explicitly states that
expiry_timestampandearliest_settlement_timestampare evaluated againstClock::unix_timestamp, describes the cluster-time drift/jump model and recommends budgeting about 60 seconds away from time boundaries. This removes the prior wall-clock documentation mismatch while leaving the on-chain time source unchanged.
Informational11 findings
Closeable mints can bypass the extension deny-list
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
CreateDvprejects Token-2022 mints with amount-mutating extensions, but it accepts mints withMintCloseAuthority. A close authority can close a zero-supply mint afterCreateDvp, then recreate the same mint address with a different token program or with a previously denied extension such asTransferFeeConfig.Terminal instructions do not store or re-check the token program and extension set accepted at creation. They only check that the supplied mint account is owned by the token program supplied to the terminal instruction. Consequently, a recreated mint can change the transfer behavior after the trade was accepted. For example, the mint authority can mint directly into the escrow and a later
SettleDvp,CancelDvp,RejectDvporReclaimDvptransfer can credit less than the requested amount because Token-2022 withholds a transfer fee.This requires control over a closeable zero-supply mint and the ability to recreate that mint account. It is still a concrete break in the one-time extension screening model because the recorded DvP state commits only to the mint address, not to the mint's accepted extension set.
Recommendation
Reject Token-2022 mints with
MintCloseAuthorityatCreateDvp,or record the accepted token program and extension-set commitment inSwapDvpand re-check them before terminal transfers. If closeable mints must remain supported, require clients to treat the close authority and mint account key holder as trusted parties for the entire lifetime of the DvP.Solana Foundation: Acknowledged.
Mint authorities can freeze or claw back escrows
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
CreateDvpdoes not reject mints with a freeze authority, including legacy SPL Token mints. It also allows Token-2022 mints with authority-controlled behavior such asPermanentDelegate,PausableandDefaultAccountState. These features are intrinsic to some regulated assets, but they give the mint authority power over tokens after a DvP is created and funded.For example, a permanent delegate can move escrowed tokens without the DvP PDA. A freeze authority can freeze the escrow ATA or set future ATAs to start frozen and a pause authority can pause transfers. These actions can make settlement fail or delay refunds until the mint authority cooperates. This is not an authorization bug in the DvP program, but it is a material integration risk for users who assume the escrow alone controls token movement.
Recommendation
Expose these mint-authority assumptions clearly to clients before funding. For stricter deployments, reject or require explicit allowlisting for legacy or Token-2022 mints with active freeze authority and for Token-2022 mints with
PermanentDelegate,PausableorDefaultAccountState. Settlement UIs should show these authorities as trusted parties, not as ordinary SPL-style mints.Solana Foundation: Acknowledged.
Closed-account lamports go to terminal signers
Severity
- Severity: Informational
Submitted by
r0bert
Description
CreateDvplets any payer fund theSwapDvpPDA and both escrow ATAs, but the program does not record that payer as the rent recipient. WhenRejectDvpcloses the trade, it sends both escrow ATA rents and theSwapDvpPDA lamports to the rejecting party.Consequently, a counterparty can reject an unfunded or partially funded DvP and receive the account rent that a maker, relayer or other payer supplied during creation. The same close-recipient rule applies to any direct SOL lamport top-up sent to an escrow token account: SPL Token
CloseAccountdrains all lamports from the token account to the close destination, not to the original lamport sender or configured leg party.This is not token loss and may be an intentional incentive choice, but it is a real value transfer that is not surfaced in the README's lifecycle or instruction table.
SettleDvpandCancelDvphave the same payer/recipient split, except their close recipient is the settlement authority.Recommendation
Document the account-lamport recipient model clearly for every terminal instruction, including rent and any direct lamport top-ups to escrow token accounts. If the create payer should recover rent, store a
rent_refund_recipientinSwapDvpand send closed-account lamports there. If the current design is intentional, clients should show that the create payer or any direct SOL sender, is subsidizing the settlement authority or rejecting party.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified. The README now documents that closed-account rent and any direct lamport top-ups go to the closing party: the settlement authority on
SettleDvp/CancelDvpand the rejecting signer onRejectDvp. This resolves the undocumented value-transfer issue while preserving the intentional close-recipient model.Confidential transfer fee denial is undocumented
Severity
- Severity: Informational
Submitted by
r0bert
Description
The code rejects Token-2022 mints with
ConfidentialTransferFeeConfig, but the public deny-list documentation does not name that extension. The README saysCreateDvprejectsTransferFee,InterestBearing,ScaledUiAmountandConfidentialTransfer. The error table repeats the same list. The processor checks bothConfidentialTransferMintandConfidentialTransferFeeConfig.The integration test header also claims negative coverage for every blocked extension, but it only lists and tests
ConfidentialTransferMintfor the confidential-transfer family. Therefore a mint withConfidentialTransferFeeConfigcan be rejected by the program without being covered by the documented extension policy or by a dedicated regression test.Recommendation
Update the README and generated documentation to list
ConfidentialTransferFeeConfigexplicitly in the blocked Token-2022 extension set.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified. The README Token-2022 note and README error table now list
ConfidentialTransferFeeConfigin the blocked Token-2022 extension set and the Token-2022 test header explains that the confidential-transfer test covers the fee-config path because that extension cannot exist independently ofConfidentialTransferMint.TransferHook account cap can brick terminal instructions
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
transfer_checked_cpicaps transfer-hook remaining accounts at 32 per leg because the CPI account arrays are stack allocated. Token-2022 transfer-hook authorities can update anExtraAccountMetaListafter a DvP has been created and funded.If a hook-bearing mint starts requiring more accounts than this cap, every terminal instruction that needs to transfer that leg fails before the token CPI can run. The result is a mint-authority-controlled denial of service: settlement, cancellation, rejection and reclaim remain blocked until the hook authority reduces the required account list. The source comments identify this as residual risk, but it is still a material integration constraint for users and integrators.
Recommendation
Treat mutable transfer-hook mints as explicitly trusted integrations. For stronger on-chain protection, reject hook-bearing mints unless the hook authority is unset or allowlisted by the parties. At minimum, surface this constraint in client-side risk checks before users fund an escrow.
Solana Foundation: Acknowledged.
Transfer hooks can receive caller signer privileges
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
transfer_checked_cpibuilds theTransferCheckedCPI account list by taking every trailing transfer-hook account and converting it withInstructionAccount::from(acc). That conversion keeps the account's existing signer and writable flags.That means the DvP program does not strip privileges from hook extras before handing them to Token-2022. If a client includes the user's signer account, the settlement authority or another transaction signer as a hook extra, the hook program receives that account as a signer during the hook call. If the extra is writable, the hook receives it as writable too.
This is not automatically a program bug. Transfer hooks are allowed to ask for extra accounts and trusted hooks may need signer or writable accounts to do their job. The problem is that a wallet or integration can hide this detail from the user. A user may think they are only signing a DvP settlement, cancellation, rejection or reclaim, while the transaction also gives a hook program access to one of their signer accounts.
This does not appear to expose the DvP PDA authority itself. Even if the hook asks for the transfer authority again, Token-2022 treats that fixed authority as read-only and not a signer before the hook runs. The real concern is with other extra accounts supplied by the caller. Those accounts can still reach the hook as signers or writable accounts.
Recommendation
Reject signer-bearing transfer-hook extras unless the integration explicitly needs them and has allowlisted the hook. At minimum, client tooling should warn before it includes any transaction signer or unrelated writable account in the hook extras.
Treat hook-bearing mints as active integrations with their own trust assumptions. They should not be presented to users as ordinary passive SPL Token mints.
Solana Foundation: Acknowledged.
README omits Token-2022 funding requirements
Severity
- Severity: Informational
Submitted by
r0bert
Description
The README says each side funds a DvP by issuing a plain SPL transfer to the escrow ATA. That is incomplete for accepted Token-2022 mints whose transfers require mint-aware or extension-aware account inputs.
For
Pausablemints, Token-2022 createsPausableAccounttoken accounts. During an unchecked transfer, Token-2022 rejects those accounts because the mint account was not supplied and the token program cannot check whether the mint is paused. The DvP program itself usesTransferCheckedfor terminal transfers, but the documented funding step tells integrators to use a plain transfer.For
TransferHookmints, funding the escrow is also a token transfer, so Token-2022 invokes the hook during funding. The funding transaction must resolve and supply the hook program, validation PDA and anyExtraAccountMetaListaccounts. A plain unchecked transfer can also fail for hook-bearing accounts because Token-2022 needs the mint account to resolve the hook.Consequently, a client can follow the README and fail to fund Token-2022 mints that
CreateDvpaccepts and that the terminal instructions can support when the right accounts are supplied. The integration tests already reflect this for hook-bearing mints by funding them withTransferCheckedplus hook extras, but the public funding documentation does not state the same requirement.Recommendation
Update the funding section to state that Token-2022 mints with extension-aware transfer behavior must be funded with
TransferCheckedor an equivalent checked token instruction. ThePausablesection should explicitly say that clients must include the mint account during funding so Token-2022 can enforce the current pause state. TheTransferHooksection should say that funding requires the same hook account-resolution logic as terminal transfers, including the hook program, validation PDA and resolved extra accounts.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified. The README funding notes now instruct clients to use
TransferCheckedfor Token-2022 mints with extension-aware transfer behavior, call outPausablemint-account requirements and state thatTransferHookfunding needs the hook program, validation PDA and resolvedExtraAccountMetaListaccounts.SBF build leaves deploy keypairs that do not match declared program IDs
Severity
- Severity: Informational
Submitted by
r0bert
Description
make buildregenerates the IDL and clients, then runscargo-build-sbffor both the DvP program and the transfer-hook fixture, but it never verifies the generatedtarget/deploy/*-keypair.jsonfiles against the program IDs compiled into the binaries and exposed to clients.After a successful
make build, the DvP deploy keypair resolved toFvyU1M9YpbvZz1Joj57zyfCgt8wGY1F7VLhwXi2iBsnm, whileprogram/src/lib.rs, the README, the IDL and generated clients all useDzG1qJupt6Khm8s8jB3p93NkhPoiAg2M7vkEhkS15CtC. The transfer-hook fixture had the same mismatch: the deploy keypair resolved toG6sc2dJebJN747T3qNxEBqzf4ezb8WdoZd4KXv5Ym9fw, while the fixture declaresHookqJupt6Khm8s8jB3p93NkhPoiAg2M7vkEhkS15CtC.The LiteSVM tests do not catch this because they load the compiled
.sobytes at fixed in-memory program IDs rather than deploying through the generated keypair files. A deployment workflow that relies ontarget/deploy/*-keypair.jsonor that omits an explicit--program-idkeypair matchingdeclare_id!, can publish a binary at an address that the README, IDL and generated clients do not use.Recommendation
Add a build or deploy verification step that runs
solana-keygen pubkey target/deploy/<program>-keypair.jsonand compares the result with each program'sdeclare_id!and generated client ID. Fail CI and deployment if they differ. For real deployments, require an explicitsolana program deploy --program-id <expected-keypair.json>path whose public key matches the documented program ID.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified. The Makefile now includes
verify-program-id, which compares the deploy keypair public key with the program ID from the generated IDL, and the README deployment flow requires running that guard beforesolana program deploy --program-id. This gives deployments an explicit mismatch check instead of relying on the random keypair emitted bycargo-build-sbf.IDL does not describe TransferHook extra accounts
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The Codama IDL is the JSON interface file for the program. Client generators, indexers, transaction builders and reviewers use it to learn which instructions exist, which accounts each instruction expects and which arguments must be encoded.
Here, the IDL is missing part of the account contract. It lists the normal fixed accounts for
reclaimDvp,settleDvp,cancelDvpandrejectDvp, but it does not say that these instructions may also need extra accounts at the end of the account list.Those extra accounts are needed for Token-2022 mints that use
TransferHook. ATransferHookis a Token-2022 extension that lets the mint call another program during a token transfer. To run that hook, Token-2022 may need extra accounts such as the hook program, the hook validation account and any accounts listed by the hook'sExtraAccountMetaList.The on-chain code expects those accounts even though the IDL does not document them.
ReclaimDvptreats every account after its six fixed accounts as hook extras for the reclaim transfer.SettleDvp,CancelDvpandRejectDvpalso accept trailing hook accounts. For those two-leg instructions,legAExtrasCounttells the program how many trailing accounts belong to leg A, the rest belong to leg B.As a result, a client built only from
idl/dvp_swap_program.jsoncan produce incomplete transactions for hook-bearing mints. A transaction reviewer or indexer can also miss that the instruction may legally include these extra accounts. The generated TypeScript builder and parser issues are symptoms of the same root problem: the checked-in IDL does not fully describe the instruction interface.Recommendation
Add Codama metadata, generation visitors or instruction documentation that describes the variable trailing accounts.
ReclaimDvpshould expose one ordered list of hook extra accounts.SettleDvp,CancelDvpandRejectDvpshould expose separate hook-extra lists for leg A and leg B, then derivelegAExtrasCountfrom those lists.Solana Foundation: Acknowledged.
Unbounded expiry_timestamp permits indefinite escrow lifetime and permanent rent lock-up
Severity
- Severity: Informational
Submitted by
Sujith S
Description
CreateDvpvalidates that expiry_timestamp > now (create_dvp.rs:263-266) but imposes no upper bound on how far in the future it may be set. Becauseexpiry_timestampis ani64, a caller can specify any value up toi64::MAX, andvalidate_argswill accept it. The same lack of bound applies toearliest_settlement_timestampindirectly and it only has to satisfyearliest <= expiry, so it inherits the same ceiling.Recommendation
Add an upper bound on
expiry_timestampinvalidate_args, expressed as a maximum duration from now rather than an absolute timestamp so the limit doesn't drift over time.Pick the horizon based on the product's longest legitimate settlement window. For institutional
T+NDvP that's typically days to weeks, so a one-year cap is generous and still defeats the i64::MAX griefing case.Solana Foundation: Fixed in commit c149cdf.
Cantina: Fix verified.
validate_argsnow rejectsexpiry_timestampvalues more than one year after creation withExpiryTooFarInFuture, andearliest_settlement_timestampremains bounded byexpiry_timestamp.Settlement window may be configured too narrow to be operationally usable
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
validate_argsenforcesearliest_settlement_timestamp <= expiry_timestampbut does not require a minimum gap between the two. As a result, callers can create a DvP withearliest == expiry(a single-slot settle window) orearliest == expiry - 1(a ~1-second window).Settle then requires
now >= earliest && now <= expiry, and given thatClock::unix_timestampadvances in integer seconds and Solana slot timing plus propagation jitter routinely costs more than that, the window can be missed even by a correctly-submitted transaction.Funds remain recoverable via
Reject, so this is not a fund-loss issue but a foot-gun where a misconfigured client silently produces a DvP that is mathematically valid but practically unsettleable.Recommendation
Add a minimum-window check in
validate_args, applied only whenearliest_settlement_timestampis set:const MIN_SETTLE_WINDOW_SECS: i64 = 600; if let Some(earliest) = args.earliest_settlement_timestamp { require!( args.expiry_timestamp.saturating_sub(earliest) >= MIN_SETTLE_WINDOW_SECS, DvpSwapProgramError::SettleWindowTooNarrow );}Solana Foundation: Acknowledged.