Skip to content

Commit 8b0cbf7

Browse files
committed
feat(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
1 parent ade84e8 commit 8b0cbf7

File tree

3 files changed

+215
-2
lines changed

3 files changed

+215
-2
lines changed

modules/sdk-coin-canton/src/lib/transaction/transaction.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,23 @@ export class Transaction extends BaseTransaction {
173173
if (!this._prepareCommand) {
174174
throw new InvalidTransactionError('Empty transaction data');
175175
}
176-
return Buffer.from(this._prepareCommand.preparedTransactionHash, 'base64');
176+
177+
const hash = Buffer.from(this._prepareCommand.preparedTransactionHash, 'base64');
178+
const preparedTx = this._prepareCommand.preparedTransaction;
179+
180+
// If no preparedTransaction available, fall back to hash only
181+
if (!preparedTx) {
182+
return hash;
183+
}
184+
185+
// Extended payload: itemCount(4 LE) || txLen(4 LE) || preparedTx || hash
186+
const preparedTxBuf = Buffer.from(preparedTx, 'base64');
187+
const itemCountBuf = Buffer.alloc(4);
188+
itemCountBuf.writeUInt32LE(2, 0); // 2 items: preparedTx + hash
189+
const lenBuf = Buffer.alloc(4);
190+
lenBuf.writeUInt32LE(preparedTxBuf.length, 0);
191+
192+
return Buffer.concat([itemCountBuf, lenBuf, preparedTxBuf, hash]);
177193
}
178194

179195
fromRawTransaction(rawTx: string): void {

modules/sdk-coin-canton/src/lib/walletInitialization/walletInitTransaction.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,43 @@ export class WalletInitTransaction extends BaseTransaction {
8080
if (!this._preparedParty) {
8181
throw new InvalidTransactionError('Empty transaction data');
8282
}
83-
return Buffer.from(this._preparedParty.multiHash, 'base64');
83+
84+
const multiHash = Buffer.from(this._preparedParty.multiHash, 'base64');
85+
const topologyTxs = this._preparedParty.topologyTransactions;
86+
87+
// If no topology transactions, fall back to multiHash only
88+
if (!topologyTxs || topologyTxs.length === 0) {
89+
return multiHash;
90+
}
91+
92+
const shouldIncludeTxnType = this._preparedParty.shouldIncludeTxnType ?? false;
93+
const itemCount = topologyTxs.length + 1;
94+
const parts: Buffer[] = [];
95+
96+
// Optional txnType for version >0.5.x
97+
if (shouldIncludeTxnType) {
98+
const txnTypeBuf = Buffer.alloc(4);
99+
txnTypeBuf.writeUInt32LE(0, 0);
100+
parts.push(txnTypeBuf);
101+
}
102+
103+
// Item count
104+
const itemCountBuf = Buffer.alloc(4);
105+
itemCountBuf.writeUInt32LE(itemCount, 0);
106+
parts.push(itemCountBuf);
107+
108+
// Topology transactions with length prefixes
109+
for (const tx of topologyTxs) {
110+
const txBuf = Buffer.from(tx, 'base64');
111+
const lenBuf = Buffer.alloc(4);
112+
lenBuf.writeUInt32LE(txBuf.length, 0);
113+
parts.push(lenBuf, txBuf);
114+
}
115+
116+
// Append multiHash
117+
parts.push(multiHash);
118+
119+
return Buffer.concat(parts);
84120
}
85121

86122
fromRawTransaction(rawTx: string): void {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import assert from 'assert';
2+
import { coins } from '@bitgo/statics';
3+
import { TransactionType } from '@bitgo/sdk-core';
4+
5+
import { Transaction, WalletInitTransaction } from '../../src';
6+
import { DUMMY_HASH } from '../../src/lib/constant';
7+
8+
describe('signablePayload', () => {
9+
const coinConfig = coins.get('tcanton');
10+
11+
describe('Transaction', () => {
12+
it('should return extended payload when preparedTransaction is present', () => {
13+
const tx = new Transaction(coinConfig);
14+
tx.transactionType = TransactionType.Send;
15+
tx.prepareCommand = {
16+
preparedTransaction: Buffer.from('test-prepared-tx').toString('base64'),
17+
preparedTransactionHash: Buffer.from('test-hash-32-bytes-long-padding!').toString('base64'),
18+
hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2',
19+
};
20+
21+
const payload = tx.signablePayload;
22+
23+
// Parse the extended payload
24+
const itemCount = payload.readUInt32LE(0);
25+
assert.strictEqual(itemCount, 2);
26+
27+
const txLen = payload.readUInt32LE(4);
28+
const preparedTxBuf = Buffer.from('test-prepared-tx');
29+
assert.strictEqual(txLen, preparedTxBuf.length);
30+
31+
const extractedTx = payload.subarray(8, 8 + txLen);
32+
assert.deepStrictEqual(extractedTx, preparedTxBuf);
33+
34+
const extractedHash = payload.subarray(8 + txLen);
35+
assert.deepStrictEqual(extractedHash, Buffer.from('test-hash-32-bytes-long-padding!'));
36+
37+
// Verify total length: 4 (itemCount) + 4 (txLen) + preparedTx.length + hash.length
38+
assert.strictEqual(payload.length, 4 + 4 + preparedTxBuf.length + 32);
39+
});
40+
41+
it('should return hash only when preparedTransaction is missing', () => {
42+
const tx = new Transaction(coinConfig);
43+
tx.transactionType = TransactionType.Send;
44+
tx.prepareCommand = {
45+
preparedTransactionHash: Buffer.from('test-hash-32-bytes-long-padding!').toString('base64'),
46+
hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2',
47+
};
48+
49+
const payload = tx.signablePayload;
50+
assert.deepStrictEqual(payload, Buffer.from('test-hash-32-bytes-long-padding!'));
51+
});
52+
53+
it('should return DUMMY_HASH for TransferAcknowledge', () => {
54+
const tx = new Transaction(coinConfig);
55+
tx.transactionType = TransactionType.TransferAcknowledge;
56+
57+
const payload = tx.signablePayload;
58+
assert.deepStrictEqual(payload, Buffer.from(DUMMY_HASH, 'base64'));
59+
});
60+
61+
it('should throw when prepareCommand is not set', () => {
62+
const tx = new Transaction(coinConfig);
63+
tx.transactionType = TransactionType.Send;
64+
65+
assert.throws(() => tx.signablePayload, /Empty transaction data/);
66+
});
67+
});
68+
69+
describe('WalletInitTransaction', () => {
70+
it('should return extended payload with topology transactions', () => {
71+
const tx = new WalletInitTransaction(coinConfig);
72+
const topoTx1 = Buffer.from('topology-tx-1');
73+
const topoTx2 = Buffer.from('topology-tx-2');
74+
const multiHash = Buffer.from('multi-hash-32-bytes-long-paddin!');
75+
76+
tx.preparedParty = {
77+
partyId: 'test-party',
78+
publicKeyFingerprint: 'test-fingerprint',
79+
topologyTransactions: [topoTx1.toString('base64'), topoTx2.toString('base64')],
80+
multiHash: multiHash.toString('base64'),
81+
};
82+
83+
const payload = tx.signablePayload;
84+
85+
// Item count = 2 topology txs + 1 multiHash = 3
86+
const itemCount = payload.readUInt32LE(0);
87+
assert.strictEqual(itemCount, 3);
88+
89+
// First topology tx
90+
let offset = 4;
91+
const len1 = payload.readUInt32LE(offset);
92+
assert.strictEqual(len1, topoTx1.length);
93+
offset += 4;
94+
assert.deepStrictEqual(payload.subarray(offset, offset + len1), topoTx1);
95+
offset += len1;
96+
97+
// Second topology tx
98+
const len2 = payload.readUInt32LE(offset);
99+
assert.strictEqual(len2, topoTx2.length);
100+
offset += 4;
101+
assert.deepStrictEqual(payload.subarray(offset, offset + len2), topoTx2);
102+
offset += len2;
103+
104+
// multiHash at the end
105+
assert.deepStrictEqual(payload.subarray(offset), multiHash);
106+
});
107+
108+
it('should include txnType prefix when shouldIncludeTxnType is true', () => {
109+
const tx = new WalletInitTransaction(coinConfig);
110+
const topoTx = Buffer.from('topology-tx');
111+
const multiHash = Buffer.from('multi-hash-value');
112+
113+
tx.preparedParty = {
114+
partyId: 'test-party',
115+
publicKeyFingerprint: 'test-fingerprint',
116+
topologyTransactions: [topoTx.toString('base64')],
117+
multiHash: multiHash.toString('base64'),
118+
shouldIncludeTxnType: true,
119+
};
120+
121+
const payload = tx.signablePayload;
122+
123+
// First 4 bytes: txnType = 0
124+
const txnType = payload.readUInt32LE(0);
125+
assert.strictEqual(txnType, 0);
126+
127+
// Next 4 bytes: item count = 2 (1 topo + 1 multiHash)
128+
const itemCount = payload.readUInt32LE(4);
129+
assert.strictEqual(itemCount, 2);
130+
131+
// Topology tx with length prefix
132+
const len = payload.readUInt32LE(8);
133+
assert.strictEqual(len, topoTx.length);
134+
assert.deepStrictEqual(payload.subarray(12, 12 + len), topoTx);
135+
136+
// multiHash at the end
137+
assert.deepStrictEqual(payload.subarray(12 + len), multiHash);
138+
});
139+
140+
it('should return multiHash only when topologyTransactions is empty', () => {
141+
const tx = new WalletInitTransaction(coinConfig);
142+
const multiHash = Buffer.from('multi-hash-value');
143+
144+
tx.preparedParty = {
145+
partyId: 'test-party',
146+
publicKeyFingerprint: 'test-fingerprint',
147+
topologyTransactions: [],
148+
multiHash: multiHash.toString('base64'),
149+
};
150+
151+
const payload = tx.signablePayload;
152+
assert.deepStrictEqual(payload, multiHash);
153+
});
154+
155+
it('should throw when preparedParty is not set', () => {
156+
const tx = new WalletInitTransaction(coinConfig);
157+
158+
assert.throws(() => tx.signablePayload, /Empty transaction data/);
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)