Skip to content
Draft
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
Binary file added modules/sdk-coin-ton/bitgo-wasm-ton-0.0.1.tgz
Binary file not shown.
1 change: 1 addition & 0 deletions modules/sdk-coin-ton/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@bitgo/sdk-core": "^36.35.0",
"@bitgo/sdk-lib-mpc": "^10.9.0",
"@bitgo/statics": "^58.31.0",
"@bitgo/wasm-ton": "file:bitgo-wasm-ton-0.0.1.tgz",
"bignumber.js": "^9.0.0",
"bn.js": "^5.2.1",
"lodash": "^4.17.21",
Expand Down
84 changes: 84 additions & 0 deletions modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* WASM-based TON transaction explanation.
*
* Built on @bitgo/wasm-ton's parseTransaction(). Derives transaction types,
* extracts outputs/inputs, and maps to BitGoJS TransactionExplanation format.
* This is BitGo-specific business logic that lives outside the wasm package.
*/

import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton';
import type { TonTransactionType, ParsedTransaction as WasmParsedTransaction } from '@bitgo/wasm-ton';
import { TransactionExplanation } from './iface';

export interface ExplainTonTransactionWasmOptions {
txBase64: string;
/** When false, use the original bounce-flag-respecting address format. Defaults to true (bounceable EQ...). */
toAddressBounceable?: boolean;
}

function mapTransactionType(wasmType: TonTransactionType): string {
return wasmType;
}

function extractOutputs(
parsed: WasmParsedTransaction,
toAddressBounceable: boolean
): {
outputs: { address: string; amount: string }[];
outputAmount: string;
withdrawAmount: string | undefined;
} {
const outputs: { address: string; amount: string }[] = [];
let withdrawAmount: string | undefined;

for (const action of parsed.sendActions) {
if (action.jettonTransfer) {
outputs.push({
address: action.jettonTransfer.destination,
amount: String(action.jettonTransfer.amount),
});
} else {
// destinationBounceable is always EQ... (bounceable)
// destination respects the original bounce flag (UQ... when bounce=false)
outputs.push({
address: toAddressBounceable ? action.destinationBounceable : action.destination,
amount: String(action.amount),
});
}

// withdrawAmount comes from the body payload parsed by WASM (not the message TON value)
if (action.withdrawAmount !== undefined) {
withdrawAmount = String(action.withdrawAmount);
}
}

const outputAmount = outputs.reduce((sum, o) => sum + BigInt(o.amount), 0n);

return { outputs, outputAmount: String(outputAmount), withdrawAmount };
}

/**
* Standalone WASM-based transaction explanation for TON.
*
* Parses the transaction via `parseTransaction(tx)` from @bitgo/wasm-ton,
* then derives the transaction type, extracts outputs/inputs, and maps
* to BitGoJS TransactionExplanation format.
*/
export function explainTonTransaction(params: ExplainTonTransactionWasmOptions): TransactionExplanation {
const toAddressBounceable = params.toAddressBounceable !== false;
const tx = WasmTonTransaction.fromBytes(Buffer.from(params.txBase64, 'base64'));
const parsed: WasmParsedTransaction = parseTransaction(tx);

const { outputs, outputAmount, withdrawAmount } = extractOutputs(parsed, toAddressBounceable);

return {
displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'],
id: tx.id,
outputs,
outputAmount,
changeOutputs: [],
changeAmount: '0',
fee: { fee: 'UNKNOWN' },
withdrawAmount,
};
}
1 change: 1 addition & 0 deletions modules/sdk-coin-ton/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export { TransferBuilder } from './transferBuilder';
export { TransactionBuilderFactory } from './transactionBuilderFactory';
export { TonWhalesVestingDepositBuilder } from './tonWhalesVestingDepositBuilder';
export { TonWhalesVestingWithdrawBuilder } from './tonWhalesVestingWithdrawBuilder';
export { explainTonTransaction } from './explainTransactionWasm';
export { Interface, Utils };
3 changes: 2 additions & 1 deletion modules/sdk-coin-ton/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export class Utils implements BaseUtils {
wc: 0,
});
const address = await wallet.getAddress();
return address.toString(isUserFriendly, true, bounceable);
const legacyAddress = address.toString(isUserFriendly, true, bounceable);
return legacyAddress;
}

