Skip to content
Merged
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
59 changes: 59 additions & 0 deletions modules/sdk-coin-kaspa/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
33 changes: 32 additions & 1 deletion modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<CoinConfig>) {
super(coinConfig);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
20 changes: 15 additions & 5 deletions modules/sdk-coin-kaspa/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
@@ -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<StaticsBaseCoin>;

export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
constructor(coinConfig: Readonly<StaticsBaseCoin>) {
this._coinConfig = coinConfig;
super(coinConfig);
}

/**
Expand All @@ -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);
Expand Down
100 changes: 100 additions & 0 deletions modules/sdk-coin-kaspa/test/unit/transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
Loading