Skip to content

Commit 89cf5dd

Browse files
feat(sdk-coin-sol): prevent blind signing sol token enablement
TICKET: WP-5744
1 parent 30c4502 commit 89cf5dd

File tree

3 files changed

+309
-10
lines changed

3 files changed

+309
-10
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { BitGoAPI } from '@bitgo/sdk-api';
2+
import { Transaction } from '@bitgo/sdk-coin-sol';
3+
import { BaseCoin, BitGoBase, PrebuildTransactionResult, Wallet } from '@bitgo/sdk-core';
4+
import { coins } from '@bitgo/statics';
5+
6+
const bitgo = new BitGoAPI({ env: 'test' });
7+
8+
// Configuration: change these values to run the example script
9+
// const accessToken = 'v2xa2cf6160d8e30ea7892863c607411ca41c06d028036db4ef3cf4f8b2b091e472'; //'v2x70080e96706e2cfa83cf5e50dd27f5b91aa304b1dd7e01872ac3a4f85e2fa7d3'; // Your BitGo access token
10+
// const walletId = '68bafee43eb5cd22aca2afd6b13ec7ad'; //'68a8ce3dea8237d5da85d1852e370901'; // Your TSOL wallet ID
11+
// const walletRootAddress = '9tJNtvXWrtkD3NQaZY5nz8ZPc3s4ezRVX3oFfdfj5US6'; // Use wallet's root address or a dummy address
12+
// const walletPassphrase = 'Ghghjkg!455544llll'; //'0L4L"5YV@*:q_Nsv'; // Your wallet passphrase
13+
14+
const accessToken = 'v2x70080e96706e2cfa83cf5e50dd27f5b91aa304b1dd7e01872ac3a4f85e2fa7d3'; // Your BitGo access token
15+
const walletId = '68a8ce3dea8237d5da85d1852e370901'; // Your TSOL wallet ID
16+
const walletRootAddress = '9tJNtvXWrtkD3NQaZY5nz8ZPc3s4ezRVX3oFfdfj5US6'; // Use wallet's root address or a dummy address
17+
const walletPassphrase = '0L4L"5YV@*:q_Nsv'; // Your wallet passphrase
18+
const enableTokens = [{ name: 'tsol:orca' }];
19+
20+
// Fake transaction data, we would expect an enabletoken but will send a transfer instead
21+
const txSendPrebuildParams = {
22+
preview: false,
23+
recipients: [
24+
{
25+
address: walletRootAddress,
26+
amount: '10', // Small amount for testing
27+
},
28+
],
29+
type: 'transfer',
30+
apiVersion: 'full',
31+
};
32+
33+
async function main() {
34+
console.log('🔧 TSOL Token Enablement Test Script (with CoinFactory)');
35+
36+
checkIfPropsAreSetOrExit();
37+
await testSendTokenEnablements();
38+
}
39+
40+
function checkIfPropsAreSetOrExit() {
41+
if (!accessToken || !walletId || !walletPassphrase) {
42+
console.error('❌ Please set the following required parameters:');
43+
console.error(' - accessToken: Your BitGo access token');
44+
console.error(' - walletId: Your TSOL wallet ID');
45+
console.error(' - walletPassphrase: Your wallet passphrase');
46+
console.error('\nYou can get these from your BitGo account settings.');
47+
process.exit(1);
48+
}
49+
50+
console.log('\n' + '='.repeat(60) + '\n');
51+
}
52+
53+
async function testSendTokenEnablements() {
54+
try {
55+
bitgo.authenticateWithAccessToken({ accessToken });
56+
console.log('Getting TSOL wallet using CoinFactory...');
57+
58+
const { register } = await import('@bitgo/sdk-coin-sol');
59+
register(bitgo as unknown as BitGoBase);
60+
const tsolCoin = bitgo.coin('tsol');
61+
console.log(`✅ TSOL coin loaded: ${tsolCoin.getFullName()}`);
62+
63+
const wallet = await tsolCoin.wallets().get({ id: walletId });
64+
logWalletData(wallet);
65+
66+
console.log('3️⃣ Building token enablement transactions...');
67+
const buildParams = {
68+
enableTokens,
69+
walletPassphrase,
70+
};
71+
72+
const unsignedBuilds = await wallet.buildTokenEnablements(buildParams);
73+
74+
logUnsignedBuildTokenEnablement(unsignedBuilds);
75+
logRawTxHexData(tsolCoin, unsignedBuilds);
76+
77+
// BLIND SIGNING simulation starts here
78+
console.log('Replacing hex with prebuilt transfer transaction...');
79+
const modifiedBuilds = await replaceTxHexWithSendTxPrebuild(wallet, unsignedBuilds);
80+
81+
// Send token enablement transactions (they're transfers masked as the call in replaceHexWithTransferPrebuild)
82+
console.log('Sending token enablement transactions...');
83+
const results = {
84+
success: [] as any[],
85+
failure: [] as Error[],
86+
};
87+
88+
for (let i = 0; i < modifiedBuilds.length; i++) {
89+
const modifiedBuild = modifiedBuilds[i];
90+
console.log(` Processing transaction ${i + 1}/${modifiedBuilds.length}...`);
91+
92+
try {
93+
const sendParams = {
94+
prebuildTx: modifiedBuild,
95+
walletPassphrase,
96+
apiVersion: 'full',
97+
} as any;
98+
99+
const sendResult = await wallet.sendTokenEnablement(sendParams);
100+
results.success.push(sendResult);
101+
console.log(` ✅ Transaction ${i + 1} sent successfully`);
102+
console.log(` Result: ${JSON.stringify(sendResult, null, 2)}`);
103+
104+
// TODO: not sure if this goes here, i'll check when I manage to do a token enablement try
105+
console.log(' You signed a non requested transfer masked as a token enablement! 💀');
106+
} catch (error) {
107+
const errorMessage = error instanceof Error ? error.message : String(error);
108+
results.failure.push(error as Error);
109+
console.log(` ❌ Transaction ${i + 1} failed: ${errorMessage}`);
110+
111+
// TODO: not sure if this goes here, i'll check when I manage to do a token enablement try
112+
console.log(' You catched an attempt to sign a non requested transfer masked as a token enablement! 🎉');
113+
}
114+
}
115+
116+
logTransactionResults(results);
117+
console.log('\n🎉 Test completed!');
118+
} catch (error) {
119+
console.error('❌ Test failed with error:', error);
120+
if (error instanceof Error && error.stack) {
121+
console.error('Stack trace:', error.stack);
122+
}
123+
process.exit(1);
124+
}
125+
}
126+
127+
async function replaceTxHexWithSendTxPrebuild(wallet: any, unsignedBuilds: any[]): Promise<any[]> {
128+
console.log('🔄 Replacing hex with prebuilt transfer transaction...');
129+
130+
try {
131+
console.log('Building prebuilt transfer transaction...');
132+
const sendTx = await wallet.prebuildTransaction(txSendPrebuildParams);
133+
console.log(`Prebuilt transfer transaction created: Original hex length: ${sendTx.txHex?.length || 0} characters`);
134+
135+
const modifiedBuilds = unsignedBuilds.map((build, index) => {
136+
const modifiedBuild = { ...build };
137+
if (sendTx.txHex) {
138+
modifiedBuild.txHex = sendTx.txHex;
139+
modifiedBuild.txRequestId = sendTx.txRequestId; // Preserve txRequestId if available
140+
console.log(` Transaction ${index + 1}: Hex replaced with transfer transaction hex`);
141+
}
142+
return modifiedBuild;
143+
});
144+
145+
console.log(`✅ Replaced hex in ${modifiedBuilds.length} transaction(s)`);
146+
return modifiedBuilds;
147+
} catch (error) {
148+
console.error(' ❌ Error creating prebuilt transfer transaction:', error);
149+
console.log(' Falling back to original unsigned builds without hex replacement.');
150+
return unsignedBuilds;
151+
}
152+
}
153+
154+
function logUnsignedBuildTokenEnablement(unsignedBuilds: PrebuildTransactionResult[]) {
155+
console.log(`✅ Built ${unsignedBuilds.length} token enablement transaction(s)`);
156+
// Log details of each unsigned build
157+
unsignedBuilds.forEach((build, index) => {
158+
console.log(` Transaction ${index + 1}:`);
159+
console.log(` Wallet ID: ${build.walletId}`);
160+
console.log('Raw txHex: ');
161+
console.log(build.txHex);
162+
console.log(` TX Hex length: ${build.txHex?.length || 0} characters`);
163+
console.log(` Fee info: ${JSON.stringify(build.feeInfo)}`);
164+
console.log(` Build params: ${JSON.stringify(build.buildParams)}`);
165+
if (build.txRequestId) {
166+
console.log(` TX Request ID: ${build.txRequestId}`);
167+
}
168+
});
169+
}
170+
171+
function logWalletData(wallet: Wallet) {
172+
console.log(`✅ Wallet retrieved: ${wallet.id()}`);
173+
console.log(` Wallet label: ${wallet.label()}`);
174+
console.log(` Wallet type: ${wallet.type()}`);
175+
console.log(` Multisig type: ${wallet.multisigType()}`);
176+
console.log(` Balance: ${wallet.balanceString()}`);
177+
console.log(` Root address: ${wallet.coinSpecific()?.rootAddress}\n`);
178+
}
179+
180+
function logTransactionResults(results: { success: any[]; failure: Error[] }) {
181+
console.log(`Final Results: SuccessTXs=> ${results.success.length}, FailedTXs=> ${results.failure.length}`);
182+
if (results.success.length > 0) {
183+
console.log('\n Successful transaction details:');
184+
results.success.forEach((result, index) => {
185+
console.log(` ${index + 1}. ${JSON.stringify(result, null, 4)}`);
186+
});
187+
}
188+
189+
if (results.failure.length > 0) {
190+
console.log('\n Failed transaction details:');
191+
results.failure.forEach((error, index) => {
192+
console.log(` ${index + 1}. ${error.message}`);
193+
if (error.stack) {
194+
console.log(` Stack: ${error.stack}`);
195+
}
196+
});
197+
}
198+
}
199+
200+
function logRawTxHexData(coin: BaseCoin, unsignedBuilds: PrebuildTransactionResult[]) {
201+
const HEX_REGEX = /^[0-9a-fA-F]+$/;
202+
const coinConfig = coins.get(coin.getChain());
203+
204+
unsignedBuilds.forEach((build, index) => {
205+
const transaction = new Transaction(coinConfig);
206+
const rawTx = build.txBase64 || build.txHex;
207+
208+
let rawTxBase64 = rawTx;
209+
if (rawTx && HEX_REGEX.test(rawTx)) {
210+
rawTxBase64 = Buffer.from(rawTx, 'hex').toString('base64');
211+
212+
transaction.fromRawTransaction(rawTxBase64);
213+
const explainedTx = transaction.explainTransaction();
214+
215+
console.log('---------------------');
216+
console.log('Explained TX:', JSON.stringify(explainedTx, null, 4));
217+
console.log('---------------------');
218+
}
219+
});
220+
}
221+
222+
if (require.main === module) {
223+
main().catch((error) => {
224+
console.error('❌ Script execution failed:', error);
225+
process.exit(1);
226+
});
227+
}

modules/sdk-coin-sol/src/sol.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import BigNumber from 'bignumber.js';
66
import * as base58 from 'bs58';
77

88
import {
9+
AuditDecryptedKeyParams,
910
BaseBroadcastTransactionOptions,
1011
BaseBroadcastTransactionResult,
1112
BaseCoin,
@@ -27,9 +28,13 @@ import {
2728
MPCTx,
2829
MPCTxs,
2930
MPCUnsignedTx,
31+
MultisigType,
32+
multisigTypes,
3033
OvcInput,
3134
OvcOutput,
3235
ParsedTransaction,
36+
PopulatedIntent,
37+
PrebuildTransactionWithIntentOptions,
3338
PresignTransactionOptions,
3439
PublicKey,
3540
RecoveryTxRequest,
@@ -40,14 +45,10 @@ import {
4045
TransactionRecipient,
4146
VerifyAddressOptions,
4247
VerifyTransactionOptions,
43-
MultisigType,
44-
multisigTypes,
45-
AuditDecryptedKeyParams,
46-
PopulatedIntent,
47-
PrebuildTransactionWithIntentOptions,
4848
} from '@bitgo/sdk-core';
4949
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
5050
import { BaseNetwork, CoinFamily, coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
51+
import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token';
5152
import * as _ from 'lodash';
5253
import * as request from 'superagent';
5354
import { KeyPair as SolKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib';
@@ -60,7 +61,6 @@ import {
6061
isValidPublicKey,
6162
validateRawTransaction,
6263
} from './lib/utils';
63-
import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token';
6464

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

@@ -173,6 +173,7 @@ export interface SolConsolidationRecoveryOptions extends MPCConsolidationRecover
173173
}
174174

175175
const HEX_REGEX = /^[0-9a-fA-F]+$/;
176+
const BLIND_SIGNING_TX_TYPES_TO_CHECK = ['enabletoken', 'disabletoken'];
176177

177178
export class Sol extends BaseCoin {
178179
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
@@ -233,6 +234,15 @@ export class Sol extends BaseCoin {
233234
return Math.pow(10, this._staticsCoin.decimalPlaces);
234235
}
235236

237+
verifyTxType(expectedType: string | undefined, actualType: string | undefined): void {
238+
// do nothing, let the tx fail way down as always
239+
if (actualType === undefined || expectedType === undefined) return;
240+
241+
if (BLIND_SIGNING_TX_TYPES_TO_CHECK.includes(expectedType) && expectedType !== actualType) {
242+
throw new Error(`Tx type "${actualType}" does not match expected txParams type "${expectedType}"`);
243+
}
244+
}
245+
236246
async verifyTransaction(params: SolVerifyTransactionOptions): Promise<boolean> {
237247
// asset name to transfer amount map
238248
const totalAmount: Record<string, BigNumber> = {};
@@ -261,6 +271,8 @@ export class Sol extends BaseCoin {
261271
transaction.fromRawTransaction(rawTxBase64);
262272
const explainedTx = transaction.explainTransaction();
263273

274+
this.verifyTxType(txParams.type, explainedTx.type);
275+
264276
// users do not input recipients for consolidation requests as they are generated by the server
265277
if (txParams.recipients !== undefined) {
266278
const filteredRecipients = txParams.recipients?.map((recipient) =>

modules/sdk-coin-sol/test/unit/sol.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import assert from 'assert';
12
import * as _ from 'lodash';
3+
import nock from 'nock';
24
import * as should from 'should';
35
import * as sinon from 'sinon';
4-
import nock from 'nock';
5-
import assert from 'assert';
66

77
import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token';
88

@@ -23,14 +23,14 @@ import {
2323
} from '@bitgo/sdk-core';
2424
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
2525
import { coins } from '@bitgo/statics';
26-
import { KeyPair, Sol, Tsol } from '../../src';
26+
import { KeyPair, Sol, SolVerifyTransactionOptions, Tsol } from '../../src';
2727
import { Transaction } from '../../src/lib';
2828
import { AtaInit, InstructionParams, TokenTransfer } from '../../src/lib/iface';
2929
import { getAssociatedTokenAccountAddress } from '../../src/lib/utils';
3030
import * as testData from '../fixtures/sol';
3131
import * as resources from '../resources/sol';
32-
import { getBuilderFactory } from './getBuilderFactory';
3332
import { solBackupKey } from './fixtures/solBackupKey';
33+
import { getBuilderFactory } from './getBuilderFactory';
3434

3535
describe('SOL:', function () {
3636
let bitgo: TestBitGoAPI;
@@ -3215,4 +3215,64 @@ describe('SOL:', function () {
32153215
);
32163216
});
32173217
});
3218+
3219+
describe('blind signing token enablement protection', () => {
3220+
it('should verify as valid the enabletoken intent when prebuild tx matchs user intent ', async function () {
3221+
const { txParams, txPrebuild, walletData } = testData.enableToken;
3222+
const wallet = new Wallet(bitgo, basecoin, walletData);
3223+
const sameIntentTx = await basecoin.verifyTransaction({
3224+
txParams,
3225+
txPrebuild,
3226+
wallet,
3227+
} as unknown as SolVerifyTransactionOptions);
3228+
3229+
sameIntentTx.should.equal(true);
3230+
});
3231+
3232+
it('should verify as valid the disabletoken intent when prebuild tx matchs user intent ', async function () {
3233+
const { txParams, txPrebuild, walletData } = testData.disableToken;
3234+
const wallet = new Wallet(bitgo, basecoin, walletData);
3235+
const sameIntentTx = await basecoin.verifyTransaction({
3236+
txParams,
3237+
txPrebuild,
3238+
wallet,
3239+
} as unknown as SolVerifyTransactionOptions);
3240+
3241+
sameIntentTx.should.equal(true);
3242+
});
3243+
3244+
it('should thrown an error when tampered prebuild tx type ', async function () {
3245+
const { txParams, txPrebuild, sendTxHex, walletData } = testData.enableToken;
3246+
const tamperedTxPrebuild = { ...txPrebuild, txHex: sendTxHex };
3247+
3248+
const wallet = new Wallet(bitgo, basecoin, walletData);
3249+
// tamperedTxPrebuild has the type Send instead of 'enabletoken'
3250+
const tamperedTypesIntentTx = await basecoin.verifyTransaction({
3251+
txParams,
3252+
txPrebuild: tamperedTxPrebuild,
3253+
wallet,
3254+
} as unknown as SolVerifyTransactionOptions);
3255+
tamperedTypesIntentTx.should.be.rejectedWith(
3256+
'Tx type "Send" does not match expected txParams type "enabletoken"'
3257+
);
3258+
});
3259+
3260+
it('should throw an error when tokenName does not match on recipients', async function () {
3261+
//TODO: we should decide on where should we throw the error inside verify
3262+
const { txParams, txPrebuild, enableMaliciousTokenTxHex, walletData } = testData.enableToken;
3263+
const tamperedTxPrebuild = { ...txPrebuild, txHex: enableMaliciousTokenTxHex };
3264+
3265+
const wallet = new Wallet(bitgo, basecoin, walletData);
3266+
// tamperedTxPrebuild enables a token that's not part of bitgo accepted ones
3267+
const tamperedTokenNameIntentTx = await basecoin.verifyTransaction({
3268+
txParams,
3269+
txPrebuild: tamperedTxPrebuild,
3270+
wallet,
3271+
} as unknown as SolVerifyTransactionOptions);
3272+
3273+
tamperedTokenNameIntentTx.should.be.rejectedWith(
3274+
'Tx tokenName "MALICIOUS" does not match expected txParams token name "orca"'
3275+
);
3276+
});
3277+
});
32183278
});

0 commit comments

Comments
 (0)