Skip to content

Commit a4f4398

Browse files
authored
Merge pull request #8813 from BitGo/kaspa-support
feat: ecdsa signing support in kaspa
2 parents f226c24 + 3e37f46 commit a4f4398

12 files changed

Lines changed: 587 additions & 118 deletions

File tree

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

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,74 @@
44
* References:
55
* - https://kaspa.org/
66
* - https://kaspa.aspectron.org/docs/
7+
* - rusty-kaspa/crypto/txscript/src/standard.rs
8+
* - rusty-kaspa/crypto/hashes/src/hashers.rs
9+
* - rusty-kaspa/consensus/core/src/hashing/sighash.rs
710
*/
811

9-
// Address format
12+
// ── Network ───────────────────────────────────────────────────────────────────
13+
1014
export const MAINNET_PREFIX = 'kaspa';
1115
export const TESTNET_PREFIX = 'kaspatest';
1216

13-
// Default transaction fee (minimum relay fee in sompi)
17+
// ── Transaction ───────────────────────────────────────────────────────────────
18+
19+
/** Default transaction fee (minimum relay fee in sompi) */
1420
export const DEFAULT_FEE = '1000'; // 0.00001 KASPA minimum
1521

16-
// Kaspa transaction version
22+
/** Kaspa transaction version */
1723
export const TX_VERSION = 0;
24+
25+
// ── SigHash type flags ────────────────────────────────────────────────────────
26+
//
27+
// Defined in rusty-kaspa consensus/core/src/hashing/sighash_type.rs
28+
29+
export const SIGHASH_ALL = 0x01;
30+
export const SIGHASH_NONE = 0x02;
31+
export const SIGHASH_SINGLE = 0x04;
32+
export const SIGHASH_ANYONECANPAY = 0x80;
33+
34+
// ── Script opcodes ────────────────────────────────────────────────────────────
35+
//
36+
// Verified against:
37+
// https://kaspa.aspectron.org/docs/enums/Opcodes.html
38+
// rusty-kaspa/crypto/txscript/src/standard.rs
39+
//
40+
// OpCheckSig = 172 = 0xAC → Schnorr P2PK (v0 address)
41+
// OpCheckSigECDSA = 171 = 0xAB → ECDSA P2PK (v1 address)
42+
43+
/** Schnorr BIP-340 checksig opcode — used by v0 Kaspa addresses */
44+
export const OP_CHECKSIG_SCHNORR = 0xac;
45+
46+
/** secp256k1 ECDSA checksig opcode — used by v1 Kaspa addresses */
47+
export const OP_CHECKSIG_ECDSA = 0xab;
48+
49+
/** Script version for standard P2PK scripts */
50+
export const SCRIPT_PUBLIC_KEY_VERSION = 0;
51+
52+
// ── Enums ─────────────────────────────────────────────────────────────────────
53+
54+
/**
55+
* Kaspa P2PK script type.
56+
* Determines the scriptPublicKey layout and the required signing algorithm.
57+
*
58+
* SCHNORR (v0): OP_DATA_32 (0x20) | xOnlyPubKey32 | OP_CHECKSIG_SCHNORR (0xAC)
59+
* ECDSA (v1): OP_DATA_33 (0x21) | compressedPubKey33 | OP_CHECKSIG_ECDSA (0xAB)
60+
*/
61+
export enum KaspaScriptType {
62+
SCHNORR = 0, // v0 — Schnorr P2PK, x-only 32-byte pubkey
63+
ECDSA = 1, // v1 — ECDSA P2PK, compressed 33-byte pubkey
64+
}
65+
66+
/**
67+
* Kaspa address version / type.
68+
* Mirrors KaspaScriptType — the version byte in the bech32 address payload
69+
* encodes which script type (and therefore which signature algorithm) applies.
70+
*
71+
* SCHNORR (v0): version byte 0x00, x-only 32-byte pubkey in address payload
72+
* ECDSA (v1): version byte 0x01, compressed 33-byte pubkey in address payload
73+
*/
74+
export enum KaspaAddressType {
75+
SCHNORR = 0, // default — v0 Schnorr P2PK address
76+
ECDSA = 1, // v1 ECDSA P2PK address
77+
}

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { bip32 } from '@bitgo/secp256k1';
1010
import { randomBytes } from 'crypto';
1111
import { pubKeyToKaspaAddress } from './utils';
12-
import { MAINNET_PREFIX, TESTNET_PREFIX } from './constants';
12+
import { MAINNET_PREFIX, TESTNET_PREFIX, KaspaAddressType } from './constants';
1313

1414
const DEFAULT_SEED_SIZE_BYTES = 16;
1515

