Skip to content

fix(sdk-coin-sol): validate Marinade unstake by transfer, not memo - #10165

Merged
hrishikeshjain merged 2 commits into
masterfrom
fix/chalo-2127-sol-prepare-for-revoke-memo
Nov 11, 2026
Merged

hrishikeshjain merged 2 commits into
masterfrom
fix/chalo-2127-sol-prepare-for-revoke-memo

Conversation

@hrishikeshjain

@hrishikeshjain hrishikeshjain commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem (CHALO-2127)

verifyTransaction is the client's only check that a server-supplied prebuild matches user intent before the TSS wallet signs, and it works on explainedTx.outputs. The SDK typed any transaction whose memo contained PrepareForRevoke as a Marinade deactivate, which suppressed the Send classification and produced no explained outputs. Because the memo is attacker-controlled, a prebuild with Transfer(wallet -> attacker, full balance) plus that memo passed every output check (consolidation, and staking flows with no recipients) 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:

  • exactly one SOL Transfer, one memo, and nothing else except nonce-advance / compute-budget
  • destination is a Marinade order address: opNS8ENpEMWdXcJUgJCsJTDp7arTXayoBEeBUg6UezP (native) or opiNSvKm1aPkbuSjnZfY5vyJUmaVxuQyTJt6jnNTW1L (select)
  • amount is exactly 2,300,000 lamports (marinade.unstakeTransferAmount in staking-service; other integrators send 3,000,000)
  • memo parses as {"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 both getTransactionType (utils.ts, used by verifyTransaction) and deriveTransactionType (explainTransactionWasm.ts).

Also:

  • verifyTransaction requires a genuine Marinade transfer's source to be the wallet root (not the fee payer, since sponsored fee payers exist).
  • parseStakingDeactivateInstructions and parseStakingWithdrawInstructions now throw NotSupported on unrecognised instructions instead of silently ignoring them (ticket point 2). Approve/Create are still allowed in the deactivate parser only when a WithdrawStake is 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

  • Genuine Marinade unstakes explain as before (StakingDeactivate, no outputs). Forged ones no longer do.
  • The two parsers no longer tolerate unexpected instructions. Two existing tests asserting that tolerance were flipped to assert the throw, and tests that fed StakeProgram.split(...) including its leading createAccount now use instructions[1], matching solInstructionFactory, which already drops it.
  • The Marinade test fixture memo was malformed JSON; it is now valid and MARINADE_STAKING_DEACTIVATE_SIGNED_TX was regenerated from the same builder inputs.
  • The Marinade addresses and amount are pinned per the staking team's guidance and live in 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 correct payment intent, but the SDK explained it as StakingDeactivate with outputs: [].

Test plan

  • Memo-tagged transfer to an arbitrary address, wrong amount, or malformed memo classifies as Send and keeps its output (WASM and legacy)
  • Genuine Marinade unstake still classifies as StakingDeactivate with no outputs
  • verifyTransaction rejects a consolidation drain behind the memo, a recipients: [] payment, and a Marinade transfer not sent from the wallet root; accepts a genuine one from the wallet root
  • Withdraw/deactivate parsers throw on unsupported instructions
  • Full sdk-coin-sol unit suite: 890 passing, 13 failing. The same 13 fail on clean master (stale dependency builds: validateSiwsMessage, deriveSafeWalletKeys, getScaledUiAmountConfig).

🤖 Generated with Claude Code

…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>
@hrishikeshjain
hrishikeshjain requested a review from a team as a code owner November 11, 2026 11:49

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@linear-code

linear-code Bot commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

CHALO-2127

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Compatibility checks are failing on Node.js 27-nightly (experimental, non-blocking). This does not block merge, but it signals an incompatibility to fix before this release becomes supported.

View run

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>
@hrishikeshjain hrishikeshjain changed the title fix(sdk-coin-sol): stop PrepareForRevoke memo from hiding transfer outputs fix(sdk-coin-sol): validate Marinade unstake by transfer, not memo Nov 11, 2026

@ashutoshkumar-6 ashutoshkumar-6 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Bind the memo user. In all 5 prod txs, memo user == transfer source == fee payer. Requiring user === transfer.fromPubkey in getMarinadeUnstakeTransfer is safe and makes the wallet-root check in sol.ts meaningful. The memo amount stays unbound (a tampered prebuild could turn a partial unstake into a full one — griefing, not theft); worth noting on the ticket.
  2. 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 become Send (funds stay safe, but the type changes for consumers). Please add a comment in constants.ts cross-referencing the staking-service config, or allow a narrow range.
  3. Jito Approve/Create exception is allowed whenever any WithdrawStake is present, but isn't matched to it (Approve delegate vs sourceTransferAuthority, created account vs destinationStake). Same class of issue — fine as a follow-up.
  4. 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.
  5. Nit: sol.ts wallet-root check throws for genuine unstakes if coinSpecific().rootAddress is missing. Also isWasmMarinadeUnstake is computed twice in the WASM path.
  6. 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).

@hrishikeshjain
hrishikeshjain merged commit c0b781a into master Nov 11, 2026
41 of 43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants