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
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@bitgo/sdk-core": "^36.31.0",
"@bitgo/sdk-lib-mpc": "^10.9.0",
"@bitgo/statics": "^58.25.0",
"@bitgo/wasm-solana": "^2.0.0",
"@solana/spl-stake-pool": "1.1.8",
"@solana/spl-token": "0.4.9",
"@solana/web3.js": "1.92.1",
Expand Down
3 changes: 3 additions & 0 deletions modules/sdk-coin-sol/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ export interface TransactionExplanation extends BaseTransactionExplanation {
memo?: string;
stakingAuthorize?: StakingAuthorizeParams;
stakingDelegate?: StakingDelegateParams;
inputs?: Array<{ address: string; value: string; coin?: string }>;
feePayer?: string;
ataOwnerMap?: Record<string, string>;
}

export class TokenAssociateRecipient {
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export { TransferBuilderV2 } from './transferBuilderV2';
export { WalletInitializationBuilder } from './walletInitializationBuilder';
export { Interface, Utils };
export { MessageBuilderFactory } from './messages';
export { InstructionBuilderTypes } from './constants';
49 changes: 34 additions & 15 deletions modules/sdk-coin-sol/src/lib/instructionParamsFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ type StakingInstructions = {
initialize?: InitializeStakeParams;
delegate?: DelegateStakeParams;
hasAtaInit?: boolean;
ataInitInstruction?: AtaInit;
};

type JitoStakingInstructions = StakingInstructions & {
Expand Down Expand Up @@ -454,7 +455,9 @@ function parseStakingActivateInstructions(

case ValidInstructionTypesEnum.InitializeAssociatedTokenAccount:
stakingInstructions.hasAtaInit = true;
instructionData.push({
// Store the ATA init instruction - we'll decide later whether to add it to instructionData
// based on staking type (Jito staking uses a flag instead of a separate instruction)
stakingInstructions.ataInitInstruction = {
type: InstructionBuilderTypes.CreateAssociatedTokenAccount,
params: {
mintAddress: instruction.keys[ataInitInstructionKeysIndexes.MintAddress].pubkey.toString(),
Expand All @@ -463,7 +466,7 @@ function parseStakingActivateInstructions(
payerAddress: instruction.keys[ataInitInstructionKeysIndexes.PayerAddress].pubkey.toString(),
tokenName: findTokenName(instruction.keys[ataInitInstructionKeysIndexes.MintAddress].pubkey.toString()),
},
});
};
break;
}
}
Expand Down Expand Up @@ -536,6 +539,12 @@ function parseStakingActivateInstructions(
}
}

// For non-Jito staking, add the ATA instruction as a separate instruction
// (Jito staking uses the createAssociatedTokenAccount flag in extraParams instead)
if (stakingType !== SolStakingTypeEnum.JITO && stakingInstructions.ataInitInstruction) {
instructionData.push(stakingInstructions.ataInitInstruction);
}

instructionData.push(stakingActivate);

return instructionData;
Expand Down Expand Up @@ -1171,7 +1180,10 @@ function parseStakingAuthorizeInstructions(
*/
function parseStakingAuthorizeRawInstructions(instructions: TransactionInstruction[]): Array<Nonce | StakingAuthorize> {
const instructionData: Array<Nonce | StakingAuthorize> = [];
assert(instructions.length === 2, 'Invalid number of instructions');
// StakingAuthorizeRaw transactions have:
// - 2 instructions: NonceAdvance + 1 Authorize (changing either staking OR withdraw authority)
// - 3 instructions: NonceAdvance + 2 Authorizes (changing BOTH staking AND withdraw authority)
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 @@ -1181,17 +1193,24 @@ 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(),
},
});

// Process all authorize instructions (1 or 2)
for (let i = 1; i < instructions.length; i++) {
const authorize = instructions[i];
// Authorize instruction keys: [stakePubkey, clockSysvar, oldAuthority, newAuthority, custodian?]
// - 4 keys: no custodian required
// - 5 keys: custodian is present (required when stake is locked)
assert(authorize.keys.length >= 4 && 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.length === 5 ? authorize.keys[4].pubkey.toString() : '',
},
});
}
return instructionData;
}

Expand Down Expand Up @@ -1239,7 +1258,7 @@ function parseCustomInstructions(
return instructionData;
}

