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'),