Skip to content
Merged
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
19 changes: 17 additions & 2 deletions modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { ParsedTransaction as WasmParsedTransaction } from '@bitgo/wasm-ton';
import { TransactionExplanation } from './iface';
import { SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT } from './constants';

/**
* @bitgo/wasm-ton is loaded lazily (not via a static top-level import) so this
Expand Down Expand Up @@ -39,25 +40,39 @@ function extractOutputs(
parsed: WasmParsedTransaction,
toAddressBounceable: boolean
): {
outputs: { address: string; amount: string }[];
outputs: { address: string; amount: string; memo?: string }[];
outputAmount: string;
withdrawAmount: string | undefined;
} {
const outputs: { address: string; amount: string }[] = [];
const outputs: { address: string; amount: string; memo?: 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),
// the jetton forward comment — the memo exchanges use to credit token deposits
...(action.memo !== undefined && action.memo !== '' ? { memo: action.memo } : {}),
});
} else {
// destinationBounceable is always EQ... (bounceable)
// destination respects the original bounce flag (UQ... when bounce=false)
// The comment is exposed as memo only for plain sends, mirroring the TonWeb
// parser: op-0 comments that are whales/single-nominator protocol text
// classify the transaction as a staking type, not a user memo.
const memo =
action.memo !== undefined &&
action.memo !== '' &&
action.memo !== 'Deposit' &&
action.memo !== 'Withdraw' &&
action.memo !== SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT
? action.memo
: undefined;
outputs.push({
address: toAddressBounceable ? action.destinationBounceable : action.destination,
amount: String(action.amount),
...(memo !== undefined ? { memo } : {}),
});
}

Expand Down
8 changes: 7 additions & 1 deletion modules/sdk-coin-ton/src/lib/tokenTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@ export class TokenTransaction extends Transaction {
explainTransaction(): TransactionExplanation {
const displayOrder = ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'];

const outputs: TransactionRecipient[] = [this.recipient];
// `message` is the jetton forward comment — the memo exchanges use to credit token deposits
const outputs: TransactionRecipient[] = [
{
...this.recipient,
...(this.message ? { memo: this.message } : {}),
},
];
const outputAmount = this.recipient.amount;
const withdrawAmount = this.withdrawAmount;
return {
Expand Down
67 changes: 61 additions & 6 deletions modules/sdk-coin-ton/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ import {
SINGLE_NOMINATOR_WITHDRAW_ALL_COMMENT,
} from './constants';

/**
* The subset of the tonweb Slice API the comment decoders rely on. tonweb ships no
* type definitions for Slice, so this structural type documents the access pattern.
*/
interface CommentSlice {
refs: unknown[];
refCursor: number;
getFreeBits(): number;
loadBits(bits: number): Uint8Array;
loadRef(): CommentSlice;
}

