From 3bdc436727a9a504a0f3be360bbc50a859014749 Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Mon, 30 Mar 2026 10:37:15 -0400 Subject: [PATCH] feat(sdk-coin-canton): return extended payload from signablePayload Override signablePayload in Transaction and WalletInitTransaction to return the full extended binary payload (transaction metadata + hash) instead of just the raw hash. This ensures signableHex matches what the HSM signs, fixing TxIntentMismatch errors. Ticket: WP-8187 --- .../src/lib/transaction/transaction.ts | 18 +- .../walletInitTransaction.ts | 38 ++++- .../test/unit/signablePayload.ts | 161 ++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-coin-canton/test/unit/signablePayload.ts diff --git a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts index d60f144785..70979b50e8 100644 --- a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts @@ -173,7 +173,23 @@ export class Transaction extends BaseTransaction { if (!this._prepareCommand) { throw new InvalidTransactionError('Empty transaction data'); } - return Buffer.from(this._prepareCommand.preparedTransactionHash, 'base64'); + + const hash = Buffer.from(this._prepareCommand.preparedTransactionHash, 'base64'); + const preparedTx = this._prepareCommand.preparedTransaction; + + // If no preparedTransaction available, fall back to hash only + if (!preparedTx) { + return hash; + } + + // Extended payload: itemCount(4 LE) || txLen(4 LE) || preparedTx || hash + const preparedTxBuf = Buffer.from(preparedTx, 'base64'); + const itemCountBuf = Buffer.alloc(4); + itemCountBuf.writeUInt32LE(2, 0); // 2 items: preparedTx + hash + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(preparedTxBuf.length, 0); + + return Buffer.concat([itemCountBuf, lenBuf, preparedTxBuf, hash]); } fromRawTransaction(rawTx: string): void { diff --git a/modules/sdk-coin-canton/src/lib/walletInitialization/walletInitTransaction.ts b/modules/sdk-coin-canton/src/lib/walletInitialization/walletInitTransaction.ts index 0564753f67..bd3ed5e074 100644 --- a/modules/sdk-coin-canton/src/lib/walletInitialization/walletInitTransaction.ts +++ b/modules/sdk-coin-canton/src/lib/walletInitialization/walletInitTransaction.ts @@ -80,7 +80,43 @@ export class WalletInitTransaction extends BaseTransaction { if (!this._preparedParty) { throw new InvalidTransactionError('Empty transaction data'); } - return Buffer.from(this._preparedParty.multiHash, 'base64'); + + const multiHash = Buffer.from(this._preparedParty.multiHash, 'base64'); + const topologyTxs = this._preparedParty.topologyTransactions; + + // If no topology transactions, fall back to multiHash only + if (!topologyTxs || topologyTxs.length === 0) { + return multiHash; + } + + const shouldIncludeTxnType = this._preparedParty.shouldIncludeTxnType ?? false; + const itemCount = topologyTxs.length + 1; + const parts: Buffer[] = []; + + // Optional txnType for version >0.5.x + if (shouldIncludeTxnType) { + const txnTypeBuf = Buffer.alloc(4); + txnTypeBuf.writeUInt32LE(0, 0); + parts.push(txnTypeBuf); + } + + // Item count + const itemCountBuf = Buffer.alloc(4); + itemCountBuf.writeUInt32LE(itemCount, 0); + parts.push(itemCountBuf); + + // Topology transactions with length prefixes + for (const tx of topologyTxs) { + const txBuf = Buffer.from(tx, 'base64'); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(txBuf.length, 0); + parts.push(lenBuf, txBuf); + } + + // Append multiHash + parts.push(multiHash); + + return Buffer.concat(parts); } fromRawTransaction(rawTx: string): void { diff --git a/modules/sdk-coin-canton/test/unit/signablePayload.ts b/modules/sdk-coin-canton/test/unit/signablePayload.ts new file mode 100644 index 0000000000..800aef215c --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/signablePayload.ts @@ -0,0 +1,161 @@ +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; + +import { Transaction, WalletInitTransaction } from '../../src'; +import { DUMMY_HASH } from '../../src/lib/constant'; + +describe('signablePayload', () => { + const coinConfig = coins.get('tcanton'); + + describe('Transaction', () => { + it('should return extended payload when preparedTransaction is present', () => { + const tx = new Transaction(coinConfig); + tx.transactionType = TransactionType.Send; + tx.prepareCommand = { + preparedTransaction: Buffer.from('test-prepared-tx').toString('base64'), + preparedTransactionHash: Buffer.from('test-hash-32-bytes-long-padding!').toString('base64'), + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }; + + const payload = tx.signablePayload; + + // Parse the extended payload + const itemCount = payload.readUInt32LE(0); + assert.strictEqual(itemCount, 2); + + const txLen = payload.readUInt32LE(4); + const preparedTxBuf = Buffer.from('test-prepared-tx'); + assert.strictEqual(txLen, preparedTxBuf.length); + + const extractedTx = payload.subarray(8, 8 + txLen); + assert.deepStrictEqual(extractedTx, preparedTxBuf); + + const extractedHash = payload.subarray(8 + txLen); + assert.deepStrictEqual(extractedHash, Buffer.from('test-hash-32-bytes-long-padding!')); + + // Verify total length: 4 (itemCount) + 4 (txLen) + preparedTx.length + hash.length + assert.strictEqual(payload.length, 4 + 4 + preparedTxBuf.length + 32); + }); + + it('should return hash only when preparedTransaction is missing', () => { + const tx = new Transaction(coinConfig); + tx.transactionType = TransactionType.Send; + tx.prepareCommand = { + preparedTransactionHash: Buffer.from('test-hash-32-bytes-long-padding!').toString('base64'), + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }; + + const payload = tx.signablePayload; + assert.deepStrictEqual(payload, Buffer.from('test-hash-32-bytes-long-padding!')); + }); + + it('should return DUMMY_HASH for TransferAcknowledge', () => { + const tx = new Transaction(coinConfig); + tx.transactionType = TransactionType.TransferAcknowledge; + + const payload = tx.signablePayload; + assert.deepStrictEqual(payload, Buffer.from(DUMMY_HASH, 'base64')); + }); + + it('should throw when prepareCommand is not set', () => { + const tx = new Transaction(coinConfig); + tx.transactionType = TransactionType.Send; + + assert.throws(() => tx.signablePayload, /Empty transaction data/); + }); + }); + + describe('WalletInitTransaction', () => { + it('should return extended payload with topology transactions', () => { + const tx = new WalletInitTransaction(coinConfig); + const topoTx1 = Buffer.from('topology-tx-1'); + const topoTx2 = Buffer.from('topology-tx-2'); + const multiHash = Buffer.from('multi-hash-32-bytes-long-paddin!'); + + tx.preparedParty = { + partyId: 'test-party', + publicKeyFingerprint: 'test-fingerprint', + topologyTransactions: [topoTx1.toString('base64'), topoTx2.toString('base64')], + multiHash: multiHash.toString('base64'), + }; + + const payload = tx.signablePayload; + + // Item count = 2 topology txs + 1 multiHash = 3 + const itemCount = payload.readUInt32LE(0); + assert.strictEqual(itemCount, 3); + + // First topology tx + let offset = 4; + const len1 = payload.readUInt32LE(offset); + assert.strictEqual(len1, topoTx1.length); + offset += 4; + assert.deepStrictEqual(payload.subarray(offset, offset + len1), topoTx1); + offset += len1; + + // Second topology tx + const len2 = payload.readUInt32LE(offset); + assert.strictEqual(len2, topoTx2.length); + offset += 4; + assert.deepStrictEqual(payload.subarray(offset, offset + len2), topoTx2); + offset += len2; + + // multiHash at the end + assert.deepStrictEqual(payload.subarray(offset), multiHash); + }); + + it('should include txnType prefix when shouldIncludeTxnType is true', () => { + const tx = new WalletInitTransaction(coinConfig); + const topoTx = Buffer.from('topology-tx'); + const multiHash = Buffer.from('multi-hash-value'); + + tx.preparedParty = { + partyId: 'test-party', + publicKeyFingerprint: 'test-fingerprint', + topologyTransactions: [topoTx.toString('base64')], + multiHash: multiHash.toString('base64'), + shouldIncludeTxnType: true, + }; + + const payload = tx.signablePayload; + + // First 4 bytes: txnType = 0 + const txnType = payload.readUInt32LE(0); + assert.strictEqual(txnType, 0); + + // Next 4 bytes: item count = 2 (1 topo + 1 multiHash) + const itemCount = payload.readUInt32LE(4); + assert.strictEqual(itemCount, 2); + + // Topology tx with length prefix + const len = payload.readUInt32LE(8); + assert.strictEqual(len, topoTx.length); + assert.deepStrictEqual(payload.subarray(12, 12 + len), topoTx); + + // multiHash at the end + assert.deepStrictEqual(payload.subarray(12 + len), multiHash); + }); + + it('should return multiHash only when topologyTransactions is empty', () => { + const tx = new WalletInitTransaction(coinConfig); + const multiHash = Buffer.from('multi-hash-value'); + + tx.preparedParty = { + partyId: 'test-party', + publicKeyFingerprint: 'test-fingerprint', + topologyTransactions: [], + multiHash: multiHash.toString('base64'), + }; + + const payload = tx.signablePayload; + assert.deepStrictEqual(payload, multiHash); + }); + + it('should throw when preparedParty is not set', () => { + const tx = new WalletInitTransaction(coinConfig); + + assert.throws(() => tx.signablePayload, /Empty transaction data/); + }); + }); +});