Skip to content

Commit 774df26

Browse files
authored
Merge pull request #8361 from BitGo/wasm-ton
feat: wire @bitgo/wasm-ton into sdk-coin-ton
2 parents 096819c + 0387a23 commit 774df26

10 files changed

Lines changed: 460 additions & 2 deletions

File tree

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

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: 44 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, decode as wasmDecode, encode as wasmEncode } 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,36 @@ export class Ton extends BaseCoin {
117119
throw new Error('missing required tx prebuild property txHex');
118120
}
119121

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

@@ -235,13 +267,25 @@ export class Ton extends BaseCoin {
235267

236268
/** @inheritDoc */
237269
async getSignablePayload(serializedTx: string): Promise<Buffer> {
270+
if (this.getChain() === 'tton') {
271+
const tx = WasmTonTransaction.fromBytes(Buffer.from(serializedTx, 'base64'));
272+
return Buffer.from(tx.signablePayload());
273+
}
238274
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
239275
const rebuiltTransaction = await factory.from(serializedTx).build();
240276
return rebuiltTransaction.signablePayload;
241277
}
242278

243279
/** @inheritDoc */
244280
async explainTransaction(params: Record<string, any>): Promise<TransactionExplanation> {
281+
if (this.getChain() === 'tton') {
282+
try {
283+
const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64');
284+
return explainTonTransaction({ txBase64, toAddressBounceable: params.toAddressBounceable });
285+
} catch {
286+
throw new Error('Invalid transaction');
287+
}
288+
}
245289
try {
246290
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
247291
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)