Skip to content

feat(sdk-coin-sol): add verifyTransaction validation for staking authorize intent - #9463

Open
bitgo-ai-agent-dev[bot] wants to merge 3 commits into
masterfrom
chalo-1294-sol-authorize-verify-transaction
Open

feat(sdk-coin-sol): add verifyTransaction validation for staking authorize intent#9463
bitgo-ai-agent-dev[bot] wants to merge 3 commits into
masterfrom
chalo-1294-sol-authorize-verify-transaction

Conversation

@bitgo-ai-agent-dev

Copy link
Copy Markdown
Contributor

What

  • Added isStakingAuthorizeTx branch in sol.ts:verifyTransaction that validates the decoded SOL staking authorize instruction against the caller's intent fields:
    • oldWithdrawAddress matches the wallet root address (Withdrawer authority only; skipped for staker-only txs where the field is '')
    • newWithdrawAddress matches txParams.newWithdrawPublicKey from the intent (when set)
    • stakingAddress matches txParams.stakeAccount from the intent (when set)
  • Extended resolveEffectiveTxParams in sdk-core to propagate newWithdrawPublicKey and stakeAccount from txRequest.intent into txParams so the fields reach verifyTransaction through the TSS signing flow
  • Added newWithdrawPublicKey and stakeAccount to PopulatedIntent (baseTypes.ts) and TransactionParams (iBaseCoin.ts)
  • Fixed explainTransaction for both the WASM (tsol) and non-WASM (sol) paths to populate stakingAuthorize with the Withdrawer instruction's fields (the security-critical one), not the Staker instruction's, when a standard two-instruction authorize tx is present
  • Added unit tests covering: happy path with all intent fields, happy path without optional fields, malicious newWithdrawPublicKey substitution, wrong stakeAccount, wrong oldWithdrawAddress, non-WASM explainTransaction path, and recipientUtils propagation

