Repository navigation
fix(sdk-coin-sol): validate Marinade unstake by transfer, not memo - #10165
Conversation
…tputs A plain SOL transfer carrying a PrepareForRevoke memo was explained as a StakingDeactivate with empty outputs, since the memo is attacker-controlled and was the only signal used. Always surface the transfer recipient and amount in explain output (WASM and legacy paths), and make verifyTransaction reject a transfer intent that decodes as a staking deactivate. Ticket: CHALO-2127 Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
|
|
The PrepareForRevoke memo is attacker-controlled yet was the only signal used to type a transaction as a Marinade deactivate, which also hid the transfer from explained outputs and let verifyTransaction pass a drain. Treat a transaction as a Marinade unstake only when it has exactly one SOL transfer to a Marinade order address for the pinned amount, one well-formed PrepareForRevoke memo, and nothing but nonce-advance/compute-budget; anything else is a Send and its output is verified normally. verifyTransaction also requires the Marinade transfer to come from the wallet root. The staking deactivate and withdraw parsers now throw on unsupported instructions. Ticket: CHALO-2127 Co-Authored-By: Claude <noreply@anthropic.com>
ashutoshkumar-6
left a comment
There was a problem hiding this comment.
LGTM — approving. Classifying Marinade unstake by transfer destination + pinned amount (instead of the memo) closes the CHALO-2127 hole, and the fail-closed parsers are a good addition.
Verified against prod: pulled the 5 most recent confirmed BitGo Marinade unstakes from the staking DB (4 native, 1 select, Oct 6–30) from mainnet and ran the new predicate on them — all 5 still classify as StakingDeactivate (one Transfer of 2,300,000 lamports to opNS8…/opiNS…, valid JSON memo, no other instructions). Jito unstake (Approve + CreateAccount + WithdrawStake) is still covered. The browser-test failure is an apt mirror DNS flake, unrelated.
Non-blocking follow-ups:
- Bind the memo
user. In all 5 prod txs, memouser== transfer source == fee payer. Requiringuser === transfer.fromPubkeyingetMarinadeUnstakeTransferis safe and makes the wallet-root check insol.tsmeaningful. The memoamountstays unbound (a tampered prebuild could turn a partial unstake into a full one — griefing, not theft); worth noting on the ticket. - Hardcoded fee/addresses are env-overridable in staking-service (
SOL_UNSTAKE_MARINADE_TRANSFER_AMOUNT,SOL_UNSTAKE_MARINADE_*); other integrators already pay 3,000,000. If our config changes, genuine unstakes silently becomeSend(funds stay safe, but the type changes for consumers). Please add a comment inconstants.tscross-referencing the staking-service config, or allow a narrow range. - Jito Approve/Create exception is allowed whenever any WithdrawStake is present, but isn't matched to it (Approve delegate vs
sourceTransferAuthority, created account vsdestinationStake). Same class of issue — fine as a follow-up. - Extra tests worth adding: genuine fee transfer + a second transfer to another address with a valid memo →
Send; Marinade-shaped tx with an SPL Approve rider →Send. - Nit:
sol.tswallet-root check throws for genuine unstakes ifcoinSpecific().rootAddressis missing. AlsoisWasmMarinadeUnstakeis computed twice in the WASM path. - The PR description is out of date after the second commit (still says the type comes from the memo, mentions the removed transfer-intent guard, and says genuine Marinade deactivates now list their transfer in outputs).
Problem (CHALO-2127)
verifyTransactionis the client's only check that a server-supplied prebuild matches user intent before the TSS wallet signs, and it works onexplainedTx.outputs. The SDK typed any transaction whose memo containedPrepareForRevokeas a Marinade deactivate, which suppressed the Send classification and produced no explained outputs. Because the memo is attacker-controlled, a prebuild withTransfer(wallet -> attacker, full balance)plus that memo passed every output check (consolidation, and staking flows with norecipients) and the wallet would sign the drain.Fix
A Marinade native-staking unstake has no program instruction to anchor on; it is one SystemProgram Transfer to a Marinade order address plus the memo, acted on off-chain. So the transfer, not the memo, is the signal. Per the staking team, a transaction is a Marinade unstake only if all of:
Transfer, one memo, and nothing else except nonce-advance / compute-budgetopNS8ENpEMWdXcJUgJCsJTDp7arTXayoBEeBUg6UezP(native) oropiNSvKm1aPkbuSjnZfY5vyJUmaVxuQyTJt6jnNTW1L(select)marinade.unstakeTransferAmountin staking-service; other integrators send 3,000,000){"PrepareForRevoke":{"user":<string>,"amount":<digits>}}Anything else is a normal
Send, so its transfer shows up in outputs and is verified as usual. Applied in bothgetTransactionType(utils.ts, used byverifyTransaction) andderiveTransactionType(explainTransactionWasm.ts).Also:
verifyTransactionrequires a genuine Marinade transfer's source to be the wallet root (not the fee payer, since sponsored fee payers exist).parseStakingDeactivateInstructionsandparseStakingWithdrawInstructionsnow throwNotSupportedon unrecognised instructions instead of silently ignoring them (ticket point 2).Approve/Createare still allowed in the deactivate parser only when aWithdrawStakeis present (Jito unstake shape).Intent-supplied recipients are deliberately not used as the check: on MPCv2 they come from the server-supplied tx request (same trust boundary as the prebuild), and MPCv1 eddsa signing doesn't call
verifyTransaction. The address allowlist is the only server-independent signal.Behaviour changes to be aware of
StakingDeactivate, no outputs). Forged ones no longer do.StakeProgram.split(...)including its leadingcreateAccountnow useinstructions[1], matchingsolInstructionFactory, which already drops it.MARINADE_STAKING_DEACTIVATE_SIGNED_TXwas regenerated from the same builder inputs.constants.ts; they need updating if Marinade rotates its order addresses or BitGo changes its fee.Repro
Reproduced on staging by building (not signing or sending) a tsol payment to the wallet's own address with memo
{"PrepareForRevoke":...}. The platform recorded a correctpaymentintent, but the SDK explained it asStakingDeactivatewithoutputs: [].Test plan
Sendand keeps its output (WASM and legacy)StakingDeactivatewith no outputsverifyTransactionrejects a consolidation drain behind the memo, arecipients: []payment, and a Marinade transfer not sent from the wallet root; accepts a genuine one from the wallet rootsdk-coin-solunit suite: 890 passing, 13 failing. The same 13 fail on clean master (stale dependency builds:validateSiwsMessage,deriveSafeWalletKeys,getScaledUiAmountConfig).🤖 Generated with Claude Code