Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions modules/sdk-coin-kaspa/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
11 changes: 7 additions & 4 deletions modules/sdk-coin-kaspa/src/lib/keyPair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
7 changes: 5 additions & 2 deletions modules/sdk-coin-kaspa/src/lib/pskt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down
115 changes: 77 additions & 38 deletions modules/sdk-coin-kaspa/src/lib/sighash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) => {
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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());
}
Loading
Loading