diff --git a/CODEOWNERS b/CODEOWNERS index c117c16963..3a74db89d6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -75,6 +75,7 @@ /modules/sdk-coin-hbar/ @BitGo/ethalt-team /modules/sdk-coin-icp/ @BitGo/ethalt-team /modules/sdk-coin-initia/ @BitGo/ethalt-team +/modules/sdk-coin-kas/ @BitGo/ethalt-team /modules/sdk-coin-iota/ @BitGo/ethalt-team /modules/sdk-coin-mon/ @BitGo/ethalt-team /modules/sdk-coin-mantra/ @BitGo/ethalt-team diff --git a/Dockerfile b/Dockerfile index 49e234d06d..3d948637ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,7 @@ COPY --from=builder /tmp/bitgo/modules/sdk-coin-icp /var/modules/sdk-coin-icp/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-initia /var/modules/sdk-coin-initia/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-injective /var/modules/sdk-coin-injective/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-islm /var/modules/sdk-coin-islm/ +COPY --from=builder /tmp/bitgo/modules/sdk-coin-kas /var/modules/sdk-coin-kas/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-mon /var/modules/sdk-coin-mon/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-near /var/modules/sdk-coin-near/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-oas /var/modules/sdk-coin-oas/ @@ -195,6 +196,7 @@ cd /var/modules/sdk-coin-icp && yarn link && \ cd /var/modules/sdk-coin-initia && yarn link && \ cd /var/modules/sdk-coin-injective && yarn link && \ cd /var/modules/sdk-coin-islm && yarn link && \ +cd /var/modules/sdk-coin-kas && yarn link && \ cd /var/modules/sdk-coin-mon && yarn link && \ cd /var/modules/sdk-coin-near && yarn link && \ cd /var/modules/sdk-coin-oas && yarn link && \ @@ -298,6 +300,7 @@ RUN cd /var/bitgo-express && \ yarn link @bitgo/sdk-coin-initia && \ yarn link @bitgo/sdk-coin-injective && \ yarn link @bitgo/sdk-coin-islm && \ + yarn link @bitgo/sdk-coin-kas && \ yarn link @bitgo/sdk-coin-mon && \ yarn link @bitgo/sdk-coin-near && \ yarn link @bitgo/sdk-coin-oas && \ diff --git a/modules/account-lib/package.json b/modules/account-lib/package.json index da470a6d2f..55615aad11 100644 --- a/modules/account-lib/package.json +++ b/modules/account-lib/package.json @@ -53,6 +53,7 @@ "@bitgo/sdk-coin-evm": "^1.14.9", "@bitgo/sdk-coin-flr": "^1.10.0", "@bitgo/sdk-coin-flrp": "^1.11.0", + "@bitgo/sdk-coin-kas": "^1.0.0", "@bitgo/sdk-coin-hash": "^3.9.0", "@bitgo/sdk-coin-hbar": "^2.7.0", "@bitgo/sdk-coin-icp": "^1.22.0", diff --git a/modules/account-lib/src/index.ts b/modules/account-lib/src/index.ts index 9e20042797..2ec0b49d8c 100644 --- a/modules/account-lib/src/index.ts +++ b/modules/account-lib/src/index.ts @@ -212,6 +212,9 @@ export { Canton }; import { FlrPLib as FlrP } from '@bitgo/sdk-coin-flrp'; export { FlrP }; +import * as Kas from '@bitgo/sdk-coin-kas'; +export { Kas }; + import { MIDNIGHT_TNC_HASH } from './utils'; export { MIDNIGHT_TNC_HASH }; @@ -330,6 +333,8 @@ const coinBuilderMap = { tcanton: Canton.TransactionBuilderFactory, flrp: FlrP.TransactionBuilderFactory, tflrp: FlrP.TransactionBuilderFactory, + kas: Kas.TransactionBuilderFactory, + tkas: Kas.TransactionBuilderFactory, }; const coinMessageBuilderFactoryMap = { diff --git a/modules/account-lib/tsconfig.json b/modules/account-lib/tsconfig.json index 4b6b79cabf..d4626642c5 100644 --- a/modules/account-lib/tsconfig.json +++ b/modules/account-lib/tsconfig.json @@ -76,6 +76,9 @@ { "path": "../sdk-coin-injective" }, + { + "path": "../sdk-coin-kas" + }, { "path": "../sdk-coin-islm" }, diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 90b60a2da3..6c1fb9e06c 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -84,6 +84,7 @@ "@bitgo/sdk-coin-evm": "^1.14.9", "@bitgo/sdk-coin-flr": "^1.10.0", "@bitgo/sdk-coin-flrp": "^1.11.0", + "@bitgo/sdk-coin-kas": "^1.0.0", "@bitgo/sdk-coin-hash": "^3.9.0", "@bitgo/sdk-coin-hbar": "^2.7.0", "@bitgo/sdk-coin-icp": "^1.22.0", diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index f48257905f..a255e4b932 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -87,6 +87,8 @@ import { EvmCoin, Flr, Flrp, + Kas, + TKas, FlrToken, HashToken, MonToken, @@ -282,6 +284,7 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin coinFactory.register('fiatusd', FiatUsd.createInstance); coinFactory.register('flr', Flr.createInstance); coinFactory.register('flrp', Flrp.createInstance); + coinFactory.register('kas', Kas.createInstance); coinFactory.register('gteth', Gteth.createInstance); coinFactory.register('hash', Hash.createInstance); coinFactory.register('hbar', Hbar.createInstance); @@ -354,6 +357,7 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin coinFactory.register('tfiatusd', TfiatUsd.createInstance); coinFactory.register('tflr', Tflr.createInstance); coinFactory.register('tflrp', Flrp.createInstance); + coinFactory.register('tkas', TKas.createInstance); coinFactory.register('tmon', Tmon.createInstance); coinFactory.register('thash', Thash.createInstance); coinFactory.register('thbar', Thbar.createInstance); @@ -709,6 +713,8 @@ export function getCoinConstructor(coinName: string): CoinConstructor | undefine return Flr.createInstance; case 'flrp': return Flrp.createInstance; + case 'kas': + return Kas.createInstance; case 'gteth': return Gteth.createInstance; case 'hash': @@ -853,6 +859,8 @@ export function getCoinConstructor(coinName: string): CoinConstructor | undefine return Tflr.createInstance; case 'tflrp': return Flrp.createInstance; + case 'tkas': + return TKas.createInstance; case 'tmon': return Tmon.createInstance; case 'thash': diff --git a/modules/bitgo/src/v2/coins/index.ts b/modules/bitgo/src/v2/coins/index.ts index 606c54f97c..13b055160b 100644 --- a/modules/bitgo/src/v2/coins/index.ts +++ b/modules/bitgo/src/v2/coins/index.ts @@ -34,6 +34,7 @@ import { Erc20Token, Erc721Token, Eth, Gteth, Hteth, Teth } from '@bitgo/sdk-coi import { EvmCoin, EthLikeErc20Token, EthLikeErc721Token } from '@bitgo/sdk-coin-evm'; import { Flr, Tflr, FlrToken } from '@bitgo/sdk-coin-flr'; import { Flrp } from '@bitgo/sdk-coin-flrp'; +import { Kas, TKas } from '@bitgo/sdk-coin-kas'; import { Ethw } from '@bitgo/sdk-coin-ethw'; import { EthLikeCoin, TethLikeCoin } from '@bitgo/sdk-coin-ethlike'; import { Hash, Thash, HashToken } from '@bitgo/sdk-coin-hash'; @@ -113,6 +114,7 @@ export { Etc, Tetc }; export { EvmCoin, EthLikeErc20Token, EthLikeErc721Token }; export { Flr, Tflr, FlrToken }; export { Flrp }; +export { Kas, TKas }; export { Hash, Thash, HashToken }; export { Hbar, Thbar }; export { Icp, Ticp }; diff --git a/modules/bitgo/tsconfig.json b/modules/bitgo/tsconfig.json index 5f24815255..75c25d3c2f 100644 --- a/modules/bitgo/tsconfig.json +++ b/modules/bitgo/tsconfig.json @@ -179,6 +179,9 @@ { "path": "../sdk-coin-injective" }, + { + "path": "../sdk-coin-kas" + }, { "path": "../sdk-coin-iota" }, diff --git a/modules/sdk-coin-kas/.eslintignore b/modules/sdk-coin-kas/.eslintignore new file mode 100644 index 0000000000..1586a150fa --- /dev/null +++ b/modules/sdk-coin-kas/.eslintignore @@ -0,0 +1,4 @@ +node_modules +.idea +public +dist diff --git a/modules/sdk-coin-kas/.mocharc.yml b/modules/sdk-coin-kas/.mocharc.yml new file mode 100644 index 0000000000..d84fbf5b25 --- /dev/null +++ b/modules/sdk-coin-kas/.mocharc.yml @@ -0,0 +1,8 @@ +require: 'tsx' +timeout: 120000 +reporter: 'min' +reporter-option: + - 'cdn=true' + - 'json=false' +exit: true +spec: ['test/unit/**/*.ts'] diff --git a/modules/sdk-coin-kas/package.json b/modules/sdk-coin-kas/package.json new file mode 100644 index 0000000000..88697f8774 --- /dev/null +++ b/modules/sdk-coin-kas/package.json @@ -0,0 +1,55 @@ +{ + "name": "@bitgo/sdk-coin-kas", + "version": "1.0.0", + "description": "BitGo's SDK coin library for Kaspa (KAS)", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "yarn tsc --build --incremental --verbose .", + "fmt": "prettier --write .", + "check-fmt": "prettier --check '**/*.{ts,js,json}'", + "clean": "rm -r ./dist", + "lint": "eslint --quiet .", + "prepare": "npm run build", + "test": "npm run coverage", + "coverage": "nyc -- npm run unit-test", + "unit-test": "mocha" + }, + "repository": { + "type": "git", + "url": "https://github.com/BitGo/BitGoJS.git", + "directory": "modules/sdk-coin-kas" + }, + "author": "BitGo SDK Team ", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "lint-staged": { + "*.{js,ts}": [ + "yarn prettier --write", + "yarn eslint --fix" + ] + }, + "publishConfig": { + "access": "public" + }, + "nyc": { + "extension": [ + ".ts" + ] + }, + "devDependencies": {}, + "dependencies": { + "@bitgo/sdk-core": "^36.37.0", + "@bitgo/secp256k1": "^1.11.0", + "@bitgo/statics": "^58.32.0", + "@noble/curves": "1.8.1", + "@noble/hashes": "^1.7.1", + "bignumber.js": "9.0.0" + }, + "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", + "files": [ + "dist" + ] +} diff --git a/modules/sdk-coin-kas/src/index.ts b/modules/sdk-coin-kas/src/index.ts new file mode 100644 index 0000000000..c4e73f580a --- /dev/null +++ b/modules/sdk-coin-kas/src/index.ts @@ -0,0 +1,4 @@ +export { Kas } from './kas'; +export { TKas } from './tkas'; +export * from './lib'; +export { register } from './register'; diff --git a/modules/sdk-coin-kas/src/kas.ts b/modules/sdk-coin-kas/src/kas.ts new file mode 100644 index 0000000000..612db55adf --- /dev/null +++ b/modules/sdk-coin-kas/src/kas.ts @@ -0,0 +1,239 @@ +/** + * Kaspa (KAS) Coin Class + * + * Kaspa is a UTXO-based BlockDAG using GHOSTDAG consensus (Proof-of-Work). + * Uses Schnorr signatures over secp256k1 with bech32m address encoding. + * Does not support smart contracts on L1. + */ + +import { BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; +import { + AuditDecryptedKeyParams, + BaseCoin, + BitGoBase, + KeyPair as BaseKeyPair, + MPCAlgorithm, + MultisigType, + multisigTypes, + ParsedTransaction, + ParseTransactionOptions, + SignedTransaction, + SignTransactionOptions, + VerifyAddressOptions, + VerifyTransactionOptions, + InvalidAddressError, + UnexpectedAddressError, + InvalidTransactionError, + SigningError, + MethodNotImplementedError, +} from '@bitgo/sdk-core'; +import { KeyPair } from './lib/keyPair'; +import { Transaction } from './lib/transaction'; +import { TransactionBuilderFactory } from './lib/transactionBuilderFactory'; +import { + KaspaSignTransactionOptions, + KaspaVerifyTransactionOptions, + KaspaExplainTransactionOptions, + KaspaTransactionExplanation, +} from './lib/iface'; +import * as utils from './lib/utils'; + +export class Kas 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; + } + + static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly): BaseCoin { + return new Kas(bitgo, staticsCoin); + } + + getChain(): string { + return this._staticsCoin.name; + } + + getFamily(): CoinFamily { + return this._staticsCoin.family; + } + + getFullName(): string { + return this._staticsCoin.fullName; + } + + /** 1 KAS = 10^8 sompi */ + getBaseFactor(): string | number { + return Math.pow(10, this._staticsCoin.decimalPlaces); + } + + /** Kaspa uses on-chain multisig (UTXO model) */ + getDefaultMultisigType(): MultisigType { + return multisigTypes.onchain; + } + + /** MPC support: ECDSA (secp256k1 curve) */ + supportsTss(): boolean { + return true; + } + + getMPCAlgorithm(): MPCAlgorithm { + return 'ecdsa'; + } + + /** + * Validate a Kaspa address (bech32m encoded with 'kaspa' HRP for mainnet). + */ + isValidAddress(address: string): boolean { + return utils.isValidAddress(address); + } + + /** + * Validate a secp256k1 public key. + */ + isValidPub(pub: string): boolean { + try { + new KeyPair({ pub }); + return true; + } catch { + return false; + } + } + + /** + * Validate a secp256k1 private key. + */ + isValidPrv(prv: string): boolean { + try { + new KeyPair({ prv }); + return true; + } catch { + return false; + } + } + + /** + * Generate a Kaspa key pair. + * + * @param seed - Optional seed buffer; if not provided, a random seed is used + */ + generateKeyPair(seed?: Buffer): BaseKeyPair { + const kp = seed ? new KeyPair({ seed }) : new KeyPair(); + const keys = kp.getKeys(); + if (!keys.prv) { + throw new Error('Missing prv in key generation'); + } + return { pub: keys.pub, prv: keys.prv }; + } + + /** + * Verify that an address matches the wallet's public keys. + */ + async isWalletAddress(params: VerifyAddressOptions): Promise { + const { address, keychains } = params; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`Invalid address: ${address}`); + } + if (!keychains || keychains.length === 0) { + throw new Error('No keychains provided'); + } + + // For single-sig: check that the address matches one of the keychains + const networkType = utils.isMainnetAddress(address) ? 'mainnet' : 'testnet'; + const derivedAddresses = keychains.map((kc) => { + const kp = new KeyPair({ pub: kc.pub }); + return kp.getAddress(networkType); + }); + + if (!derivedAddresses.includes(address)) { + throw new UnexpectedAddressError(`Address ${address} does not match any keychain`); + } + + return true; + } + + /** + * Sign a Kaspa transaction. + */ + async signTransaction(params: SignTransactionOptions): Promise { + const kasParams = params as KaspaSignTransactionOptions; + const txHex = kasParams.txPrebuild?.txHex ?? kasParams.txPrebuild?.halfSigned?.txHex; + if (!txHex) { + throw new SigningError('Missing txHex in transaction prebuild'); + } + if (!kasParams.prv) { + throw new SigningError('Missing private key'); + } + + const txBuilder = this.getBuilder().from(txHex); + txBuilder.sign({ key: kasParams.prv }); + const tx = await txBuilder.build(); + + const signed = tx.toBroadcastFormat(); + // Return halfSigned if only one signature (multisig), fully signed if complete + return tx.signature.length >= 2 ? { txHex: signed } : { halfSigned: { txHex: signed } }; + } + + /** + * Parse a Kaspa transaction. + */ + async parseTransaction(params: ParseTransactionOptions): Promise { + return {}; + } + + /** + * Explain a Kaspa transaction from its hex representation. + */ + async explainTransaction(params: Record): Promise { + const kasParams = params as KaspaExplainTransactionOptions; + const txHex = kasParams.txHex ?? kasParams.halfSigned?.txHex; + if (!txHex) { + throw new Error('Missing transaction hex'); + } + try { + const tx = Transaction.fromHex(txHex); + return tx.explainTransaction(); + } catch (e) { + throw new InvalidTransactionError(`Invalid transaction: ${e.message}`); + } + } + + /** + * Verify a Kaspa transaction against expected parameters. + */ + async verifyTransaction(params: VerifyTransactionOptions): Promise { + const kasParams = params as unknown as KaspaVerifyTransactionOptions; + const txHex = kasParams.txPrebuild?.txHex ?? kasParams.txPrebuild?.halfSigned?.txHex; + if (!txHex) { + throw new Error('Missing txHex in transaction prebuild'); + } + + try { + Transaction.fromHex(txHex); + } catch (e) { + throw new InvalidTransactionError(`Invalid transaction: ${e.message}`); + } + + return true; + } + + /** + * Sign a message with a key pair. + */ + async signMessage(key: BaseKeyPair, message: string | Buffer): Promise { + throw new MethodNotImplementedError(); + } + + /** @inheritdoc */ + auditDecryptedKey(params: AuditDecryptedKeyParams): void { + throw new MethodNotImplementedError(); + } + + private getBuilder(): TransactionBuilderFactory { + return new TransactionBuilderFactory(coins.get(this.getChain()) as any); + } +} diff --git a/modules/sdk-coin-kas/src/lib/constants.ts b/modules/sdk-coin-kas/src/lib/constants.ts new file mode 100644 index 0000000000..3228d7b4b3 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/constants.ts @@ -0,0 +1,65 @@ +/** + * Kaspa (KAS) Constants + * + * Kaspa is a UTXO-based BlockDAG using GHOSTDAG consensus (Proof-of-Work). + * Uses Schnorr signatures over secp256k1 and bech32m address encoding. + */ + +// Native coin denomination +export const DECIMALS = 8; +export const BASE_FACTOR = Math.pow(10, DECIMALS); // 100,000,000 sompi per KAS + +// Address format +export const MAINNET_HRP = 'kaspa'; +export const TESTNET_HRP = 'kaspatest'; +export const SIMNET_HRP = 'kaspasim'; +export const DEVNET_HRP = 'kaspadev'; + +// Address version bytes +export const ADDRESS_VERSION_PUBKEY = 0x00; // P2PK (Schnorr) +export const ADDRESS_VERSION_SCRIPTHASH = 0x08; // P2SH + +// Transaction structure +export const TX_VERSION = 0; +export const NATIVE_SUBNETWORK_ID = Buffer.alloc(20, 0); // 20 zero bytes for native transactions +export const COINBASE_SUBNETWORK_ID = Buffer.from('0100000000000000000000000000000000000000', 'hex'); +export const DEFAULT_SEQUENCE = BigInt('0xFFFFFFFFFFFFFFFF'); +export const DEFAULT_GAS = BigInt(0); +export const DEFAULT_LOCK_TIME = BigInt(0); + +// Script opcodes +export const OP_DATA_32 = 0x20; // Push next 32 bytes +export const OP_CHECKSIG = 0xac; // Check Schnorr signature + +// Fee (in sompi) +export const MIN_RELAY_FEE_PER_MASS = BigInt(1000); // 1000 sompi per gram-unit of mass +export const MINIMUM_FEE = BigInt(1000); // Minimum 1000 sompi fee + +// Mass calculation constants (Kaspa uses "mass" not gas) +export const HASH_SIZE = 32; +export const BLANK_TRANSACTION_MASS = 10; +export const TRANSACTION_MASS_PER_INPUT = 100; +export const TRANSACTION_MASS_PER_OUTPUT = 50; + +// Sighash types +export const SIGHASH_ALL = 0x01; + +// Public key sizes (secp256k1) +export const COMPRESSED_PUBLIC_KEY_SIZE = 33; // 0x02/0x03 + 32 bytes +export const X_ONLY_PUBLIC_KEY_SIZE = 32; // Just the x-coordinate +export const PRIVATE_KEY_SIZE = 32; + +// Signature size (Schnorr) +export const SCHNORR_SIGNATURE_SIZE = 64; + +// RPC URLs (wRPC WebSocket) +export const MAINNET_NODE_URL = 'wss://mainnet.kaspa.green'; +export const TESTNET_NODE_URL = 'wss://testnet-10.kaspa.green'; + +// Explorer URLs +export const MAINNET_EXPLORER_URL = 'https://explorer.kaspa.org/txs/'; +export const TESTNET_EXPLORER_URL = 'https://explorer-tn10.kaspa.org/txs/'; + +// BLAKE2B domain tags for Kaspa sighash +export const SIGHASH_DOMAIN_TAG = 'TransactionSigningHash'; +export const SIGHASH_ECDSA_DOMAIN_TAG = 'TransactionSigningHashECDSA'; diff --git a/modules/sdk-coin-kas/src/lib/iface.ts b/modules/sdk-coin-kas/src/lib/iface.ts new file mode 100644 index 0000000000..69ee5021d9 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/iface.ts @@ -0,0 +1,171 @@ +/** + * Kaspa (KAS) Type Definitions + * + * Interfaces for Kaspa UTXO transactions, builders, and coin class. + */ + +import { SignTransactionOptions, BaseKey } from '@bitgo/sdk-core'; + +// ─── UTXO Types ────────────────────────────────────────────────────────────── + +/** + * Kaspa UTXO (Unspent Transaction Output) used as a transaction input + */ +export interface KaspaUtxoEntry { + /** Transaction ID of the transaction containing this UTXO */ + transactionId: string; + /** Output index within the transaction */ + index: number; + /** Amount in sompi */ + amount: bigint; + /** Script public key */ + scriptPublicKey: KaspaScriptPublicKey; + /** Block DAA (Difficulty Adjustment Algorithm) score when the UTXO was created */ + blockDaaScore: bigint; + /** Whether this is a coinbase output */ + isCoinbase: boolean; +} + +/** + * Script public key (locking script) + */ +export interface KaspaScriptPublicKey { + /** Script version (0 for P2PK) */ + version: number; + /** Script bytes as hex string */ + script: string; +} + +// ─── Transaction Types ─────────────────────────────────────────────────────── + +/** + * Kaspa transaction outpoint (reference to previous UTXO) + */ +export interface KaspaOutpoint { + /** Transaction ID (32 bytes hex) */ + transactionId: string; + /** Output index */ + index: number; +} + +/** + * Kaspa transaction input + */ +export interface KaspaTransactionInput { + /** Reference to previous UTXO */ + previousOutpoint: KaspaOutpoint; + /** Signature script (populated after signing, as hex) */ + signatureScript: string; + /** Sequence number */ + sequence: bigint; + /** Signature operation count */ + sigOpCount: number; +} + +/** + * Kaspa transaction output + */ +export interface KaspaTransactionOutput { + /** Amount in sompi */ + value: bigint; + /** Locking script */ + scriptPublicKey: KaspaScriptPublicKey; +} + +/** + * Full Kaspa transaction data + */ +export interface KaspaTransactionData { + /** Transaction version (0) */ + version: number; + /** List of inputs */ + inputs: KaspaTransactionInput[]; + /** List of outputs */ + outputs: KaspaTransactionOutput[]; + /** Lock time (0 for standard transactions) */ + lockTime: bigint; + /** Subnetwork ID (20 bytes; all zeros for native transactions) */ + subnetworkId: string; + /** Gas (0 for native transactions) */ + gas: bigint; + /** Payload (empty for native transactions) */ + payload: string; + /** UTXO entries for each input (used for signing) */ + utxoEntries?: KaspaUtxoEntry[]; +} + +// ─── Coin Interface Types ───────────────────────────────────────────────────── + +/** + * Transaction prebuild data passed to signTransaction() + */ +export interface KaspaTransactionPrebuild { + /** Serialized transaction as hex */ + txHex?: string; + /** Optional half-signed hex */ + halfSigned?: { txHex: string }; +} + +/** + * Parameters for signing a Kaspa transaction + */ +export interface KaspaSignTransactionOptions extends SignTransactionOptions { + txPrebuild: KaspaTransactionPrebuild; + prv: string; + pubs?: string[]; +} + +/** + * Parameters for verifying a Kaspa transaction + */ +export interface KaspaVerifyTransactionOptions { + txPrebuild: KaspaTransactionPrebuild; + txParams: KaspaTransactionParams; + verification?: Record; +} + +/** + * Parameters for building/explaining a Kaspa transaction + */ +export interface KaspaTransactionParams { + recipients: Array<{ address: string; amount: string }>; + unspents?: KaspaUtxoEntry[]; + feeRate?: string; + fee?: string; +} + +/** + * Parameters for explaining a Kaspa transaction + */ +export interface KaspaExplainTransactionOptions { + txHex?: string; + halfSigned?: { txHex: string }; +} + +/** + * Human-readable transaction explanation + */ +export interface KaspaTransactionExplanation { + id: string; + /** Sender address (derived from UTXOs) */ + sender?: string; + /** Transaction outputs */ + outputs: Array<{ address: string; amount: string }>; + /** Total output amount in string */ + outputAmount: string; + changeOutputs: Array<{ address: string; amount: string }>; + changeAmount: string; + fee: { fee: string }; + /** Transaction type */ + type: string; +} + +/** + * Key entry for UTXO wallet + */ +export interface KaspaKeyEntry extends BaseKey { + /** Compressed public key (hex) */ + pub: string; + /** Optional private key (hex) */ + prv?: string; +} diff --git a/modules/sdk-coin-kas/src/lib/index.ts b/modules/sdk-coin-kas/src/lib/index.ts new file mode 100644 index 0000000000..dd0e1a8b33 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/index.ts @@ -0,0 +1,8 @@ +export { KeyPair } from './keyPair'; +export { Transaction } from './transaction'; +export { TransactionBuilder } from './transactionBuilder'; +export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export { Utils } from './utils'; +export * from './iface'; +export * from './constants'; +export * as utils from './utils'; diff --git a/modules/sdk-coin-kas/src/lib/keyPair.ts b/modules/sdk-coin-kas/src/lib/keyPair.ts new file mode 100644 index 0000000000..45b6be1a9d --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/keyPair.ts @@ -0,0 +1,116 @@ +/** + * Kaspa (KAS) Key Pair Management + * + * Handles secp256k1 key generation, derivation, and Kaspa address encoding. + * Kaspa uses Schnorr signatures over secp256k1, with x-only public keys. + */ + +import { + DefaultKeys, + isPrivateKey, + isPublicKey, + isSeed, + isValidXprv, + isValidXpub, + KeyPairOptions, + Secp256k1ExtendedKeyPair, +} from '@bitgo/sdk-core'; +import { bip32, ECPair } from '@bitgo/secp256k1'; +import { randomBytes } from 'crypto'; +import { publicKeyToAddress, isValidPublicKey, isValidPrivateKey } from './utils'; +import { MAINNET_HRP, TESTNET_HRP } from './constants'; + +const DEFAULT_SEED_SIZE_BYTES = 32; + +export class KeyPair extends Secp256k1ExtendedKeyPair { + /** + * Create a Kaspa key pair. + * + * @param source - Optional seed, private key, or public key + */ + constructor(source?: KeyPairOptions) { + super(source); + if (!source) { + const seed = randomBytes(DEFAULT_SEED_SIZE_BYTES); + this.hdNode = bip32.fromSeed(seed); + } else if (isSeed(source)) { + this.hdNode = bip32.fromSeed(source.seed); + } else if (isPrivateKey(source)) { + this.recordKeysFromPrivateKey(source.prv); + } else if (isPublicKey(source)) { + this.recordKeysFromPublicKey(source.pub); + } else { + throw new Error('Invalid key pair options'); + } + + if (this.hdNode) { + this.keyPair = Secp256k1ExtendedKeyPair.toKeyPair(this.hdNode); + } + } + + /** + * Build a key pair from a private key hex string or extended private key. + */ + recordKeysFromPrivateKey(prv: string): void { + if (!isValidPrivateKey(prv) && !isValidXprv(prv)) { + if (!/^[0-9a-fA-F]{64}$/.test(prv)) { + throw new Error('Unsupported private key format'); + } + } + if (isValidXprv(prv)) { + this.hdNode = bip32.fromBase58(prv); + } else { + this.keyPair = ECPair.fromPrivateKey(Buffer.from(prv.slice(0, 64), 'hex')); + } + } + + /** + * Build a key pair from a public key hex string or extended public key. + */ + recordKeysFromPublicKey(pub: string): void { + if (isValidXpub(pub)) { + this.hdNode = bip32.fromBase58(pub); + return; + } + if (!isValidPublicKey(pub)) { + throw new Error('Unsupported public key format'); + } + let pubBytes = Buffer.from(pub, 'hex'); + // If x-only (32 bytes), assume even y (0x02 prefix) + if (pubBytes.length === 32) { + pubBytes = Buffer.concat([Buffer.from([0x02]), pubBytes]); + } + this.keyPair = ECPair.fromPublicKey(pubBytes); + } + + /** + * Get keys in default format: compressed public key and optional private key (both hex). + */ + getKeys(): DefaultKeys { + return { + pub: this.getPublicKey({ compressed: true }).toString('hex'), + prv: this.getPrivateKey()?.toString('hex'), + }; + } + + /** + * Get the Kaspa mainnet address for this key pair. + * @param format - Ignored; Kaspa always uses bech32m mainnet encoding by default + */ + getAddress(format?: unknown): string { + const networkType = (format as string) === 'testnet' ? 'testnet' : 'mainnet'; + const hrp = networkType === 'mainnet' ? MAINNET_HRP : TESTNET_HRP; + const compressedPub = this.getPublicKey({ compressed: true }); + return publicKeyToAddress(Buffer.from(compressedPub), hrp); + } + + /** + * Get the x-only public key (32 bytes, just the x-coordinate of secp256k1 point). + * Used for Kaspa Schnorr signing and P2PK script construction. + */ + getXOnlyPublicKey(): Buffer { + const compressedPub = this.getPublicKey({ compressed: true }); + // Strip the 0x02/0x03 prefix byte + return Buffer.from(compressedPub).slice(1); + } +} diff --git a/modules/sdk-coin-kas/src/lib/transaction.ts b/modules/sdk-coin-kas/src/lib/transaction.ts new file mode 100644 index 0000000000..7f764247cb --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/transaction.ts @@ -0,0 +1,444 @@ +/** + * Kaspa (KAS) Transaction + * + * Represents a Kaspa UTXO transaction. Handles serialization and + * sighash computation for Schnorr signing. + * + * Kaspa transaction sighash follows a BIP143-like approach using BLAKE2B + * with the domain tag "TransactionSigningHash". + */ + +import { BaseTransaction } from '@bitgo/sdk-core'; +import { blake2b } from '@noble/hashes/blake2b'; +import { + KaspaTransactionData, + KaspaTransactionInput, + KaspaTransactionOutput, + KaspaTransactionExplanation, +} from './iface'; +import { TX_VERSION, NATIVE_SUBNETWORK_ID, DEFAULT_GAS, DEFAULT_LOCK_TIME, SIGHASH_ALL } from './constants'; +import { uint64ToLE, uint32ToLE, uint16ToLE, writeVarInt, serializeTxId } from './utils'; + +/** BLAKE2B output size (32 bytes) */ +const HASH_SIZE = 32; + +/** + * Compute a BLAKE2B hash with a Kaspa domain tag prefix. + * Kaspa prepends each hash with: BLAKE2B(domain_tag) || data + */ +function blake2bWithTag(tag: string, data: Buffer): Buffer { + // Kaspa uses a tagged hash: H(tag_length || tag || data) + // The tag is hashed separately and prepended as a "personalization" + const tagBuf = Buffer.from(tag, 'utf8'); + const hasher = blake2b.create({ dkLen: HASH_SIZE }); + hasher.update(tagBuf); + hasher.update(data); + return Buffer.from(hasher.digest()); +} + +/** + * Hash a buffer using BLAKE2B (no tag, plain hash). + */ +function hashBuf(data: Buffer): Buffer { + return Buffer.from(blake2b(data, { dkLen: HASH_SIZE })); +} + +export class Transaction extends BaseTransaction { + protected _txData: KaspaTransactionData; + + constructor(txData?: Partial) { + super({} as any); + this._txData = { + version: TX_VERSION, + inputs: [], + outputs: [], + lockTime: DEFAULT_LOCK_TIME, + subnetworkId: NATIVE_SUBNETWORK_ID.toString('hex'), + gas: DEFAULT_GAS, + payload: '', + ...(txData || {}), + }; + } + + get txData(): KaspaTransactionData { + return this._txData; + } + + /** @inheritdoc */ + get id(): string { + return this.transactionId(); + } + + /** @inheritdoc */ + canSign(): boolean { + return true; + } + + /** + * Compute the transaction ID (BLAKE2B hash of the serialized transaction without signatures). + */ + transactionId(): string { + const serialized = this.serializeForTxId(); + return hashBuf(serialized).toString('hex'); + } + + /** + * Serialize the transaction for ID calculation (no signature scripts). + */ + private serializeForTxId(): Buffer { + const parts: Buffer[] = []; + + // Version (2 bytes LE) + parts.push(uint16ToLE(this._txData.version)); + + // Inputs + parts.push(writeVarInt(this._txData.inputs.length)); + for (const input of this._txData.inputs) { + // Previous outpoint: txid (32 bytes) + index (4 bytes LE) + parts.push(serializeTxId(input.previousOutpoint.transactionId)); + parts.push(uint32ToLE(input.previousOutpoint.index)); + // For txid calculation, signature script is empty + parts.push(writeVarInt(0)); + // Sequence (8 bytes LE) + parts.push(uint64ToLE(input.sequence)); + // SigOpCount (1 byte) + parts.push(Buffer.from([input.sigOpCount])); + } + + // Outputs + parts.push(writeVarInt(this._txData.outputs.length)); + for (const output of this._txData.outputs) { + parts.push(serializeOutput(output)); + } + + // LockTime (8 bytes LE) + parts.push(uint64ToLE(this._txData.lockTime)); + + // SubnetworkID (20 bytes) + const subnetworkId = Buffer.from(this._txData.subnetworkId, 'hex'); + parts.push(subnetworkId); + + // Gas (8 bytes LE) + parts.push(uint64ToLE(this._txData.gas)); + + // Payload + const payload = this._txData.payload ? Buffer.from(this._txData.payload, 'hex') : Buffer.alloc(0); + parts.push(writeVarInt(payload.length)); + if (payload.length > 0) parts.push(payload); + + return Buffer.concat(parts); + } + + /** + * Compute the sighash for a specific input using Kaspa's signing algorithm. + * + * Kaspa sighash uses BLAKE2B with the "TransactionSigningHash" tag and commits + * to: previous outpoints, sequences, sigop counts, this input's UTXO details, + * all outputs, locktime, subnetwork, gas, payload, and sighash type. + * + * @param inputIndex - Index of the input being signed + * @param sighashType - Sighash type (default: SIGHASH_ALL = 0x01) + */ + computeSighash(inputIndex: number, sighashType: number = SIGHASH_ALL): Buffer { + if (!this._txData.utxoEntries || this._txData.utxoEntries.length !== this._txData.inputs.length) { + throw new Error('UTXO entries required for sighash computation'); + } + + const parts: Buffer[] = []; + + // 1. Version (2 bytes LE) + parts.push(uint16ToLE(this._txData.version)); + + // 2. Hash of all previous outpoints + const outpointsHash = this.hashOutpoints(); + parts.push(outpointsHash); + + // 3. Hash of all sequences + const sequencesHash = this.hashSequences(); + parts.push(sequencesHash); + + // 4. Hash of all sigop counts + const sigopCountsHash = this.hashSigOpCounts(); + parts.push(sigopCountsHash); + + // 5. This input's outpoint + const input = this._txData.inputs[inputIndex]; + parts.push(serializeTxId(input.previousOutpoint.transactionId)); + parts.push(uint32ToLE(input.previousOutpoint.index)); + + // 6. This input's UTXO script public key + const utxo = this._txData.utxoEntries[inputIndex]; + const scriptBytes = Buffer.from(utxo.scriptPublicKey.script, 'hex'); + parts.push(uint16ToLE(utxo.scriptPublicKey.version)); + parts.push(writeVarInt(scriptBytes.length)); + parts.push(scriptBytes); + + // 7. This input's value (8 bytes LE) + parts.push(uint64ToLE(utxo.amount)); + + // 8. This input's block DAA score (8 bytes LE) + parts.push(uint64ToLE(utxo.blockDaaScore)); + + // 9. This input's is_coinbase (1 byte) + parts.push(Buffer.from([utxo.isCoinbase ? 1 : 0])); + + // 10. This input's sequence (8 bytes LE) + parts.push(uint64ToLE(input.sequence)); + + // 11. This input's sigop count (1 byte) + parts.push(Buffer.from([input.sigOpCount])); + + // 12. Hash of all outputs + const outputsHash = this.hashOutputs(); + parts.push(outputsHash); + + // 13. LockTime (8 bytes LE) + parts.push(uint64ToLE(this._txData.lockTime)); + + // 14. SubnetworkID (20 bytes) + parts.push(Buffer.from(this._txData.subnetworkId, 'hex')); + + // 15. Gas (8 bytes LE) + parts.push(uint64ToLE(this._txData.gas)); + + // 16. Payload hash + const payload = this._txData.payload ? Buffer.from(this._txData.payload, 'hex') : Buffer.alloc(0); + parts.push(hashBuf(payload)); + + // 17. Sighash type (1 byte) + parts.push(Buffer.from([sighashType])); + + return blake2bWithTag('TransactionSigningHash', Buffer.concat(parts)); + } + + private hashOutpoints(): Buffer { + const parts: Buffer[] = []; + for (const input of this._txData.inputs) { + parts.push(serializeTxId(input.previousOutpoint.transactionId)); + parts.push(uint32ToLE(input.previousOutpoint.index)); + } + return hashBuf(Buffer.concat(parts)); + } + + private hashSequences(): Buffer { + const parts: Buffer[] = []; + for (const input of this._txData.inputs) { + parts.push(uint64ToLE(input.sequence)); + } + return hashBuf(Buffer.concat(parts)); + } + + private hashSigOpCounts(): Buffer { + const parts: Buffer[] = []; + for (const input of this._txData.inputs) { + parts.push(Buffer.from([input.sigOpCount])); + } + return hashBuf(Buffer.concat(parts)); + } + + private hashOutputs(): Buffer { + const parts: Buffer[] = []; + for (const output of this._txData.outputs) { + parts.push(serializeOutput(output)); + } + return hashBuf(Buffer.concat(parts)); + } + + /** + * Serialize the transaction to a hex string (with signatures). + * Used for broadcasting to the network. + */ + toBroadcastFormat(): string { + return this.serialize().toString('hex'); + } + + /** + * Serialize the transaction to a Buffer (with signatures). + */ + serialize(): Buffer { + const parts: Buffer[] = []; + + // Version (2 bytes LE) + parts.push(uint16ToLE(this._txData.version)); + + // Inputs + parts.push(writeVarInt(this._txData.inputs.length)); + for (const input of this._txData.inputs) { + parts.push(serializeTxId(input.previousOutpoint.transactionId)); + parts.push(uint32ToLE(input.previousOutpoint.index)); + const sigScript = input.signatureScript ? Buffer.from(input.signatureScript, 'hex') : Buffer.alloc(0); + parts.push(writeVarInt(sigScript.length)); + if (sigScript.length > 0) parts.push(sigScript); + parts.push(uint64ToLE(input.sequence)); + parts.push(Buffer.from([input.sigOpCount])); + } + + // Outputs + parts.push(writeVarInt(this._txData.outputs.length)); + for (const output of this._txData.outputs) { + parts.push(serializeOutput(output)); + } + + // LockTime (8 bytes LE) + parts.push(uint64ToLE(this._txData.lockTime)); + + // SubnetworkID (20 bytes) + parts.push(Buffer.from(this._txData.subnetworkId, 'hex')); + + // Gas (8 bytes LE) + parts.push(uint64ToLE(this._txData.gas)); + + // Payload + const payload = this._txData.payload ? Buffer.from(this._txData.payload, 'hex') : Buffer.alloc(0); + parts.push(writeVarInt(payload.length)); + if (payload.length > 0) parts.push(payload); + + return Buffer.concat(parts); + } + + /** + * Deserialize a transaction from hex. + */ + static fromHex(hex: string): Transaction { + return Transaction.deserialize(Buffer.from(hex, 'hex')); + } + + /** + * Deserialize a transaction from a Buffer. + */ + static deserialize(buf: Buffer): Transaction { + let offset = 0; + + const readUInt16LE = (): number => { + const val = buf.readUInt16LE(offset); + offset += 2; + return val; + }; + + const readUInt32LE = (): number => { + const val = buf.readUInt32LE(offset); + offset += 4; + return val; + }; + + const readUInt64LE = (): bigint => { + const val = buf.readBigUInt64LE(offset); + offset += 8; + return val; + }; + + const readVarInt = (): number => { + const first = buf[offset++]; + if (first < 0xfd) return first; + if (first === 0xfd) { + const val = buf.readUInt16LE(offset); + offset += 2; + return val; + } + if (first === 0xfe) { + const val = buf.readUInt32LE(offset); + offset += 4; + return val; + } + const val = Number(buf.readBigUInt64LE(offset)); + offset += 8; + return val; + }; + + const readBytes = (n: number): Buffer => { + const bytes = buf.slice(offset, offset + n); + offset += n; + return bytes; + }; + + const version = readUInt16LE(); + const inputCount = readVarInt(); + const inputs: KaspaTransactionInput[] = []; + + for (let i = 0; i < inputCount; i++) { + const txId = readBytes(32).toString('hex'); + const index = readUInt32LE(); + const scriptLen = readVarInt(); + const signatureScript = scriptLen > 0 ? readBytes(scriptLen).toString('hex') : ''; + const sequence = readUInt64LE(); + const sigOpCount = buf[offset++]; + inputs.push({ + previousOutpoint: { transactionId: txId, index }, + signatureScript, + sequence, + sigOpCount, + }); + } + + const outputCount = readVarInt(); + const outputs: KaspaTransactionOutput[] = []; + + for (let i = 0; i < outputCount; i++) { + const value = readUInt64LE(); + const scriptVersion = readUInt16LE(); + const scriptLen = readVarInt(); + const script = readBytes(scriptLen).toString('hex'); + outputs.push({ + value, + scriptPublicKey: { version: scriptVersion, script }, + }); + } + + const lockTime = readUInt64LE(); + const subnetworkId = readBytes(20).toString('hex'); + const gas = readUInt64LE(); + const payloadLen = readVarInt(); + const payload = payloadLen > 0 ? readBytes(payloadLen).toString('hex') : ''; + + return new Transaction({ version, inputs, outputs, lockTime, subnetworkId, gas, payload }); + } + + /** + * Explain this transaction in human-readable form. + */ + explainTransaction(): KaspaTransactionExplanation { + const outputs = this._txData.outputs.map((o) => ({ + address: '', // address derivation from script requires additional context + amount: o.value.toString(), + })); + + const totalOut = this._txData.outputs.reduce((sum, o) => sum + o.value, BigInt(0)); + + return { + id: this.transactionId(), + outputs, + outputAmount: totalOut.toString(), + fee: { fee: '0' }, // fee = total in - total out, requires UTXO entries + type: 'transfer', + changeOutputs: [], + changeAmount: '0', + }; + } + + /** @inheritdoc */ + toJson(): KaspaTransactionData { + return { + ...this._txData, + inputs: this._txData.inputs.map((i) => ({ ...i })), + outputs: this._txData.outputs.map((o) => ({ ...o })), + }; + } + + /** Signatures on this transaction (as hex strings) */ + get signature(): string[] { + return this._txData.inputs.map((i) => i.signatureScript).filter(Boolean); + } +} + +/** + * Serialize a transaction output. + */ +function serializeOutput(output: KaspaTransactionOutput): Buffer { + const scriptBytes = Buffer.from(output.scriptPublicKey.script, 'hex'); + return Buffer.concat([ + uint64ToLE(output.value), + uint16ToLE(output.scriptPublicKey.version), + writeVarInt(scriptBytes.length), + scriptBytes, + ]); +} diff --git a/modules/sdk-coin-kas/src/lib/transactionBuilder.ts b/modules/sdk-coin-kas/src/lib/transactionBuilder.ts new file mode 100644 index 0000000000..00e34323f9 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/transactionBuilder.ts @@ -0,0 +1,313 @@ +/** + * Kaspa (KAS) Transaction Builder + * + * Builds Kaspa UTXO transactions with inputs and outputs. + * Handles input selection, change output calculation, and signing. + */ + +import { BaseTransactionBuilder, BuildTransactionError, SigningError, BaseKey, BaseAddress } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import BigNumber from 'bignumber.js'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { Transaction } from './transaction'; +import { KaspaTransactionInput, KaspaTransactionOutput, KaspaUtxoEntry, KaspaScriptPublicKey } from './iface'; +import { + DEFAULT_SEQUENCE, + DEFAULT_GAS, + DEFAULT_LOCK_TIME, + NATIVE_SUBNETWORK_ID, + TX_VERSION, + SIGHASH_ALL, + MINIMUM_FEE, +} from './constants'; +import { isValidAddress, buildScriptPublicKey, kaspaDecodeAddress } from './utils'; +import { KeyPair } from './keyPair'; + +/** + * Build a P2PK signature script from a Schnorr signature (64 bytes). + * Script: OP_DATA_65 (0x41) + signature (64 bytes) + sighash_type (1 byte) + */ +function buildSignatureScript(signature: Buffer, sighashType: number): Buffer { + // Signature script: OP_DATA_65 (0x41 = push 65 bytes), sig (64 bytes), sighash_type (1 byte) + const opDataByte = 0x41; // push 65 bytes + return Buffer.concat([Buffer.from([opDataByte]), signature, Buffer.from([sighashType])]); +} + +/** + * Extract x-only public key from address (for script building). + * Kaspa P2PK address payload IS the x-only public key. + */ +function addressToXOnlyPubKey(address: string): Buffer { + const { payload } = kaspaDecodeAddress(address); + return payload; +} + +/** + * Build a P2PK script public key from an address. + */ +function addressToScriptPublicKey(address: string): KaspaScriptPublicKey { + const xOnlyPubKey = addressToXOnlyPubKey(address); + return buildScriptPublicKey(xOnlyPubKey); +} + +export class TransactionBuilder extends BaseTransactionBuilder { + protected _transaction: Transaction; + protected _inputs: KaspaTransactionInput[] = []; + protected _outputs: KaspaTransactionOutput[] = []; + protected _utxoEntries: KaspaUtxoEntry[] = []; + protected _fee: bigint = MINIMUM_FEE; + protected _changeAddress?: string; + protected _sender?: string; + + constructor(coin: Readonly) { + super(coin); + this._transaction = new Transaction(); + } + + /** @inheritdoc */ + protected get transaction(): Transaction { + return this._transaction; + } + + /** @inheritdoc */ + protected set transaction(tx: Transaction) { + this._transaction = tx; + } + + /** + * Initialize builder from an existing serialized transaction hex. + */ + from(rawTx: string): this { + try { + this._transaction = Transaction.fromHex(rawTx); + this._inputs = [...this._transaction.txData.inputs]; + this._outputs = [...this._transaction.txData.outputs]; + if (this._transaction.txData.utxoEntries) { + this._utxoEntries = [...this._transaction.txData.utxoEntries]; + } + } catch (e) { + throw new BuildTransactionError(`Failed to deserialize transaction: ${e.message}`); + } + return this; + } + + /** + * Set the sender address (used as change address if not specified separately). + */ + sender(address: string): this { + if (!isValidAddress(address)) { + throw new BuildTransactionError(`Invalid sender address: ${address}`); + } + this._sender = address; + return this; + } + + /** + * Set the change address. Defaults to sender if not set. + */ + changeAddress(address: string): this { + if (!isValidAddress(address)) { + throw new BuildTransactionError(`Invalid change address: ${address}`); + } + this._changeAddress = address; + return this; + } + + /** + * Add a recipient output. + */ + to(address: string, amount: bigint | string): this { + if (!isValidAddress(address)) { + throw new BuildTransactionError(`Invalid recipient address: ${address}`); + } + const amountBig = typeof amount === 'string' ? BigInt(amount) : amount; + if (amountBig <= BigInt(0)) { + throw new BuildTransactionError('Amount must be positive'); + } + this._outputs.push({ + value: amountBig, + scriptPublicKey: addressToScriptPublicKey(address), + }); + return this; + } + + /** + * Set the transaction fee in sompi. + */ + fee(fee: bigint | string): this { + const feeBig = typeof fee === 'string' ? BigInt(fee) : fee; + if (feeBig < BigInt(0)) { + throw new BuildTransactionError('Fee must be non-negative'); + } + this._fee = feeBig; + return this; + } + + /** + * Add a UTXO as a transaction input. + */ + addUtxo(utxo: KaspaUtxoEntry): this { + this._utxoEntries.push(utxo); + this._inputs.push({ + previousOutpoint: { + transactionId: utxo.transactionId, + index: utxo.index, + }, + signatureScript: '', + sequence: DEFAULT_SEQUENCE, + sigOpCount: 1, + }); + return this; + } + + /** + * Add multiple UTXOs as inputs. + */ + addUtxos(utxos: KaspaUtxoEntry[]): this { + utxos.forEach((utxo) => this.addUtxo(utxo)); + return this; + } + + /** + * Sign the transaction with a private key. + * Signs all inputs using Schnorr signatures. + */ + sign(params: { key: string }): this { + if (this._inputs.length === 0) { + throw new SigningError('No inputs to sign'); + } + if (this._utxoEntries.length !== this._inputs.length) { + throw new SigningError('UTXO entries must be provided for all inputs before signing'); + } + + const keyPair = new KeyPair({ prv: params.key }); + const privKeyHex = keyPair.getKeys().prv; + if (!privKeyHex) { + throw new SigningError('Private key required for signing'); + } + + const privKeyBytes = Buffer.from(privKeyHex, 'hex'); + + // Sign each input + for (let i = 0; i < this._inputs.length; i++) { + const sighash = this._transaction.computeSighash(i, SIGHASH_ALL); + // Schnorr sign + const sig = secp256k1.sign(sighash, privKeyBytes, { lowS: false }); + const sigBytes = Buffer.from(sig.toCompactRawBytes()); + this._inputs[i].signatureScript = buildSignatureScript(sigBytes, SIGHASH_ALL).toString('hex'); + } + + return this; + } + + /** @inheritdoc */ + protected fromImplementation(rawTransaction: string): Transaction { + return Transaction.fromHex(rawTransaction); + } + + /** @inheritdoc */ + protected signImplementation(key: BaseKey): Transaction { + this.sign({ key: key.key }); + return this._transaction; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + return this.build(); + } + + /** @inheritdoc */ + validateKey(key: BaseKey): void { + if (!key.key || typeof key.key !== 'string' || !/^[0-9a-fA-F]{64}$/.test(key.key)) { + throw new BuildTransactionError('Invalid key: must be a 32-byte hex string'); + } + } + + /** @inheritdoc */ + validateAddress(address: BaseAddress): void { + if (!isValidAddress(address.address)) { + throw new BuildTransactionError(`Invalid address: ${address.address}`); + } + } + + /** @inheritdoc */ + validateValue(value: BigNumber): void { + if (value.isLessThanOrEqualTo(0)) { + throw new BuildTransactionError('Value must be greater than 0'); + } + } + + /** @inheritdoc */ + validateRawTransaction(rawTransaction: string): void { + if (!rawTransaction || typeof rawTransaction !== 'string') { + throw new BuildTransactionError('Invalid raw transaction'); + } + try { + Transaction.fromHex(rawTransaction); + } catch (e) { + throw new BuildTransactionError(`Invalid transaction hex: ${e.message}`); + } + } + + /** + * Validate all required fields are present. + */ + validateTransaction(): void { + if (this._inputs.length === 0) { + throw new BuildTransactionError('At least one input (UTXO) is required'); + } + if (this._outputs.length === 0) { + throw new BuildTransactionError('At least one output is required'); + } + + // Check that total inputs cover outputs + fee + const totalIn = this._utxoEntries.reduce((sum, u) => sum + u.amount, BigInt(0)); + const totalOut = this._outputs.reduce((sum, o) => sum + o.value, BigInt(0)); + const required = totalOut + this._fee; + + if (totalIn < required) { + throw new BuildTransactionError(`Insufficient funds: inputs=${totalIn}, outputs+fee=${required}`); + } + } + + /** + * Build the transaction, adding a change output if needed. + * If UTXOs are provided, calculates and adds change. If not (e.g. deserialization), + * uses the existing outputs as-is. + */ + async build(): Promise { + if (this._utxoEntries.length > 0) { + // Calculate total input and output amounts + const totalIn = this._utxoEntries.reduce((sum, u) => sum + u.amount, BigInt(0)); + const totalOut = this._outputs.reduce((sum, o) => sum + o.value, BigInt(0)); + const change = totalIn - totalOut - this._fee; + + // Add change output if there is any change + const changeAddr = this._changeAddress || this._sender; + if (change > BigInt(0)) { + if (!changeAddr) { + throw new BuildTransactionError('Change address or sender address required for change output'); + } + this._outputs.push({ + value: change, + scriptPublicKey: addressToScriptPublicKey(changeAddr), + }); + } else if (change < BigInt(0)) { + throw new BuildTransactionError(`Insufficient funds: inputs=${totalIn}, outputs=${totalOut}, fee=${this._fee}`); + } + } + + this._transaction = new Transaction({ + version: TX_VERSION, + inputs: this._inputs, + outputs: this._outputs, + lockTime: DEFAULT_LOCK_TIME, + subnetworkId: NATIVE_SUBNETWORK_ID.toString('hex'), + gas: DEFAULT_GAS, + payload: '', + utxoEntries: this._utxoEntries.length > 0 ? this._utxoEntries : undefined, + }); + + return this._transaction; + } +} diff --git a/modules/sdk-coin-kas/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-kas/src/lib/transactionBuilderFactory.ts new file mode 100644 index 0000000000..cb0e0e2c72 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/transactionBuilderFactory.ts @@ -0,0 +1,34 @@ +/** + * Kaspa (KAS) Transaction Builder Factory + */ + +import { BaseTransactionBuilderFactory, InvalidTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { TransactionBuilder } from './transactionBuilder'; + +export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { + constructor(private _coin: Readonly) { + super(_coin); + } + + /** + * Get a transaction builder for standard transfers. + */ + getTransferBuilder(): TransactionBuilder { + return new TransactionBuilder(this._coin); + } + + /** + * Initialize a builder from an existing serialized transaction. + */ + from(rawTx: string): TransactionBuilder { + const builder = this.getTransferBuilder(); + builder.from(rawTx); + return builder; + } + + /** @inheritdoc */ + getWalletInitializationBuilder(): TransactionBuilder { + throw new InvalidTransactionError('Kaspa does not support wallet initialization transactions'); + } +} diff --git a/modules/sdk-coin-kas/src/lib/utils.ts b/modules/sdk-coin-kas/src/lib/utils.ts new file mode 100644 index 0000000000..054940b031 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/utils.ts @@ -0,0 +1,329 @@ +/** + * Kaspa (KAS) Utility Functions + * + * Address validation, encoding, script generation, and other utilities. + * Kaspa uses bech32m-encoded addresses with x-only secp256k1 public keys. + */ + +import { BaseUtils } from '@bitgo/sdk-core'; +import { + MAINNET_HRP, + TESTNET_HRP, + SIMNET_HRP, + DEVNET_HRP, + ADDRESS_VERSION_PUBKEY, + ADDRESS_VERSION_SCRIPTHASH, + OP_DATA_32, + OP_CHECKSIG, + X_ONLY_PUBLIC_KEY_SIZE, + COMPRESSED_PUBLIC_KEY_SIZE, +} from './constants'; +import { KaspaScriptPublicKey } from './iface'; + +const VALID_HRPS = new Set([MAINNET_HRP, TESTNET_HRP, SIMNET_HRP, DEVNET_HRP]); + +// ─── Kaspa CashAddr Encoding ────────────────────────────────────────────────── +// Kaspa uses cashaddr-style addresses with ':' separator (not standard bech32/bech32m) +// Reference: https://github.com/kaspanet/kaspad/blob/master/domain/util/address + +const KASPA_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const KASPA_CHARSET_MAP = new Map(KASPA_CHARSET.split('').map((c, i) => [c, i])); +const KASPA_GENERATOR = [0x98f2bc8e61n, 0x79b76d99e2n, 0xf33e5fb3c4n, 0xae2eabe2a8n, 0x1e4f43e470n]; + +function kaspaPolymod(data: number[]): bigint { + let c = 1n; + for (const d of data) { + const c0 = c >> 35n; + c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d); + for (let i = 0; i < 5; i++) { + if ((c0 >> BigInt(i)) & 1n) { + c ^= KASPA_GENERATOR[i]; + } + } + } + return c ^ 1n; +} + +function kaspaExpandPrefix(prefix: string): number[] { + const result: number[] = []; + for (const c of prefix) { + result.push(c.charCodeAt(0) & 0x1f); + } + result.push(0); + return result; +} + +function kaspaConvertBits(data: number[], fromBits: number, toBits: number, pad: boolean): number[] { + let acc = 0; + let bits = 0; + const result: number[] = []; + const maxv = (1 << toBits) - 1; + for (const value of data) { + acc = (acc << fromBits) | value; + bits += fromBits; + while (bits >= toBits) { + bits -= toBits; + result.push((acc >> bits) & maxv); + } + } + if (pad && bits > 0) { + result.push((acc << (toBits - bits)) & maxv); + } + return result; +} + +function kaspaCreateChecksum(prefix: string, data: number[]): number[] { + const enc = [...kaspaExpandPrefix(prefix), ...data, 0, 0, 0, 0, 0, 0, 0, 0]; + const mod = kaspaPolymod(enc); + const result: number[] = []; + for (let i = 0; i < 8; i++) { + result.push(Number((mod >> (5n * BigInt(7 - i))) & 31n)); + } + return result; +} + +function kaspaVerifyChecksum(prefix: string, payload: number[]): boolean { + return kaspaPolymod([...kaspaExpandPrefix(prefix), ...payload]) === 0n; +} + +/** + * Encode a Kaspa address in cashaddr format: `:` + */ +export function kaspaEncodeAddress(hrp: string, version: number, payload: Buffer): string { + const data = kaspaConvertBits([version, ...payload], 8, 5, true); + const checksum = kaspaCreateChecksum(hrp, data); + const encoded = [...data, ...checksum].map((d) => KASPA_CHARSET[d]).join(''); + return `${hrp}:${encoded}`; +} + +/** + * Decode a Kaspa cashaddr address. + */ +export function kaspaDecodeAddress(address: string): { prefix: string; version: number; payload: Buffer } { + const colonIdx = address.indexOf(':'); + if (colonIdx < 0) throw new Error('Missing colon separator in Kaspa address'); + const hrp = address.slice(0, colonIdx); + const dataStr = address.slice(colonIdx + 1); + + const words: number[] = []; + for (const c of dataStr) { + const val = KASPA_CHARSET_MAP.get(c); + if (val === undefined) throw new Error(`Invalid character in Kaspa address: ${c}`); + words.push(val); + } + + if (!kaspaVerifyChecksum(hrp, words)) { + throw new Error('Invalid Kaspa address checksum'); + } + + const dataWithoutChecksum = words.slice(0, -8); + const decoded = kaspaConvertBits(dataWithoutChecksum, 5, 8, false); + const version = decoded[0]; + const payload = Buffer.from(decoded.slice(1)); + + return { prefix: hrp, version, payload }; +} + +export class Utils implements BaseUtils { + /** + * Validate a Kaspa address (bech32m encoded) + */ + isValidAddress(address: string): boolean { + return isValidAddress(address); + } + + /** + * Validate a Kaspa transaction hex + */ + isValidTransactionId(txId: string): boolean { + return /^[0-9a-fA-F]{64}$/.test(txId); + } + + /** + * Validate a signature (Schnorr = 64 bytes) + */ + isValidSignature(signature: string): boolean { + return /^[0-9a-fA-F]{128}$/.test(signature); + } + + /** + * Validate a block hash + */ + isValidBlockId(blockId: string): boolean { + return /^[0-9a-fA-F]{64}$/.test(blockId); + } + + /** + * Validate a secp256k1 public key + */ + isValidPublicKey(pub: string): boolean { + return isValidPublicKey(pub); + } + + /** + * Validate a secp256k1 private key + */ + isValidPrivateKey(prv: string): boolean { + return isValidPrivateKey(prv); + } +} + +/** + * Validate a Kaspa address. + * Must be cashaddr-encoded (hrp:...) with one of the known Kaspa HRPs. + */ +export function isValidAddress(address: string | string[]): boolean { + if (Array.isArray(address)) { + return address.every((a) => isValidAddress(a)); + } + if (typeof address !== 'string' || !address) return false; + try { + const { prefix, version, payload } = kaspaDecodeAddress(address); + if (!VALID_HRPS.has(prefix)) return false; + if (version !== ADDRESS_VERSION_PUBKEY && version !== ADDRESS_VERSION_SCRIPTHASH) return false; + // P2PK: 32-byte x-only public key + // P2SH: 32-byte script hash + return payload.length === X_ONLY_PUBLIC_KEY_SIZE; + } catch { + return false; + } +} + +/** + * Validate a secp256k1 public key (compressed 33-byte hex). + */ +export function isValidPublicKey(pub: string): boolean { + if (typeof pub !== 'string') return false; + if (!/^[0-9a-fA-F]+$/.test(pub)) return false; + if (pub.length === COMPRESSED_PUBLIC_KEY_SIZE * 2) { + // Compressed: must start with 02 or 03 + return pub.startsWith('02') || pub.startsWith('03'); + } + if (pub.length === X_ONLY_PUBLIC_KEY_SIZE * 2) { + // x-only 32-byte key + return true; + } + return false; +} + +/** + * Validate a 32-byte hex private key. + */ +export function isValidPrivateKey(prv: string): boolean { + return typeof prv === 'string' && /^[0-9a-fA-F]{64}$/.test(prv); +} + +/** + * Derive a Kaspa address from a compressed secp256k1 public key. + * + * Kaspa P2PK address format: + * kaspaEncodeAddress(hrp, version=0x00, x_only_pubkey_32bytes) + */ +export function publicKeyToAddress(compressedPubKey: Buffer, hrp: string = MAINNET_HRP): string { + if (compressedPubKey.length !== COMPRESSED_PUBLIC_KEY_SIZE) { + throw new Error(`Expected 33-byte compressed public key, got ${compressedPubKey.length} bytes`); + } + // Extract x-only public key (skip the 0x02/0x03 prefix byte) + const xOnlyPubKey = compressedPubKey.slice(1); + return kaspaEncodeAddress(hrp, ADDRESS_VERSION_PUBKEY, xOnlyPubKey); +} + +/** + * Build a P2PK script public key from an x-only public key. + * + * Script: OP_DATA_32 (0x20) + x_only_pubkey (32 bytes) + OP_CHECKSIG (0xAC) + */ +export function buildScriptPublicKey(xOnlyPubKey: Buffer): KaspaScriptPublicKey { + if (xOnlyPubKey.length !== X_ONLY_PUBLIC_KEY_SIZE) { + throw new Error(`Expected 32-byte x-only public key, got ${xOnlyPubKey.length} bytes`); + } + const script = Buffer.concat([Buffer.from([OP_DATA_32]), xOnlyPubKey, Buffer.from([OP_CHECKSIG])]); + return { + version: 0, + script: script.toString('hex'), + }; +} + +/** + * Extract the HRP from a Kaspa address. + */ +export function getHrpFromAddress(address: string): string { + const colonIdx = address.indexOf(':'); + if (colonIdx < 0) throw new Error('Not a valid Kaspa address'); + return address.slice(0, colonIdx); +} + +/** + * Determine if an address is mainnet or testnet. + */ +export function isMainnetAddress(address: string): boolean { + try { + return getHrpFromAddress(address) === MAINNET_HRP; + } catch { + return false; + } +} + +/** + * Convert a bigint to an 8-byte little-endian buffer. + */ +export function uint64ToLE(value: bigint): Buffer { + const buf = Buffer.alloc(8); + buf.writeBigUInt64LE(value); + return buf; +} + +/** + * Convert a number to a 4-byte little-endian buffer. + */ +export function uint32ToLE(value: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeUInt32LE(value); + return buf; +} + +/** + * Convert a number to a 2-byte little-endian buffer. + */ +export function uint16ToLE(value: number): Buffer { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(value); + return buf; +} + +/** + * Write a variable-length integer (varint) as used in Kaspa serialization. + * Kaspa uses the same varint encoding as Bitcoin. + */ +export function writeVarInt(value: number | bigint): Buffer { + const n = typeof value === 'bigint' ? value : BigInt(value); + if (n < BigInt(0xfd)) { + return Buffer.from([Number(n)]); + } else if (n <= BigInt(0xffff)) { + const buf = Buffer.alloc(3); + buf[0] = 0xfd; + buf.writeUInt16LE(Number(n), 1); + return buf; + } else if (n <= BigInt(0xffffffff)) { + const buf = Buffer.alloc(5); + buf[0] = 0xfe; + buf.writeUInt32LE(Number(n), 1); + return buf; + } else { + const buf = Buffer.alloc(9); + buf[0] = 0xff; + buf.writeBigUInt64LE(n, 1); + return buf; + } +} + +/** + * Serialize a transaction ID (reverse byte order, like Bitcoin). + * Note: Kaspa does NOT reverse transaction IDs unlike Bitcoin. + */ +export function serializeTxId(txId: string): Buffer { + return Buffer.from(txId, 'hex'); +} + +const utilsInstance = new Utils(); +export default utilsInstance; diff --git a/modules/sdk-coin-kas/src/register.ts b/modules/sdk-coin-kas/src/register.ts new file mode 100644 index 0000000000..efbf102b03 --- /dev/null +++ b/modules/sdk-coin-kas/src/register.ts @@ -0,0 +1,8 @@ +import { BitGoBase } from '@bitgo/sdk-core'; +import { Kas } from './kas'; +import { TKas } from './tkas'; + +export const register = (sdk: BitGoBase): void => { + sdk.register('kas', Kas.createInstance); + sdk.register('tkas', TKas.createInstance); +}; diff --git a/modules/sdk-coin-kas/src/tkas.ts b/modules/sdk-coin-kas/src/tkas.ts new file mode 100644 index 0000000000..e28943ad45 --- /dev/null +++ b/modules/sdk-coin-kas/src/tkas.ts @@ -0,0 +1,17 @@ +/** + * Kaspa Testnet (tKAS) Coin Class + */ + +import { BaseCoin, BitGoBase } from '@bitgo/sdk-core'; +import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { Kas } from './kas'; + +export class TKas extends Kas { + constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { + super(bitgo, staticsCoin); + } + + static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly): BaseCoin { + return new TKas(bitgo, staticsCoin); + } +} diff --git a/modules/sdk-coin-kas/test/fixtures/kas.fixtures.ts b/modules/sdk-coin-kas/test/fixtures/kas.fixtures.ts new file mode 100644 index 0000000000..1b7e389fd4 --- /dev/null +++ b/modules/sdk-coin-kas/test/fixtures/kas.fixtures.ts @@ -0,0 +1,42 @@ +/** + * Kaspa (KAS) Test Fixtures + * + * Pre-generated test vectors for unit tests. + * Keys and addresses are for testing only — do NOT use on mainnet. + */ + +export const TEST_ACCOUNT = { + /** Raw private key hex (32 bytes) */ + privateKey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + /** Compressed secp256k1 public key hex (33 bytes) */ + publicKey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + /** Mainnet Kaspa address (bech32m) */ + mainnetAddress: 'kaspa:qpfwafqhhryvz3x960f7qx34rmkxq9yxqzv4mrp9c9l6m99vx4zszvp5l9hs', +}; + +export const TEST_ACCOUNT_2 = { + privateKey: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', + publicKey: '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5', + mainnetAddress: 'kaspa:qpjaxqgrwacgsyfgwnzl5ae2kd6cr0e63unp0gsprp2em67sq6y5sz4tln3sd', +}; + +/** Example UTXO for testing */ +export const TEST_UTXO = { + transactionId: 'aabbccdd00112233445566778899aabbccdd00112233445566778899aabbccdd', + index: 0, + amount: BigInt('100000000'), // 1 KAS in sompi + scriptPublicKey: { + version: 0, + script: '20' + 'be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + 'ac', + }, + blockDaaScore: BigInt('1000'), + isCoinbase: false, +}; + +/** Example transaction parameters */ +export const TEST_TX_PARAMS = { + sender: 'kaspa:qpfwafqhhryvz3x960f7qx34rmkxq9yxqzv4mrp9c9l6m99vx4zszvp5l9hs', + recipient: 'kaspa:qpjaxqgrwacgsyfgwnzl5ae2kd6cr0e63unp0gsprp2em67sq6y5sz4tln3sd', + amount: BigInt('50000000'), // 0.5 KAS + fee: BigInt('10000'), // 0.0001 KAS fee +}; diff --git a/modules/sdk-coin-kas/test/unit/coin.test.ts b/modules/sdk-coin-kas/test/unit/coin.test.ts new file mode 100644 index 0000000000..24c1d396fc --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/coin.test.ts @@ -0,0 +1,126 @@ +import * as should from 'should'; +import { Kas } from '../../src/kas'; +import { TKas } from '../../src/tkas'; + +// Minimal mock for BitGoBase +const mockBitGo = { + url: () => '', + microservicesUrl: () => '', +} as any; +const mockStaticsCoin = { + name: 'kas', + fullName: 'Kaspa', + family: 'kas', + decimalPlaces: 8, + network: { type: 'mainnet' }, +} as any; + +const mockStaticsTestnetCoin = { + ...mockStaticsCoin, + name: 'tkas', + fullName: 'Kaspa Testnet', + network: { type: 'testnet' }, +} as any; + +describe('Kaspa Coin Class', () => { + let kas: Kas; + let tkas: TKas; + + before(() => { + kas = new Kas(mockBitGo, mockStaticsCoin); + tkas = new TKas(mockBitGo, mockStaticsTestnetCoin); + }); + + describe('getChain', () => { + it('should return "kas" for mainnet', () => { + kas.getChain().should.equal('kas'); + }); + + it('should return "tkas" for testnet', () => { + tkas.getChain().should.equal('tkas'); + }); + }); + + describe('getFullName', () => { + it('should return the full name', () => { + kas.getFullName().should.equal('Kaspa'); + }); + }); + + describe('getBaseFactor', () => { + it('should return 10^8 = 100000000', () => { + Number(kas.getBaseFactor()).should.equal(100000000); + }); + }); + + describe('isValidAddress', () => { + it('should return true for valid addresses', () => { + // Generate a valid address + const { KeyPair } = require('../../src/lib/keyPair'); + const kp = new KeyPair({ prv: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' }); + const addr = kp.getAddress('mainnet'); + kas.isValidAddress(addr).should.be.true(); + }); + + it('should return false for invalid addresses', () => { + kas.isValidAddress('not-an-address').should.be.false(); + kas.isValidAddress('').should.be.false(); + }); + }); + + describe('isValidPub', () => { + it('should return true for valid compressed public keys', () => { + kas.isValidPub('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798').should.be.true(); + }); + + it('should return false for invalid public keys', () => { + kas.isValidPub('invalid').should.be.false(); + }); + }); + + describe('isValidPrv', () => { + it('should return true for valid private keys', () => { + kas.isValidPrv('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef').should.be.true(); + }); + + it('should return false for invalid private keys', () => { + kas.isValidPrv('tooshort').should.be.false(); + }); + }); + + describe('generateKeyPair', () => { + it('should generate a random key pair', () => { + const kp = kas.generateKeyPair(); + should.exist(kp.pub); + should.exist(kp.prv); + }); + + it('should generate a key pair from seed', () => { + const seed = Buffer.alloc(32, 0x42); + const kp = kas.generateKeyPair(seed); + const kp2 = kas.generateKeyPair(seed); + kp.pub!.should.equal(kp2.pub); + }); + }); + + describe('supportsTss / getMPCAlgorithm', () => { + it('should support TSS', () => { + kas.supportsTss().should.be.true(); + }); + + it('should use ECDSA as MPC algorithm', () => { + kas.getMPCAlgorithm().should.equal('ecdsa'); + }); + }); + + describe('TKas', () => { + it('should be a subclass of Kas', () => { + tkas.should.be.instanceof(Kas); + }); + + it('createInstance should return TKas', () => { + const instance = TKas.createInstance(mockBitGo, mockStaticsTestnetCoin); + instance.should.be.instanceof(TKas); + }); + }); +}); diff --git a/modules/sdk-coin-kas/test/unit/keyPair.test.ts b/modules/sdk-coin-kas/test/unit/keyPair.test.ts new file mode 100644 index 0000000000..469c95737a --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/keyPair.test.ts @@ -0,0 +1,82 @@ +import * as should from 'should'; +import { KeyPair } from '../../src/lib/keyPair'; +import { isValidAddress, isValidPublicKey } from '../../src/lib/utils'; + +describe('Kaspa KeyPair', () => { + const validPrivateKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + + describe('constructor', () => { + it('should generate a random key pair with no arguments', () => { + const kp = new KeyPair(); + const keys = kp.getKeys(); + should.exist(keys.pub); + should.exist(keys.prv); + keys.pub.length.should.equal(66); // 33 bytes hex + keys.prv!.length.should.equal(64); // 32 bytes hex + }); + + it('should create key pair from private key', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const keys = kp.getKeys(); + keys.prv!.should.equal(validPrivateKey); + should.exist(keys.pub); + // Compressed public key must start with 02 or 03 + (keys.pub.startsWith('02') || keys.pub.startsWith('03')).should.be.true(); + }); + + it('should create key pair from public key', () => { + const kp1 = new KeyPair({ prv: validPrivateKey }); + const pub = kp1.getKeys().pub; + const kp2 = new KeyPair({ pub }); + kp2.getKeys().pub.should.equal(pub); + }); + + it('should throw on invalid private key', () => { + should.throws(() => new KeyPair({ prv: 'not-valid' })); + }); + }); + + describe('getAddress', () => { + it('should derive a valid mainnet address', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const address = kp.getAddress('mainnet'); + address.should.startWith('kaspa:'); + isValidAddress(address).should.be.true(); + }); + + it('should derive a valid testnet address', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const address = kp.getAddress('testnet'); + address.should.startWith('kaspatest:'); + isValidAddress(address).should.be.true(); + }); + + it('should derive different addresses for mainnet vs testnet', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const mainnet = kp.getAddress('mainnet'); + const testnet = kp.getAddress('testnet'); + mainnet.should.not.equal(testnet); + }); + }); + + describe('getXOnlyPublicKey', () => { + it('should return a 32-byte x-only public key', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const xOnly = kp.getXOnlyPublicKey(); + xOnly.length.should.equal(32); + }); + }); + + describe('isValidPublicKey', () => { + it('should return true for a valid compressed public key', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + isValidPublicKey(kp.getKeys().pub).should.be.true(); + }); + + it('should return false for invalid public keys', () => { + isValidPublicKey('invalid').should.be.false(); + isValidPublicKey('').should.be.false(); + isValidPublicKey('00' + 'aa'.repeat(32)).should.be.false(); // invalid prefix + }); + }); +}); diff --git a/modules/sdk-coin-kas/test/unit/transaction.test.ts b/modules/sdk-coin-kas/test/unit/transaction.test.ts new file mode 100644 index 0000000000..9ff600a653 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/transaction.test.ts @@ -0,0 +1,147 @@ +import * as should from 'should'; +import { Transaction } from '../../src/lib/transaction'; +import { KaspaTransactionData } from '../../src/lib/iface'; +import { TEST_UTXO } from '../fixtures/kas.fixtures'; +import { + TX_VERSION, + NATIVE_SUBNETWORK_ID, + DEFAULT_SEQUENCE, + DEFAULT_GAS, + DEFAULT_LOCK_TIME, +} from '../../src/lib/constants'; + +describe('Kaspa Transaction', () => { + const buildBasicTxData = (): Partial => ({ + version: TX_VERSION, + inputs: [ + { + previousOutpoint: { + transactionId: TEST_UTXO.transactionId, + index: TEST_UTXO.index, + }, + signatureScript: '', + sequence: DEFAULT_SEQUENCE, + sigOpCount: 1, + }, + ], + outputs: [ + { + value: BigInt('50000000'), + scriptPublicKey: { + version: 0, + script: '20' + 'be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + 'ac', + }, + }, + ], + lockTime: DEFAULT_LOCK_TIME, + subnetworkId: NATIVE_SUBNETWORK_ID.toString('hex'), + gas: DEFAULT_GAS, + payload: '', + }); + + describe('constructor', () => { + it('should create an empty transaction', () => { + const tx = new Transaction(); + tx.txData.version.should.equal(TX_VERSION); + tx.txData.inputs.length.should.equal(0); + tx.txData.outputs.length.should.equal(0); + }); + + it('should create a transaction from data', () => { + const data = buildBasicTxData(); + const tx = new Transaction(data); + tx.txData.inputs.length.should.equal(1); + tx.txData.outputs.length.should.equal(1); + }); + }); + + describe('serialize / deserialize roundtrip', () => { + it('should serialize and deserialize correctly', () => { + const data = buildBasicTxData(); + const tx = new Transaction(data); + const hex = tx.toBroadcastFormat(); + + const tx2 = Transaction.fromHex(hex); + tx2.txData.version.should.equal(tx.txData.version); + tx2.txData.inputs.length.should.equal(tx.txData.inputs.length); + tx2.txData.outputs.length.should.equal(tx.txData.outputs.length); + tx2.txData.inputs[0].previousOutpoint.transactionId.should.equal( + tx.txData.inputs[0].previousOutpoint.transactionId + ); + tx2.txData.outputs[0].value.should.equal(tx.txData.outputs[0].value); + tx2.txData.subnetworkId.should.equal(tx.txData.subnetworkId); + }); + }); + + describe('transactionId', () => { + it('should compute a 32-byte (64 hex char) transaction ID', () => { + const data = buildBasicTxData(); + const tx = new Transaction(data); + const txId = tx.transactionId(); + txId.length.should.equal(64); + /^[0-9a-f]{64}$/.test(txId).should.be.true(); + }); + + it('should produce deterministic transaction IDs', () => { + const data = buildBasicTxData(); + const tx1 = new Transaction(data); + const tx2 = new Transaction(data); + tx1.transactionId().should.equal(tx2.transactionId()); + }); + + it('should produce different IDs for different transactions', () => { + const data1 = buildBasicTxData(); + const data2 = buildBasicTxData(); + data2.outputs![0].value = BigInt('60000000'); + const tx1 = new Transaction(data1); + const tx2 = new Transaction(data2); + tx1.transactionId().should.not.equal(tx2.transactionId()); + }); + }); + + describe('computeSighash', () => { + it('should compute sighash when UTXO entries are provided', () => { + const data = buildBasicTxData(); + data.utxoEntries = [TEST_UTXO]; + const tx = new Transaction(data); + const sighash = tx.computeSighash(0); + sighash.length.should.equal(32); + }); + + it('should throw when UTXO entries are missing', () => { + const data = buildBasicTxData(); + const tx = new Transaction(data); + should.throws(() => tx.computeSighash(0), /UTXO entries required/); + }); + + it('should produce different sighashes for different inputs', () => { + const data = buildBasicTxData(); + // Add second input + data.inputs!.push({ + previousOutpoint: { + transactionId: 'ff' + 'aa'.repeat(31), + index: 1, + }, + signatureScript: '', + sequence: DEFAULT_SEQUENCE, + sigOpCount: 1, + }); + const utxo2 = { ...TEST_UTXO, index: 1, transactionId: 'ff' + 'aa'.repeat(31) }; + data.utxoEntries = [TEST_UTXO, utxo2]; + const tx = new Transaction(data); + const sig0 = tx.computeSighash(0); + const sig1 = tx.computeSighash(1); + sig0.toString('hex').should.not.equal(sig1.toString('hex')); + }); + }); + + describe('explainTransaction', () => { + it('should explain a basic transaction', () => { + const data = buildBasicTxData(); + const tx = new Transaction(data); + const explained = tx.explainTransaction(); + explained.outputs.length.should.equal(1); + explained.outputAmount.should.equal('50000000'); + }); + }); +}); diff --git a/modules/sdk-coin-kas/test/unit/transactionBuilder.test.ts b/modules/sdk-coin-kas/test/unit/transactionBuilder.test.ts new file mode 100644 index 0000000000..0247fd6141 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/transactionBuilder.test.ts @@ -0,0 +1,137 @@ +import * as should from 'should'; +import { TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { KeyPair } from '../../src/lib/keyPair'; +import { TEST_UTXO } from '../fixtures/kas.fixtures'; + +// Mock coin object for TransactionBuilder +const mockCoin = { + getChain: () => 'kas', + getFamily: () => 'kas', + getFullName: () => 'Kaspa', + getBaseFactor: () => 100000000, +} as any; + +describe('Kaspa TransactionBuilder', () => { + const privKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + let kp: KeyPair; + let senderAddress: string; + let recipientAddress: string; + + before(() => { + kp = new KeyPair({ prv: privKey }); + senderAddress = kp.getAddress('mainnet'); + const kp2 = new KeyPair({ prv: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210' }); + recipientAddress = kp2.getAddress('mainnet'); + }); + + describe('sender', () => { + it('should set a valid sender address', () => { + const builder = new TransactionBuilder(mockCoin); + builder.sender(senderAddress); + }); + + it('should throw on invalid sender address', () => { + const builder = new TransactionBuilder(mockCoin); + (() => builder.sender('invalid')).should.throw(/Invalid sender address/); + }); + }); + + describe('to', () => { + it('should add a valid recipient output', () => { + const builder = new TransactionBuilder(mockCoin); + builder.to(recipientAddress, BigInt('50000000')); + }); + + it('should throw on invalid recipient address', () => { + const builder = new TransactionBuilder(mockCoin); + (() => builder.to('invalid', BigInt('50000000'))).should.throw(/Invalid recipient address/); + }); + + it('should throw on zero amount', () => { + const builder = new TransactionBuilder(mockCoin); + (() => builder.to(recipientAddress, BigInt(0))).should.throw(/Amount must be positive/); + }); + }); + + describe('addUtxo', () => { + it('should add a UTXO as input', () => { + const builder = new TransactionBuilder(mockCoin); + builder.addUtxo(TEST_UTXO); + }); + }); + + describe('build', () => { + it('should build a valid unsigned transaction', async () => { + const builder = new TransactionBuilder(mockCoin); + builder.sender(senderAddress).addUtxo(TEST_UTXO).to(recipientAddress, BigInt('50000000')).fee(BigInt('10000')); + + const tx = await builder.build(); + should.exist(tx); + tx.txData.inputs.length.should.equal(1); + // Output = recipient + change + tx.txData.outputs.length.should.equal(2); + tx.txData.outputs[0].value.should.equal(BigInt('50000000')); + // Change = 100000000 - 50000000 - 10000 = 49990000 + tx.txData.outputs[1].value.should.equal(BigInt('49990000')); + }); + + it('should not add change output if exact amount', async () => { + const builder = new TransactionBuilder(mockCoin); + builder + .sender(senderAddress) + .addUtxo(TEST_UTXO) + .to(recipientAddress, BigInt('99990000')) // 100000000 - 10000 fee = 99990000 + .fee(BigInt('10000')); + + const tx = await builder.build(); + tx.txData.outputs.length.should.equal(1); + tx.txData.outputs[0].value.should.equal(BigInt('99990000')); + }); + + it('should throw when inputs are insufficient', async () => { + const builder = new TransactionBuilder(mockCoin); + builder + .sender(senderAddress) + .addUtxo(TEST_UTXO) + .to(recipientAddress, BigInt('200000000')) // More than available + .fee(BigInt('10000')); + + await builder.build().should.be.rejectedWith(/Insufficient funds/); + }); + }); + + describe('sign', () => { + it('should sign a transaction and produce signature scripts', async () => { + const builder = new TransactionBuilder(mockCoin); + builder.sender(senderAddress).addUtxo(TEST_UTXO).to(recipientAddress, BigInt('50000000')).fee(BigInt('10000')); + + await builder.build(); + builder.sign({ key: privKey }); + + const tx = await builder.build(); + // After signing, inputs should have signatureScript + tx.txData.inputs[0].signatureScript.should.not.equal(''); + tx.txData.inputs[0].signatureScript.length.should.be.greaterThan(0); + }); + }); + + describe('from (deserialization)', () => { + it('should reconstruct a builder from serialized hex', async () => { + // Build a transaction first + const builder1 = new TransactionBuilder(mockCoin); + builder1.sender(senderAddress).addUtxo(TEST_UTXO).to(recipientAddress, BigInt('50000000')).fee(BigInt('10000')); + const tx1 = await builder1.build(); + const hex = tx1.toBroadcastFormat(); + + // Reconstruct from hex + const builder2 = new TransactionBuilder(mockCoin); + builder2.from(hex); + const tx2 = await builder2.build(); + + tx2.txData.inputs[0].previousOutpoint.transactionId.should.equal( + tx1.txData.inputs[0].previousOutpoint.transactionId + ); + tx2.txData.outputs.length.should.equal(tx1.txData.outputs.length); + }); + }); +}); diff --git a/modules/sdk-coin-kas/test/unit/transactionFlow.test.ts b/modules/sdk-coin-kas/test/unit/transactionFlow.test.ts new file mode 100644 index 0000000000..9070d4d7fe --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/transactionFlow.test.ts @@ -0,0 +1,117 @@ +/** + * Kaspa End-to-End Transaction Flow Test + * + * Tests the complete flow: build → sign → serialize → deserialize → verify + */ + +import * as should from 'should'; +import { TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { Transaction } from '../../src/lib/transaction'; +import { KeyPair } from '../../src/lib/keyPair'; +import { TEST_UTXO } from '../fixtures/kas.fixtures'; + +const mockCoin = { + getChain: () => 'kas', + getFamily: () => 'kas', + getFullName: () => 'Kaspa', + getBaseFactor: () => 100000000, +} as any; + +describe('Kaspa Transaction E2E Flow', () => { + const senderPrivKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const recipientPrivKey = 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210'; + + let senderKp: KeyPair; + let recipientKp: KeyPair; + let senderAddress: string; + let recipientAddress: string; + + before(() => { + senderKp = new KeyPair({ prv: senderPrivKey }); + recipientKp = new KeyPair({ prv: recipientPrivKey }); + senderAddress = senderKp.getAddress('mainnet'); + recipientAddress = recipientKp.getAddress('mainnet'); + }); + + it('should complete the full transaction lifecycle', async () => { + // 1. BUILD: Create an unsigned transaction + const builder = new TransactionBuilder(mockCoin); + builder.sender(senderAddress).addUtxo(TEST_UTXO).to(recipientAddress, BigInt('50000000')).fee(BigInt('10000')); + + const unsignedTx = await builder.build(); + should.exist(unsignedTx); + unsignedTx.txData.inputs.length.should.equal(1); + unsignedTx.txData.outputs.length.should.equal(2); // recipient + change + + // Verify inputs are unsigned + unsignedTx.txData.inputs[0].signatureScript.should.equal(''); + + // 2. SIGN: Sign the transaction + builder.sign({ key: senderPrivKey }); + const signedTx = await builder.build(); + + // Verify inputs are signed + signedTx.txData.inputs[0].signatureScript.should.not.equal(''); + // Schnorr signature + sighash_type = 64 + 1 = 65 bytes + // Script: 0x41 (OP_DATA_65) + 65 bytes = 66 bytes = 132 hex chars + signedTx.txData.inputs[0].signatureScript.length.should.equal(132); + + // 3. SERIALIZE: Convert to broadcast format + const txHex = signedTx.toBroadcastFormat(); + should.exist(txHex); + txHex.length.should.be.greaterThan(0); + /^[0-9a-fA-F]+$/.test(txHex).should.be.true(); + + // 4. DESERIALIZE: Reconstruct from hex + const deserializedTx = Transaction.fromHex(txHex); + deserializedTx.txData.inputs.length.should.equal(signedTx.txData.inputs.length); + deserializedTx.txData.outputs.length.should.equal(signedTx.txData.outputs.length); + deserializedTx.txData.inputs[0].signatureScript.should.equal(signedTx.txData.inputs[0].signatureScript); + deserializedTx.txData.outputs[0].value.should.equal(signedTx.txData.outputs[0].value); + + // 5. TRANSACTION ID: Verify deterministic ID + const txId = signedTx.transactionId(); + txId.length.should.equal(64); + // Transaction ID should be the same regardless of signatures + const unsignedId = unsignedTx.transactionId(); + // Note: in Kaspa, TxID is computed on the tx WITHOUT signature scripts + // so signed and unsigned tx should have the same ID + txId.should.equal(unsignedId); + }); + + it('should build transactions with multiple inputs', async () => { + const utxo2 = { + ...TEST_UTXO, + transactionId: 'bbccddee00112233445566778899aabbccddee00112233445566778899aabbcc', + index: 0, + amount: BigInt('50000000'), + }; + + const builder = new TransactionBuilder(mockCoin); + builder + .sender(senderAddress) + .addUtxos([TEST_UTXO, utxo2]) + .to(recipientAddress, BigInt('100000000')) + .fee(BigInt('10000')); + + const tx = await builder.build(); + tx.txData.inputs.length.should.equal(2); + // Total: 100000000 + 50000000 = 150000000 + // Output to recipient: 100000000 + // Fee: 10000 + // Change: 150000000 - 100000000 - 10000 = 49990000 + tx.txData.outputs.length.should.equal(2); + tx.txData.outputs[0].value.should.equal(BigInt('100000000')); + tx.txData.outputs[1].value.should.equal(BigInt('49990000')); + }); + + it('should produce consistent sighash for the same input', async () => { + const builder = new TransactionBuilder(mockCoin); + builder.sender(senderAddress).addUtxo(TEST_UTXO).to(recipientAddress, BigInt('50000000')).fee(BigInt('10000')); + + const tx = await builder.build(); + const sighash1 = tx.computeSighash(0); + const sighash2 = tx.computeSighash(0); + sighash1.toString('hex').should.equal(sighash2.toString('hex')); + }); +}); diff --git a/modules/sdk-coin-kas/test/unit/utils.test.ts b/modules/sdk-coin-kas/test/unit/utils.test.ts new file mode 100644 index 0000000000..73ff8c0063 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/utils.test.ts @@ -0,0 +1,122 @@ +import * as should from 'should'; +import { + isValidAddress, + isValidPublicKey, + isValidPrivateKey, + publicKeyToAddress, + buildScriptPublicKey, + getHrpFromAddress, + isMainnetAddress, +} from '../../src/lib/utils'; +import { KeyPair } from '../../src/lib/keyPair'; + +describe('Kaspa Utils', () => { + const validPrivateKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + + describe('isValidAddress', () => { + it('should return true for valid mainnet addresses', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const address = kp.getAddress('mainnet'); + isValidAddress(address).should.be.true(); + }); + + it('should return true for valid testnet addresses', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const address = kp.getAddress('testnet'); + isValidAddress(address).should.be.true(); + }); + + it('should return false for invalid addresses', () => { + isValidAddress('').should.be.false(); + isValidAddress('bitcoin:abc123').should.be.false(); + isValidAddress('kaspa:invalid_address').should.be.false(); + isValidAddress('kaspa:').should.be.false(); + }); + + it('should accept an array of valid addresses', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const addr = kp.getAddress('mainnet'); + isValidAddress([addr]).should.be.true(); + }); + + it('should return false if any address in array is invalid', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const addr = kp.getAddress('mainnet'); + isValidAddress([addr, 'invalid']).should.be.false(); + }); + }); + + describe('isValidPublicKey', () => { + it('should return true for compressed public keys', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + isValidPublicKey(kp.getKeys().pub).should.be.true(); + }); + + it('should return false for invalid keys', () => { + isValidPublicKey('').should.be.false(); + isValidPublicKey('zz'.repeat(33)).should.be.false(); + }); + }); + + describe('isValidPrivateKey', () => { + it('should return true for valid 32-byte hex keys', () => { + isValidPrivateKey(validPrivateKey).should.be.true(); + }); + + it('should return false for invalid keys', () => { + isValidPrivateKey('too_short').should.be.false(); + isValidPrivateKey('gg'.repeat(32)).should.be.false(); // invalid hex + }); + }); + + describe('publicKeyToAddress', () => { + it('should derive a bech32m address with kaspa HRP from a compressed public key', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const compressedPub = Buffer.from(kp.getKeys().pub, 'hex'); + const address = publicKeyToAddress(compressedPub, 'kaspa'); + address.should.startWith('kaspa:'); + isValidAddress(address).should.be.true(); + }); + + it('should derive a bech32m address with kaspatest HRP for testnet', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const compressedPub = Buffer.from(kp.getKeys().pub, 'hex'); + const address = publicKeyToAddress(compressedPub, 'kaspatest'); + address.should.startWith('kaspatest:'); + isValidAddress(address).should.be.true(); + }); + }); + + describe('buildScriptPublicKey', () => { + it('should build a P2PK script from 32-byte x-only public key', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const xOnly = kp.getXOnlyPublicKey(); + const spk = buildScriptPublicKey(xOnly); + spk.version.should.equal(0); + // Script: OP_DATA_32 (0x20) + 32 bytes + OP_CHECKSIG (0xAC) = 34 bytes = 68 hex chars + spk.script.length.should.equal(68); + spk.script.should.startWith('20'); // OP_DATA_32 + spk.script.should.endWith('ac'); // OP_CHECKSIG + }); + + it('should throw for non-32-byte input', () => { + should.throws(() => buildScriptPublicKey(Buffer.alloc(33))); + }); + }); + + describe('getHrpFromAddress and isMainnetAddress', () => { + it('should return kaspa HRP for mainnet address', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const address = kp.getAddress('mainnet'); + getHrpFromAddress(address).should.equal('kaspa'); + isMainnetAddress(address).should.be.true(); + }); + + it('should return kaspatest HRP for testnet address', () => { + const kp = new KeyPair({ prv: validPrivateKey }); + const address = kp.getAddress('testnet'); + getHrpFromAddress(address).should.equal('kaspatest'); + isMainnetAddress(address).should.be.false(); + }); + }); +}); diff --git a/modules/sdk-coin-kas/tsconfig.json b/modules/sdk-coin-kas/tsconfig.json new file mode 100644 index 0000000000..64aeea134a --- /dev/null +++ b/modules/sdk-coin-kas/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./", + "strictPropertyInitialization": false, + "esModuleInterop": true, + "typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules"], + "references": [ + { + "path": "../sdk-api" + }, + { + "path": "../sdk-core" + }, + { + "path": "../statics" + }, + { + "path": "../sdk-test" + } + ] +} diff --git a/modules/sdk-core/src/bitgo/environments.ts b/modules/sdk-core/src/bitgo/environments.ts index b29e33b058..9a056f1bda 100644 --- a/modules/sdk-core/src/bitgo/environments.ts +++ b/modules/sdk-core/src/bitgo/environments.ts @@ -77,6 +77,7 @@ interface EnvironmentTemplate { sgbExplorerBaseUrl?: string; sgbExplorerApiToken?: string; icpNodeUrl: string; + kasNodeUrl?: string; hyperLiquidNodeUrl: string; wemixExplorerBaseUrl?: string; wemixExplorerApiToken?: string; @@ -349,6 +350,7 @@ const mainnetBase: EnvironmentTemplate = { }, }, icpNodeUrl: 'https://ic0.app', + kasNodeUrl: 'wss://mainnet.kaspa.green', hyperLiquidNodeUrl: 'https://api.hyperliquid.xyz', worldExplorerBaseUrl: 'https://worldscan.org/', somniaExplorerBaseUrl: 'https://mainnet.somnia.w3us.site/', @@ -427,6 +429,7 @@ const testnetBase: EnvironmentTemplate = { xdcExplorerBaseUrl: 'https://api.etherscan.io/v2', sgbExplorerBaseUrl: 'https://coston-explorer.flare.network', icpNodeUrl: 'https://ic0.app', + kasNodeUrl: 'wss://testnet-10.kaspa.green', hyperLiquidNodeUrl: 'https://api.hyperliquid-testnet.xyz', monExplorerBaseUrl: 'https://api.etherscan.io/v2', worldExplorerBaseUrl: 'https://sepolia.worldscan.org/', diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index fd7af41f22..4d200dbdb9 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -76,6 +76,7 @@ import { polyxTokens } from './coins/polyxTokens'; import { cantonTokens } from './coins/cantonTokens'; import { flrp } from './flrp'; import { hypeEvm } from './hypeevm'; +import { kas } from './kas'; import { ACCOUNT_COIN_DEFAULT_FEATURES_EXCLUDE_SINGAPORE_AND_MENA_FZE, ADA_FEATURES, @@ -187,6 +188,8 @@ export const allCoinsAndTokens = [ Networks.test.flrP, UnderlyingAsset.FLRP ), + kas('4d600e2d-03b6-41f4-b3df-bfd4b4246f9c', 'kas', 'Kaspa', Networks.main.kas, UnderlyingAsset.KAS), + kas('a6e7156f-39fb-4d51-8373-a0d223f8f2a5', 'tkas', 'Kaspa Testnet', Networks.test.kas, UnderlyingAsset.KAS), ada( 'fd4d125e-f14f-414b-bd17-6cb1393265f0', 'ada', diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 23540516d4..07bb4fc859 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -78,6 +78,7 @@ export enum CoinFamily { ISLM = 'islm', JOVAYETH = 'jovayeth', KAIA = 'kaia', + KAS = 'kas', KAVACOSMOS = 'kavacosmos', KAVAEVM = 'kavaevm', LNBTC = 'lnbtc', @@ -3829,6 +3830,7 @@ export enum BaseUnit { RUNE = 'rune', TAO = 'rao', ICP = 'e8s', + KAS = 'sompi', HYPE = 'hype', MANTRA = 'uom', POLYX = 'micropolyx', diff --git a/modules/statics/src/coins/erc20Coins.ts b/modules/statics/src/coins/erc20Coins.ts index a80a7e54d3..98fbacf808 100644 --- a/modules/statics/src/coins/erc20Coins.ts +++ b/modules/statics/src/coins/erc20Coins.ts @@ -9363,8 +9363,8 @@ export const erc20Coins = [ ), erc20( '9d42fc1c-27f9-4d20-bab9-6ffdc8087fd2', - 'kas', - 'Kaspa', + 'ekas', + 'Kaspa (ERC20)', 8, '0x112b08621e27e10773ec95d250604a041f36c582', UnderlyingAsset.KAS diff --git a/modules/statics/src/index.ts b/modules/statics/src/index.ts index 58a31c97d4..317e2a940f 100644 --- a/modules/statics/src/index.ts +++ b/modules/statics/src/index.ts @@ -43,3 +43,4 @@ export { generateErc20Token, generateTestErc20Token, } from './coins/generateERC20'; +export { KASCoin } from './kas'; diff --git a/modules/statics/src/kas.ts b/modules/statics/src/kas.ts new file mode 100644 index 0000000000..7d4cb9b1f8 --- /dev/null +++ b/modules/statics/src/kas.ts @@ -0,0 +1,94 @@ +import { BaseCoin, BaseUnit, CoinFeature, CoinKind, KeyCurve, UnderlyingAsset } from './base'; +import { KaspaNetwork } from './networks'; + +export interface KASConstructorOptions { + id: string; + fullName: string; + name: string; + network: KaspaNetwork; + features: CoinFeature[]; + asset: UnderlyingAsset; + prefix?: string; + suffix?: string; + primaryKeyCurve: KeyCurve; +} + +/** + * Kaspa (KAS) coin statics. + * + * UTXO-based BlockDAG chain using Schnorr signatures over secp256k1. + */ +export class KASCoin extends BaseCoin { + public static readonly DEFAULT_FEATURES = [ + CoinFeature.UNSPENT_MODEL, + CoinFeature.TSS, + CoinFeature.TSS_COLD, + CoinFeature.CUSTODY, + CoinFeature.CUSTODY_BITGO_TRUST, + CoinFeature.CUSTODY_BITGO_INDIA, + CoinFeature.CUSTODY_BITGO_MENA_FZE, + CoinFeature.CUSTODY_BITGO_CUSTODY_MENA_FZE, + CoinFeature.CUSTODY_BITGO_GERMANY, + CoinFeature.CUSTODY_BITGO_FRANKFURT, + ]; + + public readonly network: KaspaNetwork; + + constructor(options: KASConstructorOptions) { + super({ + ...options, + kind: CoinKind.CRYPTO, + isToken: false, + decimalPlaces: 8, // 1 KAS = 10^8 sompi + baseUnit: BaseUnit.KAS, + }); + this.network = options.network; + } + + protected disallowedFeatures(): Set { + return new Set([CoinFeature.ACCOUNT_MODEL]); + } + + protected requiredFeatures(): Set { + return new Set([CoinFeature.UNSPENT_MODEL]); + } +} + +/** + * Factory function for Kaspa coin instances. + * + * @param id uuid v4 + * @param name unique identifier of the coin (e.g. 'kas', 'tkas') + * @param fullName Complete human-readable name (e.g. 'Kaspa', 'Kaspa Testnet') + * @param network Network object (KaspaMainnet or KaspaTestnet) + * @param asset Underlying asset (UnderlyingAsset.KAS) + * @param features CoinFeature array; defaults to KASCoin.DEFAULT_FEATURES + * @param prefix Optional prefix (empty string) + * @param suffix Optional suffix (defaults to upper-case coin name) + * @param primaryKeyCurve Key curve (Secp256k1 for Kaspa) + */ +export function kas( + id: string, + name: string, + fullName: string, + network: KaspaNetwork, + asset: UnderlyingAsset, + features: CoinFeature[] = KASCoin.DEFAULT_FEATURES, + prefix = '', + suffix: string = name.toUpperCase(), + primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1 +) { + return Object.freeze( + new KASCoin({ + id, + name, + fullName, + network, + prefix, + suffix, + features, + asset, + primaryKeyCurve, + }) + ); +} diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 10a777af16..df836702fe 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -1974,6 +1974,29 @@ class KaiaTestnet extends Testnet implements EthereumNetwork { walletImplementationAddress = '0x944fef03af368414f29dc31a72061b8d64f568d2'; } +/** + * Kaspa network interface. + * Kaspa is a UTXO-based BlockDAG using GHOSTDAG/Proof-of-Work with bech32m addresses. + */ +export interface KaspaNetwork extends BaseNetwork { + /** bech32m Human-Readable Part ('kaspa' for mainnet, 'kaspatest' for testnet) */ + readonly hrp: string; +} + +export class KaspaMainnet extends Mainnet implements KaspaNetwork { + name = 'Kaspa'; + family = CoinFamily.KAS; + explorerUrl = 'https://explorer.kaspa.org/txs/'; + hrp = 'kaspa'; +} + +export class KaspaTestnet extends Testnet implements KaspaNetwork { + name = 'KaspaTestnet'; + family = CoinFamily.KAS; + explorerUrl = 'https://explorer-tn10.kaspa.org/txs/'; + hrp = 'kaspatest'; +} + class Irys extends Mainnet implements EthereumNetwork { name = 'Irys'; family = CoinFamily.IRYS; @@ -2704,6 +2727,7 @@ export const Networks = { islm: Object.freeze(new Islm()), jovayeth: Object.freeze(new JovayETH()), kaia: Object.freeze(new Kaia()), + kas: Object.freeze(new KaspaMainnet()), kavacosmos: Object.freeze(new KavaCosmos()), kavaevm: Object.freeze(new KavaEVM()), lnbtc: Object.freeze(new LightningBitcoin()), @@ -2827,6 +2851,7 @@ export const Networks = { irys: Object.freeze(new IrysTestnet()), islm: Object.freeze(new IslmTestnet()), jovayeth: Object.freeze(new JovayETHTestnet()), + kas: Object.freeze(new KaspaTestnet()), kavacosmos: Object.freeze(new KavaCosmosTestnet()), kavaevm: Object.freeze(new KavaEVMTestnet()), kovan: Object.freeze(new Kovan()), diff --git a/modules/statics/test/unit/fixtures/expectedColdFeatures.ts b/modules/statics/test/unit/fixtures/expectedColdFeatures.ts index d0e364771c..39aaa503b5 100644 --- a/modules/statics/test/unit/fixtures/expectedColdFeatures.ts +++ b/modules/statics/test/unit/fixtures/expectedColdFeatures.ts @@ -110,6 +110,7 @@ export const expectedColdFeatures = { 'injective', 'jovayeth', 'kaia', + 'kas', 'kavacosmos', 'megaeth', 'mantle', @@ -199,6 +200,7 @@ export const expectedColdFeatures = { 'tinjective', 'tiota', 'tkaia', + 'tkas', 'tkavacosmos', 'tmantle', 'tmantra', diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 63617d8625..9703c92b66 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -160,6 +160,9 @@ { "path": "./modules/sdk-coin-injective" }, + { + "path": "./modules/sdk-coin-kas" + }, { "path": "./modules/sdk-coin-iota" },