Skip to content

Commit ca80521

Browse files
committed
feat: wire @bitgo/wasm-ton into sdk-coin-ton
Add WASM-based paths for address validation (shadow mode), signable payload extraction, and transaction explanation. The WASM paths are try/catch wrapped with fallback to legacy TransactionBuilder, so existing behavior is preserved if WASM fails. - Address: shadow-mode WASM validation and encoding in utils.ts - getSignablePayload: WASM Transaction.fromBase64 -> signablePayload() - explainTransaction: new explainTransactionWasm.ts using parseTransaction - Add @bitgo/wasm-ton dependency to package.json Ticket: BTC-3216 feat: wire @bitgo/wasm-ton into sdk-coin-ton - Add @bitgo/wasm-ton dependency (local tarball from BTC-3246 branch) - explainTransactionWasm.ts: WASM-based explain using parseTransaction, supports toAddressBounceable param, exposes withdrawAmount from body - ton.ts: tton uses WASM exclusively for getSignablePayload and explainTransaction (no legacy fallback for testnet) - Update tests to reflect correct WASM output BTC-3216
1 parent e9ede8a commit ca80521

10 files changed

Lines changed: 4579 additions & 2157 deletions

File tree

357 KB
Binary file not shown.

modules/sdk-coin-ton/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@bitgo/sdk-core": "^36.35.0",
4444
"@bitgo/sdk-lib-mpc": "^10.9.0",
4545
"@bitgo/statics": "^58.31.0",
46+
"@bitgo/wasm-ton": "file:bitgo-wasm-ton-0.0.1.tgz",
4647
"bignumber.js": "^9.0.0",
4748
"bn.js": "^5.2.1",
4849
"lodash": "^4.17.21",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* WASM-based TON transaction explanation.
3+
*
4+
* Built on @bitgo/wasm-ton's parseTransaction(). Derives transaction types,
5+
* extracts outputs/inputs, and maps to BitGoJS TransactionExplanation format.
6+
* This is BitGo-specific business logic that lives outside the wasm package.
7+
*/
8+
9+
import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton';
10+
import type { TonTransactionType, ParsedTransaction as WasmParsedTransaction } from '@bitgo/wasm-ton';
11+
import { TransactionExplanation } from './iface';
12+
13+
export interface ExplainTonTransactionWasmOptions {
14+
txBase64: string;
15+
/** When false, use the original bounce-flag-respecting address format. Defaults to true (bounceable EQ...). */
16+
toAddressBounceable?: boolean;
17+
}
18+
19+
function mapTransactionType(wasmType: TonTransactionType): string {
20+
return wasmType;
21+
}
22+
23+
function extractOutputs(
24+
parsed: WasmParsedTransaction,
25+
toAddressBounceable: boolean
26+
): {
27+
outputs: { address: string; amount: string }[];
28+
outputAmount: string;
29+
withdrawAmount: string | undefined;
30+
} {
31+
const outputs: { address: string; amount: string }[] = [];
32+
let withdrawAmount: string | undefined;
33+
34+
for (const action of parsed.sendActions) {
35+
if (action.jettonTransfer) {
36+
outputs.push({
37+
address: action.jettonTransfer.destination,
38+
amount: String(action.jettonTransfer.amount),
39+
});
40+
} else {
41+
// destinationBounceable is always EQ... (bounceable)
42+
// destination respects the original bounce flag (UQ... when bounce=false)
43+
outputs.push({
44+
address: toAddressBounceable ? action.destinationBounceable : action.destination,
45+
amount: String(action.amount),
46+
});
47+
}
48+
49+
// withdrawAmount comes from the body payload parsed by WASM (not the message TON value)
50+
if (action.withdrawAmount !== undefined) {
51+
withdrawAmount = String(action.withdrawAmount);
52+
}
53+
}
54+
55+
const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n);
56+
57+
return { outputs, outputAmount: String(outputAmount), withdrawAmount };
58+
}
59+
60+
/**
61+
* Standalone WASM-based transaction explanation for TON.
62+
*
63+
* Parses the transaction via `parseTransaction(tx)` from @bitgo/wasm-ton,
64+
* then derives the transaction type, extracts outputs/inputs, and maps
65+
* to BitGoJS TransactionExplanation format.
66+
*/
67+
export function explainTonTransaction(params: ExplainTonTransactionWasmOptions): TransactionExplanation {
68+
const toAddressBounceable = params.toAddressBounceable !== false;
69+
const tx = WasmTonTransaction.fromBytes(Buffer.from(params.txBase64, 'base64'));
70+
const parsed: WasmParsedTransaction = parseTransaction(tx);
71+
72+
const { outputs, outputAmount, withdrawAmount } = extractOutputs(parsed, toAddressBounceable);
73+
74+
return {
75+
displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'],
76+
id: tx.id,
77+
outputs,
78+
outputAmount,
79+
changeOutputs: [],
80+
changeAmount: '0',
81+
fee: { fee: 'UNKNOWN' },
82+
withdrawAmount,
83+
};
84+
}

