From 436c58d2a3ce5e172503bdb28d61278939588cd6 Mon Sep 17 00:00:00 2001 From: Rohit Saw Date: Mon, 18 May 2026 11:07:31 +0530 Subject: [PATCH] feat: kaspa support ticket: cecho-1071 --- modules/sdk-coin-kaspa/README.md | 257 +++++++- modules/sdk-coin-kaspa/package.json | 1 + modules/sdk-coin-kaspa/src/kaspa.ts | 209 +++++-- modules/sdk-coin-kaspa/src/lib/iface.ts | 48 +- modules/sdk-coin-kaspa/src/lib/index.ts | 2 + modules/sdk-coin-kaspa/src/lib/pskt.ts | 590 ++++++++++++++++++ modules/sdk-coin-kaspa/src/lib/sighash.ts | 153 +++-- modules/sdk-coin-kaspa/src/lib/transaction.ts | 141 ++++- .../src/lib/transactionBuilder.ts | 94 +-- modules/sdk-coin-kaspa/src/lib/utils.ts | 25 + modules/sdk-coin-kaspa/src/register.ts | 4 +- modules/sdk-coin-kaspa/test/unit/coin.test.ts | 202 +++++- modules/sdk-coin-kaspa/test/unit/pskt.test.ts | 450 +++++++++++++ .../test/unit/transaction.test.ts | 243 ++++++-- .../test/unit/transactionBuilder.test.ts | 43 +- .../test/unit/transactionFlow.test.ts | 87 ++- modules/statics/src/kaspa.ts | 24 +- 17 files changed, 2188 insertions(+), 385 deletions(-) create mode 100644 modules/sdk-coin-kaspa/src/lib/pskt.ts create mode 100644 modules/sdk-coin-kaspa/test/unit/pskt.test.ts diff --git a/modules/sdk-coin-kaspa/README.md b/modules/sdk-coin-kaspa/README.md index ca075f3652..37affa7f08 100644 --- a/modules/sdk-coin-kaspa/README.md +++ b/modules/sdk-coin-kaspa/README.md @@ -17,7 +17,7 @@ Supported coin identifiers: - Address derivation (kaspa bech32 P2PK Schnorr) - UTXO transaction building - Schnorr signing and verification (Blake2b-256 sighash) -- TSS/MPC support (ECDSA algorithm) +- TSS/MPC support (ECDSA algorithm, per-input DKLS sessions) - Full serialization round-trip (hex/JSON) ## Installation @@ -28,82 +28,273 @@ yarn add @bitgo/sdk-coin-kaspa ## Usage -### Register with BitGo SDK +### 1. Register with BitGo SDK ```typescript +import { BitGo } from 'bitgo'; import { register } from '@bitgo/sdk-coin-kaspa'; + +const bitgo = new BitGo({ env: 'prod' }); register(bitgo); + +const kaspa = bitgo.coin('kaspa'); +const tkaspa = bitgo.coin('tkaspa'); // testnet +``` + +Alternatively, instantiate directly (useful in tests or scripts): + +```typescript +import { Kaspa, Tkaspa } from '@bitgo/sdk-coin-kaspa'; + +const kaspa = Kaspa.createInstance(bitgo); +const tkaspa = Tkaspa.createInstance(bitgo); +``` + +--- + +### 2. Key Generation + +```typescript +// Random key pair +const kp = kaspa.generateKeyPair(); +console.log(kp.pub); // 66-char hex compressed secp256k1 public key +console.log(kp.prv); // 64-char hex private key + +// Deterministic from a 32-byte seed +const seed = Buffer.from('...32 bytes...', 'hex'); +const kpFromSeed = kaspa.generateKeyPair(seed); + +// Validation +kaspa.isValidPub(kp.pub); // true +kaspa.isValidPrv(kp.prv); // true +``` + +Using `KeyPair` directly: + +```typescript +import { KeyPair } from '@bitgo/sdk-coin-kaspa'; + +const keyPair = new KeyPair(); // random +const { pub, prv } = keyPair.getKeys(); ``` -### Key Pair +--- + +### 3. Address Generation ```typescript import { KeyPair } from '@bitgo/sdk-coin-kaspa'; -// Generate a random key pair -const kp = new KeyPair(); -const { pub, prv } = kp.getKeys(); +const keyPair = new KeyPair({ prv: '<64-char-hex-private-key>' }); + +const mainnetAddress = keyPair.getAddress('mainnet'); // kaspa:qq... +const testnetAddress = keyPair.getAddress('testnet'); // kaspatest:qq... + +kaspa.isValidAddress(mainnetAddress); // true +``` + +Computing the P2PK `scriptPublicKey` for a key (required when constructing UTXO inputs): -// Derive address -const mainnetAddress = kp.getAddress('mainnet'); -const testnetAddress = kp.getAddress('testnet'); +```typescript +import { compressedToXOnly, buildP2PKScriptPublicKey } from '@bitgo/sdk-coin-kaspa'; + +const xOnlyPub = compressedToXOnly(Buffer.from(pub, 'hex')); +const scriptPublicKey = buildP2PKScriptPublicKey(xOnlyPub).toString('hex'); ``` -### Build and Sign a Transaction +--- + +### 4. Building a Transaction ```typescript -import { TransactionBuilderFactory } from '@bitgo/sdk-coin-kaspa'; +import { TransactionBuilderFactory, Transaction } from '@bitgo/sdk-coin-kaspa'; import { coins } from '@bitgo/statics'; +import type { KaspaUtxoInput } from '@bitgo/sdk-coin-kaspa'; + +const utxo: KaspaUtxoInput = { + transactionId: '<64-char-hex-prev-tx-id>', + transactionIndex: 0, + amount: '100000000', // 1 KASPA in sompi (1e8) + scriptPublicKey: '', // P2PK script of the sender's key + sequence: '0', + sigOpCount: 1, +}; const factory = new TransactionBuilderFactory(coins.get('kaspa')); const builder = factory.getBuilder(); builder - .addInput({ - transactionId: '', - transactionIndex: 0, - amount: '100000000', // 1 KASPA in sompi - scriptPublicKey: '', - sequence: '0', - sigOpCount: 1, - }) - .to('', '99998000') + .addInput(utxo) + .to('kaspa:qq...recipient...', '99998000') // amount in sompi .fee('2000'); -const tx = await builder.build(); +const tx = (await builder.build()) as Transaction; +``` + +Multiple inputs: + +```typescript +builder + .addInput(utxo1) + .addInput(utxo2) + .to(recipientAddress, '299996000') + .fee('4000'); +``` + +--- + +### 5. Signing — Path A: Direct Private Key (non-TSS) + +```typescript +// `tx.sign` takes a 32-byte Buffer (raw private key) tx.sign(Buffer.from(privateKeyHex, 'hex')); -const broadcastPayload = tx.toBroadcastFormat(); // JSON string for RPC +// Signs every input at once. Fully signed → txHex; partial → halfSigned +const signedTxHex = tx.toHex(); // SDK-internal format for round-trips + +// Or via the coin interface: +const result = await kaspa.signTransaction({ + txPrebuild: { txHex: unsignedTxHex }, + prv: privateKeyHex, +} as any) as { txHex: string }; ``` +--- + +### 6. Signing — Path B: TSS / MPC (per-input DKLS sessions) + +Kaspa is UTXO-based: every input has its own sighash (Blake2b-256, BIP-143-like). +Each input **requires an independent DKLS session** — there is no way to produce N valid +Schnorr signatures from a single signing operation. + +```typescript +const unsignedTx = (await builder.build()) as Transaction; +const txHex = unsignedTx.toHex(); + +// Step 1: one sighash Buffer per input — the messages for each DKLS session +const sighashes: Buffer[] = unsignedTx.signablePayloads; // Buffer[N] + +// Step 2: run N DKLS sessions in parallel (one per sighash) +// Each session produces a 64-byte raw Schnorr signature + +// Step 3: collect results and call signTransaction +const signatures = sighashes.map((hash, inputIndex) => ({ + inputIndex, + pubKey: compressedPubKeyHex, // 33-byte hex + signature: dklsSession(hash), // 64-byte hex Schnorr sig +})); + +const result = await kaspa.signTransaction({ + txPrebuild: { txHex }, + signatures, +} as any) as { txHex: string } | { halfSigned: { txHex: string } }; + +// result.txHex → all inputs signed +// result.halfSigned → some inputs still unsigned (partial TSS round) +``` + +--- + +### 7. Broadcasting + +```typescript +// toBroadcastFormat() returns the Kaspa RPC-compatible JSON string +const broadcastPayload = tx.toBroadcastFormat(); + +// toHex() is the SDK-internal round-trip format (preserves amount + scriptPublicKey +// on inputs for sighash recomputation). Do NOT send this to the Kaspa node directly. +const internalHex = tx.toHex(); +``` + +--- + +### 8. Explaining / Parsing a Transaction + +```typescript +// Human-readable breakdown +const explained = await kaspa.explainTransaction({ txHex }); +console.log(explained.outputs); // [{ address, amount }] +console.log(explained.outputAmount); // total sent (sompi) +console.log(explained.fee); // fee (sompi) + +// Structured parse — inputs and outputs tagged with coin name +const parsed = await kaspa.parseTransaction({ txHex } as any); +// { inputs: [{ amount, coin: 'kaspa' }], outputs: [{ address, amount, coin: 'kaspa' }] } +``` + +--- + +### 9. Verifying a Transaction + +```typescript +const valid = await kaspa.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + recipients: [{ address: 'kaspa:qq...', amount: '99998000' }], + }, +} as any); + +console.log(valid); // true +``` + +--- + +### 10. Coin Properties + +```typescript +kaspa.getChain(); // 'kaspa' +kaspa.getFamily(); // 'kaspa' +kaspa.getFullName(); // 'Kaspa' +kaspa.getBaseFactor(); // 100_000_000 (sompi per KASPA) +kaspa.supportsTss(); // true +kaspa.getMPCAlgorithm(); // 'ecdsa' + +tkaspa.getChain(); // 'tkaspa' +tkaspa.getFullName(); // 'Testnet Kaspa' +``` + +--- + +## Key Constants + +| Property | Value | +|---|---| +| 1 KASPA | `100_000_000` sompi | +| `getBaseFactor()` | `1e8` | +| Mainnet address prefix | `kaspa:` | +| Testnet address prefix | `kaspatest:` | +| Address type | P2PK Schnorr (x-only secp256k1) | +| Signature algorithm | Schnorr (Blake2b-256 sighash) | +| TSS algorithm | `ecdsa` (DKLS) | +| Multisig type | `onchain` | + +--- + ## Module Structure ``` src/ -├── kaspa.ts # Kaspa mainnet coin class -├── tkaspa.ts # Kaspa testnet coin class -├── register.ts # SDK registration +├── kaspa.ts # AbstractKaspaLikeCoin, Kaspa, Tkaspa classes +├── register.ts # SDK registration helper ├── index.ts └── lib/ - ├── constants.ts # Chain constants (prefixes, decimals, fees) + ├── constants.ts # Chain constants (prefixes, decimals, default fee) ├── iface.ts # TypeScript interfaces - ├── keyPair.ts # secp256k1 key pair - ├── sighash.ts # Blake2b-256 Schnorr sighash - ├── transaction.ts # Transaction class (sign/verify/explain) + ├── keyPair.ts # secp256k1 key pair + address derivation + ├── sighash.ts # Blake2b-256 Schnorr sighash + script utilities + ├── transaction.ts # Transaction class (sign / verify / explain / serialize) ├── transactionBuilder.ts # UTXO transaction builder ├── transactionBuilderFactory.ts ├── utils.ts # Address validation and encoding └── index.ts test/ ├── fixtures/ -│ ├── kaspa.fixtures.ts # Deterministic test vectors -│ └── kaspaFixtures.ts # Synthetic test fixtures +│ └── kaspa.fixtures.ts # Deterministic test vectors └── unit/ ├── coin.test.ts ├── keyPair.test.ts ├── transaction.test.ts ├── transactionBuilder.test.ts - ├── transactionFlow.test.ts └── utils.test.ts ``` @@ -117,7 +308,7 @@ Kaspa uses a custom cashaddr-like bech32 encoding: ## Signing -Kaspa uses **Schnorr signatures over secp256k1** with a **Blake2b-256** sighash. The sighash preimage follows the Kaspa BIP-143-like specification. Each input is signed independently, producing a 65-byte signature: 64 bytes Schnorr + 1 byte sighash type. +Kaspa uses **Schnorr signatures over secp256k1** with a **Blake2b-256** sighash. The sighash preimage follows the Kaspa BIP-143-like specification. Each input is signed independently, producing a 65-byte signature: 64 bytes Schnorr + 1 byte sighash type (`0x01` = SIGHASH_ALL). ## References diff --git a/modules/sdk-coin-kaspa/package.json b/modules/sdk-coin-kaspa/package.json index 0f2dbf10ed..e457b29eea 100644 --- a/modules/sdk-coin-kaspa/package.json +++ b/modules/sdk-coin-kaspa/package.json @@ -40,6 +40,7 @@ ] }, "dependencies": { + "@bitgo/abstract-utxo": "^11.0.0", "@bitgo/sdk-core": "^36.44.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.39.0", diff --git a/modules/sdk-coin-kaspa/src/kaspa.ts b/modules/sdk-coin-kaspa/src/kaspa.ts index bddc3e1a39..c03cbb0b0e 100644 --- a/modules/sdk-coin-kaspa/src/kaspa.ts +++ b/modules/sdk-coin-kaspa/src/kaspa.ts @@ -1,8 +1,8 @@ -import { BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; +import { coins } from '@bitgo/statics'; import { AuditDecryptedKeyParams, - BaseCoin, BitGoBase, + IWallet, InvalidAddressError, InvalidTransactionError, KeyPair as IKeyPair, @@ -10,56 +10,65 @@ import { MPCAlgorithm, MultisigType, multisigTypes, - ParsedTransaction, - ParseTransactionOptions, SignedTransaction, UnexpectedAddressError, VerifyAddressOptions, } from '@bitgo/sdk-core'; -import * as KaspaLib from './lib'; import { - KaspaExplainTransactionOptions, - KaspaSignTransactionOptions, - KaspaVerifyTransactionOptions, - TransactionExplanation, -} from './lib/iface'; + AbstractUtxoCoin, + ExplainTransactionOptions, + ParseTransactionOptions, + SignTransactionOptions, + TransactionPrebuild, + UtxoCoinName, + UtxoCoinNameMainnet, + transaction, +} from '@bitgo/abstract-utxo'; +import * as KaspaLib from './lib'; +import { KaspaSignTransactionOptions, KaspaVerifyTransactionOptions } from './lib/iface'; import { isValidKaspaAddress } from './lib/utils'; -export class Kaspa extends BaseCoin { - protected readonly _staticsCoin: Readonly; - - constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { - super(bitgo); - - if (!staticsCoin) { - throw new Error('missing required constructor parameter staticsCoin'); - } - - this._staticsCoin = staticsCoin; +export abstract class AbstractKaspaLikeCoin extends AbstractUtxoCoin { + /** + * Kaspa has no utxo-lib network entry. This deprecated getter is overridden + * to prevent accidental usage of the Bitcoin PSBT signing stack. + */ + get network(): never { + throw new Error(`${this.getChain()} does not have a utxo-lib network entry`); } - static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly): BaseCoin { - return new Kaspa(bitgo, staticsCoin); + /** + * Kaspa family name is 'kaspa' for both mainnet and testnet. + * Override avoids going through names.ts which only knows Bitcoin-family coins. + * The cast is required because UtxoCoinNameMainnet is a closed union of Bitcoin-family coins; + * Kaspa manages its own network stack and is intentionally outside that union. + */ + getFamily(): UtxoCoinNameMainnet { + return 'kaspa' as unknown as UtxoCoinNameMainnet; } - getChain(): string { - return this._staticsCoin.name; - } + /** + * Human-readable name for this coin variant. + * Implemented in each concrete class to avoid runtime name comparisons. + */ + abstract getFullName(): string; - getFamily(): CoinFamily { - return this._staticsCoin.family; - } + /** + * Whether this is a mainnet or testnet coin. + * Implemented in each concrete class to avoid runtime name comparisons. + */ + protected abstract isMainnet(): boolean; - getFullName(): string { - return this._staticsCoin.fullName; + supportsBlockTarget(): boolean { + return false; } /** * Return the base factor (sompi per KASPA). * 1 KASPA = 100,000,000 sompi (8 decimal places) */ - getBaseFactor(): string | number { - return Math.pow(10, this._staticsCoin.decimalPlaces); + getBaseFactor(): number { + return 1e8; } /** @inheritDoc */ @@ -101,13 +110,16 @@ export class Kaspa extends BaseCoin { /** * Generate a Kaspa key pair. */ - generateKeyPair(seed?: Buffer): IKeyPair { + generateKeyPair(seed?: Buffer): { pub: string; prv: string } { const keyPair = seed ? new KaspaLib.KeyPair({ seed }) : new KaspaLib.KeyPair(); const keys = keyPair.getKeys(); if (!keys.prv) { throw new Error('Missing prv in key generation.'); } + if (!keys.pub) { + throw new Error('Missing pub in key generation.'); + } return { pub: keys.pub, @@ -129,7 +141,8 @@ export class Kaspa extends BaseCoin { throw new Error('Invalid keychains'); } - const networkType = this._staticsCoin.network.type; + // Cast to string to avoid type-overlap lint until abstract-utxo dist is rebuilt with kaspa names. + const networkType = (this.name as string) === 'kaspa' ? 'mainnet' : 'testnet'; const derivedAddress = new KaspaLib.KeyPair({ pub: keychains[0].pub }).getAddress(networkType); if (derivedAddress !== address) { @@ -145,13 +158,15 @@ export class Kaspa extends BaseCoin { /** * Parse a Kaspa transaction from prebuild. + * Overrides AbstractUtxoCoin's PSBT-based implementation with Kaspa's JSON-hex format. */ - async parseTransaction(params: ParseTransactionOptions): Promise { - const txHex = (params.txHex || (params as { halfSigned?: { txHex?: string } }).halfSigned?.txHex) as - | string - | undefined; + async parseTransaction( + params: ParseTransactionOptions + ): Promise> { + const anyParams = params as unknown as { txHex?: string; halfSigned?: { txHex?: string } }; + const txHex = anyParams.txHex ?? anyParams.halfSigned?.txHex; if (!txHex) { - return {}; + return {} as transaction.ParsedTransaction; } let tx: KaspaLib.Transaction; try { @@ -171,7 +186,7 @@ export class Kaspa extends BaseCoin { amount: output.amount, coin, })), - }; + } as unknown as transaction.ParsedTransaction; } /** @@ -205,16 +220,23 @@ export class Kaspa extends BaseCoin { /** * Explain a Kaspa transaction. + * Overrides AbstractUtxoCoin's PSBT-based implementation with Kaspa's JSON-hex format. + * The return type cast is necessary because Kaspa uses a custom TransactionExplanation + * that doesn't include Bitcoin-specific fields (chain, index) on outputs. */ - async explainTransaction(params: KaspaExplainTransactionOptions): Promise { - const txHex = params.txHex ?? params?.halfSigned?.txHex; + async explainTransaction( + params: ExplainTransactionOptions, + _wallet?: IWallet + ): Promise>> { + const anyParams = params as unknown as { txHex?: string; halfSigned?: { txHex?: string } }; + const txHex = anyParams.txHex ?? anyParams.halfSigned?.txHex; if (!txHex) { throw new Error('missing transaction hex'); } try { const txBuilder = this.getBuilder().from(txHex); const tx = (await txBuilder.build()) as KaspaLib.Transaction; - return tx.explainTransaction(); + return tx.explainTransaction() as unknown as Awaited>; } catch (e) { throw new InvalidTransactionError(`Invalid transaction: ${(e as Error).message}`); } @@ -222,9 +244,29 @@ export class Kaspa extends BaseCoin { /** * Sign a Kaspa transaction using secp256k1 Schnorr signatures. + * + * Kaspa is a UTXO coin: every input has its own sighash. + * Two signing paths are supported: + * + * Path A — `params.prv` (direct key, test / non-TSS): + * Calls `tx.sign(prv)` which loops every input and produces a Schnorr + * signature for each one in a single call. + * + * Path B — `params.signatures` (TSS multi-input): + * The caller already ran one independent DKLS session per input, using + * `tx.signablePayloads[i]` as the message for session i. Each resulting + * signature is applied to its input via `addSignatureForInput`. + * + * Platform flow: + * 1. Build tx, read `(tx as Transaction).signablePayloads` → Buffer[N] + * 2. Run N DKLS sessions in parallel, one per sighash + * 3. Call signTransaction({ signatures: [{ inputIndex, pubKey, signature }, ...] }) */ - async signTransaction(params: KaspaSignTransactionOptions): Promise { - const txHex = params.txPrebuild.txHex; + async signTransaction( + params: SignTransactionOptions + ): Promise { + const kaspaParams = params as unknown as KaspaSignTransactionOptions; + const txHex = kaspaParams.txPrebuild?.txHex; if (!txHex) { throw new InvalidTransactionError('missing txHex in txPrebuild'); } @@ -232,30 +274,59 @@ export class Kaspa extends BaseCoin { const txBuilder = this.getBuilder().from(txHex); const tx = (await txBuilder.build()) as KaspaLib.Transaction; - if (params.prv) { - const privKeyHex = params.prv.slice(0, 64); - const privKeyBuffer = Buffer.from(privKeyHex, 'hex'); + if (kaspaParams.prv) { + // Path A: direct private-key signing — correct for all inputs at once. + const privKeyBuffer = Buffer.from(kaspaParams.prv.slice(0, 64), 'hex'); tx.sign(privKeyBuffer); + } else if (kaspaParams.signatures && kaspaParams.signatures.length > 0) { + // Path B: TSS multi-input — apply each externally-computed per-input signature. + // Each signature MUST have been produced over signablePayloads[inputIndex], + // i.e. the sighash that commits specifically to that input's index. + // Applying a signature produced over a different message will be rejected + // by the Kaspa node's script engine. + for (const { inputIndex, pubKey, signature } of kaspaParams.signatures) { + tx.addSignatureForInput(inputIndex, pubKey, Buffer.from(signature, 'hex')); + } } - const signedHex = tx.toHex(); const inputCount = tx.txData.inputs.length; const sigCount = tx.signature.filter((s) => s.length > 0).length; + const signedHex = tx.toHex(); return inputCount > 0 && sigCount >= inputCount ? { txHex: signedHex } : { halfSigned: { txHex: signedHex } }; } - async signMessage(key: IKeyPair, message: string | Buffer): Promise { + /** + * Kaspa's txHex is already the custom JSON-hex format — skip the PSBT + * re-encode that AbstractUtxoCoin.postProcessPrebuild would otherwise apply. + */ + async postProcessPrebuild( + prebuild: TransactionPrebuild + ): Promise> { + return prebuild; + } + + async signMessage(_key: IKeyPair, _message: string | Buffer): Promise { throw new MethodNotImplementedError(); } /** @inheritDoc */ - auditDecryptedKey(params: AuditDecryptedKeyParams): void { + auditDecryptedKey(_params: AuditDecryptedKeyParams): void { throw new MethodNotImplementedError(); } /** - * MPC support: Kaspa uses secp256k1 (Schnorr variant). + * TSS/MPC support: Kaspa uses secp256k1 (Schnorr variant for on-chain, + * ECDSA for the off-chain TSS ceremony). + * + * Kaspa is a UTXO coin with a BIP-143-like per-input sighash scheme. + * Each input commits to its own index and produces a distinct hash. + * One independent DKLS signing session is required per input. + * + * Correct multi-input TSS flow: + * 1. Read `tx.signablePayloads` → Buffer[] (one sighash per input). + * 2. Run one DKLS signing session per sighash IN PARALLEL. + * 3. Apply each signature: `tx.addSignatureForInput(i, pubKey, sig)`. */ supportsTss(): boolean { return true; @@ -265,3 +336,35 @@ export class Kaspa extends BaseCoin { return 'ecdsa'; } } + +export class Kaspa extends AbstractKaspaLikeCoin { + readonly name = 'kaspa' as UtxoCoinName; + + getFullName(): string { + return 'Kaspa'; + } + + protected isMainnet(): boolean { + return true; + } + + static createInstance(bitgo: BitGoBase): Kaspa { + return new Kaspa(bitgo); + } +} + +export class Tkaspa extends AbstractKaspaLikeCoin { + readonly name = 'tkaspa' as UtxoCoinName; + + getFullName(): string { + return 'Testnet Kaspa'; + } + + protected isMainnet(): boolean { + return false; + } + + static createInstance(bitgo: BitGoBase): Tkaspa { + return new Tkaspa(bitgo); + } +} diff --git a/modules/sdk-coin-kaspa/src/lib/iface.ts b/modules/sdk-coin-kaspa/src/lib/iface.ts index 3e26d3a223..864cad0ac3 100644 --- a/modules/sdk-coin-kaspa/src/lib/iface.ts +++ b/modules/sdk-coin-kaspa/src/lib/iface.ts @@ -87,11 +87,38 @@ export interface KaspaTxInfo { } /** - * Kaspa sign transaction options + * A single per-input Schnorr signature produced by an external TSS session. + * Each entry corresponds to one entry in `tx.signablePayloads`. + */ +export interface KaspaInputSignature { + /** 0-based index of the input this signature covers */ + inputIndex: number; + /** Hex-encoded compressed secp256k1 public key (33 bytes) */ + pubKey: string; + /** Hex-encoded 64-byte raw Schnorr signature */ + signature: string; +} + +/** + * Kaspa sign transaction options. + * + * Two mutually exclusive signing modes: + * + * 1. `prv` — direct private-key signing (test / non-TSS). + * `tx.sign(prv)` is called, which loops all inputs. + * + * 2. `signatures` — TSS multi-input mode. + * The caller ran one independent DKLS session per input + * (over `tx.signablePayloads[i]`) and collected the + * resulting Schnorr signatures. Each signature is applied + * via `tx.addSignatureForInput(inputIndex, pubKey, sig)`. */ export interface KaspaSignTransactionOptions extends SignTransactionOptions { txPrebuild: TransactionPrebuild; - prv: string; + /** Direct private key — signs every input in one call */ + prv?: string; + /** Per-input TSS signatures — one entry per input that was signed */ + signatures?: KaspaInputSignature[]; } /** @@ -102,26 +129,9 @@ export interface KaspaTransactionParams extends TransactionParams { unspents?: KaspaUtxoInput[]; } -/** - * Kaspa explain transaction options - */ -export interface KaspaExplainTransactionOptions { - txHex?: string; - halfSigned?: { - txHex: string; - }; -} - /** * Kaspa verify transaction options */ export interface KaspaVerifyTransactionOptions extends VerifyTransactionOptions { txParams: KaspaTransactionParams; } - -/** - * Kaspa transaction fee info - */ -export interface KaspaTransactionFee { - fee: string; -} diff --git a/modules/sdk-coin-kaspa/src/lib/index.ts b/modules/sdk-coin-kaspa/src/lib/index.ts index ac56d9988b..0dfebff9b2 100644 --- a/modules/sdk-coin-kaspa/src/lib/index.ts +++ b/modules/sdk-coin-kaspa/src/lib/index.ts @@ -2,6 +2,8 @@ export { KeyPair } from './keyPair'; export { Transaction } from './transaction'; export { TransactionBuilder } from './transactionBuilder'; export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export { Pskt } from './pskt'; +export type { PsktRole, PsktKeySource, PsktGlobal, PsktUtxoEntry, PsktInput, PsktOutput, SerializedPskt } from './pskt'; export * from './iface'; export * from './constants'; export * from './utils'; diff --git a/modules/sdk-coin-kaspa/src/lib/pskt.ts b/modules/sdk-coin-kaspa/src/lib/pskt.ts new file mode 100644 index 0000000000..f56279edd3 --- /dev/null +++ b/modules/sdk-coin-kaspa/src/lib/pskt.ts @@ -0,0 +1,590 @@ +/** + * PSKT — Partially Signed Kaspa Transaction + * + * Kaspa's native analogue of Bitcoin's PSBT (BIP-174), implemented in + * TypeScript with zero external dependencies. Wire format is plain camelCase + * JSON, matching the serde serialisation of rusty-kaspa's wallet/pskt crate. + * + * Reference: https://github.com/kaspanet/rusty-kaspa/tree/master/wallet/pskt + * + * Role state machine: + * CREATOR → UPDATER → SIGNER ─┬→ COMBINER → FINALIZER → EXTRACTOR + * └──────────→ FINALIZER → EXTRACTOR + * + * Key difference from the current JSON-hex flow: + * Signatures are stored in `partialSigs` per input until the FINALIZER + * role promotes them into the push-only `finalScriptSig`. This lets an + * external HSM or a second party receive the serialised PSKT, call + * `addSignature()`, and return it — identical to how PSBT works for BTC. + */ + +import { ecc } from '@bitgo/secp256k1'; +import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; +import { computeKaspaSigningHash, SIGHASH_ALL } from './sighash'; +import { KeyPair } from './keyPair'; + +// ─── Role types ─────────────────────────────────────────────────────────────── + +export type PsktRole = 'CREATOR' | 'UPDATER' | 'SIGNER' | 'COMBINER' | 'FINALIZER' | 'EXTRACTOR'; + +// ─── Interfaces ─────────────────────────────────────────────────────────────── + +export interface PsktKeySource { + /** 4-byte master fingerprint, hex-encoded */ + keyFingerprint: string; + /** BIP-32 derivation path string, e.g. "m/44'/111111'/0'/0/0" */ + derivationPath: string; +} + +export interface PsktGlobal { + /** PSKT version (0 = no payload, 1 = payload allowed) */ + version: 0 | 1; + /** Kaspa transaction version (currently 0) */ + txVersion: number; + /** Fallback lock time (block or timestamp), as decimal string for JSON safety */ + fallbackLockTime?: string; + /** Whether inputs can still be added */ + inputsModifiable: boolean; + /** Whether outputs can still be added */ + outputsModifiable: boolean; + /** Number of inputs in this PSKT */ + inputCount: number; + /** Number of outputs in this PSKT */ + outputCount: number; + /** Extended public keys and their derivation info */ + xpubs: Record; + /** Transaction ID once known */ + id?: string; + /** Proprietary key-value pairs */ + proprietaries: Record; + /** Hex-encoded transaction payload (requires version >= 1) */ + payload?: string; +} + +export interface PsktUtxoEntry { + /** Amount in sompi (decimal string for JSON bigint safety) */ + amount: string; + /** Hex-encoded scriptPublicKey bytes */ + scriptPublicKey: string; + blockDaaScore?: string; + isCoinbase?: boolean; +} + +export interface PsktInput { + /** The outpoint being spent */ + previousOutpoint: { transactionId: string; index: number }; + /** + * UTXO data required for sighash computation. + * Must be present before signing. + */ + utxoEntry?: PsktUtxoEntry; + /** Sequence number (u64 as decimal string; absent = u64::MAX / finality) */ + sequence?: string; + /** + * Partial Schnorr signatures collected so far. + * Key: 33-byte compressed secp256k1 pubKey hex. + * Value: 64-byte raw Schnorr signature hex. + */ + partialSigs: Record; + /** SigHash type for this input (default: SIGHASH_ALL = 0x01) */ + sighashType: number; + /** Signature operation count */ + sigOpCount?: number; + /** + * Finalized scriptSig (set by the FINALIZER role). + * Layout: 0x41 (OP_DATA_65) + 64-byte sig + 1-byte sighash type = 66 bytes hex. + */ + finalScriptSig?: string; + /** BIP-32 derivation info for keys used in this input */ + bip32Derivations: Record; + /** Proprietary key-value pairs */ + proprietaries: Record; +} + +export interface PsktOutput { + /** Amount in sompi (decimal string for JSON bigint safety) */ + amount: string; + /** Script public key */ + scriptPublicKey: { version: number; scriptPublicKey: string }; + /** BIP-32 derivation info for keys used in this output */ + bip32Derivations: Record; + /** Proprietary key-value pairs */ + proprietaries: Record; +} + +/** Wire format — what `serialize()` produces and `deserialize()` consumes. */ +export interface SerializedPskt { + role: PsktRole; + global: PsktGlobal; + inputs: PsktInput[]; + outputs: PsktOutput[]; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function requireRole(current: PsktRole, ...allowed: PsktRole[]): void { + if (!allowed.includes(current)) { + throw new Error(`Operation requires role ${allowed.join(' or ')}, but current role is ${current}`); + } +} + +/** + * Build a `KaspaTransactionData` from PSKT internals, for use with + * `computeKaspaSigningHash`. Every input must have `utxoEntry` populated. + */ +function psktToKaspaTxData(global: PsktGlobal, inputs: PsktInput[], outputs: PsktOutput[]): KaspaTransactionData { + const kaspaInputs: KaspaUtxoInput[] = inputs.map((inp, i) => { + if (!inp.utxoEntry) { + throw new Error( + `Input[${i}] (${inp.previousOutpoint.transactionId}:${inp.previousOutpoint.index}) ` + + `is missing utxoEntry — required for sighash computation` + ); + } + return { + transactionId: inp.previousOutpoint.transactionId, + transactionIndex: inp.previousOutpoint.index, + amount: inp.utxoEntry.amount, + scriptPublicKey: inp.utxoEntry.scriptPublicKey, + sequence: inp.sequence, + sigOpCount: inp.sigOpCount ?? 1, + }; + }); + + const kaspaOutputs: KaspaTransactionOutput[] = outputs.map((out) => ({ + address: '', + amount: out.amount, + scriptPublicKey: out.scriptPublicKey.scriptPublicKey, + })); + + return { + version: global.txVersion, + inputs: kaspaInputs, + outputs: kaspaOutputs, + lockTime: global.fallbackLockTime ?? '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: global.payload ?? '', + }; +} + +// ─── Pskt class ─────────────────────────────────────────────────────────────── + +/** + * PSKT (Partially Signed Kaspa Transaction). + * + * Mirrors the role-based workflow of Bitcoin's PSBT but uses Kaspa's wire + * format (camelCase JSON), Blake2b sighash, and Schnorr signatures. + * + * Typical single-signer flow: + * ```ts + * const pskt = Pskt.creator() + * .toUpdater() + * .input({ ... }) + * .output({ ... }) + * .toSigner() + * .sign(privateKeyBuffer) + * .toFinalizer() + * .finalize() + * .toExtractor(); + * + * const broadcastJson = pskt.extract(); + * ``` + * + * HSM / external signing flow: + * ```ts + * const unsigned = pskt.serialize(); // send to HSM + * const signed = Pskt.deserialize(signed); // receive back + * const tx = signed.toFinalizer().finalize().toExtractor().extract(); + * ``` + */ +export class Pskt { + private _global: PsktGlobal; + private _inputs: PsktInput[]; + private _outputs: PsktOutput[]; + private _role: PsktRole; + + private constructor(global: PsktGlobal, inputs: PsktInput[], outputs: PsktOutput[], role: PsktRole) { + this._global = { ...global }; + this._inputs = inputs.map((inp) => ({ ...inp, partialSigs: { ...inp.partialSigs } })); + this._outputs = outputs.map((out) => ({ ...out })); + this._role = role; + } + + // ── Accessors ────────────────────────────────────────────────────────────── + + get role(): PsktRole { + return this._role; + } + + get global(): Readonly { + return this._global; + } + + get inputs(): ReadonlyArray> { + return this._inputs; + } + + get outputs(): ReadonlyArray> { + return this._outputs; + } + + // ── Static factories ─────────────────────────────────────────────────────── + + /** + * Create a new PSKT in CREATOR role. + * + * @param txVersion Kaspa transaction version (currently 0) + * @param fallbackLockTime Optional lock time as decimal string + */ + static creator(txVersion = 0, fallbackLockTime?: string): Pskt { + const global: PsktGlobal = { + version: 0, + txVersion, + fallbackLockTime, + inputsModifiable: true, + outputsModifiable: true, + inputCount: 0, + outputCount: 0, + xpubs: {}, + proprietaries: {}, + }; + return new Pskt(global, [], [], 'CREATOR'); + } + + /** + * Reconstruct a PSKT from a `KaspaTransactionData` object. + * + * The returned PSKT is placed in SIGNER role (inputs are locked, UTXO data + * is populated, any existing `signatureScript` is carried as `finalScriptSig`). + * Used by `Transaction.toPskt()`. + */ + static fromTxData(txData: KaspaTransactionData): Pskt { + const global: PsktGlobal = { + version: 0, + txVersion: txData.version, + fallbackLockTime: txData.lockTime ?? '0', + inputsModifiable: false, + outputsModifiable: false, + inputCount: txData.inputs.length, + outputCount: txData.outputs.length, + xpubs: {}, + proprietaries: {}, + payload: txData.payload, + }; + + const inputs: PsktInput[] = txData.inputs.map((inp) => ({ + previousOutpoint: { transactionId: inp.transactionId, index: inp.transactionIndex }, + utxoEntry: { amount: inp.amount, scriptPublicKey: inp.scriptPublicKey }, + sequence: inp.sequence, + sigOpCount: inp.sigOpCount, + partialSigs: {}, + sighashType: SIGHASH_ALL, + // Carry any existing signatureScript as the finalScriptSig + finalScriptSig: inp.signatureScript, + bip32Derivations: {}, + proprietaries: {}, + })); + + const outputs: PsktOutput[] = txData.outputs.map((out) => ({ + amount: out.amount, + scriptPublicKey: { version: 0, scriptPublicKey: out.scriptPublicKey || '' }, + bip32Derivations: {}, + proprietaries: {}, + })); + + return new Pskt(global, inputs, outputs, 'SIGNER'); + } + + /** + * Deserialise a PSKT from the JSON string produced by `serialize()`. + */ + static deserialize(json: string): Pskt { + const data = JSON.parse(json) as SerializedPskt; + if (!data.role || !data.global || !Array.isArray(data.inputs) || !Array.isArray(data.outputs)) { + throw new Error('Invalid PSKT JSON: missing required fields'); + } + return new Pskt(data.global, data.inputs, data.outputs, data.role); + } + + // ── Role transitions ─────────────────────────────────────────────────────── + + toUpdater(): Pskt { + requireRole(this._role, 'CREATOR'); + return new Pskt(this._global, this._inputs, this._outputs, 'UPDATER'); + } + + toSigner(): Pskt { + requireRole(this._role, 'UPDATER'); + // Lock structure before signing + return new Pskt( + { ...this._global, inputsModifiable: false, outputsModifiable: false }, + this._inputs, + this._outputs, + 'SIGNER' + ); + } + + toCombiner(): Pskt { + requireRole(this._role, 'SIGNER'); + return new Pskt(this._global, this._inputs, this._outputs, 'COMBINER'); + } + + /** + * Transition to FINALIZER role. + * Allowed from both SIGNER (single-party shortcut) and COMBINER. + */ + toFinalizer(): Pskt { + requireRole(this._role, 'SIGNER', 'COMBINER'); + return new Pskt(this._global, this._inputs, this._outputs, 'FINALIZER'); + } + + /** + * Transition to EXTRACTOR role. + * All inputs must have a `finalScriptSig` (call `finalize()` first). + */ + toExtractor(): Pskt { + requireRole(this._role, 'FINALIZER'); + for (let i = 0; i < this._inputs.length; i++) { + if (!this._inputs[i].finalScriptSig) { + throw new Error(`Input[${i}] is not finalised — call finalize() before toExtractor()`); + } + } + return new Pskt(this._global, this._inputs, this._outputs, 'EXTRACTOR'); + } + + // ── CREATOR / UPDATER mutations ──────────────────────────────────────────── + + /** + * Add an input. Only allowed in CREATOR or UPDATER role. + */ + input(inp: PsktInput): this { + requireRole(this._role, 'CREATOR', 'UPDATER'); + if (!this._global.inputsModifiable) { + throw new Error('Inputs are locked (inputsModifiable = false)'); + } + this._inputs.push({ ...inp, partialSigs: { ...inp.partialSigs } }); + this._global.inputCount = this._inputs.length; + return this; + } + + /** + * Add an output. Only allowed in CREATOR or UPDATER role. + */ + output(out: PsktOutput): this { + requireRole(this._role, 'CREATOR', 'UPDATER'); + if (!this._global.outputsModifiable) { + throw new Error('Outputs are locked (outputsModifiable = false)'); + } + this._outputs.push({ ...out }); + this._global.outputCount = this._outputs.length; + return this; + } + + // ── SIGNER operations ────────────────────────────────────────────────────── + + /** + * Sign all inputs with a private key (single-key / non-HSM path). + * + * Computes a distinct keyed Blake2b-256 sighash per input (Kaspa's + * BIP-143-like scheme) and writes the 64-byte Schnorr signature into + * `partialSigs[compressedPubKeyHex]` for each input. + * + * @param privateKey 32-byte private key buffer + * @param sigHashType SigHash type (default: SIGHASH_ALL = 0x01) + */ + sign(privateKey: Buffer, sigHashType: number = SIGHASH_ALL): this { + requireRole(this._role, 'SIGNER'); + if (privateKey.length !== 32) { + throw new Error(`Expected 32-byte private key, got ${privateKey.length}`); + } + + // Derive the compressed public key using the existing KeyPair infrastructure + const kp = new KeyPair({ prv: privateKey.toString('hex') }); + const pubKeyHex = kp.getKeys().pub as string; + + const txData = psktToKaspaTxData(this._global, this._inputs, this._outputs); + + for (let i = 0; i < this._inputs.length; i++) { + const inputSighashType = this._inputs[i].sighashType ?? sigHashType; + const sigHash = computeKaspaSigningHash(txData, i, inputSighashType); + const sig = Buffer.from(ecc.signSchnorr(sigHash, privateKey)); + this._inputs[i].partialSigs[pubKeyHex] = sig.toString('hex'); + } + + return this; + } + + /** + * Record an externally-produced Schnorr signature for one specific input. + * + * HSM / MPCv2 flow: + * ```ts + * const json = pskt.serialize(); // → HSM + * // HSM signs each input's sighash and returns signatures + * pskt.addSignature(0, pubKeyHex, sig0Hex) + * .addSignature(1, pubKeyHex, sig1Hex); + * ``` + * + * @param inputIndex 0-based index of the input being signed + * @param pubKeyHex 33-byte compressed secp256k1 pubKey, hex-encoded + * @param sigHex 64-byte Schnorr signature, hex-encoded + * @param sigHashType SigHash type used when producing the signature + */ + addSignature(inputIndex: number, pubKeyHex: string, sigHex: string, sigHashType: number = SIGHASH_ALL): this { + requireRole(this._role, 'SIGNER'); + if (inputIndex < 0 || inputIndex >= this._inputs.length) { + throw new Error(`Input index ${inputIndex} is out of range (tx has ${this._inputs.length} inputs)`); + } + const sigBuf = Buffer.from(sigHex, 'hex'); + if (sigBuf.length !== 64) { + throw new Error(`Expected 64-byte Schnorr signature, got ${sigBuf.length} bytes`); + } + this._inputs[inputIndex].partialSigs[pubKeyHex] = sigHex; + this._inputs[inputIndex].sighashType = sigHashType; + return this; + } + + // ── COMBINER operation ───────────────────────────────────────────────────── + + /** + * Merge `partialSigs` from another PSKT into this one. + * + * Both PSKTs must represent the same transaction (same number of inputs and + * outputs). Conflicting signatures for the same pubKey raise an error. + */ + combine(other: Pskt): this { + requireRole(this._role, 'COMBINER'); + if (other._inputs.length !== this._inputs.length) { + throw new Error( + `Cannot combine: input count mismatch (this=${this._inputs.length}, other=${other._inputs.length})` + ); + } + if (other._outputs.length !== this._outputs.length) { + throw new Error( + `Cannot combine: output count mismatch (this=${this._outputs.length}, other=${other._outputs.length})` + ); + } + for (let i = 0; i < this._inputs.length; i++) { + for (const [pubKey, sig] of Object.entries(other._inputs[i].partialSigs)) { + const existing = this._inputs[i].partialSigs[pubKey]; + if (existing && existing !== sig) { + throw new Error(`Conflicting signature for input[${i}] pubKey ${pubKey.slice(0, 16)}...`); + } + this._inputs[i].partialSigs[pubKey] = sig; + } + } + return this; + } + + // ── FINALIZER operation ──────────────────────────────────────────────────── + + /** + * Finalise all inputs: promote the first `partialSig` on each input into + * `finalScriptSig` using Kaspa's push-only script layout: + * `0x41` (OP_DATA_65) + 64-byte Schnorr sig + 1-byte sighash type = 66 bytes + * + * Inputs that already have a `finalScriptSig` are left unchanged. + */ + finalize(): this { + requireRole(this._role, 'FINALIZER'); + for (let i = 0; i < this._inputs.length; i++) { + const inp = this._inputs[i]; + if (inp.finalScriptSig) { + continue; + } + const entries = Object.entries(inp.partialSigs); + if (entries.length === 0) { + throw new Error(`Input[${i}] has no partial signatures — cannot finalise`); + } + const [, sigHex] = entries[0]; + const sig = Buffer.from(sigHex, 'hex'); + if (sig.length !== 64) { + throw new Error(`Input[${i}] partial signature is ${sig.length} bytes, expected 64`); + } + const sighashType = inp.sighashType ?? SIGHASH_ALL; + // Push-only scriptSig: OP_DATA_65 + 64-byte sig + 1-byte sighash type + inp.finalScriptSig = Buffer.concat([Buffer.from([0x41]), sig, Buffer.from([sighashType])]).toString('hex'); + } + return this; + } + + // ── EXTRACTOR operation ──────────────────────────────────────────────────── + + /** + * Extract the broadcast-ready transaction as a JSON string. + * + * Produces the same wire format as `Transaction.toBroadcastFormat()`. + * Must be in EXTRACTOR role (call `.toFinalizer().finalize().toExtractor()` first). + */ + extract(): string { + requireRole(this._role, 'EXTRACTOR'); + return JSON.stringify({ + version: this._global.txVersion, + inputs: this._inputs.map((inp) => ({ + previousOutpoint: { + transactionId: inp.previousOutpoint.transactionId, + index: inp.previousOutpoint.index, + }, + signatureScript: inp.finalScriptSig || '', + sequence: Number(inp.sequence ?? 0), + sigOpCount: inp.sigOpCount ?? 1, + })), + outputs: this._outputs.map((out) => ({ + amount: Number(out.amount), + scriptPublicKey: { + version: out.scriptPublicKey.version, + scriptPublicKey: out.scriptPublicKey.scriptPublicKey, + }, + })), + lockTime: Number(this._global.fallbackLockTime ?? 0), + subnetworkId: '0000000000000000000000000000000000000000', + gas: 0, + payload: this._global.payload ?? '', + }); + } + + // ── Serialization ────────────────────────────────────────────────────────── + + /** + * Serialise the PSKT to a JSON string for transmission (e.g. to an HSM). + * The `role` is included so `Pskt.deserialize()` can reconstruct the state. + */ + serialize(): string { + const wire: SerializedPskt = { + role: this._role, + global: this._global, + inputs: this._inputs, + outputs: this._outputs, + }; + return JSON.stringify(wire); + } + + /** + * Convert the PSKT's internal data back to a `KaspaTransactionData` object. + * + * `finalScriptSig` (if present) is mapped to `signatureScript` so that the + * resulting `Transaction` is fully signed and ready to broadcast. + * Used by `Transaction.fromPskt()`. + */ + toTxData(): KaspaTransactionData { + return { + version: this._global.txVersion, + inputs: this._inputs.map((inp) => ({ + transactionId: inp.previousOutpoint.transactionId, + transactionIndex: inp.previousOutpoint.index, + amount: inp.utxoEntry?.amount ?? '0', + scriptPublicKey: inp.utxoEntry?.scriptPublicKey ?? '', + sequence: inp.sequence, + sigOpCount: inp.sigOpCount, + signatureScript: inp.finalScriptSig, + })), + outputs: this._outputs.map((out) => ({ + address: '', + amount: out.amount, + scriptPublicKey: out.scriptPublicKey.scriptPublicKey, + })), + lockTime: this._global.fallbackLockTime ?? '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: this._global.payload ?? '', + }; + } +} diff --git a/modules/sdk-coin-kaspa/src/lib/sighash.ts b/modules/sdk-coin-kaspa/src/lib/sighash.ts index d326c27465..ecbc6c28b8 100644 --- a/modules/sdk-coin-kaspa/src/lib/sighash.ts +++ b/modules/sdk-coin-kaspa/src/lib/sighash.ts @@ -1,9 +1,14 @@ /** * Kaspa transaction sighash computation. * - * BIP-143-like scheme using Blake2b-256. All integer fields are little-endian. + * BIP-143-like scheme using keyed Blake2b-256. All integer fields are + * little-endian. + * + * ALL hashes (intermediate and final) use the same keyed Blake2b-256 hasher: + * blake2b_256(data, key="TransactionSigningHash") * * Reference (authoritative implementation): + * https://github.com/kaspanet/rusty-kaspa/blob/master/crypto/hashes/src/hashers.rs * https://github.com/kaspanet/rusty-kaspa/blob/master/consensus/core/src/hashing/sighash.rs */ import { blake2b } from 'blakejs'; @@ -19,32 +24,24 @@ export const SIGHASH_ANYONECANPAY = 0x80; export const OP_CHECKSIG_SCHNORR = 0xab; // Kaspa Schnorr checksig opcode export const SCRIPT_PUBLIC_KEY_VERSION = 0; // Standard P2PK version -function blake2b256(data: Buffer): Buffer { - return Buffer.from(blake2b(data, undefined, 32)); -} - /** - * Build P2PK Schnorr scriptPublicKey from a 32-byte x-only public key. - * Format: DATA_32(0x20) + xOnlyPubKey(32 bytes) + OP_CHECKSIG_SCHNORR(0xAB) + * The Blake2b key used for ALL sighash operations in Kaspa. + * Defined in rusty-kaspa crypto/hashes/src/hashers.rs: + * blake2b_hasher! { struct TransactionSigningHash => b"TransactionSigningHash", ... } + * which resolves to: + * blake2b_simd::Params::new().hash_length(32).key(b"TransactionSigningHash").to_state() */ -export function buildP2PKScriptPublicKey(xOnlyPubKey: Buffer): Buffer { - if (xOnlyPubKey.length !== 32) { - throw new Error(`Expected 32-byte x-only pubkey, got ${xOnlyPubKey.length}`); - } - return Buffer.concat([Buffer.from([0x20]), xOnlyPubKey, Buffer.from([OP_CHECKSIG_SCHNORR])]); -} +const SIGNING_HASH_KEY = Buffer.from('TransactionSigningHash', 'ascii'); /** - * Derive x-only public key from 33-byte compressed public key. + * Keyed Blake2b-256: blake2b(data, key="TransactionSigningHash", outlen=32). + * Used for every intermediate hash and the final sighash. */ -export function compressedToXOnly(compressedPubKey: Buffer): Buffer { - if (compressedPubKey.length !== 33) { - throw new Error(`Expected 33-byte compressed pubkey, got ${compressedPubKey.length}`); - } - return compressedPubKey.slice(1); // drop the 02/03 prefix byte +function kblake2b(data: Buffer): Buffer { + return Buffer.from(blake2b(data, SIGNING_HASH_KEY, 32)); } -// --- Intermediate hash helpers --- +// ─── Intermediate hash helpers ──────────────────────────────────────────────── function hashPreviousOutputs(inputs: KaspaUtxoInput[]): Buffer { const parts = inputs.map((inp) => { @@ -53,7 +50,7 @@ function hashPreviousOutputs(inputs: KaspaUtxoInput[]): Buffer { buf.writeUInt32LE(inp.transactionIndex, 32); return buf; }); - return blake2b256(Buffer.concat(parts)); + return kblake2b(Buffer.concat(parts)); } function hashSequences(inputs: KaspaUtxoInput[]): Buffer { @@ -61,18 +58,23 @@ function hashSequences(inputs: KaspaUtxoInput[]): Buffer { inputs.forEach((inp, i) => { buf.writeBigUInt64LE(BigInt(inp.sequence || '0'), i * 8); }); - return blake2b256(buf); + return kblake2b(buf); } function hashSigOpCounts(inputs: KaspaUtxoInput[]): Buffer { const bytes = inputs.map((inp) => inp.sigOpCount ?? 1); - return blake2b256(Buffer.from(bytes)); + return kblake2b(Buffer.from(bytes)); } +/** + * Serialize one output for hashing. + * Matches hash_output() + hash_script_public_key() in rusty-kaspa: + * write_u64(value) + write_u16(spk.version) + write_var_bytes(spk.script) + * where write_var_bytes = write_u64(len) + write(bytes). + */ function serializeOutput(output: KaspaTransactionOutput): Buffer { const scriptBytes = Buffer.from(output.scriptPublicKey || '', 'hex'); - const spkVersion = 0; // standard P2PK - // value (u64 LE, 8) + spk_version (u16 LE, 2) + script_length (u64 LE, 8) + script + const spkVersion = 0; const buf = Buffer.alloc(8 + 2 + 8 + scriptBytes.length); let offset = 0; buf.writeBigUInt64LE(BigInt(output.amount), offset); @@ -88,34 +90,64 @@ function serializeOutput(output: KaspaTransactionOutput): Buffer { function hashOutputs(tx: KaspaTransactionData, inputIndex: number, sigHashType: number): Buffer { const baseType = sigHashType & 0x1f; if (baseType === SIGHASH_NONE) { - return Buffer.alloc(32); // zero hash + return Buffer.alloc(32); } if (baseType === SIGHASH_SINGLE) { if (inputIndex >= tx.outputs.length) { return Buffer.alloc(32); } - return blake2b256(serializeOutput(tx.outputs[inputIndex])); + return kblake2b(serializeOutput(tx.outputs[inputIndex])); } // SIGHASH_ALL - const parts = tx.outputs.map(serializeOutput); - return blake2b256(Buffer.concat(parts)); + return kblake2b(Buffer.concat(tx.outputs.map(serializeOutput))); } function hashPayload(tx: KaspaTransactionData): Buffer { const subnetId = Buffer.from(tx.subnetworkId || '0000000000000000000000000000000000000000', 'hex'); - // If subnetwork is native (all zeros), payloadHash is zero + // Native subnetwork (all zeros) with empty payload → zero hash if (subnetId.every((b) => b === 0)) { return Buffer.alloc(32); } - return blake2b256(Buffer.from(tx.payload || '', 'hex')); + // write_var_bytes(payload): u64 length prefix + payload bytes + const payloadBytes = Buffer.from(tx.payload || '', 'hex'); + const lenBuf = Buffer.alloc(8); + lenBuf.writeBigUInt64LE(BigInt(payloadBytes.length)); + return kblake2b(Buffer.concat([lenBuf, payloadBytes])); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Build P2PK Schnorr scriptPublicKey from a 32-byte x-only public key. + * Format: OP_DATA_32(0x20) + xOnlyPubKey(32 bytes) + OP_CHECKSIG_SCHNORR(0xAB) + */ +export function buildP2PKScriptPublicKey(xOnlyPubKey: Buffer): Buffer { + if (xOnlyPubKey.length !== 32) { + throw new Error(`Expected 32-byte x-only pubkey, got ${xOnlyPubKey.length}`); + } + return Buffer.concat([Buffer.from([0x20]), xOnlyPubKey, Buffer.from([OP_CHECKSIG_SCHNORR])]); +} + +/** + * Derive x-only public key from 33-byte compressed public key. + */ +export function compressedToXOnly(compressedPubKey: Buffer): Buffer { + if (compressedPubKey.length !== 33) { + throw new Error(`Expected 33-byte compressed pubkey, got ${compressedPubKey.length}`); + } + return compressedPubKey.slice(1); } /** - * Compute the Kaspa sighash for a specific input. + * Compute the Kaspa Schnorr sighash for a specific input. + * + * Matches calc_schnorr_signature_hash() in rusty-kaspa sighash.rs exactly. + * All intermediate and the final hash use keyed Blake2b-256 with + * key = "TransactionSigningHash". * - * @param tx Full transaction data - * @param inputIndex Index of the input being signed - * @param sigHashType SigHash type flags (use SIGHASH_ALL = 0x01 for standard) + * @param tx Full transaction data — inputs must carry amount + scriptPublicKey + * @param inputIndex 0-based index of the input being signed + * @param sigHashType SigHash type flags (SIGHASH_ALL = 0x01 for standard) */ export function computeKaspaSigningHash( tx: KaspaTransactionData, @@ -130,7 +162,7 @@ export function computeKaspaSigningHash( throw new Error(`Input index ${inputIndex} out of range`); } - // Conditional intermediate hashes + // Intermediate hashes const prevOutputsHash = anyoneCanPay ? Buffer.alloc(32) : hashPreviousOutputs(tx.inputs); const seqHash = anyoneCanPay || baseType === SIGHASH_SINGLE || baseType === SIGHASH_NONE @@ -140,87 +172,70 @@ export function computeKaspaSigningHash( const outsHash = hashOutputs(tx, inputIndex, sigHashType); const payloadHash = hashPayload(tx); - // Parse the current input's script public key + // Current input's scriptPublicKey (from the UTXO being spent) const scriptBytes = Buffer.from(input.scriptPublicKey || '', 'hex'); - const spkVersion = 0; // standard P2PK + const spkVersion = 0; const subnetId = Buffer.from(tx.subnetworkId || '0000000000000000000000000000000000000000', 'hex'); - // Build the preimage + // Build the preimage — field order matches calc_schnorr_signature_hash() exactly const fixedSize = 2 + 32 + 32 + 32 + 32 + 4 + 2 + 8 + 8 + 8 + 1 + 32 + 8 + 20 + 8 + 32 + 1; const preimage = Buffer.alloc(fixedSize + scriptBytes.length); let offset = 0; - // 1. version + // 1. tx.version (u16 LE) preimage.writeUInt16LE(tx.version ?? 0, offset); offset += 2; - // 2. previousOutputsHash prevOutputsHash.copy(preimage, offset); offset += 32; - // 3. sequencesHash seqHash.copy(preimage, offset); offset += 32; - // 4. sigOpCountsHash sigOpHash.copy(preimage, offset); offset += 32; - - // 5. current input's previous outpoint txId + // 5. input.previousOutpoint.transactionId (32 bytes) Buffer.from(input.transactionId, 'hex').copy(preimage, offset); offset += 32; - - // 6. current input's previous outpoint index + // 6. input.previousOutpoint.index (u32 LE) preimage.writeUInt32LE(input.transactionIndex, offset); offset += 4; - - // 7. scriptPublicKey version + // 7. scriptPublicKey.version (u16 LE) preimage.writeUInt16LE(spkVersion, offset); offset += 2; - - // 8. scriptPublicKey length (u64 LE) + // 8. scriptPublicKey.script length (u64 LE) — write_var_bytes length prefix preimage.writeBigUInt64LE(BigInt(scriptBytes.length), offset); offset += 8; - - // 9. scriptPublicKey bytes + // 9. scriptPublicKey.script bytes scriptBytes.copy(preimage, offset); offset += scriptBytes.length; - - // 10. value (amount in sompi, u64 LE) + // 10. input UTXO amount (u64 LE) preimage.writeBigUInt64LE(BigInt(input.amount), offset); offset += 8; - - // 11. sequence (u64 LE) + // 11. input.sequence (u64 LE) preimage.writeBigUInt64LE(BigInt(input.sequence || '0'), offset); offset += 8; - - // 12. sigOpCount (u8) + // 12. input.sigOpCount (u8) preimage.writeUInt8(input.sigOpCount ?? 1, offset); offset += 1; - // 13. outputsHash outsHash.copy(preimage, offset); offset += 32; - - // 14. locktime (u64 LE) + // 14. tx.lockTime (u64 LE) preimage.writeBigUInt64LE(BigInt(tx.lockTime || '0'), offset); offset += 8; - - // 15. subnetworkId (20 bytes) + // 15. tx.subnetworkId (20 bytes) subnetId.copy(preimage, offset); offset += 20; - - // 16. gas (u64 LE) — always 0 for native KASPA + // 16. tx.gas (u64 LE) — always 0 for native KASPA preimage.writeBigUInt64LE(0n, offset); offset += 8; - // 17. payloadHash payloadHash.copy(preimage, offset); offset += 32; - // 18. sigHashType (u8) preimage.writeUInt8(sigHashType, offset); offset += 1; - return blake2b256(preimage.slice(0, offset)); + return kblake2b(preimage.slice(0, offset)); } diff --git a/modules/sdk-coin-kaspa/src/lib/transaction.ts b/modules/sdk-coin-kaspa/src/lib/transaction.ts index 52a3f2acbc..afe22be647 100644 --- a/modules/sdk-coin-kaspa/src/lib/transaction.ts +++ b/modules/sdk-coin-kaspa/src/lib/transaction.ts @@ -2,11 +2,14 @@ import { BaseKey, BaseTransaction, TransactionType } from '@bitgo/sdk-core'; import { ecc } from '@bitgo/secp256k1'; import { KaspaTransactionData, TransactionExplanation } from './iface'; import { computeKaspaSigningHash, SIGHASH_ALL } from './sighash'; +import { Pskt } from './pskt'; export class Transaction extends BaseTransaction { protected _txData: KaspaTransactionData; constructor(coin: string, txData?: KaspaTransactionData) { + // BaseTransaction expects a full statics CoinConfig; we only have the coin name string here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any super({ coin } as any); this._txData = txData || { version: 0, @@ -28,7 +31,7 @@ export class Transaction extends BaseTransaction { * Get the transaction fee in sompi. * If fee was explicitly set, returns that. Otherwise computes from inputs - outputs. */ - get getFee(): string { + get fee(): string { if (this._txData.fee) { return this._txData.fee; } @@ -44,43 +47,45 @@ export class Transaction extends BaseTransaction { } /** - * Returns the signable payload for TSS/MPC signing. + * Returns one sighash Buffer per input — the set of messages that must be + * signed for a multi-input MPCv2 ceremony. * - * 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 + * Kaspa uses a BIP-143-like sighash scheme (Blake2b) where each input commits + * to its own index, so input[i] has a distinct hash that cannot be re-used for + * input[j]. A correct multi-input MPCv2 flow runs one DKLS session per input + * in parallel and applies each resulting signature via addSignatureForInput(). */ - get signablePayload(): Buffer { + get signablePayloads(): Buffer[] { if (this._txData.inputs.length === 0) { - throw new Error('Cannot compute signablePayload: no inputs'); + throw new Error('Cannot compute signablePayloads: no inputs'); } - return computeKaspaSigningHash(this._txData, 0, SIGHASH_ALL); + return this._txData.inputs.map((_, i) => computeKaspaSigningHash(this._txData, i, SIGHASH_ALL)); } /** - * Apply a Schnorr signature produced by TSS/MPC signing to all inputs. + * Apply a Schnorr signature to a single specific input. * - * 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. + * Used in the multi-input MPCv2 flow where each input is signed by an + * independent DKLS session that commits to that input's sighash. Call this + * once per input with the signature produced for that input's signablePayloads[i]. * - * @param publicKey compressed secp256k1 public key (33 bytes hex) - * @param signature 64-byte Schnorr signature buffer (from TSS) + * @param index 0-based index of the input to sign + * @param publicKey compressed secp256k1 public key (33 bytes hex) — not used in + * the scriptSig bytes but kept for symmetry with addSignature + * @param signature 64-byte Schnorr signature buffer for input[index] * @param sigHashType SigHash type (default: SIGHASH_ALL) */ - addSignature(publicKey: string, signature: Buffer, sigHashType: number = SIGHASH_ALL): void { + addSignatureForInput(index: number, publicKey: string, signature: Buffer, sigHashType: number = SIGHASH_ALL): void { + if (index < 0 || index >= this._txData.inputs.length) { + throw new Error(`Input index ${index} is out of range (tx has ${this._txData.inputs.length} inputs)`); + } 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'); - } + // Kaspa script engine requires push-only signatureScripts. + // 0x41 = OP_DATA_65: push the next 65 bytes (64-byte sig + 1-byte sighash type) onto the stack. + const sigWithType = Buffer.concat([Buffer.from([0x41]), signature, Buffer.from([sigHashType])]); + this._txData.inputs[index].signatureScript = sigWithType.toString('hex'); } /** @@ -96,8 +101,8 @@ export class Transaction extends BaseTransaction { for (let i = 0; i < this._txData.inputs.length; i++) { const sigHash = computeKaspaSigningHash(this._txData, i, sigHashType); const sig = ecc.signSchnorr(sigHash, privateKey); - // 65-byte signature: 64-byte Schnorr sig + 1-byte sighash type - const sigWithType = Buffer.concat([Buffer.from(sig), Buffer.from([sigHashType])]); + // Kaspa requires push-only signatureScripts: 0x41 (OP_DATA_65) + 64-byte sig + 1-byte sighash type + const sigWithType = Buffer.concat([Buffer.from([0x41]), Buffer.from(sig), Buffer.from([sigHashType])]); this._txData.inputs[i].signatureScript = sigWithType.toString('hex'); } } @@ -115,10 +120,11 @@ export class Transaction extends BaseTransaction { return false; } const sigBytes = Buffer.from(input.signatureScript, 'hex'); - if (sigBytes.length < 65) { + // signatureScript layout: 0x41 (OP_DATA_65) + 64-byte sig + 1-byte sighash type = 66 bytes + if (sigBytes.length < 66) { return false; } - const sig = sigBytes.slice(0, 64); + const sig = sigBytes.slice(1, 65); // skip the 0x41 push opcode const sigHash = computeKaspaSigningHash(this._txData, inputIndex, sigHashType); // Accept 33-byte compressed or 32-byte x-only const xOnlyPub = publicKey.length === 33 ? publicKey.slice(1) : publicKey; @@ -159,17 +165,54 @@ export class Transaction extends BaseTransaction { } /** - * Serialize the transaction to a JSON string (broadcast format). + * Serialize the transaction to the Kaspa REST API JSON string. + * + * This is the wire format accepted by the Kaspa node REST API for broadcasting. + * Unsigned transactions (signatureScript = "") use this same structure; once + * all inputs are signed the same format is submitted to the node. + * + * Note: inputs in this format carry only `previousOutpoint`, `signatureScript`, + * `sequence`, and `sigOpCount`. The `amount` and `scriptPublicKey` fields needed + * for sighash computation are NOT present here — they live in `toJson()`. */ toBroadcastFormat(): string { - return JSON.stringify(this._txData); + return JSON.stringify({ + version: this._txData.version, + inputs: this._txData.inputs.map((input) => ({ + previousOutpoint: { + transactionId: input.transactionId, + index: input.transactionIndex, + }, + signatureScript: input.signatureScript || '', + sequence: Number(input.sequence ?? 0), + sigOpCount: input.sigOpCount ?? 1, + })), + outputs: this._txData.outputs.map((output) => ({ + amount: Number(output.amount), + scriptPublicKey: { + version: 0, + scriptPublicKey: output.scriptPublicKey || '', + }, + })), + lockTime: Number(this._txData.lockTime ?? 0), + subnetworkId: this._txData.subnetworkId ?? '0000000000000000000000000000000000000000', + gas: 0, + payload: this._txData.payload ?? '', + }); } /** - * Serialize transaction to hex (JSON-encoded then hex-encoded). + * Serialize transaction to hex using the internal `KaspaTransactionData` format. + * + * This hex is used for SDK-internal round-trips (builder serialisation / deserialisation) + * because the internal format preserves `amount` and `scriptPublicKey` on inputs, + * which are required for sighash computation but are not present in the REST API format. + * + * Use `Buffer.from(tx.toBroadcastFormat()).toString('hex')` when you need the hex + * representation of the broadcast / HSM wire format. */ toHex(): string { - return Buffer.from(this.toBroadcastFormat()).toString('hex'); + return Buffer.from(JSON.stringify(this._txData)).toString('hex'); } /** @@ -204,4 +247,38 @@ export class Transaction extends BaseTransaction { canSign(_key: BaseKey): boolean { return true; } + + /** + * Convert this transaction to a PSKT in SIGNER role. + * + * The PSKT is populated with UTXO data from each input so that sighash + * computation is possible immediately. Any existing `signatureScript` on an + * input is carried into the PSKT as `finalScriptSig`. + * + * Typical use: + * ```ts + * const pskt = tx.toPskt() + * .sign(privateKey) + * .toFinalizer() + * .finalize() + * .toExtractor(); + * const broadcastJson = pskt.extract(); + * ``` + */ + toPskt(): Pskt { + return Pskt.fromTxData(this._txData); + } + + /** + * Reconstruct a Transaction from a PSKT (any role). + * + * `finalScriptSig` from each PSKT input is mapped to `signatureScript` so + * that the resulting transaction is signed if the PSKT has been finalised. + * + * @param coin Coin name string (e.g. 'kaspa', 'tkaspa') + * @param pskt A Pskt instance (typically at FINALIZER or EXTRACTOR role) + */ + static fromPskt(coin: string, pskt: Pskt): Transaction { + return new Transaction(coin, pskt.toTxData()); + } } diff --git a/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts b/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts index bfdaee80e8..6f9ece9a72 100644 --- a/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts @@ -1,22 +1,12 @@ -import { - BaseTransactionBuilder, - BaseTransaction, - BaseKey, - PublicKey as BasePublicKey, - SigningError, -} from '@bitgo/sdk-core'; +import { BaseTransactionBuilder, BaseTransaction, BaseKey, SigningError } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; import { Transaction } from './transaction'; import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; -import { isValidKaspaAddress } from './utils'; +import { isValidKaspaAddress, addressToScriptPublicKey } from './utils'; import { KeyPair } from './keyPair'; import { DEFAULT_FEE, TX_VERSION } from './constants'; - -interface KaspaSignature { - publicKey: BasePublicKey; - signature: Buffer; -} +import { Pskt, PsktInput, PsktOutput } from './pskt'; export class TransactionBuilder extends BaseTransactionBuilder { protected _transaction: Transaction; @@ -24,7 +14,6 @@ export class TransactionBuilder extends BaseTransactionBuilder { protected _outputs: KaspaTransactionOutput[] = []; protected _fee: string = DEFAULT_FEE; protected _fromAddress = ''; - protected _signatures: KaspaSignature[] = []; constructor(coinConfig: Readonly) { super(coinConfig); @@ -59,7 +48,7 @@ export class TransactionBuilder extends BaseTransactionBuilder { if (!isValidKaspaAddress(address)) { throw new Error(`Invalid Kaspa recipient address: ${address}`); } - this._outputs.push({ address, amount }); + this._outputs.push({ address, amount, scriptPublicKey: addressToScriptPublicKey(address) }); return this; } @@ -90,22 +79,9 @@ 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); + const tx = Transaction.fromHex(this._coinConfig.name, rawTransaction); this._transaction = tx; this._inputs = tx.txData.inputs || []; this._outputs = tx.txData.outputs || []; @@ -125,12 +101,7 @@ export class TransactionBuilder extends BaseTransactionBuilder { payload: '', }; - 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); - } + this._transaction = new Transaction(this._coinConfig.name, txData); return this._transaction; } @@ -171,7 +142,7 @@ export class TransactionBuilder extends BaseTransactionBuilder { /** @inheritDoc */ validateRawTransaction(rawTransaction: string): void { try { - Transaction.fromHex((this as any)._coinConfig?.name || 'kaspa', rawTransaction); + Transaction.fromHex(this._coinConfig.name, rawTransaction); } catch { throw new Error('Invalid raw Kaspa transaction'); } @@ -183,4 +154,55 @@ export class TransactionBuilder extends BaseTransactionBuilder { throw new Error('Transaction value cannot be negative'); } } + + /** + * Build the transaction and return it as an unsigned PSKT in UPDATER role. + * + * The PSKT has all inputs and outputs populated with UTXO data but no + * signatures. It is ready to be handed to a signer (HSM, hardware wallet, + * or MPCv2 session) via `pskt.toSigner().sign(key)` or + * `pskt.toSigner().addSignature(...)`. + * + * Example: + * ```ts + * const pskt = await builder.toPskt(); + * const broadcastJson = pskt + * .toSigner() + * .sign(privateKey) + * .toFinalizer() + * .finalize() + * .toExtractor() + * .extract(); + * ``` + */ + async toPskt(): Promise { + this.validateTransaction(); + + const psktInputs: PsktInput[] = this._inputs.map((inp) => ({ + previousOutpoint: { transactionId: inp.transactionId, index: inp.transactionIndex }, + utxoEntry: { amount: inp.amount, scriptPublicKey: inp.scriptPublicKey }, + sequence: inp.sequence, + sigOpCount: inp.sigOpCount ?? 1, + partialSigs: {}, + sighashType: 0x01, // SIGHASH_ALL + bip32Derivations: {}, + proprietaries: {}, + })); + + const psktOutputs: PsktOutput[] = this._outputs.map((out) => ({ + amount: out.amount, + scriptPublicKey: { version: 0, scriptPublicKey: out.scriptPublicKey || addressToScriptPublicKey(out.address) }, + bip32Derivations: {}, + proprietaries: {}, + })); + + const pskt = Pskt.creator(TX_VERSION); + for (const inp of psktInputs) { + pskt.input(inp); + } + for (const out of psktOutputs) { + pskt.output(out); + } + return pskt.toUpdater(); + } } diff --git a/modules/sdk-coin-kaspa/src/lib/utils.ts b/modules/sdk-coin-kaspa/src/lib/utils.ts index 85250e62ad..fcdcfd02f6 100644 --- a/modules/sdk-coin-kaspa/src/lib/utils.ts +++ b/modules/sdk-coin-kaspa/src/lib/utils.ts @@ -181,6 +181,31 @@ export function pubKeyToKaspaAddress(compressedPubKey: string | Buffer, hrp: str return kaspaEncode(hrp, words); } +/** + * Derive the P2PK scriptPublicKey hex from a Kaspa address. + * + * Kaspa P2PK (Schnorr) script layout: + * OP_DATA_32 (0x20) + <32-byte x-only pubkey> + OP_CHECKSIG (0xac) + * + * The 32-byte x-only pubkey is embedded in the bech32 address payload + * after stripping the 1-byte version nibble. + */ +export function addressToScriptPublicKey(address: string): string { + const colonIdx = address.lastIndexOf(':'); + if (colonIdx < 1) { + throw new Error('Invalid Kaspa address: missing prefix'); + } + const decoded = kaspacDecode(address); + // convert 5-bit words back to bytes, no padding + const bytes = convertBits(Buffer.from(decoded.data), 5, 8, false); + // bytes[0] is the version nibble (0 = P2PK Schnorr), bytes[1..32] is the x-only pubkey + if (bytes.length < 33) { + throw new Error('Invalid Kaspa address: too short after decoding'); + } + const xOnlyPubKey = Buffer.from(bytes.slice(1, 33)); + return '20' + xOnlyPubKey.toString('hex') + 'ac'; +} + /** * Validates a secp256k1 public key (compressed or uncompressed) */ diff --git a/modules/sdk-coin-kaspa/src/register.ts b/modules/sdk-coin-kaspa/src/register.ts index 2bdbb8ff51..49d641fb95 100644 --- a/modules/sdk-coin-kaspa/src/register.ts +++ b/modules/sdk-coin-kaspa/src/register.ts @@ -1,7 +1,7 @@ import { BitGoBase } from '@bitgo/sdk-core'; -import { Kaspa } from './kaspa'; +import { Kaspa, Tkaspa } from './kaspa'; export const register = (sdk: BitGoBase): void => { sdk.register('kaspa', Kaspa.createInstance); - sdk.register('tkaspa', Kaspa.createInstance); + sdk.register('tkaspa', Tkaspa.createInstance); }; diff --git a/modules/sdk-coin-kaspa/test/unit/coin.test.ts b/modules/sdk-coin-kaspa/test/unit/coin.test.ts index b3de4ea21e..7eecc95358 100644 --- a/modules/sdk-coin-kaspa/test/unit/coin.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/coin.test.ts @@ -1,11 +1,19 @@ import * as should from 'should'; import { coins } from '@bitgo/statics'; -import { Kaspa } from '../../src'; +import { BitGoBase } from '@bitgo/sdk-core'; +import { ecc } from '@bitgo/secp256k1'; +import { Kaspa, Tkaspa } from '../../src'; import { KeyPair } from '../../src/lib/keyPair'; import { TransactionBuilder } from '../../src/lib/transactionBuilder'; import { Transaction } from '../../src/lib/transaction'; +import { KaspaVerifyTransactionOptions } from '../../src/lib/iface'; import { ADDRESSES, KEYS, UTXOS } from '../fixtures/kaspa.fixtures'; +type ParsedTx = { + inputs: { amount: string; coin: string }[]; + outputs: { address: string; amount: string; coin: string }[]; +}; + async function buildSignedTxHex(coinName: string): Promise { const builder = new TransactionBuilder(coins.get(coinName)); builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); @@ -23,7 +31,7 @@ async function buildUnsignedTxHex(coinName: string): Promise { describe('Kaspa (KASPA)', function () { let kaspa: Kaspa; - let tkaspa: Kaspa; + let tkaspa: Tkaspa; before(function () { const mockBitgo = { @@ -31,9 +39,9 @@ describe('Kaspa (KASPA)', function () { microservicesUrl: () => '', post: () => ({ result: () => Promise.resolve({}) }), get: () => ({ result: () => Promise.resolve({}) }), - } as any; - kaspa = new Kaspa(mockBitgo, coins.get('kaspa')); - tkaspa = new Kaspa(mockBitgo, coins.get('tkaspa')); + } as unknown as BitGoBase; + kaspa = Kaspa.createInstance(mockBitgo); + tkaspa = Tkaspa.createInstance(mockBitgo); }); describe('Coin Properties', function () { @@ -80,8 +88,8 @@ describe('Kaspa (KASPA)', function () { const kp = kaspa.generateKeyPair(); should.exist(kp.pub); should.exist(kp.prv); - kp.pub!.should.have.length(66); - kp.prv!.should.have.length(64); + (kp.pub as string).should.have.length(66); + (kp.prv as string).should.have.length(64); }); it('should generate a key pair from seed', function () { @@ -95,8 +103,8 @@ describe('Kaspa (KASPA)', function () { const seed = Buffer.alloc(32, 42); const kp1 = kaspa.generateKeyPair(seed); const kp2 = kaspa.generateKeyPair(seed); - kp1.pub!.should.equal(kp2.pub!); - kp1.prv!.should.equal(kp2.prv!); + (kp1.pub as string).should.equal(kp2.pub as string); + (kp1.prv as string).should.equal(kp2.prv as string); }); }); @@ -121,13 +129,13 @@ describe('Kaspa (KASPA)', function () { const kp = new KeyPair({ prv: KEYS.prv }); const address = kp.getAddress('mainnet'); const keychains = [{ pub: KEYS.pub }, { pub: KEYS.pub }, { pub: KEYS.pub }]; - const result = await kaspa.isWalletAddress({ address, keychains } as any); + const result = await kaspa.isWalletAddress({ address, keychains } as Parameters[0]); result.should.be.true(); }); it('should throw on invalid address', async function () { await kaspa - .isWalletAddress({ address: 'not-an-address', keychains: [] } as any) + .isWalletAddress({ address: 'not-an-address', keychains: [] } as Parameters[0]) .should.be.rejectedWith(/invalid address/); }); @@ -135,7 +143,7 @@ describe('Kaspa (KASPA)', function () { const kp = new KeyPair({ prv: KEYS.prv }); const address = kp.getAddress('mainnet'); await kaspa - .isWalletAddress({ address, keychains: [{ pub: KEYS.pub }] } as any) + .isWalletAddress({ address, keychains: [{ pub: KEYS.pub }] } as Parameters[0]) .should.be.rejectedWith(/Invalid keychains/); }); @@ -144,22 +152,23 @@ describe('Kaspa (KASPA)', function () { const kp = new KeyPair({ prv: KEYS.prv }); const address = kp.getAddress('mainnet'); const keychains = [{ pub: other.pub }, { pub: other.pub }, { pub: other.pub }]; - await kaspa.isWalletAddress({ address, keychains } as any).should.be.rejectedWith(/address validation failure/); + await kaspa + .isWalletAddress({ address, keychains } as Parameters[0]) + .should.be.rejectedWith(/address validation failure/); }); }); describe('parseTransaction', function () { it('should return empty object when no txHex is provided', async function () { - const parsed = await kaspa.parseTransaction({}); + const parsed = await kaspa.parseTransaction({} as Parameters[0]); parsed.should.deepEqual({}); }); it('should return inputs and outputs for a valid txHex', async function () { const txHex = await buildUnsignedTxHex('kaspa'); - const parsed = (await kaspa.parseTransaction({ txHex })) as { - inputs: { amount: string; coin: string }[]; - outputs: { address: string; amount: string; coin: string }[]; - }; + const parsed = (await kaspa.parseTransaction({ txHex } as unknown as Parameters< + typeof kaspa.parseTransaction + >[0])) as unknown as ParsedTx; parsed.inputs.should.have.length(1); parsed.inputs[0].amount.should.equal(UTXOS.simple.amount); parsed.inputs[0].coin.should.equal('kaspa'); @@ -170,7 +179,9 @@ describe('Kaspa (KASPA)', function () { }); it('should throw on invalid txHex', async function () { - await kaspa.parseTransaction({ txHex: 'notvalidhex!!' }).should.be.rejectedWith(/Invalid transaction/); + await kaspa + .parseTransaction({ txHex: 'notvalidhex!!' } as unknown as Parameters[0]) + .should.be.rejectedWith(/Invalid transaction/); }); }); @@ -183,24 +194,92 @@ describe('Kaspa (KASPA)', function () { }); it('should throw when txHex is missing', async function () { - await kaspa.explainTransaction({} as any).should.be.rejectedWith(/missing transaction hex/); + await kaspa + .explainTransaction({} as Parameters[0]) + .should.be.rejectedWith(/missing transaction hex/); }); }); describe('signTransaction', function () { - it('should sign a prebuilt transaction', async function () { + it('Path A — prv: signs all inputs with private key', async function () { const txHex = await buildUnsignedTxHex('kaspa'); const result = (await kaspa.signTransaction({ txPrebuild: { txHex }, prv: KEYS.prv, - } as any)) as { txHex: string }; + } as unknown as Parameters[0])) as { txHex: string }; + result.txHex.should.be.a.String(); + const signed = Transaction.fromHex('kaspa', result.txHex); + signed.signature[0].should.have.length(132); + }); + + it('Path B — signatures[]: applies per-input TSS signatures for multi-input tx', async function () { + // Build a 2-input unsigned transaction (simulating what the platform would build) + const builder = new TransactionBuilder(coins.get('kaspa')); + builder.addInput(UTXOS.simple).addInput(UTXOS.second).to(ADDRESSES.recipient, '299996000').fee('4000'); + const unsignedTx = (await builder.build()) as Transaction; + const txHex = unsignedTx.toHex(); + + // Simulate what the platform does: read per-input sighashes and sign each + // independently (normally via N DKLS sessions; here using the raw key directly) + const prv = Buffer.from(KEYS.prv, 'hex'); + const pub = Buffer.from(KEYS.pub, 'hex'); + const payloads = unsignedTx.signablePayloads; // Buffer[2] + payloads.should.have.length(2); + + const signatures = payloads.map((hash, inputIndex) => ({ + inputIndex, + pubKey: KEYS.pub, + signature: Buffer.from(ecc.signSchnorr(hash, prv)).toString('hex'), + })); + + // Both signatures were produced over different messages — they are distinct + signatures[0].signature.should.not.equal(signatures[1].signature); + + const result = (await kaspa.signTransaction({ + txPrebuild: { txHex }, + signatures, + } as unknown as Parameters[0])) as { txHex: string }; + result.txHex.should.be.a.String(); const signed = Transaction.fromHex('kaspa', result.txHex); - signed.signature[0].should.have.length(130); + + // Both inputs must carry a valid 65-byte signature (64 Schnorr + 1 sighash type) + signed.signature.should.have.length(2); + signed.signature[0].should.have.length(132); + signed.signature[1].should.have.length(132); + + // Each signature must verify against its own input's sighash, not the other's + signed.verifySignature(pub, 0).should.be.true(); + signed.verifySignature(pub, 1).should.be.true(); + }); + + it('Path B — halfSigned returned when only some inputs are signed', async function () { + const builder = new TransactionBuilder(coins.get('kaspa')); + builder.addInput(UTXOS.simple).addInput(UTXOS.second).to(ADDRESSES.recipient, '299996000').fee('4000'); + const unsignedTx = (await builder.build()) as Transaction; + const txHex = unsignedTx.toHex(); + + const prv = Buffer.from(KEYS.prv, 'hex'); + const hash0 = unsignedTx.signablePayloads[0]; + + // Only sign input 0 — simulates a partial TSS result + const result = await kaspa.signTransaction({ + txPrebuild: { txHex }, + signatures: [ + { inputIndex: 0, pubKey: KEYS.pub, signature: Buffer.from(ecc.signSchnorr(hash0, prv)).toString('hex') }, + ], + } as unknown as Parameters[0]); + + // Should come back as halfSigned since input 1 is still unsigned + const halfResult = result as unknown as { halfSigned?: { txHex: string }; txHex?: string }; + should.exist(halfResult.halfSigned); + should.not.exist(halfResult.txHex); }); it('should throw when txHex is missing', async function () { - await kaspa.signTransaction({ txPrebuild: {}, prv: KEYS.prv } as any).should.be.rejectedWith(/missing txHex/); + await kaspa + .signTransaction({ txPrebuild: {}, prv: KEYS.prv } as unknown as Parameters[0]) + .should.be.rejectedWith(/missing txHex/); }); }); @@ -210,14 +289,85 @@ describe('Kaspa (KASPA)', function () { const result = await kaspa.verifyTransaction({ txPrebuild: { txHex }, txParams: { recipients: [{ address: ADDRESSES.recipient, amount: '99998000' }] }, - } as any); + } as unknown as KaspaVerifyTransactionOptions); result.should.be.true(); }); + it('should pass when recipients is absent (no recipient check)', async function () { + const txHex = await buildSignedTxHex('kaspa'); + const result = await kaspa.verifyTransaction({ + txPrebuild: { txHex }, + txParams: {}, + } as unknown as KaspaVerifyTransactionOptions); + result.should.be.true(); + }); + + it('should throw when expected recipient count exceeds actual outputs', async function () { + const txHex = await buildSignedTxHex('kaspa'); + // tx has 1 output, we claim 2 recipients + await kaspa + .verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + recipients: [ + { address: ADDRESSES.recipient, amount: '50000000' }, + { address: ADDRESSES.recipient, amount: '49998000' }, + ], + }, + } as unknown as KaspaVerifyTransactionOptions) + .should.be.rejectedWith(/Expected at least 2 outputs/); + }); + it('should throw when txHex is missing', async function () { await kaspa - .verifyTransaction({ txPrebuild: {}, txParams: {} } as any) + .verifyTransaction({ txPrebuild: {}, txParams: {} } as unknown as KaspaVerifyTransactionOptions) .should.be.rejectedWith(/missing required tx prebuild property txHex/); }); + + it('should throw when txHex is invalid', async function () { + await kaspa + .verifyTransaction({ + txPrebuild: { txHex: 'deadbeef' }, + txParams: {}, + } as unknown as KaspaVerifyTransactionOptions) + .should.be.rejectedWith(/Invalid transaction/); + }); + }); + + describe('parseTransaction — halfSigned path', function () { + it('should parse a transaction passed via halfSigned.txHex', async function () { + const txHex = await buildUnsignedTxHex('kaspa'); + const parsed = (await kaspa.parseTransaction({ halfSigned: { txHex } } as unknown as Parameters< + typeof kaspa.parseTransaction + >[0])) as unknown as ParsedTx; + parsed.inputs.should.have.length(1); + parsed.inputs[0].coin.should.equal('kaspa'); + parsed.outputs.should.have.length(1); + parsed.outputs[0].amount.should.equal('99998000'); + }); + }); + + describe('explainTransaction — halfSigned path', function () { + it('should explain a transaction passed via halfSigned.txHex', async function () { + const txHex = await buildUnsignedTxHex('kaspa'); + const explained = await kaspa.explainTransaction({ halfSigned: { txHex } } as unknown as Parameters< + typeof kaspa.explainTransaction + >[0]); + explained.outputs.should.have.length(1); + explained.outputs[0].amount.should.equal('99998000'); + }); + }); + + describe('signTransaction — edge cases', function () { + it('should return halfSigned when neither prv nor signatures provided', async function () { + const txHex = await buildUnsignedTxHex('kaspa'); + const result = await kaspa.signTransaction({ txPrebuild: { txHex } } as unknown as Parameters< + typeof kaspa.signTransaction + >[0]); + // no signing happened — all signature scripts are empty, so halfSigned is returned + const typed = result as unknown as { halfSigned?: { txHex: string }; txHex?: string }; + should.exist(typed.halfSigned); + should.not.exist(typed.txHex); + }); }); }); diff --git a/modules/sdk-coin-kaspa/test/unit/pskt.test.ts b/modules/sdk-coin-kaspa/test/unit/pskt.test.ts new file mode 100644 index 0000000000..cc5932ad3a --- /dev/null +++ b/modules/sdk-coin-kaspa/test/unit/pskt.test.ts @@ -0,0 +1,450 @@ +/** + * PSKT (Partially Signed Kaspa Transaction) — Unit Tests + * + * Covers: + * - Full role flow: CREATOR → UPDATER → SIGNER → FINALIZER → EXTRACTOR + * - HSM / external signing path via addSignature() + * - Multi-party COMBINER role + * - serialize() / deserialize() round-trip + * - Extractor output matches Transaction.toBroadcastFormat() + * - Transaction.toPskt() and Transaction.fromPskt() bridge methods + * - TransactionBuilder.toPskt() unsigned PSKT construction + * - Role guard errors + */ + +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { ecc } from '@bitgo/secp256k1'; +import { Pskt, PsktInput, PsktOutput } from '../../src/lib/pskt'; +import { Transaction } from '../../src/lib/transaction'; +import { TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { computeKaspaSigningHash, SIGHASH_ALL } from '../../src/lib/sighash'; +import { KEYS, ADDRESSES, UTXOS, TRANSACTIONS, SCRIPT_PUBLIC_KEY } from '../fixtures/kaspa.fixtures'; + +const PRV_KEY_BUF = Buffer.from(KEYS.prv, 'hex'); +const PUB_KEY_BUF = Buffer.from(KEYS.pub, 'hex'); +const X_ONLY_PUB = PUB_KEY_BUF.slice(1); // 32-byte x-only key for Schnorr verify + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makePsktInput(utxo: (typeof UTXOS)['simple']): PsktInput { + return { + previousOutpoint: { transactionId: utxo.transactionId, index: utxo.transactionIndex }, + utxoEntry: { amount: utxo.amount, scriptPublicKey: utxo.scriptPublicKey }, + sequence: utxo.sequence, + sigOpCount: utxo.sigOpCount ?? 1, + partialSigs: {}, + sighashType: SIGHASH_ALL, + bip32Derivations: {}, + proprietaries: {}, + }; +} + +function makePsktOutput(): PsktOutput { + return { + amount: '99998000', + scriptPublicKey: { version: 0, scriptPublicKey: SCRIPT_PUBLIC_KEY }, + bip32Derivations: {}, + proprietaries: {}, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('PSKT — role state machine', function () { + it('enforces role transitions: CREATOR → UPDATER → SIGNER → FINALIZER → EXTRACTOR', function () { + const pskt = Pskt.creator(); + assert.equal(pskt.role, 'CREATOR'); + + const updater = pskt.toUpdater(); + assert.equal(updater.role, 'UPDATER'); + + const signer = updater.input(makePsktInput(UTXOS.simple)).output(makePsktOutput()).toSigner(); + assert.equal(signer.role, 'SIGNER'); + + const finalizer = signer.sign(PRV_KEY_BUF).toFinalizer(); + assert.equal(finalizer.role, 'FINALIZER'); + + const extractor = finalizer.finalize().toExtractor(); + assert.equal(extractor.role, 'EXTRACTOR'); + }); + + it('enforces role transitions: SIGNER → COMBINER → FINALIZER → EXTRACTOR', function () { + const signer = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .sign(PRV_KEY_BUF); + + const combiner = signer.toCombiner(); + assert.equal(combiner.role, 'COMBINER'); + + const finalizer = combiner.toFinalizer(); + assert.equal(finalizer.role, 'FINALIZER'); + + const extractor = finalizer.finalize().toExtractor(); + assert.equal(extractor.role, 'EXTRACTOR'); + }); + + it('throws when calling toUpdater() from SIGNER role', function () { + const signer = Pskt.creator().toUpdater().input(makePsktInput(UTXOS.simple)).output(makePsktOutput()).toSigner(); + assert.throws(() => signer.toUpdater(), /role/i); + }); + + it('throws when calling sign() from FINALIZER role', function () { + const finalizer = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .sign(PRV_KEY_BUF) + .toFinalizer(); + assert.throws(() => finalizer.sign(PRV_KEY_BUF), /role/i); + }); + + it('throws toExtractor() when inputs are not finalized', function () { + const finalizer = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .toFinalizer(); // skip signing + assert.throws(() => finalizer.toExtractor(), /finalised|finalScriptSig/i); + }); + + it('throws finalize() when input has no partial signatures', function () { + const finalizer = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .toFinalizer(); + assert.throws(() => finalizer.finalize(), /no partial signatures/i); + }); +}); + +describe('PSKT — sign() produces valid Schnorr signatures', function () { + it('sign() writes partialSigs for each input', function () { + const signer = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .sign(PRV_KEY_BUF); + + assert.equal(signer.inputs.length, 1); + const sigs = Object.entries(signer.inputs[0].partialSigs); + assert.equal(sigs.length, 1, 'should have exactly one partial sig'); + const [pubKeyHex, sigHex] = sigs[0]; + assert.equal(pubKeyHex, KEYS.pub, 'pubKey key should match'); + assert.equal(Buffer.from(sigHex, 'hex').length, 64, 'sig should be 64 bytes'); + }); + + it('sign() Schnorr signatures verify against the correct per-input sighash', function () { + const pskt = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .input(makePsktInput(UTXOS.second)) + .output({ + amount: '299998000', + scriptPublicKey: { version: 0, scriptPublicKey: SCRIPT_PUBLIC_KEY }, + bip32Derivations: {}, + proprietaries: {}, + }) + .toSigner() + .sign(PRV_KEY_BUF); + + // Build matching KaspaTransactionData for sighash reference + const txData = TRANSACTIONS.multiInput; + + for (let i = 0; i < pskt.inputs.length; i++) { + const sigs = Object.values(pskt.inputs[i].partialSigs); + assert.equal(sigs.length, 1); + const sig = Buffer.from(sigs[0], 'hex'); + const expectedHash = computeKaspaSigningHash(txData, i, SIGHASH_ALL); + assert.ok(ecc.verifySchnorr(expectedHash, X_ONLY_PUB, sig), `Input[${i}] Schnorr sig should verify`); + } + }); +}); + +describe('PSKT — addSignature() (HSM / external signing path)', function () { + it('addSignature() writes the provided sig into partialSigs', function () { + const signer = Pskt.creator().toUpdater().input(makePsktInput(UTXOS.simple)).output(makePsktOutput()).toSigner(); + + // Produce sig externally + const txData = TRANSACTIONS.simple; + const sigHash = computeKaspaSigningHash(txData, 0, SIGHASH_ALL); + const sig = Buffer.from(ecc.signSchnorr(sigHash, PRV_KEY_BUF)); + signer.addSignature(0, KEYS.pub, sig.toString('hex')); + + const sigs = Object.entries(signer.inputs[0].partialSigs); + assert.equal(sigs.length, 1); + assert.equal(sigs[0][0], KEYS.pub); + assert.equal(sigs[0][1], sig.toString('hex')); + }); + + it('addSignature() → finalize() produces a valid finalScriptSig', function () { + const txData = TRANSACTIONS.simple; + const sigHash = computeKaspaSigningHash(txData, 0, SIGHASH_ALL); + const sig = Buffer.from(ecc.signSchnorr(sigHash, PRV_KEY_BUF)); + + const pskt = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .addSignature(0, KEYS.pub, sig.toString('hex')) + .toFinalizer() + .finalize(); + + const finalSig = pskt.inputs[0].finalScriptSig; + assert.ok(finalSig, 'finalScriptSig should be set'); + const finalBuf = Buffer.from(finalSig, 'hex'); + assert.equal(finalBuf.length, 66, 'finalScriptSig should be 66 bytes (0x41 + 64-byte sig + 1-byte sighash)'); + assert.equal(finalBuf[0], 0x41, 'first byte should be OP_DATA_65'); + assert.equal(finalBuf[65], SIGHASH_ALL, 'last byte should be sighash type'); + }); + + it('throws on addSignature() with wrong-sized signature', function () { + const signer = Pskt.creator().toUpdater().input(makePsktInput(UTXOS.simple)).output(makePsktOutput()).toSigner(); + assert.throws(() => signer.addSignature(0, KEYS.pub, 'deadbeef'), /64-byte/i); + }); + + it('throws on addSignature() with out-of-range input index', function () { + const signer = Pskt.creator().toUpdater().input(makePsktInput(UTXOS.simple)).output(makePsktOutput()).toSigner(); + assert.throws(() => signer.addSignature(99, KEYS.pub, 'aa'.repeat(64)), /out of range/i); + }); +}); + +describe('PSKT — combine() merges partialSigs', function () { + it('combine() merges partial signatures from two signers', function () { + const buildSigned = () => + Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .sign(PRV_KEY_BUF); + + const pskt1 = buildSigned(); + const pskt2 = buildSigned(); + + // Both have the same sig; combining should succeed (idempotent) + const combined = pskt1.toCombiner().combine(pskt2); + const sigs = Object.entries(combined.inputs[0].partialSigs); + assert.equal(sigs.length, 1); + }); + + it('combine() throws on conflicting signatures for the same pubKey', function () { + // Simulate two different signers by manually injecting different sigs + const signer1 = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .addSignature(0, KEYS.pub, 'aa'.repeat(64)); + + const signer2 = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .addSignature(0, KEYS.pub, 'bb'.repeat(64)); + + assert.throws(() => signer1.toCombiner().combine(signer2), /conflicting signature/i); + }); + + it('combine() throws on input count mismatch', function () { + const a = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .toCombiner(); + + const b = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .input(makePsktInput(UTXOS.second)) + .output(makePsktOutput()) + .toSigner() + .toCombiner(); + + assert.throws(() => a.combine(b), /input count mismatch/i); + }); +}); + +describe('PSKT — serialize() / deserialize() round-trip', function () { + it('round-trips a PSKT in SIGNER role before signing', function () { + const original = Pskt.creator().toUpdater().input(makePsktInput(UTXOS.simple)).output(makePsktOutput()).toSigner(); + + const json = original.serialize(); + assert.doesNotThrow(() => JSON.parse(json), 'serialized form should be valid JSON'); + + const restored = Pskt.deserialize(json); + assert.equal(restored.role, 'SIGNER'); + assert.equal(restored.inputs.length, 1); + assert.equal(restored.outputs.length, 1); + assert.equal(restored.inputs[0].previousOutpoint.transactionId, UTXOS.simple.transactionId); + assert.equal(restored.outputs[0].amount, '99998000'); + }); + + it('round-trips a signed PSKT preserving partialSigs', function () { + const signed = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .sign(PRV_KEY_BUF); + + const restored = Pskt.deserialize(signed.serialize()); + assert.equal(restored.role, 'SIGNER'); + const sigs = Object.entries(restored.inputs[0].partialSigs); + assert.equal(sigs.length, 1, 'partialSigs should survive round-trip'); + assert.equal(sigs[0][0], KEYS.pub); + }); + + it('throws on invalid PSKT JSON', function () { + assert.throws(() => Pskt.deserialize('not json'), /JSON/i); + assert.throws(() => Pskt.deserialize('{}'), /missing required fields/i); + }); +}); + +describe('PSKT — extract() matches Transaction.toBroadcastFormat()', function () { + it('single-input: extract() === toBroadcastFormat()', function () { + const tx = Transaction.fromJson('kaspa', TRANSACTIONS.simple); + tx.sign(PRV_KEY_BUF); + + // PSKT path + const broadcastViaPskt = tx + .toPskt() // SIGNER role, finalScriptSig already set from tx.sign() + .toFinalizer() + .finalize() + .toExtractor() + .extract(); + + // Direct path + const broadcastDirect = tx.toBroadcastFormat(); + + assert.deepStrictEqual(JSON.parse(broadcastViaPskt), JSON.parse(broadcastDirect)); + }); + + it('multi-input: extract() === toBroadcastFormat()', function () { + const tx = Transaction.fromJson('kaspa', TRANSACTIONS.multiInput); + tx.sign(PRV_KEY_BUF); + + const broadcastViaPskt = tx.toPskt().toFinalizer().finalize().toExtractor().extract(); + const broadcastDirect = tx.toBroadcastFormat(); + + assert.deepStrictEqual(JSON.parse(broadcastViaPskt), JSON.parse(broadcastDirect)); + }); + + it('PSKT-signed extract() === direct sign + toBroadcastFormat()', function () { + // Build via PSKT from scratch + const broadcastViaPskt = Pskt.creator() + .toUpdater() + .input(makePsktInput(UTXOS.simple)) + .output(makePsktOutput()) + .toSigner() + .sign(PRV_KEY_BUF) + .toFinalizer() + .finalize() + .toExtractor() + .extract(); + + // Build via Transaction directly + const tx = Transaction.fromJson('kaspa', TRANSACTIONS.simple); + tx.sign(PRV_KEY_BUF); + const broadcastDirect = tx.toBroadcastFormat(); + + assert.deepStrictEqual(JSON.parse(broadcastViaPskt), JSON.parse(broadcastDirect)); + }); +}); + +describe('PSKT — Transaction.toPskt() and Transaction.fromPskt()', function () { + it('toPskt() returns a SIGNER-role PSKT with UTXO data populated', function () { + const tx = Transaction.fromJson('kaspa', TRANSACTIONS.simple); + const pskt = tx.toPskt(); + assert.equal(pskt.role, 'SIGNER'); + assert.equal(pskt.inputs.length, 1); + assert.equal(pskt.inputs[0].utxoEntry?.amount, UTXOS.simple.amount); + assert.equal(pskt.inputs[0].utxoEntry?.scriptPublicKey, UTXOS.simple.scriptPublicKey); + }); + + it('fromPskt() reconstructs a Transaction from a finalised PSKT', function () { + const original = Transaction.fromJson('kaspa', TRANSACTIONS.simple); + original.sign(PRV_KEY_BUF); + + const pskt = original.toPskt().toFinalizer().finalize(); + const restored = Transaction.fromPskt('kaspa', pskt); + + assert.deepStrictEqual(JSON.parse(restored.toBroadcastFormat()), JSON.parse(original.toBroadcastFormat())); + }); + + it('fromPskt() → verifySignature() succeeds', function () { + const original = Transaction.fromJson('kaspa', TRANSACTIONS.simple); + original.sign(PRV_KEY_BUF); + + const pskt = original.toPskt().toFinalizer().finalize(); + const restored = Transaction.fromPskt('kaspa', pskt); + + assert.ok(restored.verifySignature(PUB_KEY_BUF, 0), 'signature should verify after fromPskt()'); + }); +}); + +describe('PSKT — TransactionBuilder.toPskt()', function () { + it('returns an UPDATER-role PSKT with all inputs and outputs', async function () { + const coinConfig = coins.get('kaspa'); + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + + const pskt = await builder.toPskt(); + assert.equal(pskt.role, 'UPDATER'); + assert.equal(pskt.inputs.length, 1); + assert.equal(pskt.outputs.length, 1); + assert.equal(pskt.inputs[0].previousOutpoint.transactionId, UTXOS.simple.transactionId); + assert.equal(pskt.outputs[0].amount, '99998000'); + }); + + it('UPDATER PSKT can be signed and extracted to produce a valid broadcast payload', async function () { + const coinConfig = coins.get('kaspa'); + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + + const broadcast = (await builder.toPskt()) + .toSigner() + .sign(PRV_KEY_BUF) + .toFinalizer() + .finalize() + .toExtractor() + .extract(); + + assert.doesNotThrow(() => JSON.parse(broadcast), 'extract() should produce valid JSON'); + const parsed = JSON.parse(broadcast); + assert.ok(parsed.inputs[0].signatureScript, 'signatureScript should be set'); + assert.equal(Buffer.from(parsed.inputs[0].signatureScript, 'hex').length, 66); + }); + + it('PSKT from builder matches broadcast from Transaction.sign()', async function () { + const coinConfig = coins.get('kaspa'); + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + + const broadcastViaPskt = (await builder.toPskt()) + .toSigner() + .sign(PRV_KEY_BUF) + .toFinalizer() + .finalize() + .toExtractor() + .extract(); + + // Rebuild via standard path + const builder2 = new TransactionBuilder(coinConfig); + builder2.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await builder2.build()) as Transaction; + tx.sign(PRV_KEY_BUF); + + assert.deepStrictEqual(JSON.parse(broadcastViaPskt), JSON.parse(tx.toBroadcastFormat())); + }); +}); diff --git a/modules/sdk-coin-kaspa/test/unit/transaction.test.ts b/modules/sdk-coin-kaspa/test/unit/transaction.test.ts index e6c9569364..743cd153ea 100644 --- a/modules/sdk-coin-kaspa/test/unit/transaction.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/transaction.test.ts @@ -75,7 +75,7 @@ describe('Kaspa Transaction', function () { tx.sign(privKey); for (const input of tx.txData.inputs) { assert.ok(input.signatureScript, 'Each input should have a signatureScript'); - assert.ok(input.signatureScript!.length > 0); + assert.ok((input.signatureScript as string).length > 0); } }); @@ -93,9 +93,9 @@ describe('Kaspa Transaction', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); const privKey = Buffer.from(KEYS.prv, 'hex'); tx.sign(privKey); - const sigHex = tx.txData.inputs[0].signatureScript!; - // 65 bytes = 130 hex chars - assert.equal(sigHex.length, 130); + const sigHex = tx.txData.inputs[0].signatureScript ?? ''; + // 66 bytes = 132 hex chars: 0x41 push opcode (1) + 64-byte sig + 1-byte sighash type + assert.equal(sigHex.length, 132); // Last byte is sighash type (0x01 = SIGHASH_ALL) const lastByte = parseInt(sigHex.slice(-2), 16); assert.equal(lastByte, SIGHASH_ALL); @@ -169,10 +169,10 @@ describe('Kaspa Transaction', function () { }); }); - describe('getFee', function () { + describe('fee', function () { it('should return explicit fee when set in txData', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); - assert.equal(tx.getFee, '2000'); + assert.equal(tx.fee, '2000'); }); it('should compute fee from inputs - outputs when fee is not set', function () { @@ -180,92 +180,130 @@ describe('Kaspa Transaction', function () { delete txData.fee; const tx = new Transaction(COIN, txData); // input: 100000000, output: 99998000, fee = 2000 - assert.equal(tx.getFee, '2000'); + assert.equal(tx.fee, '2000'); }); }); - describe('signablePayload', function () { - it('should return a 32-byte Buffer (Blake2b hash)', function () { + describe('signablePayloads', function () { + it('should return one Buffer per input', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); - const payload = tx.signablePayload; - assert.ok(Buffer.isBuffer(payload)); - assert.equal(payload.length, 32); + const payloads = tx.signablePayloads; + assert.equal(payloads.length, 1); + assert.ok(Buffer.isBuffer(payloads[0])); + assert.equal(payloads[0].length, 32); + }); + + it('should return two Buffers for a two-input transaction', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const payloads = tx.signablePayloads; + assert.equal(payloads.length, 2); + for (const p of payloads) { + assert.ok(Buffer.isBuffer(p)); + assert.equal(p.length, 32); + } + }); + + it('should return distinct hashes for each input in a multi-input tx', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const [p0, p1] = tx.signablePayloads; + assert.ok(!p0.equals(p1), 'per-input sighashes must differ (each commits to its own index)'); }); it('should throw when transaction has no inputs', function () { const tx = new Transaction(COIN); assert.throws(() => { - tx.signablePayload; + tx.signablePayloads; }, /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); + it('should be deterministic for the same transaction data', function () { + const tx1 = new Transaction(COIN, TRANSACTIONS.multiInput); const tx2 = new Transaction(COIN, TRANSACTIONS.multiInput); - assert.ok(!tx1.signablePayload.equals(tx2.signablePayload)); + const [a0, a1] = tx1.signablePayloads; + const [b0, b1] = tx2.signablePayloads; + assert.ok(a0.equals(b0)); + assert.ok(a1.equals(b1)); }); }); - 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); + describe('addSignatureForInput', function () { + it('should write the signature only to the specified input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const fakeSig = Buffer.alloc(64, 0xaa); + tx.addSignatureForInput(0, 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); + assert.ok(tx.txData.inputs[0].signatureScript, 'input[0] should be signed'); + assert.equal(tx.txData.inputs[1].signatureScript, undefined, 'input[1] should remain unsigned'); }); - it('should apply signature to all inputs of a multi-input tx', function () { + it('should write different signatures to different inputs independently', function () { const tx = new Transaction(COIN, TRANSACTIONS.multiInput); - const fakeSig = Buffer.alloc(64, 0xcd); - tx.addSignature(KEYS.pub, fakeSig); + const sig0 = Buffer.alloc(64, 0xaa); + const sig1 = Buffer.alloc(64, 0xbb); - assert.equal(tx.txData.inputs.length, 2); - for (const input of tx.txData.inputs) { - assert.ok(input.signatureScript); - assert.equal(input.signatureScript!.length, 130); - } + tx.addSignatureForInput(0, KEYS.pub, sig0); + tx.addSignatureForInput(1, KEYS.pub, sig1); + + assert.notEqual( + tx.txData.inputs[0].signatureScript, + tx.txData.inputs[1].signatureScript, + 'each input should carry its own signature script' + ); }); - 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 produce a 66-byte script (push opcode + 64 sig + 1 sighash type) for the target input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + tx.addSignatureForInput(1, KEYS.pub, Buffer.alloc(64, 0xcc)); + // 66 bytes = 132 hex chars: 0x41 push opcode + 64-byte sig + 1-byte sighash type + assert.equal((tx.txData.inputs[1].signatureScript ?? '').length, 132); }); - it('should append SIGHASH_ALL byte at the end', function () { + it('should append SIGHASH_ALL byte by default', 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); + tx.addSignatureForInput(0, KEYS.pub, Buffer.alloc(64, 0xdd)); + const lastByte = parseInt((tx.txData.inputs[0].signatureScript ?? '').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 + it('should produce a verifiable signature when given the correct Schnorr sig for that input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); const privKey = Buffer.from(KEYS.prv, 'hex'); + + // sign() computes the correct per-input sighash internally tx.sign(privKey); - const realSigHex = tx.txData.inputs[0].signatureScript!; - const realSig = Buffer.from(realSigHex.slice(0, 128), 'hex'); // 64-byte Schnorr sig + // Skip the leading 0x41 push opcode (2 hex chars), read the next 64 bytes (128 hex chars) + const sig0 = Buffer.from((tx.txData.inputs[0].signatureScript ?? '').slice(2, 130), 'hex'); + const sig1 = Buffer.from((tx.txData.inputs[1].signatureScript ?? '').slice(2, 130), 'hex'); + + // Rebuild unsigned and apply via addSignatureForInput + const tx2 = new Transaction(COIN, TRANSACTIONS.multiInput); + tx2.addSignatureForInput(0, KEYS.pub, sig0); + tx2.addSignatureForInput(1, KEYS.pub, sig1); - // Now create a fresh tx and use addSignature instead - const tx2 = new Transaction(COIN, TRANSACTIONS.simple); - tx2.addSignature(KEYS.pub, realSig); + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.ok(tx2.verifySignature(pubKey, 0), 'input[0] signature should verify'); + assert.ok(tx2.verifySignature(pubKey, 1), 'input[1] signature should verify'); + }); + + it('should throw for an out-of-range index', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.throws(() => { + tx.addSignatureForInput(5, KEYS.pub, Buffer.alloc(64)); + }, /out of range/); + }); + + it('should throw for a negative index', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.throws(() => { + tx.addSignatureForInput(-1, KEYS.pub, Buffer.alloc(64)); + }, /out of range/); + }); - // The signature scripts should match (same sig bytes + same sighash type) - assert.equal(tx2.txData.inputs[0].signatureScript, tx.txData.inputs[0].signatureScript); + it('should throw for a non-64-byte signature', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.throws(() => { + tx.addSignatureForInput(0, KEYS.pub, Buffer.alloc(32)); + }, /64-byte/); }); }); @@ -277,22 +315,52 @@ describe('Kaspa Transaction', function () { assert.notEqual(json, tx.txData); }); - it('toBroadcastFormat should return a JSON string', function () { + it('toBroadcastFormat should return a REST API JSON string', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); const broadcast = tx.toBroadcastFormat(); assert.equal(typeof broadcast, 'string'); const parsed = JSON.parse(broadcast); assert.equal(parsed.version, 0); assert.equal(parsed.inputs.length, 1); + // REST API shape: inputs use previousOutpoint + assert.ok(parsed.inputs[0].previousOutpoint, 'inputs should use previousOutpoint'); + assert.equal(parsed.inputs[0].transactionId, undefined, 'transactionId should not be at root of input'); + // REST API shape: outputs use scriptPublicKey object + assert.equal(typeof parsed.outputs[0].scriptPublicKey, 'object'); + assert.equal(parsed.outputs[0].scriptPublicKey.version, 0); + }); + + it('toBroadcastFormat should include required wire fields', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const parsed = JSON.parse(tx.toBroadcastFormat()); + assert.equal(parsed.lockTime, 0); + assert.equal(parsed.subnetworkId, '0000000000000000000000000000000000000000'); + assert.equal(parsed.gas, 0); + assert.equal(parsed.payload, ''); + }); + + it('toBroadcastFormat should include signatureScript after signing', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + tx.sign(Buffer.from(KEYS.prv, 'hex')); + const parsed = JSON.parse(tx.toBroadcastFormat()); + assert.ok(parsed.inputs[0].signatureScript.length > 0); }); - it('toHex should return hex-encoded JSON', function () { + it('toHex should return hex-encoded internal JSON (for round-trips)', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); const hex = tx.toHex(); assert.ok(/^[0-9a-fA-F]+$/.test(hex)); - const decoded = Buffer.from(hex, 'hex').toString(); - const parsed = JSON.parse(decoded); - assert.equal(parsed.version, 0); + const decoded = JSON.parse(Buffer.from(hex, 'hex').toString()); + // Internal format: transactionId at root of input (not previousOutpoint) + assert.equal(decoded.inputs[0].transactionId, TRANSACTIONS.simple.inputs[0].transactionId); + assert.equal(decoded.inputs[0].amount, TRANSACTIONS.simple.inputs[0].amount); + }); + + it('toHex and toBroadcastFormat produce different formats', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const internalHex = tx.toHex(); + const broadcastHex = Buffer.from(tx.toBroadcastFormat()).toString('hex'); + assert.notEqual(internalHex, broadcastHex); }); it('fromHex should reconstruct the transaction', function () { @@ -327,4 +395,51 @@ describe('Kaspa Transaction', function () { assert.deepEqual(restored.signature, tx.signature); }); }); + + describe('toBroadcastFormat (REST API shape)', function () { + it('should map inputs to previousOutpoint shape', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const api = JSON.parse(tx.toBroadcastFormat()); + + assert.equal(api.inputs.length, 1); + assert.ok(api.inputs[0].previousOutpoint, 'input should have previousOutpoint'); + assert.equal(api.inputs[0].previousOutpoint.transactionId, TRANSACTIONS.simple.inputs[0].transactionId); + assert.equal(api.inputs[0].previousOutpoint.index, TRANSACTIONS.simple.inputs[0].transactionIndex); + }); + + it('should not include amount or scriptPublicKey on inputs', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const api = JSON.parse(tx.toBroadcastFormat()); + + assert.equal(api.inputs[0].amount, undefined); + assert.equal(api.inputs[0].scriptPublicKey, undefined); + }); + + it('should map outputs to scriptPublicKey object shape', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const api = JSON.parse(tx.toBroadcastFormat()); + + assert.equal(api.outputs.length, 1); + assert.equal(typeof api.outputs[0].scriptPublicKey, 'object'); + assert.equal(api.outputs[0].scriptPublicKey.version, 0); + assert.ok(typeof api.outputs[0].scriptPublicKey.scriptPublicKey === 'string'); + assert.equal(api.outputs[0].amount, Number(TRANSACTIONS.simple.outputs[0].amount)); + }); + + it('should include signatureScript as empty string for unsigned inputs', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const api = JSON.parse(tx.toBroadcastFormat()); + + assert.equal(api.inputs[0].signatureScript, ''); + }); + + it('should handle multi-input transactions', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const api = JSON.parse(tx.toBroadcastFormat()); + + assert.equal(api.inputs.length, 2); + assert.equal(api.inputs[0].previousOutpoint.transactionId, TRANSACTIONS.multiInput.inputs[0].transactionId); + assert.equal(api.inputs[1].previousOutpoint.transactionId, TRANSACTIONS.multiInput.inputs[1].transactionId); + }); + }); }); diff --git a/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts b/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts index 4bafb0c8ad..52c725e965 100644 --- a/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/transactionBuilder.test.ts @@ -189,7 +189,11 @@ describe('Kaspa TransactionBuilder', function () { const tx = (await builder.build()) as Transaction; builder.sign({ key: KEYS.prv }); assert.ok(tx.signature[0].length > 0, 'input should be signed'); - assert.equal(tx.signature[0].length, 130, 'Schnorr sig should be 65 bytes / 130 hex chars'); + assert.equal( + tx.signature[0].length, + 132, + 'signatureScript should be 66 bytes / 132 hex chars (push opcode + sig + sighash)' + ); }); it('should sign all inputs of a multi-input transaction', async function () { @@ -197,7 +201,7 @@ describe('Kaspa TransactionBuilder', function () { const tx = (await builder.build()) as Transaction; builder.sign({ key: KEYS.prv }); assert.equal(tx.signature.length, 2); - assert.ok(tx.signature.every((s) => s.length === 130)); + assert.ok(tx.signature.every((s) => s.length === 132)); }); it('should throw SigningError when private key is missing', async function () { @@ -209,41 +213,6 @@ 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'); diff --git a/modules/sdk-coin-kaspa/test/unit/transactionFlow.test.ts b/modules/sdk-coin-kaspa/test/unit/transactionFlow.test.ts index 94afc2120d..056411d04b 100644 --- a/modules/sdk-coin-kaspa/test/unit/transactionFlow.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/transactionFlow.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { coins } from '@bitgo/statics'; +import { ecc } from '@bitgo/secp256k1'; import { TransactionBuilder } from '../../src/lib/transactionBuilder'; import { Transaction } from '../../src/lib/transaction'; import { KEYS, ADDRESSES, UTXOS } from '../fixtures/kaspa.fixtures'; @@ -103,6 +104,90 @@ describe('Kaspa — End-to-End Transaction Flow', function () { assert.equal(rebuiltPayload, originalPayload, 'serialization should be deterministic'); }); + it('HSM flow — build unsigned → serialize → extract sighashes → apply external signatures → verify', async function () { + const prv = Buffer.from(KEYS.prv, 'hex'); + const pub = Buffer.from(KEYS.pub, 'hex'); + + // Step 1: Build an unsigned 2-input transaction + const builder = new TransactionBuilder(coinConfig); + builder.addInputs([UTXOS.simple, UTXOS.second]).to(ADDRESSES.recipient, '299996000').fee('4000'); + const unsignedTx = (await builder.build()) as Transaction; + + // Step 2: Serialize to hex — this is what gets sent to the HSM + const unsignedHex = unsignedTx.toHex(); + assert.ok( + unsignedTx.signature.every((s) => s === ''), + 'must be unsigned before leaving SDK' + ); + + // Step 3: HSM receives the hex, deserializes it, reads per-input sighashes + const txForHsm = Transaction.fromHex(coinConfig.name, unsignedHex); + const sighashes = txForHsm.signablePayloads; // Buffer[2] — one message per input + assert.equal(sighashes.length, 2); + assert.ok(!sighashes[0].equals(sighashes[1]), 'each input has a distinct sighash'); + + // Step 4: HSM signs each sighash independently (Schnorr) + // In production this is a DKLS / HSM operation; here we use the raw key directly + const externalSig0 = Buffer.from(ecc.signSchnorr(sighashes[0], prv)); // 64 bytes + const externalSig1 = Buffer.from(ecc.signSchnorr(sighashes[1], prv)); // 64 bytes + assert.equal(externalSig0.length, 64); + assert.equal(externalSig1.length, 64); + assert.ok(!externalSig0.equals(externalSig1), 'signatures over distinct hashes must differ'); + + // Step 5: Apply the external signatures to a fresh deserialized tx + const txToSign = Transaction.fromHex(coinConfig.name, unsignedHex); + txToSign.addSignatureForInput(0, KEYS.pub, externalSig0); + txToSign.addSignatureForInput(1, KEYS.pub, externalSig1); + + // Step 6: Cryptographically verify each input's Schnorr signature + assert.ok(txToSign.verifySignature(pub, 0), 'input[0] Schnorr signature must be valid'); + assert.ok(txToSign.verifySignature(pub, 1), 'input[1] Schnorr signature must be valid'); + + // Step 7: Serialize the fully-signed tx for broadcast + const signedHex = txToSign.toHex(); + const broadcast = txToSign.toBroadcastFormat(); + const rpc = JSON.parse(broadcast); + assert.equal( + rpc.inputs[0].signatureScript.length, + 132, + '66-byte script = 132 hex chars (push opcode + sig + sighash)' + ); + assert.equal(rpc.inputs[1].signatureScript.length, 132); + + // Step 8: Round-trip — reload from hex and confirm signatures are intact + const reloaded = Transaction.fromHex(coinConfig.name, signedHex); + assert.ok(reloaded.verifySignature(pub, 0), 'input[0] must still verify after round-trip'); + assert.ok(reloaded.verifySignature(pub, 1), 'input[1] must still verify after round-trip'); + }); + + it('should build and sign a transaction with multiple outputs (send + change)', async function () { + // Simulate a common real-world pattern: one recipient output + one change output + const builder = new TransactionBuilder(coinConfig); + builder + .addInput(UTXOS.simple) // 1 KASPA input + .to(ADDRESSES.recipient, '50000000') // send 0.5 KASPA + .to(ADDRESSES.sender, '49998000') // change back 0.49998 KASPA + .fee('2000'); + + const tx = (await builder.build()) as Transaction; + assert.equal(tx.txData.inputs.length, 1); + assert.equal(tx.txData.outputs.length, 2); + assert.equal(tx.txData.outputs[0].amount, '50000000'); + assert.equal(tx.txData.outputs[1].amount, '49998000'); + + // Sign and verify both outputs survive serialization + tx.sign(PRV_KEY_BUF); + const explanation = tx.explainTransaction(); + assert.equal(explanation.outputs.length, 2); + assert.equal(explanation.outputAmount, '99998000'); // sum of both outputs + + // Round-trip + const reloaded = Transaction.fromHex(coinConfig.name, tx.toHex()); + assert.equal(reloaded.txData.outputs.length, 2); + assert.equal(reloaded.txData.outputs[0].amount, '50000000'); + assert.equal(reloaded.txData.outputs[1].amount, '49998000'); + }); + it('should produce a valid RPC-submittable JSON broadcast payload', async function () { const builder = new TransactionBuilder(coinConfig); builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); @@ -122,7 +207,7 @@ describe('Kaspa — End-to-End Transaction Flow', function () { for (const input of parsed.inputs) { assert.ok(input.signatureScript, 'signed input must have signatureScript'); - assert.equal(input.signatureScript.length, 130, 'Schnorr sig should be 65 bytes = 130 hex chars'); + assert.equal(input.signatureScript.length, 132, '66-byte script = 132 hex chars (push opcode + sig + sighash)'); } }); }); diff --git a/modules/statics/src/kaspa.ts b/modules/statics/src/kaspa.ts index 906bbc52bd..ba165e8748 100644 --- a/modules/statics/src/kaspa.ts +++ b/modules/statics/src/kaspa.ts @@ -1,18 +1,6 @@ import { BaseCoin, BaseUnit, CoinFeature, CoinKind, KeyCurve, UnderlyingAsset } from './base'; import { KaspaMainnet, KaspaTestnet } from './networks'; -export interface KaspaConstructorOptions { - id: string; - fullName: string; - name: string; - network: KaspaMainnet | KaspaTestnet; - features: CoinFeature[]; - asset: UnderlyingAsset; - prefix?: string; - suffix?: string; - primaryKeyCurve: KeyCurve; -} - export class KaspaCoin extends BaseCoin { public static readonly DEFAULT_FEATURES = [ CoinFeature.UNSPENT_MODEL, @@ -29,7 +17,17 @@ export class KaspaCoin extends BaseCoin { public readonly network: KaspaMainnet | KaspaTestnet; - constructor(options: KaspaConstructorOptions) { + constructor(options: { + id: string; + fullName: string; + name: string; + network: KaspaMainnet | KaspaTestnet; + features: CoinFeature[]; + asset: UnderlyingAsset; + prefix?: string; + suffix?: string; + primaryKeyCurve: KeyCurve; + }) { super({ ...options, kind: CoinKind.CRYPTO,