Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 17 additions & 41 deletions modules/sdk-coin-sol/src/lib/explainTransactionWasm.ts
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
import { ITokenEnablement } from '@bitgo/sdk-core';
import { Transaction, parseTransaction, type ParsedTransaction, type InstructionParams } from '@bitgo/wasm-solana';
import { UNAVAILABLE_TEXT } from './constants';
import { StakingAuthorizeParams, TransactionExplanation as SolLibTransactionExplanation } from './iface';
import { TransactionExplanation as SolLibTransactionExplanation } from './iface';
import { findTokenName } from './instructionParamsFactory';
import { summarizeStakingAuthorize } from './stakingAuthorizeSummary';

export interface ExplainTransactionWasmOptions {
txBase64: string;
Expand DownExpand Up@@ -132,39 +133,6 @@ function extractTransactionId(signatures: string[]): string | undefined {
return sig;
}

// =============================================================================
// Staking authorize mapping
// =============================================================================

/**
* Map WASM StakingAuthorize instruction to the legacy BitGoJS shape.
* BitGoJS uses different field names for Staker vs Withdrawer authority changes.
*/
function mapStakingAuthorize(instr: {
stakingAddress: string;
oldAuthorizeAddress: string;
newAuthorizeAddress: string;
authorizeType: 'Staker' | 'Withdrawer';
custodianAddress?: string;
}): StakingAuthorizeParams {
if (instr.authorizeType === 'Withdrawer') {
return {
stakingAddress: instr.stakingAddress,
oldWithdrawAddress: instr.oldAuthorizeAddress,
newWithdrawAddress: instr.newAuthorizeAddress,
custodianAddress: instr.custodianAddress,
};
}
// Staker authority change
return {
stakingAddress: instr.stakingAddress,
oldWithdrawAddress: '',
newWithdrawAddress: '',
oldStakingAuthorityAddress: instr.oldAuthorizeAddress,
newStakingAuthorityAddress: instr.newAuthorizeAddress,
};
}

// =============================================================================
// Main explain function
// =============================================================================
Expand DownExpand Up@@ -272,13 +240,21 @@ export function explainSolTransaction(params: ExplainTransactionWasmOptions): So
}

// --- Staking authorize ---
let stakingAuthorize: StakingAuthorizeParams | undefined;
for (const instr of parsed.instructionsData) {
if (instr.type === 'StakingAuthorize') {
stakingAuthorize = mapStakingAuthorize(instr);
break;
}
}
// Display summary only — same rules as the legacy and raw explain paths. Signature validation
// walks every Authorize instruction in Sol.verifyStakingAuthorizeInstructions instead.
const stakingAuthorize = summarizeStakingAuthorize(
parsed.instructionsData
.filter(
(instr): instr is Extract<InstructionParams, { type: 'StakingAuthorize' }> => instr.type === 'StakingAuthorize'
)
.map((instr) => ({
stakingAddress: instr.stakingAddress,
oldAuthorizeAddress: instr.oldAuthorizeAddress,
newAuthorizeAddress: instr.newAuthorizeAddress,
authorizeType: instr.authorizeType,
custodianAddress: instr.custodianAddress,
}))
);