modules/sdk-coin-ton/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export { TransferBuilder } from './transferBuilder';
1010
export { TransactionBuilderFactory } from './transactionBuilderFactory';
1111
export { TonWhalesVestingDepositBuilder } from './tonWhalesVestingDepositBuilder';
1212
export { TonWhalesVestingWithdrawBuilder } from './tonWhalesVestingWithdrawBuilder';
13+
export { explainTonTransaction } from './explainTransactionWasm';
1314
export { Interface, Utils };

modules/sdk-coin-ton/src/lib/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export class Utils implements BaseUtils {
5858
wc: 0,
5959
});
6060
const address = await wallet.getAddress();
61-
return address.toString(isUserFriendly, true, bounceable);
61+
const legacyAddress = address.toString(isUserFriendly, true, bounceable);
62+
return legacyAddress;
6263
}
6364

6465
getAddress(address: string, bounceable = true): string {

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ import {
3232
} from '@bitgo/sdk-core';
3333
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
3434
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
35+
import { Transaction as WasmTonTransaction } from '@bitgo/wasm-ton';
3536
import { KeyPair as TonKeyPair } from './lib/keyPair';
3637
import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib';
38+
import { explainTonTransaction } from './lib/explainTransactionWasm';
3739
import { getFeeEstimate } from './lib/utils';
3840

3941
export interface TonParseTransactionOptions extends ParseTransactionOptions {
@@ -117,6 +119,37 @@ export class Ton extends BaseCoin {
117119
throw new Error('missing required tx prebuild property txHex');
118120
}
119121

122+
if (this.getChain() === 'tton') {
123+
const { decode, encode } = await import('@bitgo/wasm-ton');
124+
const toBounceable = (address: string) => {
125+
const decoded = decode(this.getAddressDetails(address).address);
126+
return encode(decoded.workchainId, decoded.addressHash, true);
127+
};
128+
const txBase64 = Buffer.from(rawTx, 'hex').toString('base64');
129+
const explainedTx = explainTonTransaction({ txBase64 });
130+
if (txParams.recipients !== undefined) {
131+
const filteredRecipients = txParams.recipients.map((recipient) => ({
132+
address: toBounceable(recipient.address),
133+
amount: BigInt(recipient.amount),
134+
}));
135+
const filteredOutputs = explainedTx.outputs.map((output) => ({
136+
address: toBounceable(output.address),
137+
amount: BigInt(output.amount),
138+
}));
139+
if (!_.isEqual(filteredOutputs, filteredRecipients)) {
140+
throw new Error('Tx outputs does not match with expected txParams recipients');
141+
}
142+
let totalAmount = new BigNumber(0);
143+
for (const recipient of txParams.recipients) {
144+
totalAmount = totalAmount.plus(recipient.amount);
145+
}
146+
if (!totalAmount.isEqualTo(explainedTx.outputAmount)) {
147+
throw new Error('Tx total amount does not match with expected total amount field');
148+
}
149+
}
150+
return true;
151+
}
152+
120153
const txBuilder = this.getBuilder().from(Buffer.from(rawTx, 'hex').toString('base64'));
121154
const transaction = await txBuilder.build();
122155

@@ -235,13 +268,25 @@ export class Ton extends BaseCoin {
235268

236269
/** @inheritDoc */
237270
async getSignablePayload(serializedTx: string): Promise<Buffer> {
271+
if (this.getChain() === 'tton') {
272+
const tx = WasmTonTransaction.fromBytes(Buffer.from(serializedTx, 'base64'));
273+
return Buffer.from(tx.signablePayload());
274+
}
238275
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
239276
const rebuiltTransaction = await factory.from(serializedTx).build();
240277
return rebuiltTransaction.signablePayload;
241278
}
242279

243280
/** @inheritDoc */
244281
async explainTransaction(params: Record<string, any>): Promise<TransactionExplanation> {
282+
if (this.getChain() === 'tton') {
283+
try {
284+
const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64');
285+
return explainTonTransaction({ txBase64, toAddressBounceable: params.toAddressBounceable });
286+
} catch {
287+
throw new Error('Invalid transaction');
288+
}
289+
}
245290
try {
246291
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
247292
const transactionBuilder = factory.from(Buffer.from(params.txHex, 'hex').toString('base64'));
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import should from 'should';
2+
import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton';
3+
import { explainTonTransaction } from '../../src/lib/explainTransactionWasm';
4+
import * as testData from '../resources/ton';
5+
6+
describe('TON WASM explainTransaction', function () {
7+
describe('explainTonTransaction', function () {
8+
it('should explain a signed send transaction', function () {
9+
const txBase64 = testData.signedSendTransaction.tx;
10+
const explained = explainTonTransaction({ txBase64 });
11+
12+
explained.outputs.length.should.be.greaterThan(0);
13+
explained.outputs[0].amount.should.equal(testData.signedSendTransaction.recipient.amount);
14+
explained.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address);
15+
explained.changeOutputs.should.be.an.Array();
16+
explained.changeAmount.should.equal('0');
17+
should.exist(explained.id);
18+
});
19+
20+
it('should explain a signed token send transaction', function () {
21+
const txBase64 = testData.signedTokenSendTransaction.tx;
22+
const explained = explainTonTransaction({ txBase64 });
23+
24+
explained.outputs.length.should.be.greaterThan(0);
25+
should.exist(explained.id);
26+
});
27+
28+
it('should explain a single nominator withdraw transaction', function () {
29+
const txBase64 = testData.signedSingleNominatorWithdrawTransaction.tx;
30+
const explained = explainTonTransaction({ txBase64 });
31+
32+
should.exist(explained.id);
33+
explained.id.should.equal(testData.signedSingleNominatorWithdrawTransaction.txId);
34+
should.exist(explained.withdrawAmount);
35+
explained.withdrawAmount!.should.equal('932178112330000');
36+
});
37+
38+
it('should explain a Ton Whales withdrawal transaction', function () {
39+
const txBase64 = testData.signedTonWhalesWithdrawalTransaction.tx;
40+
const explained = explainTonTransaction({ txBase64 });
41+
42+
should.exist(explained.id);
43+
should.exist(explained.withdrawAmount);
44+
});
45+
46+
it('should explain a Ton Whales full withdrawal transaction', function () {
47+
const txBase64 = testData.signedTonWhalesFullWithdrawalTransaction.tx;
48+
const explained = explainTonTransaction({ txBase64 });
49+
50+
should.exist(explained.id);
51+
});
52+
53+
it('should respect toAddressBounceable=false', function () {
54+
const txBase64 = testData.signedSendTransaction.tx;
55+
const bounceable = explainTonTransaction({ txBase64, toAddressBounceable: true });
56+
const nonBounceable = explainTonTransaction({ txBase64, toAddressBounceable: false });
57+
58+
bounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address);
59+
nonBounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipientBounceable.address);
60+
});
61+
});
62+
63+
describe('WASM Transaction signing flow', function () {
64+
it('should produce correct signable payload from WASM Transaction', function () {
65+
const txBase64 = testData.signedSendTransaction.tx;
66+
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
67+
const signablePayload = tx.signablePayload();
68+
69+
signablePayload.should.be.instanceOf(Uint8Array);
70+
signablePayload.length.should.equal(32);
71+
72+
const expectedSignable = Buffer.from(testData.signedSendTransaction.signable, 'base64');
73+
Buffer.from(signablePayload).toString('base64').should.equal(expectedSignable.toString('base64'));
74+
});
75+
76+
it('should parse transaction and preserve bigint amounts', function () {
77+
const txBase64 = testData.signedSendTransaction.tx;
78+
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
79+
const parsed = parseTransaction(tx);
80+
81+
parsed.transactionType.should.equal('Transfer');
82+
parsed.sendActions.length.should.be.greaterThan(0);
83+
(typeof parsed.sendActions[0].amount).should.equal('bigint');
84+
parsed.seqno.should.be.a.Number();
85+
(typeof parsed.expireAt).should.equal('bigint');
86+
});
87+
88+
it('should get transaction id', function () {
89+
const txBase64 = testData.signedSendTransaction.tx;
90+
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
91+
tx.id.should.equal(testData.signedSendTransaction.txId);
92+
});
93+
94+
it('should detect signed transaction via non-zero signature', function () {
95+
const txBase64 = testData.signedSendTransaction.tx;
96+
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
97+
const parsed = parseTransaction(tx);
98+
99+
parsed.signature.should.be.a.String();
100+
parsed.signature.length.should.be.greaterThan(0);
101+
parsed.signature.should.not.equal('0'.repeat(128));
102+
});
103+
});
104+
105+
describe('WASM parseTransaction types', function () {
106+
it('should parse Transfer type', function () {
107+
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedSendTransaction.tx, 'base64'));
108+
parseTransaction(tx).transactionType.should.equal('Transfer');
109+
});
110+
111+
it('should parse TokenTransfer type', function () {
112+
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTokenSendTransaction.tx, 'base64'));
113+
parseTransaction(tx).transactionType.should.equal('TokenTransfer');
114+
});
115+
116+
it('should parse SingleNominatorWithdraw type with correct withdrawAmount', function () {
117+
const tx = WasmTonTransaction.fromBytes(
118+
Buffer.from(testData.signedSingleNominatorWithdrawTransaction.tx, 'base64')
119+
);
120+
const parsed = parseTransaction(tx);
121+
parsed.transactionType.should.equal('SingleNominatorWithdraw');
122+
String(parsed.sendActions[0].withdrawAmount).should.equal('932178112330000');
123+
});
124+
125+
it('should parse WhalesDeposit type', function () {
126+
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesDepositTransaction.tx, 'base64'));
127+
parseTransaction(tx).transactionType.should.equal('WhalesDeposit');
128+
});
129+
130+
it('should parse WhalesWithdraw type', function () {
131+
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesWithdrawalTransaction.tx, 'base64'));
132+
parseTransaction(tx).transactionType.should.equal('WhalesWithdraw');
133+
});
134+
});
135+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ describe('TON:', function () {
260260
})) as TransactionExplanation;
261261
explainedTransaction.should.deepEqual({
262262
displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'],
263-
id: testData.signedSingleNominatorWithdrawTransaction.txIdBounceable,
263+
id: testData.signedSingleNominatorWithdrawTransaction.txId,
264264
outputs: [
265265
{
266266
address: testData.signedSingleNominatorWithdrawTransaction.recipientBounceable.address,

0 commit comments

Comments
 (0)