From 3e37f46eceac9cf560609e6976d52fecbd3d4e2b Mon Sep 17 00:00:00 2001 From: Rohit Saw Date: Wed, 20 May 2026 11:32:43 +0530 Subject: [PATCH] feat: ecdsa signing support in kaspa ticket: cecho-1071 --- modules/sdk-coin-kaspa/src/lib/constants.ts | 66 +++++++++- modules/sdk-coin-kaspa/src/lib/keyPair.ts | 11 +- modules/sdk-coin-kaspa/src/lib/pskt.ts | 7 +- modules/sdk-coin-kaspa/src/lib/sighash.ts | 115 +++++++++++------ modules/sdk-coin-kaspa/src/lib/transaction.ts | 81 +++++++++--- .../src/lib/transactionBuilder.ts | 4 +- modules/sdk-coin-kaspa/src/lib/utils.ts | 86 +++++++------ .../test/fixtures/kaspa.fixtures.ts | 91 +++++++++++-- .../sdk-coin-kaspa/test/unit/keyPair.test.ts | 42 +++++- modules/sdk-coin-kaspa/test/unit/pskt.test.ts | 3 +- .../test/unit/transaction.test.ts | 120 +++++++++++++++++- .../sdk-coin-kaspa/test/unit/utils.test.ts | 79 +++++++++++- 12 files changed, 587 insertions(+), 118 deletions(-) diff --git a/modules/sdk-coin-kaspa/src/lib/constants.ts b/modules/sdk-coin-kaspa/src/lib/constants.ts index 747e1edf9c..df0c919555 100644 --- a/modules/sdk-coin-kaspa/src/lib/constants.ts +++ b/modules/sdk-coin-kaspa/src/lib/constants.ts @@ -4,14 +4,74 @@ * References: * - https://kaspa.org/ * - https://kaspa.aspectron.org/docs/ + * - rusty-kaspa/crypto/txscript/src/standard.rs + * - rusty-kaspa/crypto/hashes/src/hashers.rs + * - rusty-kaspa/consensus/core/src/hashing/sighash.rs */ -// Address format +// ── Network ─────────────────────────────────────────────────────────────────── + export const MAINNET_PREFIX = 'kaspa'; export const TESTNET_PREFIX = 'kaspatest'; -// Default transaction fee (minimum relay fee in sompi) +// ── Transaction ─────────────────────────────────────────────────────────────── + +/** Default transaction fee (minimum relay fee in sompi) */ export const DEFAULT_FEE = '1000'; // 0.00001 KASPA minimum -// Kaspa transaction version +/** Kaspa transaction version */ export const TX_VERSION = 0; + +// ── SigHash type flags ──────────────────────────────────────────────────────── +// +// Defined in rusty-kaspa consensus/core/src/hashing/sighash_type.rs + +export const SIGHASH_ALL = 0x01; +export const SIGHASH_NONE = 0x02; +export const SIGHASH_SINGLE = 0x04; +export const SIGHASH_ANYONECANPAY = 0x80; + +// ── Script opcodes ──────────────────────────────────────────────────────────── +// +// Verified against: +// https://kaspa.aspectron.org/docs/enums/Opcodes.html +// rusty-kaspa/crypto/txscript/src/standard.rs +// +// OpCheckSig = 172 = 0xAC → Schnorr P2PK (v0 address) +// OpCheckSigECDSA = 171 = 0xAB → ECDSA P2PK (v1 address) + +/** Schnorr BIP-340 checksig opcode — used by v0 Kaspa addresses */ +export const OP_CHECKSIG_SCHNORR = 0xac; + +/** secp256k1 ECDSA checksig opcode — used by v1 Kaspa addresses */ +export const OP_CHECKSIG_ECDSA = 0xab; + +/** Script version for standard P2PK scripts */ +export const SCRIPT_PUBLIC_KEY_VERSION = 0; + +// ── Enums ───────────────────────────────────────────────────────────────────── + +/** + * Kaspa P2PK script type. + * Determines the scriptPublicKey layout and the required signing algorithm. + * + * SCHNORR (v0): OP_DATA_32 (0x20) | xOnlyPubKey32 | OP_CHECKSIG_SCHNORR (0xAC) + * ECDSA (v1): OP_DATA_33 (0x21) | compressedPubKey33 | OP_CHECKSIG_ECDSA (0xAB) + */ +export enum KaspaScriptType { + SCHNORR = 0, // v0 — Schnorr P2PK, x-only 32-byte pubkey + ECDSA = 1, // v1 — ECDSA P2PK, compressed 33-byte pubkey +} + +/** + * Kaspa address version / type. + * Mirrors KaspaScriptType — the version byte in the bech32 address payload + * encodes which script type (and therefore which signature algorithm) applies. + * + * SCHNORR (v0): version byte 0x00, x-only 32-byte pubkey in address payload + * ECDSA (v1): version byte 0x01, compressed 33-byte pubkey in address payload + */ +export enum KaspaAddressType { + SCHNORR = 0, // default — v0 Schnorr P2PK address + ECDSA = 1, // v1 ECDSA P2PK address +} diff --git a/modules/sdk-coin-kaspa/src/lib/keyPair.ts b/modules/sdk-coin-kaspa/src/lib/keyPair.ts index 0ea87c68c6..f9efa2839e 100644 --- a/modules/sdk-coin-kaspa/src/lib/keyPair.ts +++ b/modules/sdk-coin-kaspa/src/lib/keyPair.ts @@ -9,7 +9,7 @@ import { import { bip32 } from '@bitgo/secp256k1'; import { randomBytes } from 'crypto'; import { pubKeyToKaspaAddress } from './utils'; -import { MAINNET_PREFIX, TESTNET_PREFIX } from './constants'; +import { MAINNET_PREFIX, TESTNET_PREFIX, KaspaAddressType } from './constants'; const DEFAULT_SEED_SIZE_BYTES = 16; @@ -54,11 +54,14 @@ export class KeyPair extends Secp256k1ExtendedKeyPair { /** * Get a Kaspa address from this key pair. * - * @returns {string} The bech32-encoded Kaspa address + * @param network - 'mainnet' or 'testnet' (default: 'mainnet') + * @param type - address type (default: SCHNORR / v0 for hot-wallet; + * use KaspaAddressType.ECDSA / v1 for MPC/DKLS wallets) + * @returns bech32-encoded Kaspa address */ - getAddress(network = 'mainnet'): string { + getAddress(network = 'mainnet', type: KaspaAddressType = KaspaAddressType.SCHNORR): string { const hrp = network === 'testnet' ? TESTNET_PREFIX : MAINNET_PREFIX; const compressedPub = this.getPublicKey({ compressed: true }); - return pubKeyToKaspaAddress(compressedPub, hrp); + return pubKeyToKaspaAddress(compressedPub, hrp, type); } } diff --git a/modules/sdk-coin-kaspa/src/lib/pskt.ts b/modules/sdk-coin-kaspa/src/lib/pskt.ts index f56279edd3..b6a1f985c2 100644 --- a/modules/sdk-coin-kaspa/src/lib/pskt.ts +++ b/modules/sdk-coin-kaspa/src/lib/pskt.ts @@ -20,7 +20,8 @@ import { ecc } from '@bitgo/secp256k1'; import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; -import { computeKaspaSigningHash, SIGHASH_ALL } from './sighash'; +import { computeKaspaSigningHash } from './sighash'; +import { SIGHASH_ALL } from './constants'; import { KeyPair } from './keyPair'; // ─── Role types ─────────────────────────────────────────────────────────────── @@ -480,8 +481,10 @@ export class Pskt { /** * 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 + * `0x41` (OP_DATA_65) + 64-byte sig + 1-byte sighash type = 66 bytes * + * Works for both Schnorr (v0) and ECDSA (v1) inputs since both produce a + * 64-byte compact signature stored in `partialSigs`. * Inputs that already have a `finalScriptSig` are left unchanged. */ finalize(): this { diff --git a/modules/sdk-coin-kaspa/src/lib/sighash.ts b/modules/sdk-coin-kaspa/src/lib/sighash.ts index ecbc6c28b8..bd5566f379 100644 --- a/modules/sdk-coin-kaspa/src/lib/sighash.ts +++ b/modules/sdk-coin-kaspa/src/lib/sighash.ts @@ -12,17 +12,17 @@ * https://github.com/kaspanet/rusty-kaspa/blob/master/consensus/core/src/hashing/sighash.rs */ import { blake2b } from 'blakejs'; +import { createHash } from 'crypto'; import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; - -// SigHash type flags -export const SIGHASH_ALL = 0x01; -export const SIGHASH_NONE = 0x02; -export const SIGHASH_SINGLE = 0x04; -export const SIGHASH_ANYONECANPAY = 0x80; - -// Script constants -export const OP_CHECKSIG_SCHNORR = 0xab; // Kaspa Schnorr checksig opcode -export const SCRIPT_PUBLIC_KEY_VERSION = 0; // Standard P2PK version +import { + SIGHASH_ALL, + SIGHASH_NONE, + SIGHASH_SINGLE, + SIGHASH_ANYONECANPAY, + OP_CHECKSIG_SCHNORR, + OP_CHECKSIG_ECDSA, + KaspaScriptType, +} from './constants'; /** * The Blake2b key used for ALL sighash operations in Kaspa. @@ -33,15 +33,46 @@ export const SCRIPT_PUBLIC_KEY_VERSION = 0; // Standard P2PK version */ const SIGNING_HASH_KEY = Buffer.from('TransactionSigningHash', 'ascii'); -/** - * Keyed Blake2b-256: blake2b(data, key="TransactionSigningHash", outlen=32). - * Used for every intermediate hash and the final sighash. - */ function kblake2b(data: Buffer): Buffer { return Buffer.from(blake2b(data, SIGNING_HASH_KEY, 32)); } -// ─── Intermediate hash helpers ──────────────────────────────────────────────── +/** + * Build a Kaspa P2PK scriptPublicKey. + * + * @param pubKey - For SCHNORR: 32-byte x-only key. + * For ECDSA: 33-byte compressed key. + * @param type - Address type (default: SCHNORR / v0). + * + * Resulting script formats (per rusty-kaspa crypto/txscript/src/standard.rs): + * SCHNORR (v0): OP_DATA_32(0x20) || xOnlyPubKey(32B) || OpCheckSig(0xAC) + * ECDSA (v1): OP_DATA_33(0x21) || compressedPubKey(33B) || OpCheckSigECDSA(0xAB) + */ +export function buildP2PKScriptPublicKey(pubKey: Buffer, type: KaspaScriptType = KaspaScriptType.SCHNORR): Buffer { + if (type === KaspaScriptType.SCHNORR) { + if (pubKey.length !== 32) { + throw new Error(`SCHNORR script expects 32-byte x-only pubkey, got ${pubKey.length}`); + } + return Buffer.concat([Buffer.from([0x20]), pubKey, Buffer.from([OP_CHECKSIG_SCHNORR])]); + } else { + if (pubKey.length !== 33) { + throw new Error(`ECDSA script expects 33-byte compressed pubkey, got ${pubKey.length}`); + } + return Buffer.concat([Buffer.from([0x21]), pubKey, Buffer.from([OP_CHECKSIG_ECDSA])]); + } +} + +/** + * 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); // drop the 02/03 prefix byte +} + +// --- Intermediate hash helpers --- function hashPreviousOutputs(inputs: KaspaUtxoInput[]): Buffer { const parts = inputs.map((inp) => { @@ -115,29 +146,6 @@ function hashPayload(tx: KaspaTransactionData): Buffer { 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 Schnorr sighash for a specific input. * @@ -239,3 +247,34 @@ export function computeKaspaSigningHash( return kblake2b(preimage.slice(0, offset)); } + +/** + * SHA-256 domain separator for ECDSA signing. + * Matches TransactionSigningHashECDSA in rusty-kaspa/crypto/hashes/src/hashers.rs: + * sha256_hasher! { struct TransactionSigningHashECDSA => "TransactionSigningHashECDSA" } + * The hasher is seeded with SHA256("TransactionSigningHashECDSA") before any data. + */ +const ECDSA_DOMAIN_SEP: Buffer = Buffer.from(createHash('sha256').update('TransactionSigningHashECDSA').digest()); + +/** + * Compute the Kaspa ECDSA sighash for a specific input. + * + * ECDSA signing uses a two-step hash defined in rusty-kaspa sighash.rs: + * 1. schnorr_hash = blake2b256_keyed("TransactionSigningHash", preimage) + * 2. ecdsa_hash = SHA256( SHA256("TransactionSigningHashECDSA") || schnorr_hash ) + * + * This differs from the Schnorr hash — using the Schnorr hash for ECDSA signing + * will produce a locally-valid signature that the network always rejects. + * + * @param tx Full transaction data with UTXO amount + scriptPublicKey on inputs + * @param inputIndex 0-based index of the input being signed + * @param sigHashType SigHash type flags (SIGHASH_ALL = 0x01) + */ +export function computeKaspaEcdsaSigningHash( + tx: KaspaTransactionData, + inputIndex: number, + sigHashType: number = SIGHASH_ALL +): Buffer { + const schnorrHash = computeKaspaSigningHash(tx, inputIndex, sigHashType); + return Buffer.from(createHash('sha256').update(ECDSA_DOMAIN_SEP).update(schnorrHash).digest()); +} diff --git a/modules/sdk-coin-kaspa/src/lib/transaction.ts b/modules/sdk-coin-kaspa/src/lib/transaction.ts index afe22be647..fbbb8d7dcb 100644 --- a/modules/sdk-coin-kaspa/src/lib/transaction.ts +++ b/modules/sdk-coin-kaspa/src/lib/transaction.ts @@ -1,9 +1,20 @@ 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 { KaspaTransactionData, KaspaUtxoInput, TransactionExplanation } from './iface'; +import { computeKaspaSigningHash, computeKaspaEcdsaSigningHash } from './sighash'; +import { OP_CHECKSIG_ECDSA, SIGHASH_ALL } from './constants'; import { Pskt } from './pskt'; +/** + * Returns true when `input`'s scriptPublicKey belongs to a Kaspa ECDSA (v1) + * address — last opcode is OpCheckSigECDSA (0xAB). + * Schnorr (v0) scripts end with OpCheckSig (0xAC). + */ +function isEcdsaInput(input: KaspaUtxoInput): boolean { + if (!input.scriptPublicKey || input.scriptPublicKey.length < 2) return false; + return parseInt(input.scriptPublicKey.slice(-2), 16) === OP_CHECKSIG_ECDSA; +} + export class Transaction extends BaseTransaction { protected _txData: KaspaTransactionData; @@ -54,16 +65,24 @@ export class Transaction extends BaseTransaction { * 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(). + * + * The hash type is automatically selected per input: + * - Schnorr (scriptPublicKey ends 0xAC): keyed Blake2b-256 + * - ECDSA (scriptPublicKey ends 0xAB): keyed Blake2b-256 + SHA256 double-hash */ get signablePayloads(): Buffer[] { if (this._txData.inputs.length === 0) { throw new Error('Cannot compute signablePayloads: no inputs'); } - return this._txData.inputs.map((_, i) => computeKaspaSigningHash(this._txData, i, SIGHASH_ALL)); + return this._txData.inputs.map((input, i) => + isEcdsaInput(input) + ? computeKaspaEcdsaSigningHash(this._txData, i, SIGHASH_ALL) + : computeKaspaSigningHash(this._txData, i, SIGHASH_ALL) + ); } /** - * Apply a Schnorr signature to a single specific input. + * Apply a signature to a single specific input. * * 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 @@ -72,7 +91,7 @@ export class Transaction extends BaseTransaction { * @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 signature 64-byte signature buffer for input[index] * @param sigHashType SigHash type (default: SIGHASH_ALL) */ addSignatureForInput(index: number, publicKey: string, signature: Buffer, sigHashType: number = SIGHASH_ALL): void { @@ -80,7 +99,7 @@ export class Transaction extends BaseTransaction { 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}`); + throw new Error(`Expected 64-byte signature, got ${signature.length}`); } // 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. @@ -89,9 +108,17 @@ export class Transaction extends BaseTransaction { } /** - * Sign all inputs with the given private key using Schnorr signatures. + * Sign all inputs with the given private key. + * + * Automatically selects the signature algorithm per input by inspecting each + * input's scriptPublicKey: + * - Schnorr (last opcode 0xAC, v0 address): BIP-340 Schnorr + Blake2b sighash + * - ECDSA (last opcode 0xAB, v1 address): secp256k1 ECDSA + Blake2b+SHA256 sighash + * + * A single transaction may mix both address types and each input will be + * signed correctly without any extra parameters. * - * @param privateKey 32-byte private key buffer + * @param privateKey 32-byte private key buffer * @param sigHashType SigHash type (default: SIGHASH_ALL = 0x01) */ sign(privateKey: Buffer, sigHashType: number = SIGHASH_ALL): void { @@ -99,8 +126,19 @@ export class Transaction extends BaseTransaction { throw new Error(`Expected 32-byte private key, got ${privateKey.length}`); } for (let i = 0; i < this._txData.inputs.length; i++) { - const sigHash = computeKaspaSigningHash(this._txData, i, sigHashType); - const sig = ecc.signSchnorr(sigHash, privateKey); + const input = this._txData.inputs[i]; + let sig: Uint8Array; + let sigHash: Buffer; + + if (isEcdsaInput(input)) { + // v1 ECDSA address: double-hash + compact secp256k1 ECDSA signature + sigHash = computeKaspaEcdsaSigningHash(this._txData, i, sigHashType); + sig = ecc.sign(sigHash, privateKey); + } else { + // v0 Schnorr address (default): Blake2b sighash + BIP-340 Schnorr signature + sigHash = computeKaspaSigningHash(this._txData, i, sigHashType); + sig = ecc.signSchnorr(sigHash, privateKey); + } // 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'); @@ -108,9 +146,12 @@ export class Transaction extends BaseTransaction { } /** - * Verify that a Schnorr signature on a specific input is valid. + * Verify that the signature on a specific input is valid. + * + * Automatically selects the verification algorithm based on the input's + * scriptPublicKey (ECDSA for 0xAB, Schnorr for 0xAC), matching `sign()`. * - * @param publicKey 33-byte compressed public key (or 32-byte x-only) + * @param publicKey 33-byte compressed public key (or 32-byte x-only for Schnorr) * @param inputIndex Index of the input to verify * @param sigHashType SigHash type used when signing */ @@ -125,10 +166,18 @@ export class Transaction extends BaseTransaction { return false; } 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; - return ecc.verifySchnorr(sigHash, xOnlyPub, sig); + + if (isEcdsaInput(input)) { + const sigHash = computeKaspaEcdsaSigningHash(this._txData, inputIndex, sigHashType); + // ECDSA verification requires 33-byte compressed public key + const compressedPub = publicKey.length === 33 ? publicKey : Buffer.concat([Buffer.from([0x02]), publicKey]); + return ecc.verify(sigHash, compressedPub, sig); + } else { + const sigHash = computeKaspaSigningHash(this._txData, inputIndex, sigHashType); + // Schnorr verification uses x-only (32-byte) public key + const xOnlyPub = publicKey.length === 33 ? publicKey.slice(1) : publicKey; + return ecc.verifySchnorr(sigHash, xOnlyPub, sig); + } } /** diff --git a/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts b/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts index 6f9ece9a72..e170de60cb 100644 --- a/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-kaspa/src/lib/transactionBuilder.ts @@ -5,7 +5,7 @@ import { Transaction } from './transaction'; import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; import { isValidKaspaAddress, addressToScriptPublicKey } from './utils'; import { KeyPair } from './keyPair'; -import { DEFAULT_FEE, TX_VERSION } from './constants'; +import { DEFAULT_FEE, TX_VERSION, SIGHASH_ALL } from './constants'; import { Pskt, PsktInput, PsktOutput } from './pskt'; export class TransactionBuilder extends BaseTransactionBuilder { @@ -184,7 +184,7 @@ export class TransactionBuilder extends BaseTransactionBuilder { sequence: inp.sequence, sigOpCount: inp.sigOpCount ?? 1, partialSigs: {}, - sighashType: 0x01, // SIGHASH_ALL + sighashType: SIGHASH_ALL, bip32Derivations: {}, proprietaries: {}, })); diff --git a/modules/sdk-coin-kaspa/src/lib/utils.ts b/modules/sdk-coin-kaspa/src/lib/utils.ts index fcdcfd02f6..318dbe3cce 100644 --- a/modules/sdk-coin-kaspa/src/lib/utils.ts +++ b/modules/sdk-coin-kaspa/src/lib/utils.ts @@ -1,5 +1,5 @@ import { BaseUtils, isValidXprv, isValidXpub } from '@bitgo/sdk-core'; -import { MAINNET_PREFIX, TESTNET_PREFIX } from './constants'; +import { MAINNET_PREFIX, TESTNET_PREFIX, KaspaAddressType } from './constants'; // Kaspa address encoding uses a bech32-like scheme with ':' as separator // and a custom checksum polynomial (same as Bitcoin Cash cashaddr). @@ -154,12 +154,53 @@ export function isValidTestnetAddress(address: string): boolean { } /** - * Derive a Kaspa P2PK (Schnorr) address from a compressed secp256k1 public key. + * Decode a Kaspa address to its P2PK scriptPublicKey hex string. + * + * Reverses pubKeyToKaspaAddress: decodes the bech32 payload, reads the version + * byte to determine the script type, and builds the locking script. + * + * v0 (Schnorr): 0x20 || xOnlyPubKey(32B) || 0xAC (OpCheckSig) + * v1 (ECDSA): 0x21 || compressedPubKey(33B) || 0xAB (OpCheckSigECDSA) + * + * Used by TransactionBuilder to populate output scriptPublicKeys from addresses. + */ +export function addressToScriptPublicKey(address: string): string { + const { data: words } = kaspacDecode(address); + const payload = Buffer.from(convertBits(Buffer.from(words), 5, 8, false)); + + // First byte is the version / address type. + const version = payload[0]; + const keyBytes = payload.slice(1); + + if (version === KaspaAddressType.SCHNORR) { + // v0: keyBytes is 32-byte x-only pubkey + if (keyBytes.length !== 32) { + throw new Error(`Invalid Schnorr address payload length: ${keyBytes.length}`); + } + return Buffer.concat([Buffer.from([0x20]), keyBytes, Buffer.from([0xac])]).toString('hex'); + } else if (version === KaspaAddressType.ECDSA) { + // v1: keyBytes is 33-byte compressed pubkey + if (keyBytes.length !== 33) { + throw new Error(`Invalid ECDSA address payload length: ${keyBytes.length}`); + } + return Buffer.concat([Buffer.from([0x21]), keyBytes, Buffer.from([0xab])]).toString('hex'); + } else { + throw new Error(`Unknown Kaspa address version: ${version}`); + } +} + +/** + * Derive a Kaspa P2PK address from a compressed secp256k1 public key. * * @param compressedPubKey - 33-byte compressed secp256k1 public key (hex string or Buffer) - * @param hrp - human-readable part ('kaspa' or 'kaspatest') + * @param hrp - human-readable part ('kaspa' or 'kaspatest') + * @param type - address type (default: SCHNORR / v0) */ -export function pubKeyToKaspaAddress(compressedPubKey: string | Buffer, hrp: string): string { +export function pubKeyToKaspaAddress( + compressedPubKey: string | Buffer, + hrp: string, + type: KaspaAddressType = KaspaAddressType.SCHNORR +): string { const pubKeyBytes = Buffer.isBuffer(compressedPubKey) ? compressedPubKey : Buffer.from(compressedPubKey as string, 'hex'); @@ -168,44 +209,15 @@ export function pubKeyToKaspaAddress(compressedPubKey: string | Buffer, hrp: str throw new Error(`Expected 33-byte compressed public key, got ${pubKeyBytes.length}`); } - // X-only public key: drop the prefix byte (02 or 03), keep 32-byte x-coordinate - const xOnlyPubKey = pubKeyBytes.slice(1); - - // Kaspa P2PK address: - // - version nibble: 0 (Schnorr secp256k1 P2PK) - // - payload: x-only public key (32 bytes) - const versionByte = 0; - const payload = Buffer.concat([Buffer.from([versionByte]), xOnlyPubKey]); + // Schnorr (v0): payload uses the 32-byte x-only key (drop the 02/03 prefix). + // ECDSA (v1): payload uses the full 33-byte compressed key (prefix byte kept). + const keyPayload = type === KaspaAddressType.SCHNORR ? pubKeyBytes.slice(1) : pubKeyBytes; + const payload = Buffer.concat([Buffer.from([type]), keyPayload]); const words = convertBits(payload, 8, 5, true); 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/test/fixtures/kaspa.fixtures.ts b/modules/sdk-coin-kaspa/test/fixtures/kaspa.fixtures.ts index afaa678578..aa323f1069 100644 --- a/modules/sdk-coin-kaspa/test/fixtures/kaspa.fixtures.ts +++ b/modules/sdk-coin-kaspa/test/fixtures/kaspa.fixtures.ts @@ -7,6 +7,7 @@ import { KeyPair } from '../../src/lib/keyPair'; import { compressedToXOnly, buildP2PKScriptPublicKey } from '../../src/lib/sighash'; +import { KaspaScriptType, KaspaAddressType } from '../../src/lib/constants'; import { KaspaTransactionData, KaspaUtxoInput } from '../../src/lib/iface'; // Fixed 32-byte private key for deterministic tests only @@ -14,9 +15,15 @@ export const TEST_PRV_KEY = 'b94f5374fce5edbc8e2a8697c15331677e6ebf0b00000000000 const _kp = new KeyPair({ prv: TEST_PRV_KEY }); const _keys = _kp.getKeys(); -const _xOnlyPub = compressedToXOnly(Buffer.from(_keys.pub, 'hex')); +const _compressedPub = Buffer.from(_keys.pub, 'hex'); +const _xOnlyPub = compressedToXOnly(_compressedPub); + +// Schnorr (v0): OP_DATA_32 | xOnlyPubKey32 | OP_CHECKSIG_SCHNORR (0xAC) const _scriptPublicKey = buildP2PKScriptPublicKey(_xOnlyPub).toString('hex'); +// ECDSA (v1): OP_DATA_33 | compressedPubKey33 | OP_CHECKSIG_ECDSA (0xAB) +const _ecdsaScriptPublicKey = buildP2PKScriptPublicKey(_compressedPub, KaspaScriptType.ECDSA).toString('hex'); + export const KEYS = { prv: TEST_PRV_KEY, pub: _keys.pub, @@ -25,12 +32,15 @@ export const KEYS = { export const ADDRESSES = { valid: _kp.getAddress('mainnet'), testnet: _kp.getAddress('testnet'), + validEcdsa: _kp.getAddress('mainnet', KaspaAddressType.ECDSA), + testnetEcdsa: _kp.getAddress('testnet', KaspaAddressType.ECDSA), invalid: 'notanaddress', sender: _kp.getAddress('mainnet'), recipient: _kp.getAddress('mainnet'), }; export const SCRIPT_PUBLIC_KEY = _scriptPublicKey; +export const ECDSA_SCRIPT_PUBLIC_KEY = _ecdsaScriptPublicKey; // Getters return fresh objects to prevent mutation leakage between tests export const UTXOS = { @@ -54,6 +64,27 @@ export const UTXOS = { sigOpCount: 1, }; }, + /** ECDSA v1 input — scriptPublicKey ends with 0xAB */ + get ecdsa(): KaspaUtxoInput { + return { + transactionId: 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + transactionIndex: 0, + amount: '150000000', + scriptPublicKey: _ecdsaScriptPublicKey, + sequence: '0', + sigOpCount: 1, + }; + }, + get ecdsaSecond(): KaspaUtxoInput { + return { + transactionId: 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + transactionIndex: 1, + amount: '250000000', + scriptPublicKey: _ecdsaScriptPublicKey, + sequence: '0', + sigOpCount: 1, + }; + }, }; // Use getter functions to return fresh deep copies, preventing test mutation leakage @@ -63,11 +94,38 @@ export const TRANSACTIONS = { JSON.stringify({ version: 0, inputs: [UTXOS.simple], + outputs: [{ address: _kp.getAddress('mainnet'), amount: '99998000', scriptPublicKey: _scriptPublicKey }], + fee: '2000', + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: '', + }) + ) as KaspaTransactionData; + }, + get multiInput(): KaspaTransactionData { + return JSON.parse( + JSON.stringify({ + version: 0, + inputs: [UTXOS.simple, UTXOS.second], + outputs: [{ address: _kp.getAddress('mainnet'), amount: '299998000', scriptPublicKey: _scriptPublicKey }], + fee: '2000', + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: '', + }) + ) as KaspaTransactionData; + }, + /** Single ECDSA (v1) input */ + get simpleEcdsa(): KaspaTransactionData { + return JSON.parse( + JSON.stringify({ + version: 0, + inputs: [UTXOS.ecdsa], outputs: [ { - address: _kp.getAddress('mainnet'), - amount: '99998000', - scriptPublicKey: _scriptPublicKey, + address: _kp.getAddress('mainnet', KaspaAddressType.ECDSA), + amount: '149998000', + scriptPublicKey: _ecdsaScriptPublicKey, }, ], fee: '2000', @@ -77,16 +135,17 @@ export const TRANSACTIONS = { }) ) as KaspaTransactionData; }, - get multiInput(): KaspaTransactionData { + /** Two ECDSA inputs */ + get multiInputEcdsa(): KaspaTransactionData { return JSON.parse( JSON.stringify({ version: 0, - inputs: [UTXOS.simple, UTXOS.second], + inputs: [UTXOS.ecdsa, UTXOS.ecdsaSecond], outputs: [ { - address: _kp.getAddress('mainnet'), - amount: '299998000', - scriptPublicKey: _scriptPublicKey, + address: _kp.getAddress('mainnet', KaspaAddressType.ECDSA), + amount: '399998000', + scriptPublicKey: _ecdsaScriptPublicKey, }, ], fee: '2000', @@ -96,4 +155,18 @@ export const TRANSACTIONS = { }) ) as KaspaTransactionData; }, + /** Mixed: first input Schnorr, second input ECDSA */ + get mixedSchnorrEcdsa(): KaspaTransactionData { + return JSON.parse( + JSON.stringify({ + version: 0, + inputs: [UTXOS.simple, UTXOS.ecdsa], + outputs: [{ address: _kp.getAddress('mainnet'), amount: '249998000', scriptPublicKey: _scriptPublicKey }], + fee: '2000', + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: '', + }) + ) as KaspaTransactionData; + }, }; diff --git a/modules/sdk-coin-kaspa/test/unit/keyPair.test.ts b/modules/sdk-coin-kaspa/test/unit/keyPair.test.ts index 3515535a3f..2f5ad8d578 100644 --- a/modules/sdk-coin-kaspa/test/unit/keyPair.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/keyPair.test.ts @@ -1,6 +1,7 @@ import * as should from 'should'; import { KeyPair } from '../../src/lib/keyPair'; -import { KEYS } from '../fixtures/kaspa.fixtures'; +import { KaspaAddressType } from '../../src/lib/constants'; +import { KEYS, ADDRESSES } from '../fixtures/kaspa.fixtures'; describe('Kaspa KeyPair', function () { describe('Key Generation', function () { @@ -59,4 +60,43 @@ describe('Kaspa KeyPair', function () { kp1.getAddress('mainnet').should.not.equal(kp2.getAddress('mainnet')); }); }); + + describe('ECDSA Address Derivation (v1)', function () { + it('should derive a mainnet ECDSA address', function () { + const kp = new KeyPair({ prv: KEYS.prv }); + const address = kp.getAddress('mainnet', KaspaAddressType.ECDSA); + address.should.startWith('kaspa'); + address.should.containEql(':'); + }); + + it('should derive a testnet ECDSA address', function () { + const kp = new KeyPair({ prv: KEYS.prv }); + const address = kp.getAddress('testnet', KaspaAddressType.ECDSA); + address.should.startWith('kaspatest'); + address.should.containEql(':'); + }); + + it('Schnorr and ECDSA addresses for the same key should differ', function () { + const kp = new KeyPair({ prv: KEYS.prv }); + const schnorrAddr = kp.getAddress('mainnet'); + const ecdsaAddr = kp.getAddress('mainnet', KaspaAddressType.ECDSA); + schnorrAddr.should.not.equal(ecdsaAddr); + }); + + it('Schnorr address matches fixture ADDRESSES.valid', function () { + const kp = new KeyPair({ prv: KEYS.prv }); + kp.getAddress('mainnet').should.equal(ADDRESSES.valid); + }); + + it('ECDSA address matches fixture ADDRESSES.validEcdsa', function () { + const kp = new KeyPair({ prv: KEYS.prv }); + kp.getAddress('mainnet', KaspaAddressType.ECDSA).should.equal(ADDRESSES.validEcdsa); + }); + + it('should be consistent across two KeyPair instances for ECDSA', function () { + const kp1 = new KeyPair({ prv: KEYS.prv }); + const kp2 = new KeyPair({ prv: KEYS.prv }); + kp1.getAddress('mainnet', KaspaAddressType.ECDSA).should.equal(kp2.getAddress('mainnet', KaspaAddressType.ECDSA)); + }); + }); }); diff --git a/modules/sdk-coin-kaspa/test/unit/pskt.test.ts b/modules/sdk-coin-kaspa/test/unit/pskt.test.ts index cc5932ad3a..71a756f79b 100644 --- a/modules/sdk-coin-kaspa/test/unit/pskt.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/pskt.test.ts @@ -18,7 +18,8 @@ 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 { computeKaspaSigningHash } from '../../src/lib/sighash'; +import { SIGHASH_ALL } from '../../src/lib/constants'; import { KEYS, ADDRESSES, UTXOS, TRANSACTIONS, SCRIPT_PUBLIC_KEY } from '../fixtures/kaspa.fixtures'; const PRV_KEY_BUF = Buffer.from(KEYS.prv, 'hex'); diff --git a/modules/sdk-coin-kaspa/test/unit/transaction.test.ts b/modules/sdk-coin-kaspa/test/unit/transaction.test.ts index 743cd153ea..026e7b4215 100644 --- a/modules/sdk-coin-kaspa/test/unit/transaction.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/transaction.test.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { Transaction } from '../../src/lib/transaction'; import { TransactionType } from '@bitgo/sdk-core'; -import { SIGHASH_ALL } from '../../src/lib/sighash'; +import { SIGHASH_ALL } from '../../src/lib/constants'; import { KEYS, TRANSACTIONS } from '../fixtures/kaspa.fixtures'; const COIN = 'kaspa'; @@ -142,6 +142,124 @@ describe('Kaspa Transaction', function () { }); }); + // ── ECDSA (v1 address) signing ──────────────────────────────────────────── + // + // These tests cover the auto-detection path in sign() / verifySignature() / + // signablePayloads when the input's scriptPublicKey ends with 0xAB (ECDSA). + + describe('sign (ECDSA inputs)', function () { + it('should sign a single ECDSA input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simpleEcdsa); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + const sigScript = tx.txData.inputs[0].signatureScript ?? ''; + assert.ok(sigScript.length > 0, 'ECDSA input should have a signatureScript'); + // 66 bytes = 132 hex chars: 0x41 + 64-byte sig + 1-byte sighash type + assert.equal(sigScript.length, 132); + assert.equal(parseInt(sigScript.slice(-2), 16), SIGHASH_ALL); + }); + + it('should sign multiple ECDSA inputs', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInputEcdsa); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + assert.equal(tx.txData.inputs.length, 2); + for (const input of tx.txData.inputs) { + assert.ok(input.signatureScript && input.signatureScript.length > 0); + } + }); + + it('should produce distinct sighashes for ECDSA vs Schnorr inputs on the same key', function () { + const schnorrTx = new Transaction(COIN, TRANSACTIONS.simple); + const ecdsaTx = new Transaction(COIN, TRANSACTIONS.simpleEcdsa); + const [schnorrHash] = schnorrTx.signablePayloads; + const [ecdsaHash] = ecdsaTx.signablePayloads; + assert.ok(!schnorrHash.equals(ecdsaHash), 'ECDSA and Schnorr hashes must differ'); + }); + }); + + describe('verifySignature (ECDSA inputs)', function () { + it('should verify a valid ECDSA signature with compressed pubkey', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simpleEcdsa); + tx.sign(Buffer.from(KEYS.prv, 'hex')); + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.ok(tx.verifySignature(pubKey, 0), 'ECDSA signature should verify'); + }); + + it('should return false for wrong public key on ECDSA input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simpleEcdsa); + tx.sign(Buffer.from(KEYS.prv, 'hex')); + const wrongPub = Buffer.from('02' + 'ab'.repeat(32), 'hex'); + assert.equal(tx.verifySignature(wrongPub, 0), false); + }); + + it('should verify all inputs in a multi-input ECDSA transaction', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInputEcdsa); + tx.sign(Buffer.from(KEYS.prv, 'hex')); + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.ok(tx.verifySignature(pubKey, 0)); + assert.ok(tx.verifySignature(pubKey, 1)); + }); + }); + + describe('sign + verifySignature (mixed Schnorr + ECDSA inputs)', function () { + it('should sign and verify each input with the correct algorithm', function () { + const tx = new Transaction(COIN, TRANSACTIONS.mixedSchnorrEcdsa); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + + assert.equal(tx.txData.inputs.length, 2, 'should have 2 inputs'); + + const pubKey = Buffer.from(KEYS.pub, 'hex'); + // input[0] is Schnorr (scriptPublicKey ends 0xAC) + assert.ok(tx.verifySignature(pubKey, 0), 'Schnorr input[0] should verify'); + // input[1] is ECDSA (scriptPublicKey ends 0xAB) + assert.ok(tx.verifySignature(pubKey, 1), 'ECDSA input[1] should verify'); + }); + + it('should produce different script types for Schnorr and ECDSA inputs', function () { + const tx = new Transaction(COIN, TRANSACTIONS.mixedSchnorrEcdsa); + tx.sign(Buffer.from(KEYS.prv, 'hex')); + // Both produce 66-byte (132 hex char) scriptSigs but via different algorithms — + // the last opcode of their scriptPublicKey is the differentiator. + assert.equal((tx.txData.inputs[0].signatureScript ?? '').length, 132); + assert.equal((tx.txData.inputs[1].signatureScript ?? '').length, 132); + // The signatures themselves should differ (different sighash algorithm) + assert.notEqual(tx.txData.inputs[0].signatureScript, tx.txData.inputs[1].signatureScript); + }); + }); + + describe('signablePayloads (ECDSA inputs)', function () { + it('should return a 32-byte hash for a single ECDSA input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simpleEcdsa); + const [hash] = tx.signablePayloads; + assert.ok(Buffer.isBuffer(hash)); + assert.equal(hash.length, 32); + }); + + it('should return distinct hashes for each ECDSA input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInputEcdsa); + const [h0, h1] = tx.signablePayloads; + assert.ok(!h0.equals(h1)); + }); + + it('should return different hashes for Schnorr and ECDSA inputs on the same index', function () { + const schnorrTx = new Transaction(COIN, TRANSACTIONS.simple); + const ecdsaTx = new Transaction(COIN, TRANSACTIONS.simpleEcdsa); + // Both transactions have the same structure except scriptPublicKey type. + // The ECDSA hash applies an additional SHA256 step so the result must differ. + assert.ok(!schnorrTx.signablePayloads[0].equals(ecdsaTx.signablePayloads[0])); + }); + + it('mixed tx: Schnorr hash at index 0, ECDSA hash at index 1 — both 32 bytes', function () { + const tx = new Transaction(COIN, TRANSACTIONS.mixedSchnorrEcdsa); + const [h0, h1] = tx.signablePayloads; + assert.equal(h0.length, 32); + assert.equal(h1.length, 32); + assert.ok(!h0.equals(h1)); + }); + }); + describe('explainTransaction', function () { it('should return a TransactionExplanation with correct fields', function () { const tx = new Transaction(COIN, TRANSACTIONS.simple); diff --git a/modules/sdk-coin-kaspa/test/unit/utils.test.ts b/modules/sdk-coin-kaspa/test/unit/utils.test.ts index cf05d2f2a2..7093f067b5 100644 --- a/modules/sdk-coin-kaspa/test/unit/utils.test.ts +++ b/modules/sdk-coin-kaspa/test/unit/utils.test.ts @@ -7,10 +7,12 @@ import { isValidPrivateKey, isValidTransactionId, pubKeyToKaspaAddress, + addressToScriptPublicKey, Utils, } from '../../src/lib/utils'; +import { KaspaAddressType } from '../../src/lib/constants'; import { KeyPair } from '../../src/lib/keyPair'; -import { KEYS, ADDRESSES, UTXOS } from '../fixtures/kaspa.fixtures'; +import { KEYS, ADDRESSES, UTXOS, SCRIPT_PUBLIC_KEY, ECDSA_SCRIPT_PUBLIC_KEY } from '../fixtures/kaspa.fixtures'; describe('Kaspa Utils', function () { describe('isValidKaspaAddress', function () { @@ -68,14 +70,14 @@ describe('Kaspa Utils', function () { }); describe('pubKeyToKaspaAddress', function () { - it('should derive a valid mainnet address from compressed public key', function () { + it('should derive a valid mainnet Schnorr address from compressed public key', function () { const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); const address = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspa'); assert.ok(address.startsWith('kaspa:')); assert.ok(isValidKaspaAddress(address)); }); - it('should derive a valid testnet address from compressed public key', function () { + it('should derive a valid testnet Schnorr address from compressed public key', function () { const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); const address = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspatest'); assert.ok(address.startsWith('kaspatest:')); @@ -93,12 +95,81 @@ describe('Kaspa Utils', function () { }); }); - it('should produce same address as KeyPair.getAddress', function () { + it('should produce same Schnorr address as KeyPair.getAddress', function () { const kp = new KeyPair({ pub: KEYS.pub }); const fromKp = kp.getAddress('mainnet'); const fromUtil = pubKeyToKaspaAddress(Buffer.from(KEYS.pub, 'hex'), 'kaspa'); assert.equal(fromUtil, fromKp); }); + + it('should derive a valid mainnet ECDSA address', function () { + const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); + const address = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspa', KaspaAddressType.ECDSA); + assert.ok(address.startsWith('kaspa:')); + assert.ok(isValidKaspaAddress(address)); + }); + + it('should derive a valid testnet ECDSA address', function () { + const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); + const address = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspatest', KaspaAddressType.ECDSA); + assert.ok(address.startsWith('kaspatest:')); + assert.ok(isValidKaspaAddress(address)); + }); + + it('Schnorr and ECDSA addresses from the same key should differ', function () { + const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); + const schnorrAddr = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspa'); + const ecdsaAddr = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspa', KaspaAddressType.ECDSA); + assert.notEqual(schnorrAddr, ecdsaAddr); + }); + + it('ECDSA address matches KeyPair.getAddress(ECDSA)', function () { + const kp = new KeyPair({ pub: KEYS.pub }); + const fromKp = kp.getAddress('mainnet', KaspaAddressType.ECDSA); + const fromUtil = pubKeyToKaspaAddress(Buffer.from(KEYS.pub, 'hex'), 'kaspa', KaspaAddressType.ECDSA); + assert.equal(fromUtil, fromKp); + }); + + it('ECDSA address matches fixture ADDRESSES.validEcdsa', function () { + const address = pubKeyToKaspaAddress(Buffer.from(KEYS.pub, 'hex'), 'kaspa', KaspaAddressType.ECDSA); + assert.equal(address, ADDRESSES.validEcdsa); + }); + }); + + describe('addressToScriptPublicKey', function () { + it('should derive the Schnorr scriptPublicKey from a Schnorr address', function () { + const script = addressToScriptPublicKey(ADDRESSES.valid); + // Schnorr: OP_DATA_32 (0x20) + 32-byte xOnly + OP_CHECKSIG_SCHNORR (0xAC) = 34 bytes = 68 hex chars + assert.equal(script.length, 68); + assert.ok(script.endsWith('ac'), 'Schnorr script should end with 0xAC'); + assert.ok(script.startsWith('20'), 'Schnorr script should start with OP_DATA_32'); + }); + + it('should match the fixture SCRIPT_PUBLIC_KEY for the Schnorr address', function () { + const script = addressToScriptPublicKey(ADDRESSES.valid); + assert.equal(script, SCRIPT_PUBLIC_KEY); + }); + + it('should derive the ECDSA scriptPublicKey from an ECDSA address', function () { + const script = addressToScriptPublicKey(ADDRESSES.validEcdsa); + // ECDSA: OP_DATA_33 (0x21) + 33-byte compressed + OP_CHECKSIG_ECDSA (0xAB) = 35 bytes = 70 hex chars + assert.equal(script.length, 70); + assert.ok(script.endsWith('ab'), 'ECDSA script should end with 0xAB'); + assert.ok(script.startsWith('21'), 'ECDSA script should start with OP_DATA_33'); + }); + + it('should match the fixture ECDSA_SCRIPT_PUBLIC_KEY for the ECDSA address', function () { + const script = addressToScriptPublicKey(ADDRESSES.validEcdsa); + assert.equal(script, ECDSA_SCRIPT_PUBLIC_KEY); + }); + + it('should throw for an invalid address', function () { + assert.throws(() => addressToScriptPublicKey('notanaddress')); + }); + + it('should throw for an address without a colon prefix', function () { + assert.throws(() => addressToScriptPublicKey('nocolon')); + }); }); describe('isValidPublicKey', function () {