// --- Resolve token names and convert bigint to string at serialization boundary ---
const resolvedOutputs = outputs.map((o) => ({
Expand Down
26 changes: 25 additions & 1 deletion modules/sdk-coin-sol/src/lib/iface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,14 +181,38 @@ export interface StakingWithdraw {
params: { fromAddress: string; stakingAddress: string; amount: string };
}

/**
* Which stake authority an Authorize instruction changes. Mirrors Solana's `StakeAuthorize`
* enum (`StakeAuthorizationLayout`: `Staker` = 0, `Withdrawer` = 1) and the `authorizeType`
* emitted by the WASM parser, so all parse paths share one vocabulary.
*/
export type StakeAuthorizeType = 'Staker' | 'Withdrawer';

export interface StakingAuthorize {
type: InstructionBuilderTypes.StakingAuthorize;
params: {
stakingAddress: string;
oldAuthorizeAddress;
/** The authority signing the change — the staker or the withdrawer, per `authorizeType`. */
oldAuthorizeAddress: string;
/** The new authority — the new staker or the new withdrawer, per `authorizeType`. */
newAuthorizeAddress: string;
/**
* The lockup custodian account, NOT a withdraw authority. Named `newWithdrawAddress` for
* historical reasons: {@link stakingAuthorizeInstruction} passes it as `custodianPubkey`, and
* the decoder reads that account back into this field. Solana documents the custodian as
* optional for Withdrawer changes under lockup; the encoding can still attach or omit it
* independently of `authorizeType` — use `authorizeType` for the authority being changed.
*/
newWithdrawAddress?: string;
/** The lockup custodian account, as decoded by the raw (AuthorizeChecked) parser. */
custodianAddress?: string;
/**
* Which authority this instruction changes, decoded from Solana's `stakeAuthorizationType`.
* This is the only authoritative discriminator; do not infer it from custodian presence.
* Optional because transaction *builders* construct these params before encoding, where the
* type is implied by the builder. Both instruction parsers always populate it.
*/
authorizeType?: StakeAuthorizeType;
};
}

Expand Down
64 changes: 52 additions & 12 deletions modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
DelegateStakeParams,
InitializeStakeParams,
SplitStakeParams,
StakeAuthorizationLayout,
StakeInstruction,
StakeProgram,
SystemInstruction,
Expand All@@ -41,6 +42,7 @@ import {
Memo,
MintTo,
Nonce,
StakeAuthorizeType,
StakingActivate,
StakingAuthorize,
StakingDeactivate,
Expand DownExpand Up@@ -1171,6 +1173,35 @@ function parseAtaCloseInstructions(instructions: TransactionInstruction[]): Arra
return instructionData;
}

/**
* Map Solana's numeric `stakeAuthorizationType` onto our discriminator.
*
* `StakeAuthorizationLayout` defines `Staker = 0` and `Withdrawer = 1`. Any other value is
* rejected rather than defaulted, so an unrecognised authority type can never be silently
* treated as a staker change (which would suppress the withdraw-authority validation).
*/
function toStakeAuthorizeType(index: number): StakeAuthorizeType {
if (index === StakeAuthorizationLayout.Withdrawer.index) {
return 'Withdrawer';
}
if (index === StakeAuthorizationLayout.Staker.index) {
return 'Staker';
}
throw new NotSupported(`Invalid transaction, unknown stake authorization type: ${index}`);
}

/**
* Decode the authority type of a raw `AuthorizeChecked` instruction.
*
* The raw path exists because web3.js cannot decode `AuthorizeChecked`, so the type is read
* straight from the instruction data: a little-endian u32 opcode (10) followed by a
* little-endian u32 `StakeAuthorize` discriminant.
*/
function decodeRawAuthorizeType(instruction: TransactionInstruction): StakeAuthorizeType {
assert(instruction.data.length >= 8, 'Invalid authorize instruction data');
return toStakeAuthorizeType(instruction.data.readUInt32LE(4));
}

/**
* Parses Solana instructions to authorized staking account params
* Only supports Nonce, Authorize instructions
Expand DownExpand Up@@ -1210,7 +1241,9 @@ function parseStakingAuthorizeInstructions(
stakingAddress: authorize.stakePubkey.toString(),
oldAuthorizeAddress: authorize.authorizedPubkey.toString(),
newAuthorizeAddress: authorize.newAuthorizedPubkey.toString(),
// The custodian (lockup authority) account, not a withdraw authority — see iface.
newWithdrawAddress: authorize.custodianPubkey?.toString() || '',
authorizeType: toStakeAuthorizeType(authorize.stakeAuthorizationType.index),
},
});
break;
Expand All@@ -1229,7 +1262,9 @@ function parseStakingAuthorizeInstructions(
*/
function parseStakingAuthorizeRawInstructions(instructions: TransactionInstruction[]): Array<Nonce | StakingAuthorize> {
const instructionData: Array<Nonce | StakingAuthorize> = [];
assert(instructions.length === 2, 'Invalid number of instructions');
// validateRawMsgInstruction accepts a nonce advance followed by one or two authorize
// instructions (a Staker change, a Withdrawer change, or both).
assert(instructions.length === 2 || instructions.length === 3, 'Invalid number of instructions');
const advanceNonceInstruction = SystemInstruction.decodeNonceAdvance(instructions[0]);
const nonce: Nonce = {
type: InstructionBuilderTypes.NonceAdvance,
Expand All@@ -1239,17 +1274,22 @@ function parseStakingAuthorizeRawInstructions(instructions: TransactionInstructi
},
};
instructionData.push(nonce);
const authorize = instructions[1];
assert(authorize.keys.length === 5, 'Invalid number of keys in authorize instruction');
instructionData.push({
type: InstructionBuilderTypes.StakingAuthorize,
params: {
stakingAddress: authorize.keys[0].pubkey.toString(),
oldAuthorizeAddress: authorize.keys[2].pubkey.toString(),
newAuthorizeAddress: authorize.keys[3].pubkey.toString(),
custodianAddress: authorize.keys[4].pubkey.toString(),
},
});
for (const authorize of instructions.slice(1)) {
// AuthorizeChecked accounts: [0] stake, [1] clock sysvar, [2] current authority,
// [3] new authority, [4] lockup custodian. The custodian is optional in Solana, so require
// only the four mandatory accounts — matching the explain path, which accepts the same shape.
assert(authorize.keys.length >= 4, 'Invalid number of keys in authorize instruction');
instructionData.push({
type: InstructionBuilderTypes.StakingAuthorize,
params: {
stakingAddress: authorize.keys[0].pubkey.toString(),
oldAuthorizeAddress: authorize.keys[2].pubkey.toString(),
newAuthorizeAddress: authorize.keys[3].pubkey.toString(),
custodianAddress: authorize.keys[4]?.pubkey.toString(),
authorizeType: decodeRawAuthorizeType(authorize),
},
});
}
return instructionData;
}

Expand Down
79 changes: 79 additions & 0 deletions modules/sdk-coin-sol/src/lib/stakingAuthorizeSummary.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
import { StakeAuthorizeType, StakingAuthorizeParams } from './iface';

/**
* A single decoded Authorize instruction, normalised across the three parse paths
* (legacy web3.js `Authorize`, raw `AuthorizeChecked`, and the WASM parser).
*/
export interface AuthorizeInstructionView {
stakingAddress: string;
/** The authority signing this change — staker or withdrawer, per `authorizeType`. */
oldAuthorizeAddress: string;
/** The new authority — new staker or new withdrawer, per `authorizeType`. */
newAuthorizeAddress: string;
/** Decoded from Solana's `stakeAuthorizationType`. Undefined only if decoding failed. */
authorizeType?: StakeAuthorizeType;
/** The lockup custodian account, if the instruction carries one. */
custodianAddress?: string;
}

/**
* Reduce the Authorize instructions of a transaction to a single human-readable summary for
* `explainTransaction`.
*
* This is a **display** summary, not a security boundary. Because it collapses many instructions
* into one it cannot describe a transaction that re-authorises several stake accounts, so nothing
* may authorise a signature on the strength of it. `Sol.verifyStakingAuthorizeInstructions`
* validates every instruction individually against the intent instead.
*
* Selection rules, and why:
*
* - The authority being changed is taken **only** from `authorizeType`, never inferred from the
* presence of a custodian. Solana documents the lockup custodian as an optional account for
* Withdrawer changes under lockup, and the instruction encoding can still attach one on a
* Staker change (or omit it on a Withdrawer change), so custodian presence is not a type
* discriminator — inferring from it would let a decoy Staker change be reported as the
* withdraw authority.
* - A Withdrawer change outranks a Staker change, so the withdraw authority is never masked by a
* staker-only instruction.
* - Among instructions of the same authority type the **last** one wins, matching Solana's
* sequential execution for a single stake account.
* - Withdraw fields are left empty for a staker-only transaction, so a staker address is never
* presented as a withdraw address.
*
* @param instructions decoded Authorize instructions, in transaction order
* @returns the summary, or undefined when the transaction has no Authorize instruction
*/
export function summarizeStakingAuthorize(
instructions: AuthorizeInstructionView[]
): StakingAuthorizeParams | undefined {
if (instructions.length === 0) {
return undefined;
}

const lastOfType = (type: StakeAuthorizeType): AuthorizeInstructionView | undefined =>
[...instructions].reverse().find((instruction) => instruction.authorizeType === type);

const withdrawer = lastOfType('Withdrawer');
if (withdrawer) {
return {
stakingAddress: withdrawer.stakingAddress,
oldWithdrawAddress: withdrawer.oldAuthorizeAddress,
newWithdrawAddress: withdrawer.newAuthorizeAddress,
custodianAddress: withdrawer.custodianAddress,
};
}

// No withdraw-authority change in this transaction. Report the staker change (if the type was
// decodable at all) and leave the withdraw fields empty.
const staker = lastOfType('Staker');
const fallback = staker ?? instructions[instructions.length - 1];
return {
stakingAddress: fallback.stakingAddress,
oldWithdrawAddress: '',
newWithdrawAddress: '',
...(staker && {
oldStakingAuthorityAddress: staker.oldAuthorizeAddress,
newStakingAuthorityAddress: staker.newAuthorizeAddress,
}),
};
}
Loading
Loading