Skip to content

Commit 9467fea

Browse files
committed
feat(sdk-coin-kaspa): transaction util funtions
ticket: cecho-663
1 parent abb73a8 commit 9467fea

5 files changed

Lines changed: 256 additions & 6 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,65 @@ export class Transaction extends BaseTransaction {
2424
return this._txData;
2525
}
2626

27+
/**
28+
* Get the transaction fee in sompi.
29+
* If fee was explicitly set, returns that. Otherwise computes from inputs - outputs.
30+
*/
31+
get getFee(): string {
32+
if (this._txData.fee) {
33+
return this._txData.fee;
34+
}
35+
let totalIn = BigInt(0);
36+
let totalOut = BigInt(0);
37+
for (const input of this._txData.inputs) {
38+
totalIn += BigInt(input.amount);
39+
}
40+
for (const output of this._txData.outputs) {
41+
totalOut += BigInt(output.amount);
42+
}
43+
return (totalIn - totalOut).toString();
44+
}
45+
46+
/**
47+
* Returns the signable payload for TSS/MPC signing.
48+
*
49+
* For Kaspa, each input has its own sighash (BIP-143-like scheme with Blake2b).
50+
* This returns the sighash for the first input, which is what TSS signs.
51+
* For multi-input transactions, all inputs share the same key so the same
52+
* Schnorr signature is applied to each input's individual sighash in addSignature().
53+
*
54+
* @see ADA's Transaction.signablePayload for the equivalent pattern
55+
*/
56+
get signablePayload(): Buffer {
57+
if (this._txData.inputs.length === 0) {
58+
throw new Error('Cannot compute signablePayload: no inputs');
59+
}
60+
return computeKaspaSigningHash(this._txData, 0, SIGHASH_ALL);
61+
}
62+
63+
/**
64+
* Apply a Schnorr signature produced by TSS/MPC signing to all inputs.
65+
*
66+
* In TSS flow, the keyserver signs the first input's sighash. Since each input
67+
* has a different sighash, we re-sign each input individually using the
68+
* x-only public key derived from the compressed public key.
69+
*
70+
* @param publicKey compressed secp256k1 public key (33 bytes hex)
71+
* @param signature 64-byte Schnorr signature buffer (from TSS)
72+
* @param sigHashType SigHash type (default: SIGHASH_ALL)
73+
*/
74+
addSignature(publicKey: string, signature: Buffer, sigHashType: number = SIGHASH_ALL): void {
75+
if (signature.length !== 64) {
76+
throw new Error(`Expected 64-byte Schnorr signature, got ${signature.length}`);
77+
}
78+
79+
for (let i = 0; i < this._txData.inputs.length; i++) {
80+
// Each input gets the same signature format: 64-byte sig + sighash type byte
81+
const sigWithType = Buffer.concat([signature, Buffer.from([sigHashType])]);
82+
this._txData.inputs[i].signatureScript = sigWithType.toString('hex');
83+
}
84+
}
85+
2786
/**
2887
* Sign all inputs with the given private key using Schnorr signatures.
2988
*

modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { BaseTransactionBuilder, BaseTransaction, BaseKey, SigningError } from '@bitgo/sdk-core';
1+
import {
2+
BaseTransactionBuilder,
3+
BaseTransaction,
4+
BaseKey,
5+
PublicKey as BasePublicKey,
6+
SigningError,
7+
} from '@bitgo/sdk-core';
28
import { BaseCoin as CoinConfig } from '@bitgo/statics';
39
import BigNumber from 'bignumber.js';
410
import { Transaction } from './transaction';
@@ -7,12 +13,18 @@ import { isValidKaspaAddress } from './utils';
713
import { KeyPair } from './keyPair';
814
import { DEFAULT_FEE, TX_VERSION } from './constants';
915

16+
interface KaspaSignature {
17+
publicKey: BasePublicKey;
18+
signature: Buffer;
19+
}
20+
1021
export class TransactionBuilder extends BaseTransactionBuilder {
1122
protected _transaction: Transaction;
1223
protected _inputs: KaspaUtxoInput[] = [];
1324
protected _outputs: KaspaTransactionOutput[] = [];
1425
protected _fee: string = DEFAULT_FEE;
1526
protected _fromAddress = '';
27+
protected _signatures: KaspaSignature[] = [];
1628

1729
constructor(coinConfig: Readonly<CoinConfig>) {
1830
super(coinConfig);
@@ -78,6 +90,19 @@ export class TransactionBuilder extends BaseTransactionBuilder {
7890
return this;
7991
}
8092

93+
/**
94+
* Add an externally-produced signature (from TSS/MPC signing) to the transaction.
95+
* The signature will be applied to all inputs during build().
96+
*
97+
* This follows the same pattern as ADA's TransactionBuilder.addSignature().
98+
*
99+
* @param publicKey The compressed secp256k1 public key that produced the signature
100+
* @param signature The 64-byte Schnorr signature buffer
101+
*/
102+
addSignature(publicKey: BasePublicKey, signature: Buffer): void {
103+
this._signatures.push({ publicKey, signature });
104+
}
105+
81106
/** @inheritDoc */
82107
protected fromImplementation(rawTransaction: string): Transaction {
83108
const tx = Transaction.fromHex((this as any)._coinConfig?.name || 'kaspa', rawTransaction);
@@ -101,6 +126,12 @@ export class TransactionBuilder extends BaseTransactionBuilder {
101126
};
102127

103128
this._transaction = new Transaction((this as any)._coinConfig?.name || 'kaspa', txData);
129+
130+
// Apply any externally-produced signatures (from TSS/MPC)
131+
for (const sig of this._signatures) {
132+
this._transaction.addSignature(sig.publicKey.pub, sig.signature);
133+
}
134+
104135
return this._transaction;
105136
}
106137