getAddress(address: string, bounceable = true): string {
Expand Down
45 changes: 45 additions & 0 deletions modules/sdk-coin-ton/src/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ import {
} from '@bitgo/sdk-core';
import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
import { Transaction as WasmTonTransaction } from '@bitgo/wasm-ton';
import { KeyPair as TonKeyPair } from './lib/keyPair';
import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib';
import { explainTonTransaction } from './lib/explainTransactionWasm';
import { getFeeEstimate } from './lib/utils';

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

if (this.getChain() === 'tton') {
const { decode, encode } = await import('@bitgo/wasm-ton');
const toBounceable = (address: string) => {
const decoded = decode(this.getAddressDetails(address).address);
return encode(decoded.workchainId, decoded.addressHash, true);
};
const txBase64 = Buffer.from(rawTx, 'hex').toString('base64');
const explainedTx = explainTonTransaction({ txBase64 });
if (txParams.recipients !== undefined) {
const filteredRecipients = txParams.recipients.map((recipient) => ({
address: toBounceable(recipient.address),
amount: BigInt(recipient.amount),
}));
const filteredOutputs = explainedTx.outputs.map((output) => ({
address: toBounceable(output.address),
amount: BigInt(output.amount),
}));
if (!_.isEqual(filteredOutputs, filteredRecipients)) {
throw new Error('Tx outputs does not match with expected txParams recipients');
}
let totalAmount = new BigNumber(0);
for (const recipient of txParams.recipients) {
totalAmount = totalAmount.plus(recipient.amount);
}
if (!totalAmount.isEqualTo(explainedTx.outputAmount)) {
throw new Error('Tx total amount does not match with expected total amount field');
}
}
return true;
}

const txBuilder = this.getBuilder().from(Buffer.from(rawTx, 'hex').toString('base64'));
const transaction = await txBuilder.build();

Expand Down Expand Up @@ -235,13 +268,25 @@ export class Ton extends BaseCoin {

/** @inheritDoc */
async getSignablePayload(serializedTx: string): Promise<Buffer> {
if (this.getChain() === 'tton') {
const tx = WasmTonTransaction.fromBytes(Buffer.from(serializedTx, 'base64'));
return Buffer.from(tx.signablePayload());
}
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
const rebuiltTransaction = await factory.from(serializedTx).build();
return rebuiltTransaction.signablePayload;
}

/** @inheritDoc */
async explainTransaction(params: Record<string, any>): Promise<TransactionExplanation> {
if (this.getChain() === 'tton') {
try {
const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64');
return explainTonTransaction({ txBase64, toAddressBounceable: params.toAddressBounceable });
} catch {
throw new Error('Invalid transaction');
}
}
try {
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
const transactionBuilder = factory.from(Buffer.from(params.txHex, 'hex').toString('base64'));
Expand Down
135 changes: 135 additions & 0 deletions modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import should from 'should';
import { Transaction as WasmTonTransaction, parseTransaction } from '@bitgo/wasm-ton';
import { explainTonTransaction } from '../../src/lib/explainTransactionWasm';
import * as testData from '../resources/ton';

describe('TON WASM explainTransaction', function () {
describe('explainTonTransaction', function () {
it('should explain a signed send transaction', function () {
const txBase64 = testData.signedSendTransaction.tx;
const explained = explainTonTransaction({ txBase64 });

explained.outputs.length.should.be.greaterThan(0);
explained.outputs[0].amount.should.equal(testData.signedSendTransaction.recipient.amount);
explained.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address);
explained.changeOutputs.should.be.an.Array();
explained.changeAmount.should.equal('0');
should.exist(explained.id);
});

it('should explain a signed token send transaction', function () {
const txBase64 = testData.signedTokenSendTransaction.tx;
const explained = explainTonTransaction({ txBase64 });

explained.outputs.length.should.be.greaterThan(0);
should.exist(explained.id);
});

it('should explain a single nominator withdraw transaction', function () {
const txBase64 = testData.signedSingleNominatorWithdrawTransaction.tx;
const explained = explainTonTransaction({ txBase64 });

should.exist(explained.id);
explained.id.should.equal(testData.signedSingleNominatorWithdrawTransaction.txId);
should.exist(explained.withdrawAmount);
explained.withdrawAmount!.should.equal('932178112330000');
});

it('should explain a Ton Whales withdrawal transaction', function () {
const txBase64 = testData.signedTonWhalesWithdrawalTransaction.tx;
const explained = explainTonTransaction({ txBase64 });

should.exist(explained.id);
should.exist(explained.withdrawAmount);
});

it('should explain a Ton Whales full withdrawal transaction', function () {
const txBase64 = testData.signedTonWhalesFullWithdrawalTransaction.tx;
const explained = explainTonTransaction({ txBase64 });

should.exist(explained.id);
});

it('should respect toAddressBounceable=false', function () {
const txBase64 = testData.signedSendTransaction.tx;
const bounceable = explainTonTransaction({ txBase64, toAddressBounceable: true });
const nonBounceable = explainTonTransaction({ txBase64, toAddressBounceable: false });

bounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address);
nonBounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipientBounceable.address);
});
});

describe('WASM Transaction signing flow', function () {
it('should produce correct signable payload from WASM Transaction', function () {
const txBase64 = testData.signedSendTransaction.tx;
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
const signablePayload = tx.signablePayload();

signablePayload.should.be.instanceOf(Uint8Array);
signablePayload.length.should.equal(32);

const expectedSignable = Buffer.from(testData.signedSendTransaction.signable, 'base64');
Buffer.from(signablePayload).toString('base64').should.equal(expectedSignable.toString('base64'));
});

it('should parse transaction and preserve bigint amounts', function () {
const txBase64 = testData.signedSendTransaction.tx;
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
const parsed = parseTransaction(tx);

parsed.transactionType.should.equal('Transfer');
parsed.sendActions.length.should.be.greaterThan(0);
(typeof parsed.sendActions[0].amount).should.equal('bigint');
parsed.seqno.should.be.a.Number();
(typeof parsed.expireAt).should.equal('bigint');
});

it('should get transaction id', function () {
const txBase64 = testData.signedSendTransaction.tx;
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
tx.id.should.equal(testData.signedSendTransaction.txId);
});

it('should detect signed transaction via non-zero signature', function () {
const txBase64 = testData.signedSendTransaction.tx;
const tx = WasmTonTransaction.fromBytes(Buffer.from(txBase64, 'base64'));
const parsed = parseTransaction(tx);

parsed.signature.should.be.a.String();
parsed.signature.length.should.be.greaterThan(0);
parsed.signature.should.not.equal('0'.repeat(128));
});
});

describe('WASM parseTransaction types', function () {
it('should parse Transfer type', function () {
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedSendTransaction.tx, 'base64'));
parseTransaction(tx).transactionType.should.equal('Transfer');
});

it('should parse TokenTransfer type', function () {
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTokenSendTransaction.tx, 'base64'));
parseTransaction(tx).transactionType.should.equal('TokenTransfer');
});

it('should parse SingleNominatorWithdraw type with correct withdrawAmount', function () {
const tx = WasmTonTransaction.fromBytes(
Buffer.from(testData.signedSingleNominatorWithdrawTransaction.tx, 'base64')
);
const parsed = parseTransaction(tx);
parsed.transactionType.should.equal('SingleNominatorWithdraw');
String(parsed.sendActions[0].withdrawAmount).should.equal('932178112330000');
});

it('should parse WhalesDeposit type', function () {
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesDepositTransaction.tx, 'base64'));
parseTransaction(tx).transactionType.should.equal('WhalesDeposit');
});

it('should parse WhalesWithdraw type', function () {
const tx = WasmTonTransaction.fromBytes(Buffer.from(testData.signedTonWhalesWithdrawalTransaction.tx, 'base64'));
parseTransaction(tx).transactionType.should.equal('WhalesWithdraw');
});
});
});
2 changes: 1 addition & 1 deletion modules/sdk-coin-ton/test/unit/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ describe('TON:', function () {
})) as TransactionExplanation;
explainedTransaction.should.deepEqual({
displayOrder: ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'],
id: testData.signedSingleNominatorWithdrawTransaction.txIdBounceable,
id: testData.signedSingleNominatorWithdrawTransaction.txId,
outputs: [
{
address: testData.signedSingleNominatorWithdrawTransaction.recipientBounceable.address,
Expand Down
Loading