Skip to content
Closed
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-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": "*",
"bignumber.js": "^9.0.0",
"bn.js": "^5.2.1",
"lodash": "^4.17.21",
Expand Down
88 changes: 88 additions & 0 deletions modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* 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, parseTransaction, TransactionType as WasmTransactionType } from '@bitgo/wasm-ton';
import type { TransactionExplanation } from './iface';

// =============================================================================
// Types
// =============================================================================

export interface ExplainTonTransactionWasmParams {
/** Base64-encoded BOC string */
txBase64: string;
}

// =============================================================================
// Main explain function
// =============================================================================

/**
* Explain a TON transaction using the WASM parser.
*
* Parses the transaction via WASM, derives the transaction type and
* recipients, then maps to BitGoJS TransactionExplanation format.
*
* @param params.txBase64 - Base64-encoded BOC string
* @returns BitGoJS TransactionExplanation
*/
export function explainTonTransaction(params: ExplainTonTransactionWasmParams): TransactionExplanation {
const tx = Transaction.fromBase64(params.txBase64);
const parsed = parseTransaction(tx);

// Build outputs from recipients. For jetton (SendToken) txs the primary
// output address is the jetton transfer destination, not the inner message
// recipient (which is the jetton wallet contract).
const outputs = parsed.recipients.map((r) => {
if (parsed.transactionType === WasmTransactionType.SendToken && parsed.jettonTransfer) {
return {
address: parsed.jettonTransfer.destination,
// jettonTransfer.amount is arbitrary precision string (token units)
amount: parsed.jettonTransfer.amount,
};
}
return {
address: r.address,
// r.amount is nanotons bigint — convert at the serialization boundary
amount: String(r.amount),
};
});

// outputAmount: sum of native TON amounts for non-token txs; 0 string for token
let outputAmount: string;
if (parsed.transactionType === WasmTransactionType.SendToken && parsed.jettonTransfer) {
outputAmount = parsed.jettonTransfer.amount;
} else {
outputAmount = String(parsed.recipients.reduce((sum, r) => sum + r.amount, 0n));
}

// withdrawAmount: only present for SingleNominatorWithdraw (maps to the
// withdrawal amount in the op cell). We surface the first recipient amount
// as a proxy since the legacy implementation did the same.
let withdrawAmount: string | undefined;
if (parsed.transactionType === WasmTransactionType.SingleNominatorWithdraw && parsed.recipients.length > 0) {
// The legacy parser set withdrawAmount from the opcode payload.
// WASM exposes the outer message value (gas attachment).
// Keep undefined here — callers that need the exact withdraw amount should
// use the raw parsed data; the field was already UNKNOWN in many cases.
withdrawAmount = undefined;
}

const displayOrder = ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'];

return {
displayOrder,
id: parsed.id ?? '',
outputs,
outputAmount,
changeOutputs: [],
changeAmount: '0',
fee: { fee: 'UNKNOWN' },
withdrawAmount,
};
}
3 changes: 3 additions & 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,7 @@ export { TransferBuilder } from './transferBuilder';
export { TransactionBuilderFactory } from './transactionBuilderFactory';
export { TonWhalesVestingDepositBuilder } from './tonWhalesVestingDepositBuilder';
export { TonWhalesVestingWithdrawBuilder } from './tonWhalesVestingWithdrawBuilder';
export { explainTonTransaction } from './explainTransactionWasm';
export { getAddressFromPublicKey, isValidTonAddress } from './wasmAddress';
export { getSignablePayload, applySignature } from './wasmSigner';
export { Interface, Utils };
12 changes: 4 additions & 8 deletions modules/sdk-coin-ton/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BN } from 'bn.js';
import { BaseUtils, isValidEd25519PublicKey } from '@bitgo/sdk-core';
import { VESTING_CONTRACT_CODE_B64 } from './constants';
import { VestingContractParams } from './iface';
import { getAddressFromPublicKey as wasmGetAddressFromPublicKey } from './wasmAddress';
export class Utils implements BaseUtils {
/** @inheritdoc */
isValidAddress(address: string): boolean {
Expand Down Expand Up @@ -51,14 +52,9 @@ export class Utils implements BaseUtils {
}

async getAddressFromPublicKey(publicKey: string, bounceable = true, isUserFriendly = true): Promise<string> {
const tonweb = new TonWeb(new TonWeb.HttpProvider(''));
const WalletClass = tonweb.wallet.all['v4R2'];
const wallet = new WalletClass(tonweb.provider, {
publicKey: TonWeb.utils.hexToBytes(publicKey),
wc: 0,
});
const address = await wallet.getAddress();
return address.toString(isUserFriendly, true, bounceable);
// Delegate to the synchronous WASM implementation. The async signature is
// preserved for backward compatibility with callers that await this method.
return wasmGetAddressFromPublicKey(publicKey, bounceable);
}

getAddress(address: string, bounceable = true): string {
Expand Down
42 changes: 42 additions & 0 deletions modules/sdk-coin-ton/src/lib/wasmAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* WASM-based TON address derivation.
*
* Replaces the async TonWeb-based getAddressFromPublicKey with a synchronous
* call into @bitgo/wasm-ton's encodeAddress.
*/

import { encodeAddress, validateAddress } from '@bitgo/wasm-ton';

/**
* Derive a TON address from a hex-encoded Ed25519 public key.
*
* Defaults to V4R2 wallet contract and non-bounceable format (UQ...) which
* is the standard for user-facing TON addresses.
*
* @param publicKeyHex - 64-character hex-encoded 32-byte Ed25519 public key
* @param bounceable - whether to produce a bounceable (EQ...) address (default: false)
* @param walletVersion - wallet contract version (default: "V4R2")
* @returns Base64url-encoded TON address
*/
export function getAddressFromPublicKey(
publicKeyHex: string,
bounceable = false,
walletVersion: 'V3R2' | 'V4R2' | 'V5R1' = 'V4R2'
): string {
const pubKeyBytes = Buffer.from(publicKeyHex, 'hex');
return encodeAddress(pubKeyBytes, { bounceable, walletVersion });
}

/**
* Validate a TON address string (base64url encoded, 48 characters).
*
* @param address - TON address to validate
* @returns true if valid
*/
export function isValidTonAddress(address: string): boolean {
try {
return validateAddress(address);
} catch {
return false;
}
}
46 changes: 46 additions & 0 deletions modules/sdk-coin-ton/src/lib/wasmSigner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* WASM-based TON transaction signing.
*
* Replaces the TonWeb-based rebuild-and-sign path with direct WASM calls:
* Transaction.fromBase64() → addSignature() → toBroadcastFormat()
*
* The signable payload is a 32-byte cell hash (Ed25519 signs this directly).
* The broadcast format is base64-encoded BOC, same as the input format.
*/

import { Transaction } from '@bitgo/wasm-ton';

export interface WasmSignResult {
/** Base64-encoded signed BOC ready for broadcast */
broadcastFormat: string;
/** Transaction ID (base64url), undefined for unsigned transactions */
id: string | undefined;
}

/**
* Get the signable payload (32-byte cell hash) from a base64 BOC.
*
* @param txBase64 - Base64-encoded unsigned BOC
* @returns 32-byte Uint8Array to sign with Ed25519
*/
export function getSignablePayload(txBase64: string): Uint8Array {
const tx = Transaction.fromBase64(txBase64);
return tx.signablePayload();
}

/**
* Add an Ed25519 signature to a base64 BOC and return the signed BOC.
*
* @param txBase64 - Base64-encoded unsigned BOC
* @param signatureHex - 128-character hex-encoded 64-byte Ed25519 signature
* @returns Signed broadcast-ready BOC and transaction ID
*/
export function applySignature(txBase64: string, signatureHex: string): WasmSignResult {
const tx = Transaction.fromBase64(txBase64);
const signatureBytes = Buffer.from(signatureHex, 'hex');
tx.addSignature(signatureBytes);
return {
broadcastFormat: tx.toBroadcastFormat(),
id: tx.id,
};
}
27 changes: 10 additions & 17 deletions modules/sdk-coin-ton/src/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
import { KeyPair as TonKeyPair } from './lib/keyPair';
import { TransactionBuilderFactory, Utils, TransferBuilder, TokenTransferBuilder, TransactionBuilder } from './lib';
import { getFeeEstimate } from './lib/utils';
import { explainTonTransaction } from './lib/explainTransactionWasm';
import { getSignablePayload as wasmGetSignablePayload } from './lib/wasmSigner';

export interface TonParseTransactionOptions extends ParseTransactionOptions {
txHex: string;
Expand Down Expand Up @@ -235,29 +237,20 @@ export class Ton extends BaseCoin {

/** @inheritDoc */
async getSignablePayload(serializedTx: string): Promise<Buffer> {
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
const rebuiltTransaction = await factory.from(serializedTx).build();
return rebuiltTransaction.signablePayload;
// serializedTx is base64-encoded BOC. Use WASM to extract the 32-byte
// cell hash (the Ed25519 signing payload) without rebuilding the tx.
const payload = wasmGetSignablePayload(serializedTx);
return Buffer.from(payload);
}

/** @inheritDoc */
async explainTransaction(params: Record<string, any>): Promise<TransactionExplanation> {
try {
const factory = new TransactionBuilderFactory(coins.get(this.getChain()));
const transactionBuilder = factory.from(Buffer.from(params.txHex, 'hex').toString('base64'));

const { toAddressBounceable, fromAddressBounceable } = params;

if (typeof toAddressBounceable === 'boolean') {
transactionBuilder.toAddressBounceable(toAddressBounceable);
if (!params.txHex) {
throw new Error('missing txHex');
}

if (typeof fromAddressBounceable === 'boolean') {
transactionBuilder.fromAddressBounceable(fromAddressBounceable);
}

const rebuiltTransaction = await transactionBuilder.build();
return rebuiltTransaction.explainTransaction();
const txBase64 = Buffer.from(params.txHex, 'hex').toString('base64');
return explainTonTransaction({ txBase64 });
} catch {
throw new Error('Invalid transaction');
}
Expand Down
71 changes: 71 additions & 0 deletions modules/sdk-coin-ton/test/unit/wasmAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Tests for WASM-based TON address derivation.
*
* Validates that getAddressFromPublicKey() produces addresses that match
* known fixtures, and that isValidTonAddress() correctly validates addresses.
*/

/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
import should from 'should';
import { getAddressFromPublicKey, isValidTonAddress } from '../../src/lib/wasmAddress';
import * as testData from '../resources/ton';

describe('WASM address derivation:', function () {
describe('getAddressFromPublicKey', function () {
it('should derive a non-bounceable address (UQ...) from a known public key', async function () {
// The sender public key is known from test fixtures
const { publicKey } = testData.sender;
const address = getAddressFromPublicKey(publicKey, false, 'V4R2');

// Should start with UQ (non-bounceable, user-friendly)
address.should.startWith('UQ');
address.should.equal(testData.sender.address);
});

it('should derive a bounceable address (EQ...) from a known public key', async function () {
const { publicKey } = testData.sender;
const bounceable = getAddressFromPublicKey(publicKey, true, 'V4R2');

// Should start with EQ (bounceable)
bounceable.should.startWith('EQ');
});

it('should produce a 48-character base64url address', function () {
const { publicKey } = testData.sender;
const address = getAddressFromPublicKey(publicKey, false, 'V4R2');

// TON addresses are 48 characters in base64url
address.replace(/\?memoId=.*/, '').length.should.equal(48);
});

it('should derive correct address for token sender public key', function () {
const { publicKey } = testData.tokenSender;
const address = getAddressFromPublicKey(publicKey, false, 'V4R2');

address.should.startWith('UQ');
address.should.equal(testData.tokenSender.address);
});
});

describe('isValidTonAddress', function () {
it('should validate known good addresses', function () {
for (const addr of testData.addresses.validAddresses) {
isValidTonAddress(addr).should.be.true(`${addr} should be valid`);
}
});

it('should reject invalid addresses', function () {
isValidTonAddress('randomString').should.be.false();
isValidTonAddress('0xc4173a804406a365e69dfb297ddfgsdcvf').should.be.false();
isValidTonAddress('').should.be.false();
});

it('should validate EQ... (bounceable) addresses', function () {
isValidTonAddress(testData.signedSendTransaction.recipient.address).should.be.true();
});

it('should validate UQ... (non-bounceable) addresses', function () {
isValidTonAddress(testData.signedSendTransaction.recipientBounceable.address).should.be.true();
});
});
});
Loading
Loading