diff --git a/modules/sdk-coin-kaspa/src/lib/transaction.ts b/modules/sdk-coin-kaspa/src/lib/transaction.ts index ea7c10958e..52a3f2acbc 100644 --- a/modules/sdk-coin-kaspa/src/lib/transaction.ts +++ b/modules/sdk-coin-kaspa/src/lib/transaction.ts @@ -24,6 +24,65 @@ export class Transaction extends BaseTransaction { return this._txData; } + /** + * Get the transaction fee in sompi. + * If fee was explicitly set, returns that. Otherwise computes from inputs - outputs. + */ + get getFee(): string { + if (this._txData.fee) { + return this._txData.fee; + } + let totalIn = BigInt(0); + let totalOut = BigInt(0); + for (const input of this._txData.inputs) { + totalIn += BigInt(input.amount); + } + for (const output of this._txData.outputs) { + totalOut += BigInt(output.amount); + } + return (totalIn - totalOut).toString(); + } + + /** + * Returns the signable payload for TSS/MPC signing. + * + * For Kaspa, each input has its own sighash (BIP-143-like scheme with Blake2b). + * This returns the sighash for the first input, which is what TSS signs. + * For multi-input transactions, all inputs share the same key so the same + * Schnorr signature is applied to each input's individual sighash in addSignature(). + * + * @see ADA's Transaction.signablePayload for the equivalent pattern + */ + get signablePayload(): Buffer { + if (this._txData.inputs.length === 0) { + throw new Error('Cannot compute signablePayload: no inputs'); + } + return computeKaspaSigningHash(this._txData, 0, SIGHASH_ALL); + } + + /** + * Apply a Schnorr signature produced by TSS/MPC signing to all inputs. + * + * In TSS flow, the keyserver signs the first input's sighash. Since each input + * has a different sighash, we re-sign each input individually using the + * x-only public key derived from the compressed public key. + * + * @param publicKey compressed secp256k1 public key (33 bytes hex) + * @param signature 64-byte Schnorr signature buffer (from TSS) + * @param sigHashType SigHash type (default: SIGHASH_ALL) + */ + addSignature(publicKey: string, signature: Buffer, sigHashType: number = SIGHASH_ALL): void { + if (signature.length !== 64) { + throw new Error(`Expected 64-byte Schnorr signature, got ${signature.length}`); + } + + for (let i = 0; i < this._txData.inputs.length; i++) { + // Each input gets the same signature format: 64-byte sig + sighash type byte + const sigWithType = Buffer.concat([signature, Buffer.from([sigHashType])]); + this._txData.inputs[i].signatureScript = sigWithType.toString('hex'); + } + } + /** * Sign all inputs with the given private key using Schnorr signatures. * diff --git a/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts b/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts index 37679a440d..bfdaee80e8 100644 --- a/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts @@ -1,4 +1,10 @@ -import { BaseTransactionBuilder, BaseTransaction, BaseKey, SigningError } from '@bitgo/sdk-core'; +import { + BaseTransactionBuilder, + BaseTransaction, + BaseKey, + PublicKey as BasePublicKey, + SigningError, +} from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; import { Transaction } from './transaction'; @@ -7,12 +13,18 @@ import { isValidKaspaAddress } from './utils'; import { KeyPair } from './keyPair'; import { DEFAULT_FEE, TX_VERSION } from './constants'; +interface KaspaSignature { + publicKey: BasePublicKey; + signature: Buffer; +} + export class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; protected _inputs: KaspaUtxoInput[] = []; protected _outputs: KaspaTransactionOutput[] = []; protected _fee: string = DEFAULT_FEE; protected _fromAddress = ''; + protected _signatures: KaspaSignature[] = []; constructor(coinConfig: Readonly) { super(coinConfig); @@ -78,6 +90,19 @@ export class TransactionBuilder extends BaseTransactionBuilder { return this; } + /** + * Add an externally-produced signature (from TSS/MPC signing) to the transaction. + * The signature will be applied to all inputs during build(). + * + * This follows the same pattern as ADA's TransactionBuilder.addSignature(). + * + * @param publicKey The compressed secp256k1 public key that produced the signature + * @param signature The 64-byte Schnorr signature buffer + */ + addSignature(publicKey: BasePublicKey, signature: Buffer): void { + this._signatures.push({ publicKey, signature }); + } + /** @inheritDoc */ protected fromImplementation(rawTransaction: string): Transaction { const tx = Transaction.fromHex((this as any)._coinConfig?.name || 'kaspa', rawTransaction); @@ -101,6 +126,12 @@ export class TransactionBuilder extends BaseTransactionBuilder { }; this._transaction = new Transaction((this as any)._coinConfig?.name || 'kaspa', txData); + + // Apply any externally-produced signatures (from TSS/MPC) + for (const sig of this._signatures) { + this._transaction.addSignature(sig.publicKey.pub, sig.signature); + } + return this._transaction; } diff --git a/modules/sdk-coin-kaspa/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-kaspa/src/lib/transactionBuilderFactory.ts index 2e59e3f671..cbbe0d96df 100644 --- a/modules/sdk-coin-kaspa/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-kaspa/src/lib/transactionBuilderFactory.ts @@ -1,11 +1,10 @@ +import { BaseTransactionBuilderFactory, NotImplementedError } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import { TransactionBuilder } from './transactionBuilder'; -export class TransactionBuilderFactory { - protected _coinConfig: Readonly; - +export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(coinConfig: Readonly) { - this._coinConfig = coinConfig; + super(coinConfig); } /** @@ -15,9 +14,20 @@ export class TransactionBuilderFactory { return new TransactionBuilder(this._coinConfig); } + /** @inheritdoc */ + getTransferBuilder(): TransactionBuilder { + return this.getBuilder(); + } + /** - * Reconstruct a transaction builder from a raw transaction hex. + * Kaspa does not have a wallet initialization transaction. + * @throws NotImplementedError */ + getWalletInitializationBuilder(): never { + throw new NotImplementedError('getWalletInitializationBuilder is not supported for Kaspa'); + } + + /** @inheritdoc */ from(rawTransaction: string): TransactionBuilder { const builder = this.getBuilder(); builder.from(rawTransaction); diff --git a/modules/sdk-coin-kaspa/test/unit/transaction.test.ts b/modules/sdk-coin-kaspa/test/unit/transaction.test.ts index 71f11fe23b..e6c9569364 100644 --- a/modules/sdk-coin-kaspa/test/unit/transaction.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/transaction.test.ts @@ -169,6 +169,106 @@ describe('Kaspa Transaction', function () { }); }); + describe('getFee', function () { + it('should return explicit fee when set in txData', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.equal(tx.getFee, '2000'); + }); + + it('should compute fee from inputs - outputs when fee is not set', function () { + const txData = { ...TRANSACTIONS.simple }; + delete txData.fee; + const tx = new Transaction(COIN, txData); + // input: 100000000, output: 99998000, fee = 2000 + assert.equal(tx.getFee, '2000'); + }); + }); + + describe('signablePayload', function () { + it('should return a 32-byte Buffer (Blake2b hash)', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const payload = tx.signablePayload; + assert.ok(Buffer.isBuffer(payload)); + assert.equal(payload.length, 32); + }); + + it('should throw when transaction has no inputs', function () { + const tx = new Transaction(COIN); + assert.throws(() => { + tx.signablePayload; + }, /no inputs/); + }); + + it('should return deterministic hash for same transaction data', function () { + const tx1 = new Transaction(COIN, TRANSACTIONS.simple); + const tx2 = new Transaction(COIN, TRANSACTIONS.simple); + assert.ok(tx1.signablePayload.equals(tx2.signablePayload)); + }); + + it('should return different hashes for different transactions', function () { + const tx1 = new Transaction(COIN, TRANSACTIONS.simple); + const tx2 = new Transaction(COIN, TRANSACTIONS.multiInput); + assert.ok(!tx1.signablePayload.equals(tx2.signablePayload)); + }); + }); + + describe('addSignature', function () { + it('should apply a 64-byte Schnorr signature to all inputs', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const fakeSig = Buffer.alloc(64, 0xab); + tx.addSignature(KEYS.pub, fakeSig); + + assert.equal(tx.txData.inputs.length, 1); + assert.ok(tx.txData.inputs[0].signatureScript); + // 65 bytes = 130 hex chars (64 sig + 1 sighash type) + assert.equal(tx.txData.inputs[0].signatureScript!.length, 130); + }); + + it('should apply signature to all inputs of a multi-input tx', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const fakeSig = Buffer.alloc(64, 0xcd); + tx.addSignature(KEYS.pub, fakeSig); + + assert.equal(tx.txData.inputs.length, 2); + for (const input of tx.txData.inputs) { + assert.ok(input.signatureScript); + assert.equal(input.signatureScript!.length, 130); + } + }); + + it('should throw for non-64-byte signature', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.throws(() => { + tx.addSignature(KEYS.pub, Buffer.alloc(32)); + }, /64-byte/); + }); + + it('should append SIGHASH_ALL byte at the end', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const fakeSig = Buffer.alloc(64, 0xab); + tx.addSignature(KEYS.pub, fakeSig); + const sigHex = tx.txData.inputs[0].signatureScript!; + const lastByte = parseInt(sigHex.slice(-2), 16); + assert.equal(lastByte, SIGHASH_ALL); + }); + + it('should produce a signature that verifies when signed with the correct private key', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + // Sign properly with private key to get a real signature + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + const realSigHex = tx.txData.inputs[0].signatureScript!; + const realSig = Buffer.from(realSigHex.slice(0, 128), 'hex'); // 64-byte Schnorr sig + + // Now create a fresh tx and use addSignature instead + const tx2 = new Transaction(COIN, TRANSACTIONS.simple); + tx2.addSignature(KEYS.pub, realSig); + + // The signature scripts should match (same sig bytes + same sighash type) + assert.equal(tx2.txData.inputs[0].signatureScript, tx.txData.inputs[0].signatureScript); + }); + }); + describe('Serialization', function () { it('toJson should return a copy of txData', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); diff --git a/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts b/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts index 62baf71f47..4bafb0c8ad 100644 --- a/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts @@ -209,6 +209,41 @@ describe('Kaspa TransactionBuilder', function () { }); }); + describe('addSignature (TSS/MPC flow)', function () { + it('should store the signature and apply it during build', async function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + + // Simulate TSS: build unsigned, get signablePayload, produce signature externally + const unsignedTx = (await builder.build()) as Transaction; + const signablePayload = unsignedTx.signablePayload; + assert.ok(signablePayload.length === 32); + + // Now add signature via builder addSignature (like wallet-platform does) + const fakeSig = Buffer.alloc(64, 0xab); + builder.addSignature({ pub: KEYS.pub }, fakeSig); + + // Rebuild — signatures should be applied + const signedTx = (await builder.build()) as Transaction; + assert.ok(signedTx.txData.inputs[0].signatureScript); + assert.equal(signedTx.txData.inputs[0].signatureScript!.length, 130); + }); + + it('should apply signature to multi-input transactions', async function () { + builder.addInputs([UTXOS.simple, UTXOS.second]).to(ADDRESSES.recipient, '299998000').fee('2000'); + await builder.build(); + + const fakeSig = Buffer.alloc(64, 0xcd); + builder.addSignature({ pub: KEYS.pub }, fakeSig); + + const signedTx = (await builder.build()) as Transaction; + assert.equal(signedTx.txData.inputs.length, 2); + for (const input of signedTx.txData.inputs) { + assert.ok(input.signatureScript); + assert.equal(input.signatureScript!.length, 130); + } + }); + }); + describe('from (rebuild from hex)', function () { it('should reconstruct a builder from a serialized transaction', async function () { builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); @@ -238,6 +273,21 @@ describe('Kaspa TransactionBuilderFactory', function () { }); }); + describe('getTransferBuilder', function () { + it('should return a new TransactionBuilder (same as getBuilder)', function () { + const builder = factory.getTransferBuilder(); + assert.ok(builder instanceof TransactionBuilder); + }); + + it('should build a valid transaction', async function () { + const builder = factory.getTransferBuilder(); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await builder.build()) as Transaction; + assert.equal(tx.txData.inputs.length, 1); + assert.equal(tx.txData.outputs.length, 1); + }); + }); + describe('from', function () { it('should reconstruct a builder from a serialized transaction hex', async function () { const originalBuilder = factory.getBuilder();