function findTokenName(
export function findTokenName(
mintAddress: string,
instructionMetadata?: InstructionParams[],
_useTokenAddressTokenName?: boolean
Expand Down
6 changes: 5 additions & 1 deletion modules/sdk-coin-sol/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,11 @@ export function getTransactionType(transaction: SolTransaction): TransactionType
if (memoData?.includes('WalletConnectDefiCustomTx')) {
return TransactionType.CustomTx;
}
if (instructions.filter((instruction) => getInstructionType(instruction) === 'Deactivate').length === 0) {
if (
instructions.filter(
(instruction) => getInstructionType(instruction) === ValidInstructionTypesEnum.StakingDeactivate
).length === 0
) {
for (const instruction of instructions) {
const instructionType = getInstructionType(instruction);
// Check if memo instruction is there and if it contains 'PrepareForRevoke' because Marinade staking deactivate transaction will have this
Expand Down
83 changes: 83 additions & 0 deletions modules/sdk-coin-sol/src/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ import {
} from '@bitgo/sdk-core';
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
import {
explainTransaction as wasmExplainTransaction,
type ExplainedTransaction as WasmExplainedTransaction,
} from '@bitgo/wasm-solana';
import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib';
import { UNAVAILABLE_TEXT } from './lib/constants';
import { TransactionExplanation as SolLibTransactionExplanation } from './lib/iface';
import {
getAssociatedTokenAccountAddress,
getSolTokenFromAddress,
Expand All @@ -67,6 +73,7 @@ import {
isValidPublicKey,
validateRawTransaction,
} from './lib/utils';
import { findTokenName } from './lib/instructionParamsFactory';

export const DEFAULT_SCAN_FACTOR = 20; // default number of receive addresses to scan for funds

Expand All @@ -80,6 +87,7 @@ export interface ExplainTransactionOptions {
txBase64: string;
feeInfo: TransactionFee;
tokenAccountRentExemptAmount?: string;
coinName?: string;
}

export interface TxInfo {
Expand Down Expand Up @@ -696,6 +704,7 @@ export class Sol extends BaseCoin {
}

async parseTransaction(params: SolParseTransactionOptions): Promise<SolParsedTransaction> {
// explainTransaction now uses WASM for testnet automatically
const transactionExplanation = await this.explainTransaction({
txBase64: params.txBase64,
feeInfo: params.feeInfo,
Expand Down Expand Up @@ -741,9 +750,16 @@ export class Sol extends BaseCoin {

/**
* Explain a Solana transaction from txBase64
* Uses WASM-based parsing for testnet, with fallback to legacy builder approach.
* @param params
*/
async explainTransaction(params: ExplainTransactionOptions): Promise<SolTransactionExplanation> {
// Use WASM-based parsing for testnet (simpler, faster, no @solana/web3.js rebuild)
if (this.getChain() === 'tsol') {
return this.explainTransactionWithWasm(params) as SolTransactionExplanation;
}

// Legacy approach for mainnet (until WASM is fully validated)
const factory = this.getBuilder();
let rebuiltTransaction;

Expand All @@ -767,6 +783,14 @@ export class Sol extends BaseCoin {
return explainedTransaction as SolTransactionExplanation;
}

/**
* Explain a Solana transaction using WASM parsing (bypasses @solana/web3.js rebuild).
* Delegates to standalone explainSolTransaction().
*/
explainTransactionWithWasm(params: ExplainTransactionOptions): SolLibTransactionExplanation {
return explainSolTransaction({ ...params, coinName: params.coinName ?? this.getChain() });
}

/** @inheritDoc */
async getSignablePayload(serializedTx: string): Promise<Buffer> {
const factory = this.getBuilder();
Expand Down Expand Up @@ -1745,3 +1769,62 @@ export class Sol extends BaseCoin {
}
}
}

/**
* Standalone WASM-based transaction explanation — no class instance needed.
* Thin adapter over @bitgo/wasm-solana's explainTransaction that resolves
* token names via @bitgo/statics and maps to BitGoJS TransactionExplanation.
*/
export function explainSolTransaction(
params: ExplainTransactionOptions & { coinName: string }
): SolLibTransactionExplanation {
const txBytes = Buffer.from(params.txBase64, 'base64');
const explained: WasmExplainedTransaction = wasmExplainTransaction(txBytes, {
lamportsPerSignature: BigInt(params.feeInfo?.fee || '0'),
tokenAccountRentExemptAmount: params.tokenAccountRentExemptAmount
? BigInt(params.tokenAccountRentExemptAmount)
: undefined,
});

// Resolve token mint addresses → human-readable names (e.g. "tsol:usdc")
const outputs = explained.outputs.map((o) => ({
address: o.address,
amount: o.amount,
...(o.tokenName ? { tokenName: findTokenName(o.tokenName, undefined, true) } : {}),
}));

// Build tokenEnablements with resolved token names
const tokenEnablements: ITokenEnablement[] = explained.tokenEnablements.map((te) => ({
address: te.address,
tokenName: findTokenName(te.mintAddress, undefined, true),
tokenAddress: te.mintAddress,
}));

return {
displayOrder: [
'id',
'type',
'blockhash',
'durableNonce',
'outputAmount',
'changeAmount',
'outputs',
'changeOutputs',
'tokenEnablements',
'fee',
'memo',
],
id: explained.id ?? UNAVAILABLE_TEXT,
type: explained.type,
changeOutputs: [],
changeAmount: '0',
outputAmount: explained.outputAmount,
outputs,
fee: { fee: explained.fee, feeRate: Number(params.feeInfo?.fee || '0') },
memo: explained.memo,
blockhash: explained.blockhash,
durableNonce: explained.durableNonce,
tokenEnablements,
ataOwnerMap: explained.ataOwnerMap,
};
}
50 changes: 50 additions & 0 deletions modules/sdk-coin-sol/test/unit/jitoWasmVerification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Verification test: Jito WASM parsing works in BitGoJS
*/
import * as should from 'should';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { BitGoAPI } from '@bitgo/sdk-api';
import { Tsol } from '../../src';

describe('Jito WASM Verification', function () {
let bitgo: TestBitGoAPI;
let tsol: Tsol;

// From BitGoJS test/resources/sol.ts - JITO_STAKING_ACTIVATE_SIGNED_TX
const JITO_TX_BASE64 =
'AdOUrFCk9yyhi1iB1EfOOXHOeiaZGQnLRwnypt+be8r9lrYMx8w7/QTnithrqcuBApg1ctJAlJMxNZ925vMP2Q0BAAQKReV5vPklPPaLR9/x+zo6XCwhusWyPAmuEqbgVWvwi0Ecg6pe+BOG2OETfAVS9ftz6va1oE4onLBolJ2N+ZOOhJ6naP7fZEyKrpuOIYit0GvFUPv3Fsgiuc5jx3g9lS4fCeaj/uz5kDLhwd9rlyLcs2NOe440QJNrw0sMwcjrUh/80UHpgyyvEK2RdJXKDycbWyk81HAn6nNwB+1A6zmgvQSKPgjDtJW+F/RUJ9ib7FuAx+JpXBhk12dD2zm+00bWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABU5Z4kwFGooUp7HpeX8OEs36dJAhZlMZWmpRKm8WZgKwaBTtTK9ooXRnL9rIYDGmPoTqFe+h1EtyKT9tvbABZQBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKnjMtr5L6vs6LY/96RABeX9/Zr6FYdWthxalfkEs7jQgQEICgUHAgABAwEEBgkJDuCTBAAAAAAA';

before(function () {
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' });
bitgo.safeRegister('tsol', Tsol.createInstance);
bitgo.initializeTestVars();
tsol = bitgo.coin('tsol') as Tsol;
});

it('should parse Jito DepositSol transaction via WASM', function () {
// Verify the raw WASM parsing returns StakePoolDepositSol
const { parseTransaction } = require('@bitgo/wasm-solana');
const txBytes = Buffer.from(JITO_TX_BASE64, 'base64');
const wasmTx = parseTransaction(txBytes);
const wasmParsed = wasmTx.parse();

// Verify WASM returns StakePoolDepositSol instruction
const depositSolInstr = wasmParsed.instructionsData.find((i: { type: string }) => i.type === 'StakePoolDepositSol');
should.exist(depositSolInstr, 'WASM should parse StakePoolDepositSol instruction');
depositSolInstr.lamports.should.equal(300000n);

// Now test explainTransactionWithWasm - should map to StakingActivate
const explained = tsol.explainTransactionWithWasm({
txBase64: JITO_TX_BASE64,
feeInfo: { fee: '5000' },
});

// Verify the transaction is correctly interpreted
should.exist(explained.id);
explained.type.should.equal('StakingActivate');
explained.outputAmount.should.equal('300000');
explained.outputs.length.should.equal(1);
explained.outputs[0].address.should.equal('Jito4APyf642JPZPx3hGc6WWJ8zPKtRbRs4P815Awbb');
explained.outputs[0].amount.should.equal('300000');
});
});
Loading
Loading