From 3bacce977f84f96e7fdd7352d141c49b9a2afde9 Mon Sep 17 00:00:00 2001 From: Mukesh Mahadev Prajapati Date: Wed, 11 Nov 2026 15:54:31 +0530 Subject: [PATCH] fix(sdk-coin-ton): bind transfer comment to memoId in verifyTransaction Ton.verifyTransaction compared only (address, amount) and stripped the ?memoId= suffix, so a prebuild whose on-chain comment differed from the intended memoId verified successfully, enabling exchange-deposit redirection. Verification now binds the parsed transfer comment (plain-send body comment and jetton forward comment) to the declared memoId on both the tton WASM and mainnet TonWeb paths, rejecting different, missing, and extra comments. Explanations expose the comment as outputs[].memo, and the TonWeb jetton parser now accepts the empty forward-payload cell the builder writes for comment-less transfers. Ticket: CSHLD-2160 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/explainTransactionWasm.ts | 19 +- .../sdk-coin-ton/src/lib/tokenTransaction.ts | 8 +- modules/sdk-coin-ton/src/lib/transaction.ts | 67 +++++- modules/sdk-coin-ton/src/ton.ts | 45 +++- modules/sdk-coin-ton/test/resources/ton.ts | 95 ++++++++- .../test/unit/explainTransactionWasm.ts | 15 ++ modules/sdk-coin-ton/test/unit/jettonToken.ts | 151 ++++++++++++++ .../test/unit/tokenTransferBuilder.ts | 20 +- modules/sdk-coin-ton/test/unit/ton.ts | 194 ++++++++++++++++++ 9 files changed, 589 insertions(+), 25 deletions(-) diff --git a/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts index bfaaeb5c1d7..48e9a0a99d7 100644 --- a/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts +++ b/modules/sdk-coin-ton/src/lib/explainTransactionWasm.ts @@ -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 @@ -39,11 +40,11 @@ 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) { @@ -51,13 +52,27 @@ function extractOutputs( 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 } : {}), }); } diff --git a/modules/sdk-coin-ton/src/lib/tokenTransaction.ts b/modules/sdk-coin-ton/src/lib/tokenTransaction.ts index c55b1aa674c..67f9ea461a0 100644 --- a/modules/sdk-coin-ton/src/lib/tokenTransaction.ts +++ b/modules/sdk-coin-ton/src/lib/tokenTransaction.ts @@ -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 { diff --git a/modules/sdk-coin-ton/src/lib/transaction.ts b/modules/sdk-coin-ton/src/lib/transaction.ts index 38f51bcea7b..ed3b5f8e1c1 100644 --- a/modules/sdk-coin-ton/src/lib/transaction.ts +++ b/modules/sdk-coin-ton/src/lib/transaction.ts @@ -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; @@ -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 { @@ -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 @@ -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') { @@ -382,7 +423,13 @@ 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'); @@ -390,10 +437,18 @@ export class Transaction extends BaseTransaction { let message = ''; if (order.loadBit()) { order = order.loadRef(); + // 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 = { diff --git a/modules/sdk-coin-ton/src/ton.ts b/modules/sdk-coin-ton/src/ton.ts index 80870c13290..ef6eb702bfb 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -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 { const { txPrebuild: txPrebuild, txParams: txParams } = params; const rawTx = txPrebuild.txHex; @@ -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)) { @@ -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)) { @@ -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 { const { keychains, address: newAddress, index } = params; diff --git a/modules/sdk-coin-ton/test/resources/ton.ts b/modules/sdk-coin-ton/test/resources/ton.ts index 13b34aad4b8..01e6d890878 100644 --- a/modules/sdk-coin-ton/test/resources/ton.ts +++ b/modules/sdk-coin-ton/test/resources/ton.ts @@ -95,14 +95,14 @@ export const v3CompatibleSignedSendTransaction = { }; export const signedTokenSendTransaction = { - tx: 'te6cckECGgEABB0AAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAkAAAAAGpldHRvbiB0ZXN0aW5nwHtw7A==', + tx: 'te6cckECGgEABAsAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAAH532hA==', tx2: 'te6cckECGgEABBQAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFoYgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOaAX14QAAAAAAAAAAAAAAAAAARgBrg+KfqUAAAAAAAAAAEO5rKAIAPwRnQYv0dNXq6rsSTrz4BinJQWsFLNzAItPyHXLSkM/ACqQzfyg0cZ+8t9v2YoHmFFxG5jgjvQRTZ2yeDjO5z5ZRzEtARkAEAAAAAB0ZXN0MfeoHg==', txBounceable: - 'te6cckECGgEABB0AAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmYgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAkAAAAAGpldHRvbiB0ZXN0aW5ndUM5RQ==', - txId: 'J9ncpPEo7P5Lx3TLLhTFKQ2OroGQ53rv2nSB3jYgJLQ=', - txIdBounceable: '5AkPiyTMl5oUANd2UAzLmXmqIyU65xShhCooLq4igV8=', - signable: 'rq4tq/sFuXVLcQSfyxDd7QuxOif/5BQwpm0gwOa+sOE=', - bounceableSignable: 'eBYl/HjDEwBYsk9zRoppcKZ1hp2OrAMRuvchHV8MYuU=', + 'te6cckECGgEABAsAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmYgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAAQGMAYA==', + txId: '-hXmpzH04GNJPFjsQODW9HBAVfjtk0hiJQpPFVKLdxM=', + txIdBounceable: 'mdLSSLnwnOEaHW5k0LQAeQgvyICUOuFMsVQavqyzwYU=', + signable: '5nanPVWfaTqQLq2fXZPeoLzttnTfZ8k1VQYkfBoHgVg=', + bounceableSignable: '8yb1vf11uZ67QS/3PGHzfsDq0uXK9fuffqvVndSGySw=', recipient: { address: 'EQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhnx5K', amount: '1000000000', @@ -113,8 +113,21 @@ export const signedTokenSendTransaction = { }, }; -export const signedTokenSendTransactionForMemoId = { +// A jetton transfer carrying a forward comment ('jetton testing') to a plain recipient. +// Since the memo binding fix, verification must reject this prebuild: the comment the user +// never declared is exactly the exchange-credit redirection CSHLD-2160 describes. +export const signedTokenSendTransactionWithComment = { tx: 'te6cckECGgEABB0AAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAkAAAAAGpldHRvbiB0ZXN0aW5nwHtw7A==', + txId: 'J9ncpPEo7P5Lx3TLLhTFKQ2OroGQ53rv2nSB3jYgJLQ=', + signable: 'rq4tq/sFuXVLcQSfyxDd7QuxOif/5BQwpm0gwOa+sOE=', + recipient: { + address: 'EQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhnx5K', + amount: '1000000000', + }, +}; + +export const signedTokenSendTransactionForMemoId = { + tx: 'te6cckECGgEABBMAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAQAAAAADEyMzTLzbIP', recipient: { address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP?memoId=1234', amount: '1000000000', @@ -122,13 +135,79 @@ export const signedTokenSendTransactionForMemoId = { }; export const signedSendTransactionForMemoId = { - tx: 'te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w==', + tx: 'te6cckEBAgEAsQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU1NGLsotvRwAAAAMAAcAQB2QgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAAAAAADEyMzTMY6r7', + recipient: { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG?memoId=1234', + amount: '10000000', + }, +}; + +// Adversarial prebuilds (unsigned, zero-sig): the comment cell carries the declared memoId +// '1234' in its first cell plus a snake-format continuation ref holding '5678'. Standard +// chain readers concatenate the whole snake chain and credit '12345678', so the parser must +// expose the full text as memo — verification then rejects it against a recipient whose +// memoId is only '1234' (or who declares no memo at all). +export const signedSendTransactionWithSnakeComment = { + tx: 'te6cckECGQEAA70AAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QAF2QgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAAAAAADEyMzQYAAg1Njc4m/h5ug==', + recipient: { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG?memoId=1234', + amount: '10000000', + }, +}; + +// Adversarial prebuild (unsigned, zero-sig): the comment cell carries TWO snake +// continuation refs, which the standard reader (@ton/core readBuffer) rejects. Decoding +// must fail closed the same way rather than expose the first-cell text '1234' as the memo. +export const signedSendTransactionWithMalformedComment = { + tx: 'te6cckECGgEAA8QAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QAJ2QgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAAAAAADEyMzQYGQAIQUFBQQAIQkJCQksNENM=', recipient: { address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG?memoId=1234', amount: '10000000', }, }; +// Plain send whose op-0 comment is the literal string 'Deposit' — the parser classifies +// it as a whales vesting deposit, which must NOT expose the comment as memo (contrast +// with the memoId fixtures, where a '1234' comment is exposed). +export const signedSendTransactionWithDepositComment = { + tx: 'te6cckECGAEAA7kAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QAB8QgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAAAAAAERlcG9zaXQYljG9', + recipient: { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG', + amount: '10000000', + }, +}; + +export const signedTokenSendTransactionWithSnakeComment = { + tx: 'te6cckECGwEABBwAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFoQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOaAcnDgAAAAAAAAAAAAAAAAAARgBsA+KfqUAAAAAAAAAAEO5rKAIAPwRnQYv0dNXq6rsSTrz4BinJQWsFLNzAItPyHXLSkM/ACqQzfyg0cZ+8t9v2YoHmFFxG5jgjvQRTZ2yeDjO5z5ZSAX14QEZARAAAAAAMTIzNBoACDU2NzhdNmJv', + recipient: { + address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP?memoId=1234', + amount: '1000000000', + }, +}; + +// Adversarial prebuild (unsigned, zero-sig): a jetton transfer whose TEP-74 body sets the +// custom_payload Maybe bit, with a decoy comment cell '1234' in refs[0] and the true +// forward-payload comment '5678' in refs[1]. A parser that skips the custom_payload bit +// without consuming its ref reads the decoy as the memo. +export const signedTokenSendTransactionWithCustomPayload = { + tx: 'te6cckECGwEABCAAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFoQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOaAcnDgAAAAAAAAAAAAAAAAAARgCsA+KfqUAAAAAAAAAAEO5rKAIAPwRnQYv0dNXq6rsSTrz4BinJQWsFLNzAItPyHXLSkM/ACqQzfyg0cZ+8t9v2YoHmFFxG5jgjvQRTZ2yeDjO5z5ZaAX14QEZGgAQAAAAADEyMzQAEAAAAAA1Njc4IU0pFg==', + recipient: { + address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP?memoId=5678', + amount: '1000000000', + }, +}; + +// Adversarial prebuild (unsigned, zero-sig): a jetton transfer whose forward_payload uses +// the Either bit 0 form — the comment '5678' is written inline in the body cell instead of +// in a ref. A parser that reads only the ref form treats this as no comment. +export const signedTokenSendTransactionWithInlinePayload = { + tx: 'te6cckECGQEABBIAAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFoQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOaAcnDgAAAAAAAAAAAAAAAAAARgAwA+KfqUAAAAAAAAAAEO5rKAIAPwRnQYv0dNXq6rsSTrz4BinJQWsFLNzAItPyHXLSkM/ACqQzfyg0cZ+8t9v2YoHmFFxG5jgjvQRTZ2yeDjO5z5ZSAX14QAAAAAANTY3ONqd4Ig=', + recipient: { + address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP?memoId=5678', + amount: '1000000000', + }, +}; + export const signedSingleNominatorWithdrawTransaction = { tx: 'te6cckECGAEAA8MAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QACPQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr6A613oAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAHA0/PoUC5EIEyWuPg==', txBounceable: diff --git a/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts index d6ee3145bc6..0f856472ead 100644 --- a/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts +++ b/modules/sdk-coin-ton/test/unit/explainTransactionWasm.ts @@ -58,6 +58,21 @@ describe('TON WASM explainTransaction', function () { bounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipient.address); nonBounceable.outputs[0].address.should.equal(testData.signedSendTransaction.recipientBounceable.address); }); + + it('should explain the transfer comment as the output memo', async function () { + const explained = await explainTonTransaction({ txBase64: testData.signedSendTransactionForMemoId.tx }); + explained.outputs[0]!.memo!.should.equal('1234'); + }); + + it('should not expose the whales vesting Deposit comment as memo', async function () { + const explained = await explainTonTransaction({ txBase64: testData.signedSendTransactionWithDepositComment.tx }); + should.equal(explained.outputs[0]!.memo, undefined); + }); + + it('should explain the jetton forward comment as the output memo', async function () { + const explained = await explainTonTransaction({ txBase64: testData.signedTokenSendTransactionForMemoId.tx }); + explained.outputs[0]!.memo!.should.equal('1234'); + }); }); describe('WASM Transaction signing flow', function () { diff --git a/modules/sdk-coin-ton/test/unit/jettonToken.ts b/modules/sdk-coin-ton/test/unit/jettonToken.ts index 99592900dba..e0cab87c2fc 100644 --- a/modules/sdk-coin-ton/test/unit/jettonToken.ts +++ b/modules/sdk-coin-ton/test/unit/jettonToken.ts @@ -188,6 +188,150 @@ describe('Jetton Tokens', function () { } as any); isTransactionVerified.should.equal(true); }); + it('should fail to verify transaction when the prebuild is missing the memo comment', async function () { + const txParams = { + recipients: [ + { + address: `${testData.signedTokenSendTransaction.recipientBounceable.address}?memoId=1234`, + amount: testData.signedTokenSendTransaction.recipientBounceable.amount, + }, + ], + }; + const txPrebuild = { + txHex: Buffer.from(testData.signedTokenSendTransaction.tx, 'base64').toString('hex'), + txInfo: {}, + }; + await testnetJettonToken + .verifyTransaction({ txParams, txPrebuild, verification: {} } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify transaction when the memo comment differs from the memoId', async function () { + const txParams = { + recipients: [ + { + address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP?memoId=9999', + amount: testData.signedTokenSendTransactionForMemoId.recipient.amount, + }, + ], + }; + const txPrebuild = { + txHex: Buffer.from(testData.signedTokenSendTransactionForMemoId.tx, 'base64').toString('hex'), + txInfo: {}, + }; + await testnetJettonToken + .verifyTransaction({ txParams, txPrebuild, verification: {} } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify transaction when the prebuild carries an undeclared comment', async function () { + const txParams = { + recipients: [testData.signedTokenSendTransactionWithComment.recipient], + }; + const txPrebuild = { + txHex: Buffer.from(testData.signedTokenSendTransactionWithComment.tx, 'base64').toString('hex'), + txInfo: {}, + }; + await testnetJettonToken + .verifyTransaction({ txParams, txPrebuild, verification: {} } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should expose the full snake-format forward comment as the memo (mainnet)', async function () { + const explainedTransaction = (await mainnetJettonToken.explainTransaction({ + txHex: Buffer.from(testData.signedTokenSendTransactionWithSnakeComment.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + explainedTransaction.outputs[0]!.memo!.should.equal('12345678'); + }); + + it('should fail to verify a memoId recipient when the forward comment carries a snake-format continuation (mainnet)', async function () { + await mainnetJettonToken + .verifyTransaction({ + txParams: { recipients: [testData.signedTokenSendTransactionWithSnakeComment.recipient] }, + txPrebuild: { + txHex: Buffer.from(testData.signedTokenSendTransactionWithSnakeComment.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify an undeclared snake-format forward comment when the recipient declares no memo (mainnet)', async function () { + await mainnetJettonToken + .verifyTransaction({ + txParams: { + recipients: [ + { + address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP', + amount: '1000000000', + }, + ], + }, + txPrebuild: { + txHex: Buffer.from(testData.signedTokenSendTransactionWithSnakeComment.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should read the forward payload comment, not the custom_payload, when both are present (mainnet)', async function () { + const explainedTransaction = (await mainnetJettonToken.explainTransaction({ + txHex: Buffer.from(testData.signedTokenSendTransactionWithCustomPayload.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + explainedTransaction.outputs[0]!.memo!.should.equal('5678'); + }); + + it('should verify a custom_payload transfer against the forward payload memoId (mainnet)', async function () { + await mainnetJettonToken.verifyTransaction({ + txParams: { recipients: [testData.signedTokenSendTransactionWithCustomPayload.recipient] }, + txPrebuild: { + txHex: Buffer.from(testData.signedTokenSendTransactionWithCustomPayload.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any); + }); + + it('should fail to verify a custom_payload transfer against the decoy custom_payload memoId (mainnet)', async function () { + await mainnetJettonToken + .verifyTransaction({ + txParams: { + recipients: [ + { + address: 'UQB-CM6DF-jpq9XVdiSdefAMU5KC1gpZuYBFp-Q65aUhn0OP?memoId=1234', + amount: '1000000000', + }, + ], + }, + txPrebuild: { + txHex: Buffer.from(testData.signedTokenSendTransactionWithCustomPayload.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should expose an inline forward payload comment as the memo (mainnet)', async function () { + const explainedTransaction = (await mainnetJettonToken.explainTransaction({ + txHex: Buffer.from(testData.signedTokenSendTransactionWithInlinePayload.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + explainedTransaction.outputs[0]!.memo!.should.equal('5678'); + }); + + it('should verify an inline forward payload transfer against its memoId (mainnet)', async function () { + await mainnetJettonToken.verifyTransaction({ + txParams: { recipients: [testData.signedTokenSendTransactionWithInlinePayload.recipient] }, + txPrebuild: { + txHex: Buffer.from(testData.signedTokenSendTransactionWithInlinePayload.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any); + }); }); describe('Explain Jetton Transaction: ', () => { @@ -235,6 +379,13 @@ describe('Jetton Tokens', function () { }); }); + it('should explain the jetton forward comment as the output memo', async function () { + const explainedTransaction = (await testnetJettonToken.explainTransaction({ + txHex: Buffer.from(testData.signedTokenSendTransactionWithComment.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + explainedTransaction.outputs[0]!.memo!.should.equal('jetton testing'); + }); + it('should fail to explain transaction with missing params', async function () { try { await testnetJettonToken.explainTransaction({}); diff --git a/modules/sdk-coin-ton/test/unit/tokenTransferBuilder.ts b/modules/sdk-coin-ton/test/unit/tokenTransferBuilder.ts index e1879d42e06..672c3a72d72 100644 --- a/modules/sdk-coin-ton/test/unit/tokenTransferBuilder.ts +++ b/modules/sdk-coin-ton/test/unit/tokenTransferBuilder.ts @@ -5,6 +5,7 @@ import { coins } from '@bitgo/statics'; import { TransactionBuilderFactory, KeyPair } from '../../src'; import * as testData from '../resources/ton'; import * as utils from '../../src/lib/utils'; +import { explainTonTransaction } from '../../src/lib/explainTransactionWasm'; describe('Ton Token Transfer Builder', () => { const tokenName = 'tton:ukwny-us'; @@ -21,7 +22,6 @@ describe('Ton Token Transfer Builder', () => { txBuilder.setSenderJettonWalletAddress(testData.tokenSender.jettonAddress); txBuilder.setTonAmount(testData.tokenSender.tonAmount); // amount of TON to send to the sender's jetton wallet txBuilder.setForwardTonAmount(testData.tokenSender.forwardTonAmount); // amount of TON to cover forward fees in case of notify transfer - txBuilder.setMessage(testData.tokenSender.message); const tx = await txBuilder.build(); should.equal(tx.type, TransactionType.SendToken); should.equal(tx.toJson().bounceable, false); @@ -53,7 +53,6 @@ describe('Ton Token Transfer Builder', () => { txBuilder.setSenderJettonWalletAddress(testData.tokenSender.jettonAddress); txBuilder.setTonAmount(testData.tokenSender.tonAmount); // amount of TON to send to the sender's jetton wallet txBuilder.setForwardTonAmount(testData.tokenSender.forwardTonAmount); // amount of TON to cover forward fees in case of notify transfer - txBuilder.setMessage(testData.tokenSender.message); txBuilder.bounceable(true); const tx = await txBuilder.build(); @@ -76,6 +75,23 @@ describe('Ton Token Transfer Builder', () => { rawTx.should.equal(testData.signedTokenSendTransaction.txBounceable); }); + it('should encode setMessage as the forward payload comment', async function () { + const txBuilder = factory.getTokenTransferBuilder(); + txBuilder.sender(testData.tokenSender.address); + txBuilder.sequenceNumber(0); + txBuilder.publicKey(testData.tokenSender.publicKey); + txBuilder.expireTime(testData.tokenSender.expireTime); + txBuilder.send(testData.tokenRecipients[0]); + txBuilder.setSenderJettonWalletAddress(testData.tokenSender.jettonAddress); + txBuilder.setTonAmount(testData.tokenSender.tonAmount); + txBuilder.setForwardTonAmount(testData.tokenSender.forwardTonAmount); + txBuilder.setMessage(testData.tokenSender.message); + + const tx = await txBuilder.build(); + const explained = await explainTonTransaction({ txBase64: tx.toBroadcastFormat() }); + explained.outputs[0]!.memo!.should.equal(testData.tokenSender.message); + }); + it('should build a send from rawTx', async function () { const txBuilder = factory.from(testData.signedTokenSendTransaction.tx); const builtTx = await txBuilder.build(); diff --git a/modules/sdk-coin-ton/test/unit/ton.ts b/modules/sdk-coin-ton/test/unit/ton.ts index 61fdd64ab8e..1a2ace8821a 100644 --- a/modules/sdk-coin-ton/test/unit/ton.ts +++ b/modules/sdk-coin-ton/test/unit/ton.ts @@ -194,6 +194,193 @@ describe('TON:', function () { } as any); isTransactionVerified.should.equal(true); }); + + it('should fail to verify transaction when the prebuild is missing the memo comment', async function () { + // sends to the same address and amount the memoId recipient expects, but carries no comment + const txParams = { + recipients: [ + { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG?memoId=1234', + amount: '10000000', + }, + ], + }; + const txPrebuild = { + txHex: Buffer.from(testData.signedSendTransaction.tx, 'base64').toString('hex'), + txInfo: {}, + }; + await basecoin + .verifyTransaction({ txParams, txPrebuild, verification: {} } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify transaction when the memo comment differs from the memoId', async function () { + const txParams = { + recipients: [ + { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG?memoId=9999', + amount: '10000000', + }, + ], + }; + const txPrebuild = { + txHex: Buffer.from(testData.signedSendTransactionForMemoId.tx, 'base64').toString('hex'), + txInfo: {}, + }; + await basecoin + .verifyTransaction({ txParams, txPrebuild, verification: {} } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify transaction when the prebuild carries an undeclared comment', async function () { + const txParams = { + recipients: [ + { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG', + amount: '10000000', + }, + ], + }; + const txPrebuild = { + txHex: Buffer.from(testData.signedSendTransactionForMemoId.tx, 'base64').toString('hex'), + txInfo: {}, + }; + await basecoin + .verifyTransaction({ txParams, txPrebuild, verification: {} } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + }); + + describe('Verify transaction (mainnet TonWeb path): ', () => { + const mainnetCoin = bitgo.coin('ton'); + const txHex = Buffer.from(testData.signedSendTransactionForMemoId.tx, 'base64').toString('hex'); + + it('should succeed to verify a memoId recipient when the comment matches', async function () { + const isVerified = await mainnetCoin.verifyTransaction({ + txParams: { recipients: [testData.signedSendTransactionForMemoId.recipient] }, + txPrebuild: { txHex, txInfo: {} }, + verification: {}, + } as any); + isVerified.should.equal(true); + }); + + it('should succeed to verify a plain send without a comment', async function () { + const isVerified = await mainnetCoin.verifyTransaction({ + txParams: { recipients: [testData.signedSendTransaction.recipient] }, + txPrebuild: { + txHex: Buffer.from(testData.signedSendTransaction.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any); + isVerified.should.equal(true); + }); + + it('should fail to verify when the prebuild carries an undeclared comment', async function () { + await mainnetCoin + .verifyTransaction({ + txParams: { + recipients: [ + { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG', + amount: '10000000', + }, + ], + }, + txPrebuild: { txHex, txInfo: {} }, + verification: {}, + } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should expose the full snake-format comment as the memo', async function () { + const explainedTransaction = (await mainnetCoin.explainTransaction({ + txHex: Buffer.from(testData.signedSendTransactionWithSnakeComment.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + explainedTransaction.outputs[0]!.memo!.should.equal('12345678'); + }); + + it('should fail to verify a memoId recipient when the comment carries a snake-format continuation', async function () { + await mainnetCoin + .verifyTransaction({ + txParams: { recipients: [testData.signedSendTransactionWithSnakeComment.recipient] }, + txPrebuild: { + txHex: Buffer.from(testData.signedSendTransactionWithSnakeComment.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify an undeclared snake-format comment when the recipient declares no memo', async function () { + await mainnetCoin + .verifyTransaction({ + txParams: { + recipients: [ + { + address: 'EQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG', + amount: '10000000', + }, + ], + }, + txPrebuild: { + txHex: Buffer.from(testData.signedSendTransactionWithSnakeComment.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any) + .should.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail closed on a malformed snake-format comment continuation', async function () { + const txHex = Buffer.from(testData.signedSendTransactionWithMalformedComment.tx, 'base64').toString('hex'); + await mainnetCoin.explainTransaction({ txHex }).should.rejectedWith('Invalid transaction'); + await mainnetCoin + .verifyTransaction({ + txParams: { recipients: [testData.signedSendTransactionWithMalformedComment.recipient] }, + txPrebuild: { txHex, txInfo: {} }, + verification: {}, + } as any) + .should.rejectedWith('invalid raw transaction'); + }); + + it('should explain a whales deposit opcode without exposing its payload as memo', async function () { + const explainedTransaction = (await mainnetCoin.explainTransaction({ + txHex: Buffer.from(testData.signedTonWhalesDepositTransaction.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + should.equal(explainedTransaction.outputs[0]!.memo, undefined); + }); + + it('should classify a plain Deposit comment as a whales deposit and not expose it as memo', async function () { + const explainedTransaction = (await mainnetCoin.explainTransaction({ + txHex: Buffer.from(testData.signedSendTransactionWithDepositComment.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + should.equal(explainedTransaction.outputs[0]!.memo, undefined); + }); + + // Accepted limitation (both paths): a comment that is exactly 'Deposit', 'Withdraw', or + // the single-nominator withdraw-all text classifies as a staking protocol comment, so a + // plain send whose intended memo happens to be one of those strings cannot verify. + it('should not verify a plain Deposit comment even when Deposit is the declared memo', async function () { + await mainnetCoin + .verifyTransaction({ + txParams: { + recipients: [ + { + address: `${testData.signedSendTransactionWithDepositComment.recipient.address}?memoId=Deposit`, + amount: testData.signedSendTransactionWithDepositComment.recipient.amount, + }, + ], + }, + txPrebuild: { + txHex: Buffer.from(testData.signedSendTransactionWithDepositComment.tx, 'base64').toString('hex'), + txInfo: {}, + }, + verification: {}, + } as any) + .should.be.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); }); describe('Explain Transaction: ', () => { @@ -219,6 +406,13 @@ describe('TON:', function () { }); }); + it('should explain the transfer comment as the output memo', async function () { + const explainedTransaction = (await basecoin.explainTransaction({ + txHex: Buffer.from(testData.signedSendTransactionForMemoId.tx, 'base64').toString('hex'), + })) as TransactionExplanation; + explainedTransaction.outputs[0]!.memo!.should.equal('1234'); + }); + it('should explain a non-bounceable transfer transaction', async function () { const explainedTransaction = (await basecoin.explainTransaction({ txHex: Buffer.from(testData.signedSendTransaction.tx, 'base64').toString('hex'),