Why

  • 'authorize' was added to NO_RECIPIENT_TX_TYPES (WCI-1111 / PR fix(sdk-core): wire resolveEffectiveTxParams into EddsaMPCv2Utils (WCI-1111) #9394) because SolAuthorizeIntent has empty recipients by design. Without coin-layer validation, a compromised BitGo server could present a StakingAuthorize txHex that rotates the stake account's withdraw authority to an attacker-controlled key, and the client would sign it with no checks.
  • This closes the security gap identified in TOB-BITGOEDMPC-1 (Trail of Bits security audit) and explicitly flagged in WCI-1110 scoping notes as requiring coin-layer validation before the signing flow could be considered safe for authorize intents.

Test plan

  • yarn unit-test --scope @bitgo/sdk-coin-sol — 646 tests pass
  • yarn unit-test --scope @bitgo/sdk-core (recipientUtils tests) — 32 tests pass
  • Type checks pass for both sdk-coin-sol and sdk-core
  • Happy path: valid authorize tx with matching newWithdrawPublicKey and stakeAccount passes
  • Malicious case: swapped newWithdrawPublicKey correctly throws
  • Malicious case: wrong stakeAccount correctly throws
  • Wrong oldWithdrawAddress (wallet root mismatch) correctly throws
  • Staker-only authorize tx (empty oldWithdrawAddress) does not block legitimate staker-authority changes

Ticket: CHALO-1294

@linear-code

linear-codeBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

CHALO-1294

VL-7606

@ralph-bitgo
ralph-bitgoBotforce-pushed the chalo-1294-sol-authorize-verify-transaction branch from 61816b4 to b9f9be7CompareAugust 11, 2026 04:27
@bitgo-ai-agent-dev
bitgo-ai-agent-devBotforce-pushed the chalo-1294-sol-authorize-verify-transaction branch from b9f9be7 to c0322c5CompareAugust 11, 2026 04:27
@maheshbitgo
maheshbitgo marked this pull request as ready for review August 11, 2026 08:18
@maheshbitgo
maheshbitgo requested review from a team as code ownersAugust 11, 2026 08:18
…orize
SOL authorize transactions carry no recipients by design, so 'authorize'
was added to NO_RECIPIENT_TX_TYPES in WCI-1111 to keep the signing flow
working. That left sol.ts:verifyTransaction with no checks at all for
these transactions: a compromised server could present a txHex that
rotates a stake account's withdraw authority to an attacker key and the
client would sign it without noticing.
Thread the authorize intent fields through to the coin layer and
validate the decoded instruction against them:
- sdk-core baseTypes.ts / iBaseCoin.ts: add newWithdrawPublicKey and
stakeAccount to PopulatedIntent and TransactionParams
- sdk-core recipientUtils.ts: propagate both fields from the intent in
resolveEffectiveTxParams, so they reach verifyTransaction via the
existing txParams argument without new plumbing in signRequestBase
- sdk-coin-sol explainTransactionWasm.ts / transaction.ts: populate
explainedTx.stakingAuthorize, preferring the Withdrawer instruction
over Staker so the security-critical newWithdrawAddress is not
dropped when a tx changes both authorities
- sdk-coin-sol sol.ts: validate oldWithdrawAddress against the wallet
root address, newWithdrawAddress against the intended
newWithdrawPublicKey, and stakingAddress against the intended
stakeAccount, whenever those intent fields are present
The staker/withdrawer distinction matters because verifyTransaction
always explains via the legacy Transaction.explainTransaction path,
never the WASM one. Neither instruction parser surfaces Solana's
stakeAuthorizationType, so a Withdrawer-type instruction is identified
by its custodian key; a staker-only authorize populates the staking
authority fields and leaves the withdraw fields empty rather than
reporting staker addresses as withdraw addresses.
The authorize checks deliberately fall through to the rest of
verifyTransaction rather than returning early, so authorize
transactions remain subject to the fee payer, durable nonce, memo and
recipient checks.
TICKET: CHALO-1294
@maheshbitgo
maheshbitgoforce-pushed the chalo-1294-sol-authorize-verify-transaction branch from c0322c5 to f3d445eCompareAugust 11, 2026 08:56

@davidkaplanbitgodavidkaplanbitgo 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.

BTC related changes look fine

// validates. Staker-only instructions must not populate the withdraw fields,
// otherwise a staker address would be compared against an intended withdraw key.
const isWithdrawerAuthorize = !!(
authorizeInstruction.params.newWithdrawAddress || authorizeInstruction.params.custodianAddress

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.

Can you confirm whether the non-WASM instruction decoder guarantees newWithdrawAddress is empty/unset for Staker-only authorize instructions? If not, we may need an explicit authorizeType field on the parsed instruction (like the WASM path has) rather than inferring from field presence.

Comment threadmodules/sdk-coin-sol/src/sol.ts Outdated
authorizeParams.oldWithdrawAddress
);
}
if (txParams.newWithdrawPublicKey && authorizeParams.newWithdrawAddress !== txParams.newWithdrawPublicKey) {

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.

Can you trace where SolAuthorizeIntent is constructed and confirm whether newWithdrawPublicKey and stakeAccount are guaranteed present?

stakingAuthorize = {
stakingAddress: authorizeInstruction.params.stakingAddress,
oldWithdrawAddress: authorizeInstruction.params.oldAuthorizeAddress,
newWithdrawAddress: authorizeInstruction.params.newAuthorizeAddress,

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.

"Your detection says 'this is a Withdrawer instruction because newWithdrawAddress is set,' but then you validate newAuthorizeAddress — what if they diverge? You'd be checking the wrong field and approving a malicious tx.

maheshbitgoand others added 2 commits August 14, 2026 15:00
Select the authorize instruction that defines the withdraw authority from
Solana's stakeAuthorizationType rather than from the presence of a lockup
custodian.
A custodian is orthogonal to StakeAuthorize: Solana permits a Staker change
to carry one and a Withdrawer change to omit one. Inferring the authority
type from it let a crafted transaction pair a real Withdrawer change to an
attacker key with a later Staker change to the expected key; both matched
the custodian heuristic, the decoy overwrote the real change, and
verifyTransaction compared the decoy and passed. This affected the legacy
web3.js parse path used by mainnet. The WASM path already read
authorizeType and was not vulnerable.
Both instruction parsers now surface the decoded authority type, and all
three explain paths share one summarizer: a Withdrawer change outranks a
Staker change, the last instruction of a type wins to match Solana's
sequential execution, and withdraw fields stay empty when no Withdrawer
change is present so a staker address is never compared against an
intended withdraw key. An unrecognised authority type is rejected rather
than defaulted.
Also fixes the raw AuthorizeChecked path, which read only instructions[1]
and asserted exactly two instructions, so a legitimate message carrying
both a Staker and a Withdrawer change was mis-explained or rejected
outright.
verifyTransaction now fails closed: a missing newWithdrawPublicKey or
stakeAccount aborts instead of skipping the comparison, since a server
able to omit a field could otherwise disable the check meant to constrain
it. SolAuthorizeIntent declares both as required.
TICKET: CHALO-1294
Validating a single summarised authority change left the rest of the
transaction unconstrained. Solana executes every instruction, so a crafted
txHex could satisfy the check with one instruction while a second did
something the user never asked for:
- a Withdrawer change on a different stake account the same wallet
controls, smuggled alongside a correct change on the intended account
- a Staker change to an attacker key, paired with a correct Withdrawer
change; its holder can delegate, deactivate and split the stake
Verification now walks every Authorize instruction and requires each to
target the intended stakeAccount, to be signed away by the wallet root,
and to move the authority to the intended newWithdrawPublicKey. The
staker authority is held to the same key because the intent carries one
new authority and the builder points both authorities at it. At least one
Withdrawer change is required, since an authorize intent always transfers
the withdraw authority.
The instruction list comes from toJson rather than the explanation:
summarising many instructions into one cannot describe a transaction that
touches several stake accounts, so the summary is display-only and is
documented as such.
Also aligns the raw AuthorizeChecked parser with the explain path by
requiring the four mandatory accounts rather than five, since Solana
makes the lockup custodian optional.
Both attack cases are covered by tests on the legacy and WASM explain
paths, and both fail against the previous commit.
TICKET: CHALO-1294
Co-authored-by: Cursor <cursoragent@cursor.com>
@BitGoBitGo deleted a comment from github-actionsBotAug 17, 2026
@BitGoBitGo deleted a comment from github-actionsBotAug 17, 2026
@maheshbitgo

Copy link
Copy Markdown
Contributor

@claude Review the changes for security, correctness and adversarial effects/regression.

@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @maheshbitgo's task —— View job


Security and Correctness Review

Reviewing the SOL staking authorize intent validation changes for security vulnerabilities, correctness issues, and potential adversarial bypass scenarios.

Todo List

  • Analyze core validation logic in sol.ts
  • Review instruction parsing and type detection
  • Examine test coverage for adversarial scenarios
  • Address reviewer concerns about field validation
  • Check for potential bypass or regression risks
  • Provide comprehensive security assessment

Core Validation Analysis

The verifyStakingAuthorizeInstructions method in sol.ts:541-612 implements strong security validation:

Strengths:

  • Fail-closed design: Missing intent fields (newWithdrawPublicKey, stakeAccount) cause immediate rejection rather than skipping validation
  • Comprehensive instruction validation: Every authorize instruction is validated against the intent, preventing bypass via multiple instructions
  • Authority chain verification: Validates oldAuthorizeAddress matches wallet root to ensure the wallet controls what it's signing away
  • Single stake account enforcement: Prevents transactions from affecting multiple stake accounts beyond the intended one
  • Withdrawer requirement: Ensures at least one withdrawer authority change occurs, preventing staker-only transactions from bypassing withdraw authority validation

Critical Security Issue: Field Naming Confusion ⚠️

The reviewer's concern about field validation is valid and serious:

Problem: In instructionParamsFactory.ts:1245, the non-WASM parser incorrectly maps the custodian field to newWithdrawAddress:

newWithdrawAddress: authorize.custodianPubkey?.toString()||'',

However, the validation logic correctly uses newAuthorizeAddress for validation:

if(params.newAuthorizeAddress!==txParams.newWithdrawPublicKey)

Security Impact: This creates a potential bypass if malicious transactions manipulate the custodian field while keeping the wrong newAuthorizeAddress. The current validation is correct, but the field mapping inconsistency needs fixing.

Test Coverage Analysis

Excellent adversarial test coverage includes:

  • Valid authorize transactions with all intent fields (sol.ts:1048-1064)
  • Missing newWithdrawPublicKey rejection (sol.ts:1069-1082)
  • Missing stakeAccount rejection (sol.ts:1084-1097)
  • Malicious key substitution attacks (sol.ts:1099-1113)
  • Wrong stake account attacks (sol.ts:1115-1129)
  • Decoy staker instruction hiding withdrawer changes (sol.ts:1182-1215)
  • Multi-instruction bypass attempts (sol.ts:1234-1263)

Integration Safety

Proper integration: The authorize type is correctly added to NO_RECIPIENT_TX_TYPES, ensuring the validation is triggered in the TSS signing flow.

Final Assessment

Overall: The security model is fundamentally sound and provides strong protection against the Trail of Bits audit findings. The validation logic correctly prevents the key security risks.

Recommendation: 🔴 Fix the field mapping inconsistency in instructionParamsFactory.ts to align with the security validation expectations, but the core security is intact.


Job Run: View Details
Branch: chalo-1294-sol-authorize-verify-transaction

Sign up for freeto 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.

3 participants

@maheshbitgo@bhavidhingra@davidkaplanbitgo