modules/sdk-coin-kaspa/src/lib/transactionBuilderFactory.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1+
import { BaseTransactionBuilderFactory, NotImplementedError } from '@bitgo/sdk-core';
12
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
23
import { TransactionBuilder } from './transactionBuilder';
34

4-
export class TransactionBuilderFactory {
5-
protected _coinConfig: Readonly<StaticsBaseCoin>;
6-
5+
export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
76
constructor(coinConfig: Readonly<StaticsBaseCoin>) {
8-
this._coinConfig = coinConfig;
7+
super(coinConfig);
98
}
109

1110
/**
@@ -15,9 +14,20 @@ export class TransactionBuilderFactory {
1514
return new TransactionBuilder(this._coinConfig);
1615
}
1716

17+
/** @inheritdoc */
18+
getTransferBuilder(): TransactionBuilder {
19+
return this.getBuilder();
20+
}
21+
1822
/**
19-
* Reconstruct a transaction builder from a raw transaction hex.
23+
* Kaspa does not have a wallet initialization transaction.
24+
* @throws NotImplementedError
2025
*/
26+
getWalletInitializationBuilder(): never {
27+
throw new NotImplementedError('getWalletInitializationBuilder is not supported for Kaspa');
28+
}
29+
30+
/** @inheritdoc */
2131
from(rawTransaction: string): TransactionBuilder {
2232
const builder = this.getBuilder();
2333
builder.from(rawTransaction);

modules/sdk-coin-kaspa/test/unit/transaction.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,106 @@ describe('Kaspa Transaction', function () {
169169
});
170170
});
171171