@@ -54,11 +54,14 @@ export class KeyPair extends Secp256k1ExtendedKeyPair {
5454
/**
5555
* Get a Kaspa address from this key pair.
5656
*
57-
* @returns {string} The bech32-encoded Kaspa address
57+
* @param network - 'mainnet' or 'testnet' (default: 'mainnet')
58+
* @param type - address type (default: SCHNORR / v0 for hot-wallet;
59+
* use KaspaAddressType.ECDSA / v1 for MPC/DKLS wallets)
60+
* @returns bech32-encoded Kaspa address
5861
*/
59-
getAddress(network = 'mainnet'): string {
62+
getAddress(network = 'mainnet', type: KaspaAddressType = KaspaAddressType.SCHNORR): string {
6063
const hrp = network === 'testnet' ? TESTNET_PREFIX : MAINNET_PREFIX;
6164
const compressedPub = this.getPublicKey({ compressed: true });
62-
return pubKeyToKaspaAddress(compressedPub, hrp);
65+
return pubKeyToKaspaAddress(compressedPub, hrp, type);
6366
}
6467
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020

2121
import { ecc } from '@bitgo/secp256k1';
2222
import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface';
23-
import { computeKaspaSigningHash, SIGHASH_ALL } from './sighash';
23+
import { computeKaspaSigningHash } from './sighash';
24+
import { SIGHASH_ALL } from './constants';
2425
import { KeyPair } from './keyPair';
2526

2627
// ─── Role types ───────────────────────────────────────────────────────────────
@@ -480,8 +481,10 @@ export class Pskt {
480481
/**
481482
* Finalise all inputs: promote the first `partialSig` on each input into
482483
* `finalScriptSig` using Kaspa's push-only script layout:
483-
* `0x41` (OP_DATA_65) + 64-byte Schnorr sig + 1-byte sighash type = 66 bytes
484+
* `0x41` (OP_DATA_65) + 64-byte sig + 1-byte sighash type = 66 bytes
484485
*
486+
* Works for both Schnorr (v0) and ECDSA (v1) inputs since both produce a
487+
* 64-byte compact signature stored in `partialSigs`.
485488
* Inputs that already have a `finalScriptSig` are left unchanged.
486489
*/
487490
finalize(): this {

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

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
* https://github.com/kaspanet/rusty-kaspa/blob/master/consensus/core/src/hashing/sighash.rs
1313
*/
1414
import { blake2b } from 'blakejs';
15+
import { createHash } from 'crypto';
1516
import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface';
16-
17-
// SigHash type flags
18-
export const SIGHASH_ALL = 0x01;
19-
export const SIGHASH_NONE = 0x02;
20-
export const SIGHASH_SINGLE = 0x04;
21-
export const SIGHASH_ANYONECANPAY = 0x80;
22-
23-
// Script constants
24-
export const OP_CHECKSIG_SCHNORR = 0xab; // Kaspa Schnorr checksig opcode
25-
export const SCRIPT_PUBLIC_KEY_VERSION = 0; // Standard P2PK version
17+
import {
18+
SIGHASH_ALL,
19+
SIGHASH_NONE,
20+
SIGHASH_SINGLE,
21+
SIGHASH_ANYONECANPAY,
22+
OP_CHECKSIG_SCHNORR,
23+
OP_CHECKSIG_ECDSA,
24+
KaspaScriptType,
25+
} from './constants';
2626

2727
/**
2828
* The Blake2b key used for ALL sighash operations in Kaspa.
@@ -33,15 +33,46 @@ export const SCRIPT_PUBLIC_KEY_VERSION = 0; // Standard P2PK version
3333
*/
3434
const SIGNING_HASH_KEY = Buffer.from('TransactionSigningHash', 'ascii');
3535

36-
/**
37-
* Keyed Blake2b-256: blake2b(data, key="TransactionSigningHash", outlen=32).
38-
* Used for every intermediate hash and the final sighash.
39-
*/
4036
function kblake2b(data: Buffer): Buffer {
4137
return Buffer.from(blake2b(data, SIGNING_HASH_KEY, 32));
4238
}
4339

44-
// ─── Intermediate hash helpers ────────────────────────────────────────────────
40+
/**
41+
* Build a Kaspa P2PK scriptPublicKey.
42+
*
43+
* @param pubKey - For SCHNORR: 32-byte x-only key.
44+
* For ECDSA: 33-byte compressed key.
45+
* @param type - Address type (default: SCHNORR / v0).
46+
*
47+
* Resulting script formats (per rusty-kaspa crypto/txscript/src/standard.rs):
48+
* SCHNORR (v0): OP_DATA_32(0x20) || xOnlyPubKey(32B) || OpCheckSig(0xAC)
49+
* ECDSA (v1): OP_DATA_33(0x21) || compressedPubKey(33B) || OpCheckSigECDSA(0xAB)
50+
*/
51+
export function buildP2PKScriptPublicKey(pubKey: Buffer, type: KaspaScriptType = KaspaScriptType.SCHNORR): Buffer {
52+
if (type === KaspaScriptType.SCHNORR) {
53+
if (pubKey.length !== 32) {
54+
throw new Error(`SCHNORR script expects 32-byte x-only pubkey, got ${pubKey.length}`);
55+
}
56+
return Buffer.concat([Buffer.from([0x20]), pubKey, Buffer.from([OP_CHECKSIG_SCHNORR])]);
57+
} else {
58+
if (pubKey.length !== 33) {
59+
throw new Error(`ECDSA script expects 33-byte compressed pubkey, got ${pubKey.length}`);
60+
}
61+
return Buffer.concat([Buffer.from([0x21]), pubKey, Buffer.from([OP_CHECKSIG_ECDSA])]);
62+
}
63+
}
64+
65+
/**
66+
* Derive x-only public key from 33-byte compressed public key.
67+
*/
68+
export function compressedToXOnly(compressedPubKey: Buffer): Buffer {
69+
if (compressedPubKey.length !== 33) {
70+
throw new Error(`Expected 33-byte compressed pubkey, got ${compressedPubKey.length}`);
71+
}
72+
return compressedPubKey.slice(1); // drop the 02/03 prefix byte
73+
}
74+
75+
// --- Intermediate hash helpers ---
4576

4677
function hashPreviousOutputs(inputs: KaspaUtxoInput[]): Buffer {
4778
const parts = inputs.map((inp) => {
@@ -115,29 +146,6 @@ function hashPayload(tx: KaspaTransactionData): Buffer {
115146
return kblake2b(Buffer.concat([lenBuf, payloadBytes]));
116147
}
117148

118-
// ─── Public API ───────────────────────────────────────────────────────────────
119-
120-
/**
121-
* Build P2PK Schnorr scriptPublicKey from a 32-byte x-only public key.
122-
* Format: OP_DATA_32(0x20) + xOnlyPubKey(32 bytes) + OP_CHECKSIG_SCHNORR(0xAB)
123-
*/
124-
export function buildP2PKScriptPublicKey(xOnlyPubKey: Buffer): Buffer {
125-
if (xOnlyPubKey.length !== 32) {
126-
throw new Error(`Expected 32-byte x-only pubkey, got ${xOnlyPubKey.length}`);
127-
}
128-
return Buffer.concat([Buffer.from([0x20]), xOnlyPubKey, Buffer.from([OP_CHECKSIG_SCHNORR])]);
129-
}
130-
131-
/**
132-
* Derive x-only public key from 33-byte compressed public key.
133-
*/
134-
export function compressedToXOnly(compressedPubKey: Buffer): Buffer {
135-
if (compressedPubKey.length !== 33) {
136-
throw new Error(`Expected 33-byte compressed pubkey, got ${compressedPubKey.length}`);
137-
}
138-
return compressedPubKey.slice(1);
139-
}
140-
141149
/**
142150
* Compute the Kaspa Schnorr sighash for a specific input.
143151
*
@@ -239,3 +247,34 @@ export function computeKaspaSigningHash(
239247

240248
return kblake2b(preimage.slice(0, offset));
241249
}
250+
251+
/**
252+
* SHA-256 domain separator for ECDSA signing.
253+
* Matches TransactionSigningHashECDSA in rusty-kaspa/crypto/hashes/src/hashers.rs:
254+
* sha256_hasher! { struct TransactionSigningHashECDSA => "TransactionSigningHashECDSA" }
255+
* The hasher is seeded with SHA256("TransactionSigningHashECDSA") before any data.
256+
*/
257+
const ECDSA_DOMAIN_SEP: Buffer = Buffer.from(createHash('sha256').update('TransactionSigningHashECDSA').digest());
258+
259+
/**
260+
* Compute the Kaspa ECDSA sighash for a specific input.
261+
*
262+
* ECDSA signing uses a two-step hash defined in rusty-kaspa sighash.rs:
263+
* 1. schnorr_hash = blake2b256_keyed("TransactionSigningHash", preimage)
264+
* 2. ecdsa_hash = SHA256( SHA256("TransactionSigningHashECDSA") || schnorr_hash )
265+
*
266+
* This differs from the Schnorr hash — using the Schnorr hash for ECDSA signing
267+
* will produce a locally-valid signature that the network always rejects.
268+
*
269+
* @param tx Full transaction data with UTXO amount + scriptPublicKey on inputs
270+
* @param inputIndex 0-based index of the input being signed
271+
* @param sigHashType SigHash type flags (SIGHASH_ALL = 0x01)
272+
*/
273+
export function computeKaspaEcdsaSigningHash(
274+
tx: KaspaTransactionData,
275+
inputIndex: number,
276+
sigHashType: number = SIGHASH_ALL
277+
): Buffer {
278+
const schnorrHash = computeKaspaSigningHash(tx, inputIndex, sigHashType);
279+
return Buffer.from(createHash('sha256').update(ECDSA_DOMAIN_SEP).update(schnorrHash).digest());
280+
}

0 commit comments

Comments
 (0)