Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion modules/sdk-coin-canton/src/lib/transaction/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
161 changes: 161 additions & 0 deletions modules/sdk-coin-canton/test/unit/signablePayload.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
});
Loading