172+
describe('getFee', function () {
173+
it('should return explicit fee when set in txData', function () {
174+
const tx = new Transaction(COIN, TRANSACTIONS.simple);
175+
assert.equal(tx.getFee, '2000');
176+
});
177+
178+
it('should compute fee from inputs - outputs when fee is not set', function () {
179+
const txData = { ...TRANSACTIONS.simple };
180+
delete txData.fee;
181+
const tx = new Transaction(COIN, txData);
182+
// input: 100000000, output: 99998000, fee = 2000
183+
assert.equal(tx.getFee, '2000');
184+
});
185+
});
186+
187+
describe('signablePayload', function () {
188+
it('should return a 32-byte Buffer (Blake2b hash)', function () {
189+
const tx = new Transaction(COIN, TRANSACTIONS.simple);
190+
const payload = tx.signablePayload;
191+
assert.ok(Buffer.isBuffer(payload));
192+
assert.equal(payload.length, 32);
193+
});
194+
195+
it('should throw when transaction has no inputs', function () {
196+
const tx = new Transaction(COIN);
197+
assert.throws(() => {
198+
tx.signablePayload;
199+
}, /no inputs/);
200+
});
201+
202+
it('should return deterministic hash for same transaction data', function () {
203+
const tx1 = new Transaction(COIN, TRANSACTIONS.simple);
204+
const tx2 = new Transaction(COIN, TRANSACTIONS.simple);
205+
assert.ok(tx1.signablePayload.equals(tx2.signablePayload));
206+
});
207+
208+
it('should return different hashes for different transactions', function () {
209+
const tx1 = new Transaction(COIN, TRANSACTIONS.simple);
210+
const tx2 = new Transaction(COIN, TRANSACTIONS.multiInput);
211+
assert.ok(!tx1.signablePayload.equals(tx2.signablePayload));
212+
});
213+
});
214+
215+
describe('addSignature', function () {
216+
it('should apply a 64-byte Schnorr signature to all inputs', function () {
217+
const tx = new Transaction(COIN, TRANSACTIONS.simple);
218+
const fakeSig = Buffer.alloc(64, 0xab);
219+
tx.addSignature(KEYS.pub, fakeSig);
220+
221+
assert.equal(tx.txData.inputs.length, 1);
222+
assert.ok(tx.txData.inputs[0].signatureScript);
223+
// 65 bytes = 130 hex chars (64 sig + 1 sighash type)
224+
assert.equal(tx.txData.inputs[0].signatureScript!.length, 130);
225+
});
226+
227+
it('should apply signature to all inputs of a multi-input tx', function () {
228+
const tx = new Transaction(COIN, TRANSACTIONS.multiInput);
229+
const fakeSig = Buffer.alloc(64, 0xcd);
230+
tx.addSignature(KEYS.pub, fakeSig);
231+
232+
assert.equal(tx.txData.inputs.length, 2);
233+
for (const input of tx.txData.inputs) {
234+
assert.ok(input.signatureScript);
235+
assert.equal(input.signatureScript!.length, 130);
236+
}
237+
});
238+
239+
it('should throw for non-64-byte signature', function () {
240+
const tx = new Transaction(COIN, TRANSACTIONS.simple);
241+
assert.throws(() => {
242+
tx.addSignature(KEYS.pub, Buffer.alloc(32));
243+
}, /64-byte/);
244+
});
245+
246+
it('should append SIGHASH_ALL byte at the end', function () {
247+
const tx = new Transaction(COIN, TRANSACTIONS.simple);
248+
const fakeSig = Buffer.alloc(64, 0xab);
249+
tx.addSignature(KEYS.pub, fakeSig);
250+
const sigHex = tx.txData.inputs[0].signatureScript!;
251+
const lastByte = parseInt(sigHex.slice(-2), 16);
252+
assert.equal(lastByte, SIGHASH_ALL);
253+
});
254+
255+
it('should produce a signature that verifies when signed with the correct private key', function () {
256+
const tx = new Transaction(COIN, TRANSACTIONS.simple);
257+
// Sign properly with private key to get a real signature
258+
const privKey = Buffer.from(KEYS.prv, 'hex');
259+
tx.sign(privKey);
260+
const realSigHex = tx.txData.inputs[0].signatureScript!;
261+
const realSig = Buffer.from(realSigHex.slice(0, 128), 'hex'); // 64-byte Schnorr sig
262+
263+
// Now create a fresh tx and use addSignature instead
264+
const tx2 = new Transaction(COIN, TRANSACTIONS.simple);
265+
tx2.addSignature(KEYS.pub, realSig);
266+
267+
// The signature scripts should match (same sig bytes + same sighash type)
268+
assert.equal(tx2.txData.inputs[0].signatureScript, tx.txData.inputs[0].signatureScript);
269+
});
270+
});
271+
172272
describe('Serialization', function () {
173273
it('toJson should return a copy of txData', function () {
174274
const tx = new Transaction(COIN, TRANSACTIONS.simple);

modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,41 @@ describe('Kaspa TransactionBuilder', function () {
209209
});
210210
});
211211

212+
describe('addSignature (TSS/MPC flow)', function () {
213+
it('should store the signature and apply it during build', async function () {
214+
builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000');
215+
216+
// Simulate TSS: build unsigned, get signablePayload, produce signature externally
217+
const unsignedTx = (await builder.build()) as Transaction;
218+
const signablePayload = unsignedTx.signablePayload;
219+
assert.ok(signablePayload.length === 32);
220+
221+
// Now add signature via builder addSignature (like wallet-platform does)
222+
const fakeSig = Buffer.alloc(64, 0xab);
223+
builder.addSignature({ pub: KEYS.pub }, fakeSig);
224+
225+
// Rebuild — signatures should be applied
226+
const signedTx = (await builder.build()) as Transaction;
227+
assert.ok(signedTx.txData.inputs[0].signatureScript);
228+
assert.equal(signedTx.txData.inputs[0].signatureScript!.length, 130);
229+
});
230+
231+
it('should apply signature to multi-input transactions', async function () {
232+
builder.addInputs([UTXOS.simple, UTXOS.second]).to(ADDRESSES.recipient, '299998000').fee('2000');
233+
await builder.build();
234+
235+
const fakeSig = Buffer.alloc(64, 0xcd);
236+
builder.addSignature({ pub: KEYS.pub }, fakeSig);
237+
238+
const signedTx = (await builder.build()) as Transaction;
239+
assert.equal(signedTx.txData.inputs.length, 2);
240+
for (const input of signedTx.txData.inputs) {
241+
assert.ok(input.signatureScript);
242+
assert.equal(input.signatureScript!.length, 130);
243+
}
244+
});
245+
});
246+
212247
describe('from (rebuild from hex)', function () {
213248
it('should reconstruct a builder from a serialized transaction', async function () {
214249
builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000');
@@ -238,6 +273,21 @@ describe('Kaspa TransactionBuilderFactory', function () {
238273
});
239274
});
240275

276+
describe('getTransferBuilder', function () {
277+
it('should return a new TransactionBuilder (same as getBuilder)', function () {
278+
const builder = factory.getTransferBuilder();
279+
assert.ok(builder instanceof TransactionBuilder);
280+
});
281+
282+
it('should build a valid transaction', async function () {
283+
const builder = factory.getTransferBuilder();
284+
builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000');
285+
const tx = (await builder.build()) as Transaction;
286+
assert.equal(tx.txData.inputs.length, 1);
287+
assert.equal(tx.txData.outputs.length, 1);
288+
});
289+
});
290+
241291
describe('from', function () {
242292
it('should reconstruct a builder from a serialized transaction hex', async function () {
243293
const originalBuilder = factory.getBuilder();

0 commit comments

Comments
 (0)