export class Transaction extends BaseTransaction {
public recipient: Recipient;
public bounceable: boolean;
Expand Down Expand Up @@ -235,7 +247,14 @@ export class Transaction extends BaseTransaction {
explainTransaction(): TransactionExplanation {
const displayOrder = ['id', 'outputs', 'outputAmount', 'changeOutputs', 'changeAmount', 'fee', 'withdrawAmount'];

const outputs: TransactionRecipient[] = [this.recipient];
// Only a plain send's body comment is a user memo; the other transaction types carry
// protocol payloads (e.g. withdraw opcodes) in `message`, which must not be exposed as memo.
const outputs: TransactionRecipient[] = [
{
...this.recipient,
...(this.type === TransactionType.Send && this.message ? { memo: this.message } : {}),
},
];
const outputAmount = this.recipient.amount;
const withdrawAmount = this.withdrawAmount;
return {
Expand Down Expand Up @@ -295,6 +314,29 @@ export class Transaction extends BaseTransaction {
};
}

/**
* Decode a comment cell chain per the TON snake format (TL-B SnakeData: raw bytes
* per cell, refs chain to continuations with no length prefix), mirroring the
* standard reader (@ton/core readBuffer) exactly: byte-aligned cells, at most one
* ref per cell, whole-cell text concatenated across the chain. Binding the memo to
* the full text means a prebuild cannot carry the declared memoId in its first
* cell while chain readers credit a longer, different comment, nor smuggle an
* undeclared comment past verification as no comment. Violations fail closed
* (throw) exactly where the standard reader throws.
*/
private loadComment(slice: CommentSlice): string {
const freeBits = slice.getFreeBits();
const remainingRefs = slice.refs.length - slice.refCursor;
if (freeBits % 8 !== 0 || (remainingRefs !== 0 && remainingRefs !== 1)) {
throw new Error('invalid snake-format comment');
}
let text = new TextDecoder().decode(slice.loadBits(freeBits));
if (remainingRefs === 1) {
text += this.loadComment(slice.loadRef());
}
return text;
}

private parseTransactionBody(slice: any): any {
const signature = Buffer.from(slice.loadBits(512)).toString('hex');
// signing message
Expand Down Expand Up @@ -354,8 +396,7 @@ export class Transaction extends BaseTransaction {
if (order.getFreeBits() > 32) {
const opcode = order.loadUint(32).toNumber();
if (opcode === 0) {
const payloadBytes = order.loadBits(order.getFreeBits());
payload = new TextDecoder().decode(payloadBytes);
payload = this.loadComment(order);
if (payload === 'Deposit') {
this.transactionType = TransactionType.TonWhalesVestingDeposit;
} else if (payload === 'Withdraw') {
Expand All @@ -382,18 +423,32 @@ export class Transaction extends BaseTransaction {
const forwarderAddress = order.loadAddress();
if (!forwarderAddress) throw new Error('invalid forwarderAddress');

order.loadBit(); // skip bit
const hasCustomPayload = order.loadBit();
if (hasCustomPayload) {
// consume the custom_payload ref (TEP-74): BitGo builders never write one,
// but a prebuild that sets it shifts the ref cursor so the forward payload
// would otherwise be decoded from the wrong cell
order.loadRef();
}

const forwardTonAmount = order.loadCoins();
if (!forwardTonAmount.gt(new BN(0))) throw new Error('invalid forwardTonAmount');

let message = '';
if (order.loadBit()) {
order = order.loadRef();
Comment thread
mukeshsp marked this conversation as resolved.
Resolved
// the ref may be an empty cell — TokenTransferBuilder always writes a forward
// payload ref, empty when the transfer carries no comment
if (order.getFreeBits() > 0) {
const messageOpcode = order.loadUint(32).toNumber();
if (messageOpcode !== 0) throw new Error('invalid message opcode');
message = this.loadComment(order);
}
} else if (order.getFreeBits() > 32) {
// Either bit 0: the forward payload is inline in the remaining bits
const messageOpcode = order.loadUint(32).toNumber();
if (messageOpcode !== 0) throw new Error('invalid message opcode');
const messageBytes = order.loadBits(order.getFreeBits());
message = new TextDecoder().decode(messageBytes);
message = this.loadComment(order);
}

payload = {
Expand Down
45 changes: 39 additions & 6 deletions modules/sdk-coin-ton/src/ton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ export class Ton extends BaseCoin {
}
}

/**
* Verifies that the prebuilt transaction matches the user's intent.
*
* Compares (bounceable address, amount, memo) per output against txParams.recipients, plus the
* total amount. TON custodial deposits are credited by the transfer's text comment (or the
* jetton forward comment for token transfers), which BitGo models as a `?memoId=` suffix on
* the recipient address. The parsed comment is bound to the intended memoId: a prebuild whose
* comment differs from, is missing from, or is extra to the declared memo fails verification
* on both the tton WASM and the mainnet TonWeb paths.
*/
async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
const { txPrebuild: txPrebuild, txParams: txParams } = params;
const rawTx = txPrebuild.txHex;
Expand All @@ -132,15 +142,21 @@ export class Ton extends BaseCoin {
const explainedTx = await explainTonTransaction({ txBase64 });
if (txParams.recipients !== undefined) {
const filteredRecipients = await Promise.all(
txParams.recipients.map(async (recipient) => ({
address: await toBounceable(recipient.address),
amount: BigInt(recipient.amount),
}))
txParams.recipients.map(async (recipient) => {
const { address, memoId } = this.getAddressDetails(recipient.address);
const memo = this.resolveExpectedMemo(memoId, recipient.memo);
return {
address: await toBounceable(address),
amount: BigInt(recipient.amount),
...(memo !== undefined ? { memo } : {}),
};
})
);
const filteredOutputs = await Promise.all(
explainedTx.outputs.map(async (output) => ({
address: await toBounceable(output.address),
amount: BigInt(output.amount),
...(output.memo !== undefined && output.memo !== '' ? { memo: output.memo } : {}),
}))
);
if (!_.isEqual(filteredOutputs, filteredRecipients)) {
Expand All @@ -163,16 +179,19 @@ export class Ton extends BaseCoin {
const explainedTx = transaction.explainTransaction();
if (txParams.recipients !== undefined) {
const filteredRecipients = txParams.recipients?.map((recipient) => {
const destination = this.getAddressDetails(recipient.address);
const { address, memoId } = this.getAddressDetails(recipient.address);
const memo = this.resolveExpectedMemo(memoId, recipient.memo);
return {
address: new TonWeb.Address(destination.address).toString(true, true, true),
address: new TonWeb.Address(address).toString(true, true, true),
amount: BigInt(recipient.amount),
...(memo !== undefined ? { memo } : {}),
};
});
const filteredOutputs = explainedTx.outputs.map((output) => {
return {
address: new TonWeb.Address(output.address).toString(true, true, true),
amount: BigInt(output.amount),
...(output.memo !== undefined && output.memo !== '' ? { memo: output.memo } : {}),
};
});
if (!_.isEqual(filteredOutputs, filteredRecipients)) {
Expand All @@ -189,6 +208,20 @@ export class Ton extends BaseCoin {
return true;
}

/**
* Resolve the memo a recipient's intent declares. Declared sources are the `?memoId=` suffix
* of the recipient address (see getAddressDetails) and `recipient.memo`; empty strings mean
* "no memo", and conflicting distinct declarations are rejected as ambiguous intent.
* Returns undefined when no memo is declared anywhere.
*/
private resolveExpectedMemo(memoId: string | undefined, recipientMemo: string | undefined): string | undefined {
const declaredMemos = [memoId, recipientMemo].filter((memo): memo is string => memo !== undefined && memo !== '');
if (declaredMemos.some((memo) => memo !== declaredMemos[0])) {
throw new Error('Tx outputs does not match with expected txParams recipients');
}
return declaredMemos[0];
}

async isWalletAddress(params: TssVerifyAddressOptions): Promise<boolean> {
const { keychains, address: newAddress, index } = params;

Expand Down
Loading
Loading