From acaa71362ab2fe1e0ac9320826971d08a8b518bc Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Tue, 7 Apr 2026 12:32:51 +0530 Subject: [PATCH 1/8] feat(sdk-coin-kas): add Kaspa (KAS) unique chain SDK integration Adds full BitGoJS SDK module for Kaspa (KAS), a UTXO/BlockDAG chain using secp256k1 Schnorr signatures and the Kaspa cashaddr bech32 encoding. - New module modules/sdk-coin-kas with coin class, TransactionBuilder, Transaction, KeyPair, utils, constants, interfaces, and register entry points - Statics: KAS CoinFamily/BaseUnit enums, KaspaNetwork classes (mainnet + testnet-10), kas/tkas coin entries, KASCoin statics class - BitGoJS wiring: coinFactory registration, coins/index export, package.json dependency, tsconfig project reference, environments node URLs - 123 unit tests passing: keyPair, utils, transaction, transactionBuilder, transactionFlow (build -> serialize -> deserialize -> explain -> round-trip) Closes CECHO-388 Co-Authored-By: Claude Sonnet 4.6 --- modules/bitgo/package.json | 1 + modules/bitgo/src/v2/coinFactory.ts | 8 + modules/bitgo/src/v2/coins/index.ts | 2 + modules/bitgo/tsconfig.json | 3 + modules/sdk-coin-kas/.mocharc.yml | 8 + modules/sdk-coin-kas/package.json | 59 ++++ modules/sdk-coin-kas/src/index.ts | 4 + modules/sdk-coin-kas/src/kas.ts | 246 +++++++++++++++++ modules/sdk-coin-kas/src/lib/constants.ts | 35 +++ modules/sdk-coin-kas/src/lib/iface.ts | 127 +++++++++ modules/sdk-coin-kas/src/lib/index.ts | 9 + modules/sdk-coin-kas/src/lib/keyPair.ts | 95 +++++++ modules/sdk-coin-kas/src/lib/sighash.ts | 230 ++++++++++++++++ modules/sdk-coin-kas/src/lib/transaction.ts | 152 ++++++++++ .../src/lib/transactionBuilder.ts | 148 ++++++++++ .../src/lib/transactionBuilderFactory.ts | 26 ++ modules/sdk-coin-kas/src/lib/utils.ts | 260 ++++++++++++++++++ modules/sdk-coin-kas/src/register.ts | 8 + modules/sdk-coin-kas/src/tkas.ts | 13 + .../test/fixtures/kas.fixtures.ts | 99 +++++++ .../sdk-coin-kas/test/fixtures/kasFixtures.ts | 61 ++++ modules/sdk-coin-kas/test/unit/coin.test.ts | 100 +++++++ modules/sdk-coin-kas/test/unit/kas.test.ts | 59 ++++ .../sdk-coin-kas/test/unit/keyPair.test.ts | 62 +++++ .../test/unit/transaction.test.ts | 233 ++++++++++++++++ .../test/unit/transactionBuilder.test.ts | 229 +++++++++++++++ .../test/unit/transactionFlow.test.ts | 134 +++++++++ modules/sdk-coin-kas/test/unit/utils.test.ts | 202 ++++++++++++++ modules/sdk-coin-kas/tsconfig.json | 26 ++ modules/sdk-core/src/bitgo/environments.ts | 3 + modules/statics/src/allCoinsAndTokens.ts | 4 + modules/statics/src/base.ts | 4 + modules/statics/src/coins/erc20Coins.ts | 4 +- modules/statics/src/index.ts | 1 + modules/statics/src/kas.ts | 90 ++++++ modules/statics/src/networks.ts | 54 ++++ 36 files changed, 2797 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-coin-kas/.mocharc.yml create mode 100644 modules/sdk-coin-kas/package.json create mode 100644 modules/sdk-coin-kas/src/index.ts create mode 100644 modules/sdk-coin-kas/src/kas.ts create mode 100644 modules/sdk-coin-kas/src/lib/constants.ts create mode 100644 modules/sdk-coin-kas/src/lib/iface.ts create mode 100644 modules/sdk-coin-kas/src/lib/index.ts create mode 100644 modules/sdk-coin-kas/src/lib/keyPair.ts create mode 100644 modules/sdk-coin-kas/src/lib/sighash.ts create mode 100644 modules/sdk-coin-kas/src/lib/transaction.ts create mode 100644 modules/sdk-coin-kas/src/lib/transactionBuilder.ts create mode 100644 modules/sdk-coin-kas/src/lib/transactionBuilderFactory.ts create mode 100644 modules/sdk-coin-kas/src/lib/utils.ts create mode 100644 modules/sdk-coin-kas/src/register.ts create mode 100644 modules/sdk-coin-kas/src/tkas.ts create mode 100644 modules/sdk-coin-kas/test/fixtures/kas.fixtures.ts create mode 100644 modules/sdk-coin-kas/test/fixtures/kasFixtures.ts create mode 100644 modules/sdk-coin-kas/test/unit/coin.test.ts create mode 100644 modules/sdk-coin-kas/test/unit/kas.test.ts create mode 100644 modules/sdk-coin-kas/test/unit/keyPair.test.ts create mode 100644 modules/sdk-coin-kas/test/unit/transaction.test.ts create mode 100644 modules/sdk-coin-kas/test/unit/transactionBuilder.test.ts create mode 100644 modules/sdk-coin-kas/test/unit/transactionFlow.test.ts create mode 100644 modules/sdk-coin-kas/test/unit/utils.test.ts create mode 100644 modules/sdk-coin-kas/tsconfig.json create mode 100644 modules/statics/src/kas.ts diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 3a919827e1..fded0c3020 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -91,6 +91,7 @@ "@bitgo/sdk-coin-injective": "^3.7.0", "@bitgo/sdk-coin-iota": "^1.10.0", "@bitgo/sdk-coin-islm": "^2.6.0", + "@bitgo/sdk-coin-kas": "^1.0.0", "@bitgo/sdk-coin-lnbtc": "^1.7.0", "@bitgo/sdk-coin-ltc": "^3.8.0", "@bitgo/sdk-coin-mon": "^1.8.0", diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index f48257905f..3de5753b78 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -106,6 +106,8 @@ import { Injective, Iota, Islm, + Kas, + Tkas, JettonToken, Lnbtc, Ltc, @@ -290,6 +292,7 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin coinFactory.register('ltc', Ltc.createInstance); coinFactory.register('mon', Mon.createInstance); coinFactory.register('icp', Icp.createInstance); + coinFactory.register('kas', Kas.createInstance); coinFactory.register('initia', Initia.createInstance); coinFactory.register('injective', Injective.createInstance); coinFactory.register('iota', Iota.createInstance); @@ -358,6 +361,7 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin coinFactory.register('thash', Thash.createInstance); coinFactory.register('thbar', Thbar.createInstance); coinFactory.register('ticp', Ticp.createInstance); + coinFactory.register('tkas', Tkas.createInstance); coinFactory.register('tinitia', Tinitia.createInstance); coinFactory.register('tinjective', Tinjective.createInstance); coinFactory.register('tiota', Iota.createInstance); @@ -725,6 +729,10 @@ export function getCoinConstructor(coinName: string): CoinConstructor | undefine return Mon.createInstance; case 'icp': return Icp.createInstance; + case 'kas': + return Kas.createInstance; + case 'tkas': + return Tkas.createInstance; case 'initia': return Initia.createInstance; case 'injective': diff --git a/modules/bitgo/src/v2/coins/index.ts b/modules/bitgo/src/v2/coins/index.ts index 606c54f97c..34ec63b411 100644 --- a/modules/bitgo/src/v2/coins/index.ts +++ b/modules/bitgo/src/v2/coins/index.ts @@ -39,6 +39,7 @@ import { EthLikeCoin, TethLikeCoin } from '@bitgo/sdk-coin-ethlike'; import { Hash, Thash, HashToken } from '@bitgo/sdk-coin-hash'; import { Hbar, Thbar } from '@bitgo/sdk-coin-hbar'; import { Icp, Ticp } from '@bitgo/sdk-coin-icp'; +import { Kas, Tkas } from '@bitgo/sdk-coin-kas'; import { Initia, Tinitia } from '@bitgo/sdk-coin-initia'; import { Injective, Tinjective } from '@bitgo/sdk-coin-injective'; import { Iota } from '@bitgo/sdk-coin-iota'; @@ -116,6 +117,7 @@ export { Flrp }; export { Hash, Thash, HashToken }; export { Hbar, Thbar }; export { Icp, Ticp }; +export { Kas, Tkas }; export { Initia, Tinitia }; export { Iota }; export { Lnbtc, Tlnbtc }; diff --git a/modules/bitgo/tsconfig.json b/modules/bitgo/tsconfig.json index 5f24815255..64dbd25e30 100644 --- a/modules/bitgo/tsconfig.json +++ b/modules/bitgo/tsconfig.json @@ -173,6 +173,9 @@ { "path": "../sdk-coin-icp" }, + { + "path": "../sdk-coin-kas" + }, { "path": "../sdk-coin-initia" }, diff --git a/modules/sdk-coin-kas/.mocharc.yml b/modules/sdk-coin-kas/.mocharc.yml new file mode 100644 index 0000000000..f499ec0a83 --- /dev/null +++ b/modules/sdk-coin-kas/.mocharc.yml @@ -0,0 +1,8 @@ +require: 'tsx' +timeout: '60000' +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..dc62e9bb38 --- /dev/null +++ b/modules/sdk-coin-kas/package.json @@ -0,0 +1,59 @@ +{ + "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": { + "@bitgo/sdk-api": "^1.76.0", + "@bitgo/sdk-test": "^9.1.34" + }, + "dependencies": { + "@bitgo/sdk-core": "^36.36.0", + "@bitgo/secp256k1": "^1.10.0", + "@bitgo/statics": "^58.32.0", + "bech32": "^2.0.0", + "blakejs": "^1.2.1", + "bs58": "^6.0.0", + "create-hash": "^1.2.0", + "safe-buffer": "^5.2.1" + }, + "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..67a06f1ffa --- /dev/null +++ b/modules/sdk-coin-kas/src/index.ts @@ -0,0 +1,4 @@ +export * from './kas'; +export * from './tkas'; +export * as KasLib from './lib'; +export * 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..c7438f1424 --- /dev/null +++ b/modules/sdk-coin-kas/src/kas.ts @@ -0,0 +1,246 @@ +import { BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; +import { + AuditDecryptedKeyParams, + BaseCoin, + BitGoBase, + InvalidAddressError, + InvalidTransactionError, + KeyPair as IKeyPair, + MethodNotImplementedError, + MPCAlgorithm, + MultisigType, + multisigTypes, + ParsedTransaction, + ParseTransactionOptions, + SignedTransaction, + UnexpectedAddressError, + VerifyAddressOptions, +} from '@bitgo/sdk-core'; +import * as KasLib from './lib'; +import { + KaspaExplainTransactionOptions, + KaspaSignTransactionOptions, + KaspaVerifyTransactionOptions, + TransactionExplanation, +} from './lib/iface'; +import { isValidKaspaAddress } 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; + } + + /** + * Return the base factor (sompi per KAS). + * 1 KAS = 100,000,000 sompi (8 decimal places) + */ + getBaseFactor(): string | number { + return Math.pow(10, this._staticsCoin.decimalPlaces); + } + + /** @inheritDoc */ + getDefaultMultisigType(): MultisigType { + return multisigTypes.onchain; + } + + /** + * Validate a Kaspa address. + */ + isValidAddress(address: string): boolean { + return isValidKaspaAddress(address); + } + + /** + * Validate a public key (secp256k1 compressed or uncompressed). + */ + isValidPub(pub: string): boolean { + try { + new KasLib.KeyPair({ pub }); + return true; + } catch { + return false; + } + } + + /** + * Validate a private key. + */ + isValidPrv(prv: string): boolean { + try { + new KasLib.KeyPair({ prv }); + return true; + } catch { + return false; + } + } + + /** + * Generate a Kaspa key pair. + */ + generateKeyPair(seed?: Buffer): IKeyPair { + const keyPair = seed ? new KasLib.KeyPair({ seed }) : new KasLib.KeyPair(); + const keys = keyPair.getKeys(); + + if (!keys.prv) { + throw new Error('Missing prv in key generation.'); + } + + return { + pub: keys.pub, + prv: keys.prv, + }; + } + + /** + * Check if address belongs to wallet by deriving from keychains. + */ + async isWalletAddress(params: VerifyAddressOptions): Promise { + const { address, keychains } = params; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + if (!keychains || keychains.length !== 3) { + throw new Error('Invalid keychains'); + } + + const networkType = this._staticsCoin.network.type; + const derivedAddress = new KasLib.KeyPair({ pub: keychains[0].pub }).getAddress(networkType); + + if (derivedAddress !== address) { + throw new UnexpectedAddressError(`address validation failure: ${address} is not of this wallet`); + } + + return true; + } + + private getBuilder(): KasLib.TransactionBuilderFactory { + return new KasLib.TransactionBuilderFactory(coins.get(this.getChain())); + } + + /** + * Parse a Kaspa transaction from prebuild. + */ + async parseTransaction(params: ParseTransactionOptions): Promise { + return {}; + } + + /** + * Verify a Kaspa transaction against expected params. + */ + async verifyTransaction(params: KaspaVerifyTransactionOptions): Promise { + const txHex = params.txPrebuild?.txHex; + if (!txHex) { + throw new Error('missing required tx prebuild property txHex'); + } + + let tx: KasLib.Transaction; + try { + const txBuilder = this.getBuilder().from(txHex); + tx = (await txBuilder.build()) as KasLib.Transaction; + } catch (error) { + throw new InvalidTransactionError(`Invalid transaction: ${(error as Error).message}`); + } + + const explainedTx = tx.explainTransaction(); + + if (params.txParams.recipients) { + const recipientCount = params.txParams.recipients.length; + if (explainedTx.outputs.length < recipientCount) { + throw new Error(`Expected at least ${recipientCount} outputs, transaction had ${explainedTx.outputs.length}`); + } + } + + return true; + } + + /** + * Explain a Kaspa transaction. + */ + async explainTransaction(params: KaspaExplainTransactionOptions): Promise { + const txHex = params.txHex ?? params?.halfSigned?.txHex; + if (!txHex) { + throw new Error('missing transaction hex'); + } + try { + const txBuilder = this.getBuilder().from(txHex); + const tx = (await txBuilder.build()) as KasLib.Transaction; + return tx.explainTransaction(); + } catch (e) { + throw new InvalidTransactionError(`Invalid transaction: ${(e as Error).message}`); + } + } + + /** + * Sign a Kaspa transaction using secp256k1 Schnorr signatures. + * + * Each input is signed with the provided private key. The transaction is + * considered fully signed when all inputs have a valid signatureScript. + */ + async signTransaction(params: KaspaSignTransactionOptions): Promise { + const txHex = params.txPrebuild.txHex; + if (!txHex) { + throw new InvalidTransactionError('missing txHex in txPrebuild'); + } + + const txBuilder = this.getBuilder().from(txHex); + const tx = (await txBuilder.build()) as KasLib.Transaction; + + if (params.prv) { + const privKeyHex = params.prv.slice(0, 64); + const privKeyBuffer = Buffer.from(privKeyHex, 'hex'); + tx.sign(privKeyBuffer); + } + + const signedHex = tx.toHex(); + const inputCount = tx.txData.inputs.length; + const sigCount = tx.signature.filter((s) => s.length > 0).length; + + return inputCount > 0 && sigCount >= inputCount ? { txHex: signedHex } : { halfSigned: { txHex: signedHex } }; + } + + async signMessage(key: IKeyPair, message: string | Buffer): Promise { + throw new MethodNotImplementedError(); + } + + /** @inheritDoc */ + auditDecryptedKey(params: AuditDecryptedKeyParams): void { + throw new MethodNotImplementedError(); + } + + /** + * MPC support: Kaspa uses secp256k1 (Schnorr variant). + */ + supportsTss(): boolean { + return true; + } + + getMPCAlgorithm(): MPCAlgorithm { + return 'ecdsa'; + } +} 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..4eb3bcf05a --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/constants.ts @@ -0,0 +1,35 @@ +/** + * Kaspa (KAS) Constants + * + * References: + * - https://kaspa.org/ + * - https://kaspa.aspectron.org/docs/ + */ + +// Address format +export const MAINNET_PREFIX = 'kaspa'; +export const TESTNET_PREFIX = 'kaspatest'; + +// Kaspa address version bytes (encoded as 5-bit value in bech32) +export const VERSION_PUBKEY = 0; // Schnorr P2PK (secp256k1 x-only pubkey) +export const VERSION_SCRIPT = 8; // P2SH + +// Decimals: 1 KAS = 100_000_000 sompi (8 decimal places) +export const DECIMALS = 8; +export const BASE_FACTOR = 100_000_000; + +// RPC endpoints +export const MAINNET_RPC_URL = 'mainnet.kaspa.green'; +export const TESTNET10_RPC_URL = 'testnet-10.kaspa.green'; +export const TESTNET11_RPC_URL = 'testnet-11.kaspa.green'; + +// Default transaction fee (minimum relay fee in sompi) +export const DEFAULT_FEE = '1000'; // 0.00001 KAS minimum + +// Key constants +export const COMPRESSED_PUBKEY_LENGTH = 33; // bytes (02/03 + 32 bytes x-coord) +export const XONLY_PUBKEY_LENGTH = 32; // bytes (x-coordinate only for Schnorr) +export const PRIVATE_KEY_LENGTH = 32; // bytes + +// Kaspa transaction version +export const TX_VERSION = 0; 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..409b979d65 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/iface.ts @@ -0,0 +1,127 @@ +import { + TransactionExplanation as BaseTransactionExplanation, + TransactionPrebuild as BaseTransactionPrebuild, + SignTransactionOptions, + TransactionParams, + TransactionRecipient, + TransactionType, + VerifyTransactionOptions, +} from '@bitgo/sdk-core'; + +/** + * Kaspa UTXO input (reference to a previous transaction output) + */ +export interface KaspaUtxoInput { + /** Previous transaction ID (hex string) */ + transactionId: string; + /** Output index in the previous transaction */ + transactionIndex: number; + /** Amount in sompi */ + amount: string; + /** Script public key of the UTXO */ + scriptPublicKey: string; + /** Sequence number */ + sequence?: string; + /** Signature operation count */ + sigOpCount?: number; + /** Signature script — hex-encoded (65 bytes: 64-byte Schnorr sig + 1-byte sighash type) */ + signatureScript?: string; +} + +/** + * Kaspa transaction output + */ +export interface KaspaTransactionOutput { + /** Recipient address */ + address: string; + /** Amount in sompi */ + amount: string; + /** Script public key */ + scriptPublicKey?: string; +} + +/** + * Kaspa transaction data structure + */ +export interface KaspaTransactionData { + /** Transaction version */ + version: number; + /** Transaction inputs (UTXOs being spent) */ + inputs: KaspaUtxoInput[]; + /** Transaction outputs */ + outputs: KaspaTransactionOutput[]; + /** Lock time */ + lockTime?: string; + /** Subnetwork ID (all-zeros for native KAS transfers) */ + subnetworkId?: string; + /** Transaction payload (empty for native transfers) */ + payload?: string; + /** Fee in sompi */ + fee?: string; + /** Transaction ID (computed) */ + id?: string; +} + +/** + * Kaspa transaction explanation for users + */ +export interface TransactionExplanation extends BaseTransactionExplanation { + type: TransactionType; + inputs: KaspaUtxoInput[]; +} + +/** + * Kaspa transaction prebuild for signing + */ +export interface TransactionPrebuild extends BaseTransactionPrebuild { + /** Serialized transaction as hex or JSON string */ + txHex: string; + txInfo: KaspaTxInfo; + source: string; +} + +export interface KaspaTxInfo { + recipients: TransactionRecipient[]; + from: string; + utxos?: KaspaUtxoInput[]; +} + +/** + * Kaspa sign transaction options + */ +export interface KaspaSignTransactionOptions extends SignTransactionOptions { + txPrebuild: TransactionPrebuild; + prv: string; +} + +/** + * Kaspa transaction params + */ +export interface KaspaTransactionParams extends TransactionParams { + type?: string; + unspents?: KaspaUtxoInput[]; +} + +/** + * Kaspa explain transaction options + */ +export interface KaspaExplainTransactionOptions { + txHex?: string; + halfSigned?: { + txHex: string; + }; +} + +/** + * Kaspa verify transaction options + */ +export interface KaspaVerifyTransactionOptions extends VerifyTransactionOptions { + txParams: KaspaTransactionParams; +} + +/** + * Kaspa transaction fee info + */ +export interface KaspaTransactionFee { + fee: string; +} diff --git a/modules/sdk-coin-kas/src/lib/index.ts b/modules/sdk-coin-kas/src/lib/index.ts new file mode 100644 index 0000000000..0916694ba0 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/index.ts @@ -0,0 +1,9 @@ +export { KeyPair } from './keyPair'; +export { Transaction } from './transaction'; +export { TransactionBuilder } from './transactionBuilder'; +export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export * from './iface'; +export * from './constants'; +export * from './utils'; +export * from './sighash'; +export { default as Utils, Utils as KasUtils } 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..a4263a5f6e --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/keyPair.ts @@ -0,0 +1,95 @@ +import { + DefaultKeys, + isPrivateKey, + isPublicKey, + isSeed, + isValidXprv, + isValidXpub, + KeyPairOptions, + Secp256k1ExtendedKeyPair, +} from '@bitgo/sdk-core'; +import { bip32, ECPair } from '@bitgo/secp256k1'; +import { randomBytes } from 'crypto'; +import { pubKeyToKaspaAddress, isValidPrivateKey } from './utils'; +import { MAINNET_PREFIX, TESTNET_PREFIX } from './constants'; + +const DEFAULT_SEED_SIZE_BYTES = 16; + +export class KeyPair extends Secp256k1ExtendedKeyPair { + /** + * Public constructor. By default, creates a key pair with a random master seed. + * + * @param { KeyPairOptions } source Either a master seed, a private key, or a 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 keypair from a private key or extended private key. + * + * @param {string} prv A raw private key + */ + recordKeysFromPrivateKey(prv: string): void { + if (!isValidPrivateKey(prv)) { + throw new Error('Unsupported private key'); + } + if (isValidXprv(prv)) { + this.hdNode = bip32.fromBase58(prv); + } else { + this.keyPair = ECPair.fromPrivateKey(Buffer.from(prv.slice(0, 64), 'hex')); + } + } + + /** + * Build an ECPair from a public key or extended public key. + * + * @param {string} pub A raw public key + */ + recordKeysFromPublicKey(pub: string): void { + if (isValidXpub(pub)) { + this.hdNode = bip32.fromBase58(pub); + } else { + this.keyPair = ECPair.fromPublicKey(Buffer.from(pub, 'hex')); + } + } + + /** + * Default keys format is a pair of hex keys. + * + * @returns { DefaultKeys } The keys in the defined format + */ + getKeys(): DefaultKeys { + return { + pub: this.getPublicKey({ compressed: true }).toString('hex'), + prv: this.getPrivateKey()?.toString('hex'), + }; + } + + /** + * Get a Kaspa mainnet address from this key pair. + * + * @returns {string} The bech32-encoded Kaspa address + */ + getAddress(network = 'mainnet'): string { + const hrp = network === 'testnet' ? TESTNET_PREFIX : MAINNET_PREFIX; + const compressedPub = this.getPublicKey({ compressed: true }); + return pubKeyToKaspaAddress(compressedPub, hrp); + } +} diff --git a/modules/sdk-coin-kas/src/lib/sighash.ts b/modules/sdk-coin-kas/src/lib/sighash.ts new file mode 100644 index 0000000000..6b60ccda2a --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/sighash.ts @@ -0,0 +1,230 @@ +/** + * Kaspa transaction sighash computation. + * + * Based on the Kaspa BIP-143-like sighash specification: + * https://github.com/kaspanet/docs/blob/main/Specs/BIP143-like%20SigHashes.md + * + * Uses Blake2b-256. All integer fields are little-endian. + */ +import { blake2b } from 'blakejs'; +import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; + +// SigHash type flags +export const SIGHASH_ALL = 0x01; +export const SIGHASH_NONE = 0x02; +export const SIGHASH_SINGLE = 0x04; +export const SIGHASH_ANYONECANPAY = 0x80; + +// Script constants +export const OP_CHECKSIG_SCHNORR = 0xab; // Kaspa Schnorr checksig opcode +export const SCRIPT_PUBLIC_KEY_VERSION = 0; // Standard P2PK version + +function blake2b256(data: Buffer): Buffer { + return Buffer.from(blake2b(data, undefined, 32)); +} + +/** + * Build P2PK Schnorr scriptPublicKey from a 32-byte x-only public key. + * Format: DATA_32(0x20) + xOnlyPubKey(32 bytes) + OP_CHECKSIG_SCHNORR(0xAB) + */ +export function buildP2PKScriptPublicKey(xOnlyPubKey: Buffer): Buffer { + if (xOnlyPubKey.length !== 32) { + throw new Error(`Expected 32-byte x-only pubkey, got ${xOnlyPubKey.length}`); + } + return Buffer.concat([Buffer.from([0x20]), xOnlyPubKey, Buffer.from([OP_CHECKSIG_SCHNORR])]); +} + +/** + * Derive x-only public key from 33-byte compressed public key. + */ +export function compressedToXOnly(compressedPubKey: Buffer): Buffer { + if (compressedPubKey.length !== 33) { + throw new Error(`Expected 33-byte compressed pubkey, got ${compressedPubKey.length}`); + } + return compressedPubKey.slice(1); // drop the 02/03 prefix byte +} + +// --- Intermediate hash helpers --- + +function hashPreviousOutputs(inputs: KaspaUtxoInput[]): Buffer { + const parts = inputs.map((inp) => { + const buf = Buffer.alloc(36); + Buffer.from(inp.transactionId, 'hex').copy(buf, 0); + buf.writeUInt32LE(inp.transactionIndex, 32); + return buf; + }); + return blake2b256(Buffer.concat(parts)); +} + +function hashSequences(inputs: KaspaUtxoInput[]): Buffer { + const buf = Buffer.alloc(inputs.length * 8); + inputs.forEach((inp, i) => { + buf.writeBigUInt64LE(BigInt(inp.sequence || '0'), i * 8); + }); + return blake2b256(buf); +} + +function hashSigOpCounts(inputs: KaspaUtxoInput[]): Buffer { + const bytes = inputs.map((inp) => inp.sigOpCount ?? 1); + return blake2b256(Buffer.from(bytes)); +} + +function serializeOutput(output: KaspaTransactionOutput): Buffer { + const scriptBytes = Buffer.from(output.scriptPublicKey || '', 'hex'); + const spkVersion = 0; // standard P2PK + // value (u64 LE, 8) + spk_version (u16 LE, 2) + script_length (u64 LE, 8) + script + const buf = Buffer.alloc(8 + 2 + 8 + scriptBytes.length); + let offset = 0; + buf.writeBigUInt64LE(BigInt(output.amount), offset); + offset += 8; + buf.writeUInt16LE(spkVersion, offset); + offset += 2; + buf.writeBigUInt64LE(BigInt(scriptBytes.length), offset); + offset += 8; + scriptBytes.copy(buf, offset); + return buf; +} + +function hashOutputs(tx: KaspaTransactionData, inputIndex: number, sigHashType: number): Buffer { + const baseType = sigHashType & 0x1f; + if (baseType === SIGHASH_NONE) { + return Buffer.alloc(32); // zero hash + } + if (baseType === SIGHASH_SINGLE) { + if (inputIndex >= tx.outputs.length) { + return Buffer.alloc(32); + } + return blake2b256(serializeOutput(tx.outputs[inputIndex])); + } + // SIGHASH_ALL + const parts = tx.outputs.map(serializeOutput); + return blake2b256(Buffer.concat(parts)); +} + +function hashPayload(tx: KaspaTransactionData): Buffer { + const subnetId = Buffer.from(tx.subnetworkId || '0000000000000000000000000000000000000000', 'hex'); + // If subnetwork is native (all zeros), payloadHash is zero + if (subnetId.every((b) => b === 0)) { + return Buffer.alloc(32); + } + return blake2b256(Buffer.from(tx.payload || '', 'hex')); +} + +/** + * Compute the Kaspa sighash for a specific input. + * + * @param tx Full transaction data + * @param inputIndex Index of the input being signed + * @param sigHashType SigHash type flags (use SIGHASH_ALL = 0x01 for standard) + */ +export function computeKaspaSigningHash( + tx: KaspaTransactionData, + inputIndex: number, + sigHashType: number = SIGHASH_ALL +): Buffer { + const anyoneCanPay = !!(sigHashType & SIGHASH_ANYONECANPAY); + const baseType = sigHashType & 0x1f; + + const input = tx.inputs[inputIndex]; + if (!input) { + throw new Error(`Input index ${inputIndex} out of range`); + } + + // Conditional intermediate hashes + const prevOutputsHash = anyoneCanPay ? Buffer.alloc(32) : hashPreviousOutputs(tx.inputs); + const seqHash = + anyoneCanPay || baseType === SIGHASH_SINGLE || baseType === SIGHASH_NONE + ? Buffer.alloc(32) + : hashSequences(tx.inputs); + const sigOpHash = anyoneCanPay ? Buffer.alloc(32) : hashSigOpCounts(tx.inputs); + const outsHash = hashOutputs(tx, inputIndex, sigHashType); + const payloadHash = hashPayload(tx); + + // Parse the current input's script public key + const scriptBytes = Buffer.from(input.scriptPublicKey || '', 'hex'); + const spkVersion = 0; // standard P2PK + const subnetId = Buffer.from(tx.subnetworkId || '0000000000000000000000000000000000000000', 'hex'); + + // Build the preimage + // version(2) + prevOutputsHash(32) + seqHash(32) + sigOpHash(32) + // + txId(32) + outIdx(4) + spkVersion(2) + spkLen(8) + spkBytes(n) + // + value(8) + sequence(8) + sigOpCount(1) + // + outsHash(32) + locktime(8) + subnetId(20) + gas(8) + payloadHash(32) + sigHashType(1) + const fixedSize = 2 + 32 + 32 + 32 + 32 + 4 + 2 + 8 + 8 + 8 + 1 + 32 + 8 + 20 + 8 + 32 + 1; + const preimage = Buffer.alloc(fixedSize + scriptBytes.length); + let offset = 0; + + // 1. version + preimage.writeUInt16LE(tx.version ?? 0, offset); + offset += 2; + + // 2. previousOutputsHash + prevOutputsHash.copy(preimage, offset); + offset += 32; + + // 3. sequencesHash + seqHash.copy(preimage, offset); + offset += 32; + + // 4. sigOpCountsHash + sigOpHash.copy(preimage, offset); + offset += 32; + + // 5. current input's previous outpoint txId + Buffer.from(input.transactionId, 'hex').copy(preimage, offset); + offset += 32; + + // 6. current input's previous outpoint index + preimage.writeUInt32LE(input.transactionIndex, offset); + offset += 4; + + // 7. scriptPublicKey version + preimage.writeUInt16LE(spkVersion, offset); + offset += 2; + + // 8. scriptPublicKey length (u64 LE) + preimage.writeBigUInt64LE(BigInt(scriptBytes.length), offset); + offset += 8; + + // 9. scriptPublicKey bytes + scriptBytes.copy(preimage, offset); + offset += scriptBytes.length; + + // 10. value (amount in sompi, u64 LE) + preimage.writeBigUInt64LE(BigInt(input.amount), offset); + offset += 8; + + // 11. sequence (u64 LE) + preimage.writeBigUInt64LE(BigInt(input.sequence || '0'), offset); + offset += 8; + + // 12. sigOpCount (u8) + preimage.writeUInt8(input.sigOpCount ?? 1, offset); + offset += 1; + + // 13. outputsHash + outsHash.copy(preimage, offset); + offset += 32; + + // 14. locktime (u64 LE) + preimage.writeBigUInt64LE(BigInt(tx.lockTime || '0'), offset); + offset += 8; + + // 15. subnetworkId (20 bytes) + subnetId.copy(preimage, offset); + offset += 20; + + // 16. gas (u64 LE) — always 0 for native KAS + preimage.writeBigUInt64LE(0n, offset); + offset += 8; + + // 17. payloadHash + payloadHash.copy(preimage, offset); + offset += 32; + + // 18. sigHashType (u8) + preimage.writeUInt8(sigHashType, offset); + offset += 1; + + return blake2b256(preimage.slice(0, offset)); +} 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..fe886cb03d --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/transaction.ts @@ -0,0 +1,152 @@ +import { BaseKey, BaseTransaction, TransactionType } from '@bitgo/sdk-core'; +import { ecc } from '@bitgo/secp256k1'; +import { KaspaTransactionData, TransactionExplanation } from './iface'; +import { computeKaspaSigningHash, SIGHASH_ALL } from './sighash'; + +export class Transaction extends BaseTransaction { + protected _txData: KaspaTransactionData; + + constructor(coin: string, txData?: KaspaTransactionData) { + super({ coin } as any); + this._txData = txData || { + version: 0, + inputs: [], + outputs: [], + }; + } + + /** @inheritDoc */ + get id(): string { + return this._txData.id || ''; + } + + get txData(): KaspaTransactionData { + return this._txData; + } + + /** + * Sign all inputs with the given private key using Schnorr signatures. + * + * Each input's `scriptPublicKey` must be populated (hex-encoded P2PK script). + * After signing, the 65-byte signature (64-byte Schnorr || 1-byte sighash type) + * is stored in `input.signatureScript` as a hex string. + * + * @param privateKey 32-byte private key buffer + * @param sigHashType SigHash type (default: SIGHASH_ALL = 0x01) + */ + sign(privateKey: Buffer, sigHashType: number = SIGHASH_ALL): void { + if (privateKey.length !== 32) { + throw new Error(`Expected 32-byte private key, got ${privateKey.length}`); + } + for (let i = 0; i < this._txData.inputs.length; i++) { + const sigHash = computeKaspaSigningHash(this._txData, i, sigHashType); + const sig = ecc.signSchnorr(sigHash, privateKey); + // 65-byte signature: 64-byte Schnorr sig + 1-byte sighash type + const sigWithType = Buffer.concat([Buffer.from(sig), Buffer.from([sigHashType])]); + this._txData.inputs[i].signatureScript = sigWithType.toString('hex'); + } + } + + /** + * Verify that a Schnorr signature on a specific input is valid. + * + * @param publicKey 33-byte compressed public key (or 32-byte x-only) + * @param inputIndex Index of the input to verify + * @param sigHashType SigHash type used when signing + */ + verifySignature(publicKey: Buffer, inputIndex: number, sigHashType: number = SIGHASH_ALL): boolean { + const input = this._txData.inputs[inputIndex]; + if (!input?.signatureScript) { + return false; + } + const sigBytes = Buffer.from(input.signatureScript, 'hex'); + if (sigBytes.length < 64) { + return false; + } + const sig = sigBytes.slice(0, 64); + const sigHash = computeKaspaSigningHash(this._txData, inputIndex, sigHashType); + // Accept 33-byte compressed or 32-byte x-only + const xOnlyPub = publicKey.length === 33 ? publicKey.slice(1) : publicKey; + return ecc.verifySchnorr(sigHash, xOnlyPub, sig); + } + + /** + * Explain the transaction in a human-readable format. + */ + explainTransaction(): TransactionExplanation { + let totalIn = BigInt(0); + let totalOut = BigInt(0); + + for (const input of this._txData.inputs) { + totalIn += BigInt(input.amount); + } + for (const output of this._txData.outputs) { + totalOut += BigInt(output.amount); + } + + const fee = (totalIn - totalOut).toString(); + + const outputs = this._txData.outputs.map((o) => ({ + address: o.address, + amount: o.amount, + })); + + return { + id: this._txData.id || '', + type: TransactionType.Send, + outputs, + outputAmount: totalOut.toString(), + changeOutputs: [], + changeAmount: '0', + fee: { fee: this._txData.fee || fee }, + inputs: this._txData.inputs, + }; + } + + /** + * Serialize the transaction to a JSON string (broadcast format). + */ + toBroadcastFormat(): string { + return JSON.stringify(this._txData); + } + + /** + * Serialize transaction to hex (JSON-encoded then hex-encoded). + */ + toHex(): string { + return Buffer.from(this.toBroadcastFormat()).toString('hex'); + } + + /** + * Return transaction data as a plain object. + */ + toJson(): KaspaTransactionData { + return { ...this._txData }; + } + + /** + * Deserialize from hex. + */ + static fromHex(coin: string, hex: string): Transaction { + const json = JSON.parse(Buffer.from(hex, 'hex').toString()); + return new Transaction(coin, json); + } + + /** + * Deserialize from JSON string or object. + */ + static fromJson(coin: string, data: string | KaspaTransactionData): Transaction { + const txData: KaspaTransactionData = typeof data === 'string' ? JSON.parse(data) : data; + return new Transaction(coin, txData); + } + + /** @inheritDoc */ + get signature(): string[] { + return this._txData.inputs.map((i) => i.signatureScript || ''); + } + + /** @inheritDoc */ + canSign(_key: BaseKey): boolean { + return true; + } +} 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..8f93dfa4c3 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/transactionBuilder.ts @@ -0,0 +1,148 @@ +import { BaseTransactionBuilder, BaseTransaction } from '@bitgo/sdk-core'; +import BigNumber from 'bignumber.js'; +import { Transaction } from './transaction'; +import { KaspaTransactionData, KaspaUtxoInput, KaspaTransactionOutput } from './iface'; +import { isValidKaspaAddress } from './utils'; +import { DEFAULT_FEE, TX_VERSION } from './constants'; + +export class TransactionBuilder extends BaseTransactionBuilder { + protected _transaction: Transaction; + protected _inputs: KaspaUtxoInput[] = []; + protected _outputs: KaspaTransactionOutput[] = []; + protected _fee: string = DEFAULT_FEE; + protected _fromAddress = ''; + + constructor(coinConfig: any) { + super(coinConfig); + this._transaction = new Transaction(coinConfig.name); + } + + /** @inheritDoc */ + protected get transaction(): BaseTransaction { + return this._transaction; + } + + /** @inheritDoc */ + protected set transaction(tx: BaseTransaction) { + this._transaction = tx as Transaction; + } + + /** + * Set the sender address. + */ + sender(address: string): this { + if (!isValidKaspaAddress(address)) { + throw new Error(`Invalid Kaspa address: ${address}`); + } + this._fromAddress = address; + return this; + } + + /** + * Add a recipient output. + */ + to(address: string, amount: string): this { + if (!isValidKaspaAddress(address)) { + throw new Error(`Invalid Kaspa recipient address: ${address}`); + } + this._outputs.push({ address, amount }); + return this; + } + + /** + * Set transaction fee in sompi. + */ + fee(fee: string): this { + this._fee = fee; + return this; + } + + /** + * Add a UTXO input. + */ + addInput(utxo: KaspaUtxoInput): this { + if (!utxo.transactionId || utxo.transactionIndex === undefined) { + throw new Error('Invalid UTXO: missing transactionId or transactionIndex'); + } + this._inputs.push(utxo); + return this; + } + + /** + * Add multiple UTXO inputs. + */ + addInputs(utxos: KaspaUtxoInput[]): this { + utxos.forEach((u) => this.addInput(u)); + return this; + } + + /** @inheritDoc */ + protected fromImplementation(rawTransaction: string): Transaction { + const tx = Transaction.fromHex((this as any)._coinConfig?.name || 'kas', rawTransaction); + this._transaction = tx; + this._inputs = tx.txData.inputs || []; + this._outputs = tx.txData.outputs || []; + this._fee = tx.txData.fee || DEFAULT_FEE; + return tx; + } + + /** @inheritDoc */ + protected async buildImplementation(): Promise { + const txData: KaspaTransactionData = { + version: TX_VERSION, + inputs: this._inputs, + outputs: this._outputs, + fee: this._fee, + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: '', + }; + + this._transaction = new Transaction((this as any)._coinConfig?.name || 'kas', txData); + return this._transaction; + } + + /** @inheritDoc */ + protected signImplementation(key: any): BaseTransaction { + // Signing will be performed outside via signTransaction on the coin class + return this._transaction; + } + + /** @inheritDoc */ + validateTransaction(_transaction?: BaseTransaction): void { + if (this._inputs.length === 0) { + throw new Error('At least one UTXO input is required'); + } + if (this._outputs.length === 0) { + throw new Error('At least one output is required'); + } + } + + /** @inheritDoc */ + validateKey(key: { key: string }): void { + // Key validation handled in KeyPair + } + + /** @inheritDoc */ + validateAddress(address: { address: string }): void { + if (!isValidKaspaAddress(address.address)) { + throw new Error(`Invalid Kaspa address: ${address.address}`); + } + } + + /** @inheritDoc */ + validateRawTransaction(rawTransaction: string): void { + try { + Transaction.fromHex((this as any)._coinConfig?.name || 'kas', rawTransaction); + } catch { + throw new Error('Invalid raw Kaspa transaction'); + } + } + + /** @inheritDoc */ + validateValue(value: BigNumber): void { + if (value.isLessThan(0)) { + throw new Error('Transaction value cannot be negative'); + } + } +} 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..2e59e3f671 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/transactionBuilderFactory.ts @@ -0,0 +1,26 @@ +import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { TransactionBuilder } from './transactionBuilder'; + +export class TransactionBuilderFactory { + protected _coinConfig: Readonly; + + constructor(coinConfig: Readonly) { + this._coinConfig = coinConfig; + } + + /** + * Get a base transaction builder for Kaspa transfers. + */ + getBuilder(): TransactionBuilder { + return new TransactionBuilder(this._coinConfig); + } + + /** + * Reconstruct a transaction builder from a raw transaction hex. + */ + from(rawTransaction: string): TransactionBuilder { + const builder = this.getBuilder(); + builder.from(rawTransaction); + return builder; + } +} 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..5ef51cbc47 --- /dev/null +++ b/modules/sdk-coin-kas/src/lib/utils.ts @@ -0,0 +1,260 @@ +import { BaseUtils, isValidXprv, isValidXpub } from '@bitgo/sdk-core'; +import { MAINNET_PREFIX, TESTNET_PREFIX } from './constants'; + +// Kaspa address encoding uses a bech32-like scheme with ':' as separator +// and a custom checksum polynomial (same as Bitcoin Cash cashaddr). + +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const CHARSET_REVERSE: Record = {}; +for (let i = 0; i < CHARSET.length; i++) { + CHARSET_REVERSE[CHARSET[i]] = i; +} + +const GENERATOR = [ + BigInt('0x98f2bc8e61'), + BigInt('0x79b76d99e2'), + BigInt('0xf33e5fb3c4'), + BigInt('0xae2eabe2a8'), + BigInt('0x1e4f43e470'), +]; + +function polymod(values: number[]): bigint { + let c = 1n; + for (const d of values) { + const c0 = c >> 35n; + c = ((c & BigInt('0x07ffffffff')) << 5n) ^ BigInt(d); + for (let i = 0; i < 5; i++) { + if ((c0 >> BigInt(i)) & 1n) { + c ^= GENERATOR[i]; + } + } + } + return c ^ 1n; +} + +function prefixExpand(prefix: string): number[] { + return [...prefix].map((c) => c.charCodeAt(0) & 0x1f).concat([0]); +} + +function createChecksum(prefix: string, data: number[]): number[] { + const values = prefixExpand(prefix).concat(data).concat([0, 0, 0, 0, 0, 0, 0, 0]); + const mod = polymod(values); + const ret: number[] = []; + for (let i = 7; i >= 0; i--) { + ret.push(Number((mod >> BigInt(5 * i)) & 31n)); + } + return ret; +} + +function verifyChecksum(prefix: string, data: number[]): boolean { + const values = prefixExpand(prefix).concat(data); + return polymod(values) === 0n; +} + +function convertBits(data: Buffer | Uint8Array, from: number, to: number, pad: boolean): number[] { + let acc = 0; + let bits = 0; + const ret: number[] = []; + const maxv = (1 << to) - 1; + for (const value of data) { + acc = (acc << from) | value; + bits += from; + while (bits >= to) { + bits -= to; + ret.push((acc >> bits) & maxv); + } + } + if (pad && bits > 0) { + ret.push((acc << (to - bits)) & maxv); + } + return ret; +} + +/** + * Encode data into a Kaspa bech32-like address. + */ +function kaspaEncode(prefix: string, data: number[]): string { + const checksum = createChecksum(prefix, data); + return ( + prefix + + ':' + + data + .concat(checksum) + .map((d) => CHARSET[d]) + .join('') + ); +} + +/** + * Decode a Kaspa bech32-like address. + * Returns { prefix, data } or throws on error. + */ +function kaspacDecode(address: string): { prefix: string; data: number[] } { + const colonIdx = address.lastIndexOf(':'); + if (colonIdx < 1) { + throw new Error('Missing prefix separator'); + } + const prefix = address.slice(0, colonIdx).toLowerCase(); + const encoded = address.slice(colonIdx + 1).toLowerCase(); + + const data: number[] = []; + for (const c of encoded) { + const val = CHARSET_REVERSE[c]; + if (val === undefined) { + throw new Error(`Invalid character: ${c}`); + } + data.push(val); + } + + if (!verifyChecksum(prefix, data)) { + throw new Error('Invalid checksum'); + } + + return { prefix, data: data.slice(0, -8) }; +} + +/** + * Validates a Kaspa address (mainnet or testnet) + */ +export function isValidKaspaAddress(address: string): boolean { + if (!address || typeof address !== 'string') { + return false; + } + + const colonIdx = address.lastIndexOf(':'); + if (colonIdx < 1) { + return false; + } + + const prefix = address.slice(0, colonIdx).toLowerCase(); + if (prefix !== MAINNET_PREFIX && prefix !== TESTNET_PREFIX) { + return false; + } + + try { + const decoded = kaspacDecode(address); + return decoded.data.length > 0; + } catch { + return false; + } +} + +/** + * Validates a Kaspa mainnet address + */ +export function isValidMainnetAddress(address: string): boolean { + return isValidKaspaAddress(address) && address.toLowerCase().startsWith(MAINNET_PREFIX + ':'); +} + +/** + * Validates a Kaspa testnet address + */ +export function isValidTestnetAddress(address: string): boolean { + return isValidKaspaAddress(address) && address.toLowerCase().startsWith(TESTNET_PREFIX + ':'); +} + +/** + * Derive a Kaspa P2PK (Schnorr) address from a compressed secp256k1 public key. + * + * Kaspa P2PK uses x-only public key (32 bytes) with version nibble 0. + * + * @param compressedPubKey - 33-byte compressed secp256k1 public key (hex string or Buffer) + * @param hrp - human-readable part ('kaspa' or 'kaspatest') + */ +export function pubKeyToKaspaAddress(compressedPubKey: string | Buffer, hrp: string): string { + const pubKeyBytes = Buffer.isBuffer(compressedPubKey) + ? compressedPubKey + : Buffer.from(compressedPubKey as string, 'hex'); + + if (pubKeyBytes.length !== 33) { + throw new Error(`Expected 33-byte compressed public key, got ${pubKeyBytes.length}`); + } + + // X-only public key: drop the prefix byte (02 or 03), keep 32-byte x-coordinate + const xOnlyPubKey = pubKeyBytes.slice(1); + + // Kaspa P2PK address: + // - version nibble: 0 (Schnorr secp256k1 P2PK) + // - payload: x-only public key (32 bytes) + // Convert 8-bit groups to 5-bit groups for the address body + const versionByte = 0; + const payload = Buffer.concat([Buffer.from([versionByte]), xOnlyPubKey]); + const words = convertBits(payload, 8, 5, true); + + return kaspaEncode(hrp, words); +} + +/** + * Validates a secp256k1 public key (compressed or uncompressed) + */ +export function isValidPublicKey(pub: string): boolean { + if (!pub || typeof pub !== 'string') { + return false; + } + try { + const buf = Buffer.from(pub, 'hex'); + if (buf.length === 33) { + return buf[0] === 0x02 || buf[0] === 0x03; + } + if (buf.length === 65) { + return buf[0] === 0x04; + } + return false; + } catch { + return false; + } +} + +/** + * Validates a secp256k1 private key (32-byte hex) + */ +export function isValidPrivateKey(prv: string): boolean { + if (!prv || typeof prv !== 'string') { + return false; + } + if (isValidXprv(prv) || isValidXpub(prv)) { + return true; + } + try { + const buf = Buffer.from(prv.slice(0, 64), 'hex'); + return buf.length === 32; + } catch { + return false; + } +} + +/** + * Validates a transaction ID (64-char hex) + */ +export function isValidTransactionId(txId: string): boolean { + return /^[0-9a-fA-F]{64}$/.test(txId); +} + +export class Utils implements BaseUtils { + isValidAddress(address: string): boolean { + return isValidKaspaAddress(address); + } + + isValidBlockId(hash: string): boolean { + return /^[0-9a-fA-F]{64}$/.test(hash); + } + + isValidPrivateKey(key: string): boolean { + return isValidPrivateKey(key); + } + + isValidPublicKey(key: string): boolean { + return isValidPublicKey(key); + } + + isValidSignature(signature: string): boolean { + return /^[0-9a-fA-F]{128,130}$/.test(signature); + } + + isValidTransactionId(txId: string): boolean { + return isValidTransactionId(txId); + } +} + +const utils = new Utils(); +export default utils; diff --git a/modules/sdk-coin-kas/src/register.ts b/modules/sdk-coin-kas/src/register.ts new file mode 100644 index 0000000000..a76ec60a22 --- /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..4a978eb20a --- /dev/null +++ b/modules/sdk-coin-kas/src/tkas.ts @@ -0,0 +1,13 @@ +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..b0c094fa58 --- /dev/null +++ b/modules/sdk-coin-kas/test/fixtures/kas.fixtures.ts @@ -0,0 +1,99 @@ +/** + * Kaspa (KAS) Test Fixtures + * + * Deterministic test data derived from a fixed private key. + * Do NOT use these keys in production. + */ + +import { KeyPair } from '../../src/lib/keyPair'; +import { compressedToXOnly, buildP2PKScriptPublicKey } from '../../src/lib/sighash'; +import { KaspaTransactionData } from '../../src/lib/iface'; + +// Fixed 32-byte private key for deterministic tests only +export const TEST_PRV_KEY = 'b94f5374fce5edbc8e2a8697c15331677e6ebf0b000000000000000000000001'; + +const _kp = new KeyPair({ prv: TEST_PRV_KEY }); +const _keys = _kp.getKeys(); +const _xOnlyPub = compressedToXOnly(Buffer.from(_keys.pub, 'hex')); +const _scriptPublicKey = buildP2PKScriptPublicKey(_xOnlyPub).toString('hex'); + +export const KEYS = { + prv: TEST_PRV_KEY, + pub: _keys.pub, +}; + +export const ADDRESSES = { + valid: _kp.getAddress('mainnet'), + testnet: _kp.getAddress('testnet'), + invalid: 'notanaddress', + sender: _kp.getAddress('mainnet'), + recipient: _kp.getAddress('mainnet'), +}; + +export const SCRIPT_PUBLIC_KEY = _scriptPublicKey; + +// Getters return fresh objects to prevent mutation leakage between tests +export const UTXOS = { + get simple() { + return { + transactionId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionIndex: 0, + amount: '100000000', + scriptPublicKey: _scriptPublicKey, + sequence: '0', + sigOpCount: 1, + }; + }, + get second() { + return { + transactionId: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + transactionIndex: 1, + amount: '200000000', + scriptPublicKey: _scriptPublicKey, + sequence: '0', + sigOpCount: 1, + }; + }, +}; + +// Use getter functions to return fresh deep copies, preventing test mutation leakage +export const TRANSACTIONS = { + get simple(): KaspaTransactionData { + return JSON.parse( + JSON.stringify({ + version: 0, + inputs: [UTXOS.simple], + outputs: [ + { + address: _kp.getAddress('mainnet'), + amount: '99998000', + scriptPublicKey: _scriptPublicKey, + }, + ], + fee: '2000', + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: '', + }) + ) as KaspaTransactionData; + }, + get multiInput(): KaspaTransactionData { + return JSON.parse( + JSON.stringify({ + version: 0, + inputs: [UTXOS.simple, UTXOS.second], + outputs: [ + { + address: _kp.getAddress('mainnet'), + amount: '299998000', + scriptPublicKey: _scriptPublicKey, + }, + ], + fee: '2000', + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + payload: '', + }) + ) as KaspaTransactionData; + }, +}; diff --git a/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts b/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts new file mode 100644 index 0000000000..534daba1fb --- /dev/null +++ b/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts @@ -0,0 +1,61 @@ +/** + * Kaspa test fixtures. + * These are synthetic test vectors for unit testing the SDK module. + */ + +import { KaspaTransactionData } from '../../src/lib/iface'; + +// Test key vectors (secp256k1) +export const testKeyData = { + prv: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + pub: '03d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35', + mainnetAddress: '', // will be computed in tests + testnetAddress: '', // will be computed in tests +}; + +// Test UTXO +export const testUtxo = { + txid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + outputIndex: 0, + address: '', // will be set in tests based on derived address + amount: '200000000', // 2 KAS in sompi + scriptPublicKey: { + version: 0, + script: '21' + '03d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35' + 'ac', + }, +}; + +// Test transaction data +export const testTransactionData: KaspaTransactionData = { + version: 0, + inputs: [ + { + previousOutpoint: { + transactionId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + index: 0, + }, + signatureScript: '', + sequence: '18446744073709551615', + sigOpCount: 1, + utxoEntry: { + amount: '200000000', + scriptPublicKey: { version: 0, script: '2103d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35ac' }, + blockDaaScore: '0', + isCoinbase: false, + }, + }, + ], + outputs: [ + { + value: '100000000', // 1 KAS + scriptPublicKey: { version: 0, script: '2103d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35ac' }, + }, + ], + lockTime: '0', + subnetworkId: '0000000000000000000000000000000000000000', + gas: '0', + payload: '', +}; + +// Hex-serialized test transaction +export const testTransactionHex = Buffer.from(JSON.stringify(testTransactionData)).toString('hex'); 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..917e66fc6c --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/coin.test.ts @@ -0,0 +1,100 @@ +import * as should from 'should'; +import { coins } from '@bitgo/statics'; +import { Kas, Tkas } from '../../src'; +import { KeyPair } from '../../src/lib/keyPair'; + +describe('Kaspa (KAS)', function () { + let kas: Kas; + let tkas: Tkas; + + before(function () { + const mockBitgo = { + url: () => '', + microservicesUrl: () => '', + post: () => ({ result: () => Promise.resolve({}) }), + get: () => ({ result: () => Promise.resolve({}) }), + } as any; + kas = new Kas(mockBitgo, coins.get('kas')); + tkas = new Tkas(mockBitgo, coins.get('tkas')); + }); + + describe('Coin Properties', function () { + it('should have the correct chain name', function () { + kas.getChain().should.equal('kas'); + tkas.getChain().should.equal('tkas'); + }); + + it('should have the correct family', function () { + kas.getFamily().should.equal('kas'); + }); + + it('should have the correct full name', function () { + kas.getFullName().should.equal('Kaspa'); + tkas.getFullName().should.equal('Testnet Kaspa'); + }); + + it('should have the correct base factor (10^8)', function () { + kas.getBaseFactor().should.equal(100000000); + }); + + it('should support TSS (ECDSA MPC)', function () { + kas.supportsTss().should.be.true(); + kas.getMPCAlgorithm().should.equal('ecdsa'); + }); + }); + + describe('Key Validation', function () { + it('should validate a valid public key', function () { + kas.isValidPub('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798').should.be.true(); + }); + + it('should reject an invalid public key', function () { + kas.isValidPub('not-a-key').should.be.false(); + }); + + it('should validate a valid private key', function () { + kas.isValidPrv('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2').should.be.true(); + }); + }); + + describe('Key Generation', function () { + it('should generate a key pair', function () { + const kp = kas.generateKeyPair(); + should.exist(kp.pub); + should.exist(kp.prv); + kp.pub!.should.have.length(66); + kp.prv!.should.have.length(64); + }); + + it('should generate a key pair from seed', function () { + const seed = Buffer.alloc(32, 1); + const kp = kas.generateKeyPair(seed); + should.exist(kp.pub); + should.exist(kp.prv); + }); + + it('should generate consistent keys from same seed', function () { + const seed = Buffer.alloc(32, 42); + const kp1 = kas.generateKeyPair(seed); + const kp2 = kas.generateKeyPair(seed); + kp1.pub!.should.equal(kp2.pub!); + kp1.prv!.should.equal(kp2.prv!); + }); + }); + + describe('Address Validation', function () { + it('should validate a mainnet address', function () { + const kp = new KeyPair(); + const address = kp.getAddress('mainnet'); + kas.isValidAddress(address).should.be.true(); + }); + + it('should reject an invalid address', function () { + kas.isValidAddress('not-an-address').should.be.false(); + }); + + it('should reject empty address', function () { + kas.isValidAddress('').should.be.false(); + }); + }); +}); diff --git a/modules/sdk-coin-kas/test/unit/kas.test.ts b/modules/sdk-coin-kas/test/unit/kas.test.ts new file mode 100644 index 0000000000..33e1bc8751 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/kas.test.ts @@ -0,0 +1,59 @@ +import assert from 'assert'; +import { KeyPair, isValidKaspaAddress } from '../../src/lib'; + +describe('Kaspa (KAS)', function () { + describe('KeyPair', function () { + it('should generate a random key pair', function () { + const kp = new KeyPair(); + const keys = kp.getKeys(); + assert.ok(keys.pub, 'pub key should exist'); + assert.ok(keys.prv, 'prv key should exist'); + assert.equal(typeof keys.pub, 'string'); + assert.equal(typeof keys.prv, 'string'); + }); + + it('should create key pair from private key', function () { + const kp = new KeyPair(); + const keys = kp.getKeys(); + const kp2 = new KeyPair({ prv: keys.prv! }); + assert.equal(kp2.getKeys().pub, keys.pub); + }); + + it('should create key pair from public key', function () { + const kp = new KeyPair(); + const keys = kp.getKeys(); + const kp2 = new KeyPair({ pub: keys.pub }); + assert.equal(kp2.getKeys().pub, keys.pub); + }); + + it('should derive a valid Kaspa mainnet address', function () { + const kp = new KeyPair(); + const address = kp.getAddress('mainnet'); + assert.ok(address.startsWith('kaspa:'), `Expected kaspa: prefix, got ${address}`); + assert.ok(isValidKaspaAddress(address), `Expected valid address, got ${address}`); + }); + + it('should derive a valid Kaspa testnet address', function () { + const kp = new KeyPair(); + const address = kp.getAddress('testnet'); + assert.ok(address.startsWith('kaspatest:'), `Expected kaspatest: prefix, got ${address}`); + assert.ok(isValidKaspaAddress(address), `Expected valid address, got ${address}`); + }); + }); + + describe('Address Validation', function () { + it('should reject invalid addresses', function () { + assert.equal(isValidKaspaAddress(''), false); + assert.equal(isValidKaspaAddress('invalid'), false); + assert.equal(isValidKaspaAddress('bitcoin:123'), false); + }); + + it('should accept valid addresses derived from key pair', function () { + const kp = new KeyPair(); + const mainnetAddr = kp.getAddress('mainnet'); + const testnetAddr = kp.getAddress('testnet'); + assert.ok(isValidKaspaAddress(mainnetAddr)); + assert.ok(isValidKaspaAddress(testnetAddr)); + }); + }); +}); 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..7552406d75 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/keyPair.test.ts @@ -0,0 +1,62 @@ +import * as should from 'should'; +import { KeyPair } from '../../src/lib/keyPair'; +import { testKeyData } from '../fixtures/kasFixtures'; + +describe('Kaspa KeyPair', function () { + describe('Key Generation', function () { + it('should generate a random key pair', function () { + const kp = new KeyPair(); + const keys = kp.getKeys(); + should.exist(keys.pub); + should.exist(keys.prv); + keys.pub.should.have.length(66); // compressed secp256k1 public key + keys.prv!.should.have.length(64); // 32-byte private key in hex + }); + + it('should create a key pair from a private key', function () { + const kp = new KeyPair({ prv: testKeyData.prv }); + const keys = kp.getKeys(); + keys.pub.should.equal(testKeyData.pub); + keys.prv!.should.equal(testKeyData.prv); + }); + + it('should create a key pair from a public key', function () { + const kp = new KeyPair({ pub: testKeyData.pub }); + const keys = kp.getKeys(); + keys.pub.should.equal(testKeyData.pub); + should.not.exist(keys.prv); + }); + + it('should throw for an invalid private key', function () { + (() => new KeyPair({ prv: 'invalid-prv' })).should.throw(); + }); + }); + + describe('Address Derivation', function () { + it('should derive a mainnet address', function () { + const kp = new KeyPair({ prv: testKeyData.prv }); + const address = kp.getAddress('mainnet'); + address.should.startWith('kaspa'); + address.should.containEql(':'); + }); + + it('should derive a testnet address', function () { + const kp = new KeyPair({ prv: testKeyData.prv }); + const address = kp.getAddress('testnet'); + address.should.startWith('kaspatest'); + address.should.containEql(':'); + }); + + it('should derive consistent addresses for the same key', function () { + const kp1 = new KeyPair({ prv: testKeyData.prv }); + const kp2 = new KeyPair({ prv: testKeyData.prv }); + kp1.getAddress('mainnet').should.equal(kp2.getAddress('mainnet')); + }); + + it('should derive different addresses for different keys', function () { + const kp1 = new KeyPair({ prv: testKeyData.prv }); + const kp2 = new KeyPair(); + kp1.getAddress('mainnet').should.not.equal(kp2.getAddress('mainnet')); + }); + }); +}); 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..334838ca6b --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/transaction.test.ts @@ -0,0 +1,233 @@ +import assert from 'assert'; +import { Transaction } from '../../src/lib/transaction'; +import { TransactionType } from '@bitgo/sdk-core'; +import { SIGHASH_ALL } from '../../src/lib/sighash'; +import { KEYS, TRANSACTIONS } from '../fixtures/kas.fixtures'; + +const COIN = 'kas'; + +describe('Kaspa Transaction', function () { + describe('Constructor', function () { + it('should create an empty transaction', function () { + const tx = new Transaction(COIN); + assert.ok(tx); + assert.deepEqual(tx.txData.inputs, []); + assert.deepEqual(tx.txData.outputs, []); + }); + + it('should create a transaction from txData', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.equal(tx.txData.inputs.length, 1); + assert.equal(tx.txData.outputs.length, 1); + assert.equal(tx.txData.fee, '2000'); + }); + }); + + describe('id getter', function () { + it('should return empty string when no id is set', function () { + const tx = new Transaction(COIN); + assert.equal(tx.id, ''); + }); + + it('should return the transaction id if set', function () { + const txData = { ...TRANSACTIONS.simple, id: 'deadbeef' + '00'.repeat(30) }; + const tx = new Transaction(COIN, txData); + assert.equal(tx.id, txData.id); + }); + }); + + describe('signature getter', function () { + it('should return empty signatures for unsigned tx', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const sigs = tx.signature; + assert.equal(sigs.length, TRANSACTIONS.simple.inputs.length); + assert.ok(sigs.every((s) => s === '')); + }); + + it('should return signature scripts after signing', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + const sigs = tx.signature; + assert.equal(sigs.length, 1); + assert.ok(sigs[0].length > 0); + }); + }); + + describe('canSign', function () { + it('should always return true', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.ok(tx.canSign({ key: KEYS.prv })); + }); + }); + + describe('sign', function () { + it('should throw on non-32-byte private key', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + assert.throws(() => { + tx.sign(Buffer.from('0102', 'hex')); + }, /32-byte/); + }); + + it('should sign all inputs with a valid private key', function () { + const tx = new Transaction(COIN, { ...TRANSACTIONS.simple }); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + for (const input of tx.txData.inputs) { + assert.ok(input.signatureScript, 'Each input should have a signatureScript'); + assert.ok(input.signatureScript!.length > 0); + } + }); + + it('should sign multiple inputs', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + assert.equal(tx.txData.inputs.length, 2); + for (const input of tx.txData.inputs) { + assert.ok(input.signatureScript && input.signatureScript.length > 0); + } + }); + + it('should produce 65-byte signatures (64 Schnorr + 1 sighash type)', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + const sigHex = tx.txData.inputs[0].signatureScript!; + // 65 bytes = 130 hex chars + assert.equal(sigHex.length, 130); + // Last byte is sighash type (0x01 = SIGHASH_ALL) + const lastByte = parseInt(sigHex.slice(-2), 16); + assert.equal(lastByte, SIGHASH_ALL); + }); + }); + + describe('verifySignature', function () { + it('should return false for unsigned input', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.equal(tx.verifySignature(pubKey, 0), false); + }); + + it('should return true after signing with matching key', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.ok(tx.verifySignature(pubKey, 0)); + }); + + it('should accept x-only (32-byte) public key', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + // x-only = drop 02/03 prefix byte + const xOnlyPub = Buffer.from(KEYS.pub, 'hex').slice(1); + assert.ok(tx.verifySignature(xOnlyPub, 0)); + }); + + it('should return false for wrong public key', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + // Use a different key + const wrongPub = Buffer.from('02' + 'ab'.repeat(32), 'hex'); + assert.equal(tx.verifySignature(wrongPub, 0), false); + }); + + it('should return false for out-of-range input index', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.equal(tx.verifySignature(pubKey, 99), false); + }); + }); + + describe('explainTransaction', function () { + it('should return a TransactionExplanation with correct fields', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const explanation = tx.explainTransaction(); + + assert.equal(explanation.id, ''); + assert.equal(explanation.type, TransactionType.Send); + assert.equal(explanation.outputs.length, 1); + assert.equal(explanation.outputs[0].amount, '99998000'); + assert.equal(explanation.inputs.length, 1); + assert.equal(explanation.inputs[0].amount, '100000000'); + }); + + it('should calculate the fee as totalIn - totalOut', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const explanation = tx.explainTransaction(); + // fee = 100000000 - 99998000 = 2000 + assert.equal(explanation.fee.fee, '2000'); + }); + + it('should sum all outputs in outputAmount', function () { + const tx = new Transaction(COIN, TRANSACTIONS.multiInput); + const explanation = tx.explainTransaction(); + assert.equal(explanation.outputAmount, '299998000'); + }); + }); + + describe('Serialization', function () { + it('toJson should return a copy of txData', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const json = tx.toJson(); + assert.deepEqual(json, TRANSACTIONS.simple); + // Should be a copy, not the same reference + assert.notEqual(json, tx.txData); + }); + + it('toBroadcastFormat should return a JSON string', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const broadcast = tx.toBroadcastFormat(); + assert.equal(typeof broadcast, 'string'); + const parsed = JSON.parse(broadcast); + assert.equal(parsed.version, 0); + assert.equal(parsed.inputs.length, 1); + }); + + it('toHex should return hex-encoded JSON', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const hex = tx.toHex(); + assert.ok(/^[0-9a-fA-F]+$/.test(hex)); + const decoded = Buffer.from(hex, 'hex').toString(); + const parsed = JSON.parse(decoded); + assert.equal(parsed.version, 0); + }); + + it('fromHex should reconstruct the transaction', function () { + const original = new Transaction(COIN, TRANSACTIONS.simple); + const hex = original.toHex(); + const restored = Transaction.fromHex(COIN, hex); + assert.deepEqual(restored.toJson(), original.toJson()); + }); + + it('fromJson should reconstruct from object', function () { + const original = new Transaction(COIN, TRANSACTIONS.simple); + const json = original.toJson(); + const restored = Transaction.fromJson(COIN, json); + assert.deepEqual(restored.toJson(), json); + }); + + it('fromJson should reconstruct from string', function () { + const original = new Transaction(COIN, TRANSACTIONS.simple); + const jsonStr = JSON.stringify(original.toJson()); + const restored = Transaction.fromJson(COIN, jsonStr); + assert.deepEqual(restored.toJson(), original.toJson()); + }); + + it('round-trip through toHex/fromHex should preserve signatures', function () { + const tx = new Transaction(COIN, TRANSACTIONS.simple); + const privKey = Buffer.from(KEYS.prv, 'hex'); + tx.sign(privKey); + + const hex = tx.toHex(); + const restored = Transaction.fromHex(COIN, hex); + + assert.deepEqual(restored.signature, tx.signature); + }); + }); +}); 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..cd2ab75a1d --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/transactionBuilder.test.ts @@ -0,0 +1,229 @@ +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { Transaction } from '../../src/lib/transaction'; +import { TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; +import { DEFAULT_FEE } from '../../src/lib/constants'; +import { ADDRESSES, UTXOS } from '../fixtures/kas.fixtures'; + +const coinConfig = coins.get('kas'); + +describe('Kaspa TransactionBuilder', function () { + let builder: TransactionBuilder; + + beforeEach(function () { + builder = new TransactionBuilder(coinConfig); + }); + + describe('sender', function () { + it('should set the sender address and return this (fluent)', function () { + const result = builder.sender(ADDRESSES.sender); + assert.equal(result, builder); + }); + + it('should throw on invalid sender address', function () { + assert.throws(() => { + builder.sender(ADDRESSES.invalid); + }, /Invalid Kaspa address/); + }); + }); + + describe('to', function () { + it('should add an output and return this (fluent)', function () { + const result = builder.to(ADDRESSES.recipient, '1000'); + assert.equal(result, builder); + }); + + it('should throw on invalid recipient address', function () { + assert.throws(() => { + builder.to('notanaddress', '1000'); + }, /Invalid Kaspa/); + }); + }); + + describe('fee', function () { + it('should set the fee and return this', function () { + const result = builder.fee('5000'); + assert.equal(result, builder); + }); + + it('should default to DEFAULT_FEE', async function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000'); + const tx = (await builder.build()) as Transaction; + assert.equal(tx.txData.fee, DEFAULT_FEE); + }); + }); + + describe('addInput', function () { + it('should add a UTXO input and return this', function () { + const result = builder.addInput(UTXOS.simple); + assert.equal(result, builder); + }); + + it('should throw when transactionId is missing', function () { + assert.throws(() => { + builder.addInput({ ...UTXOS.simple, transactionId: '' }); + }, /Invalid UTXO/); + }); + + it('should throw when transactionIndex is undefined', function () { + assert.throws(() => { + builder.addInput({ ...UTXOS.simple, transactionIndex: undefined as unknown as number }); + }, /Invalid UTXO/); + }); + }); + + describe('addInputs', function () { + it('should add multiple inputs', function () { + builder.addInputs([UTXOS.simple, UTXOS.second]); + // No error thrown; build should reflect 2 inputs + }); + }); + + describe('validateTransaction', function () { + it('should throw when no inputs are set', function () { + builder.to(ADDRESSES.recipient, '1000'); + assert.throws(() => { + builder.validateTransaction(); + }, /At least one UTXO input/); + }); + + it('should throw when no outputs are set', function () { + builder.addInput(UTXOS.simple); + assert.throws(() => { + builder.validateTransaction(); + }, /At least one output/); + }); + + it('should not throw with valid inputs and outputs', function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000'); + assert.doesNotThrow(() => { + builder.validateTransaction(); + }); + }); + }); + + describe('validateAddress', function () { + it('should not throw for a valid address', function () { + assert.doesNotThrow(() => { + builder.validateAddress({ address: ADDRESSES.valid }); + }); + }); + + it('should throw for an invalid address', function () { + assert.throws(() => { + builder.validateAddress({ address: 'invalid' }); + }, /Invalid Kaspa address/); + }); + }); + + describe('validateRawTransaction', function () { + it('should not throw for valid hex', async function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000'); + const tx = (await builder.build()) as Transaction; + const hex = tx.toHex(); + assert.doesNotThrow(() => { + builder.validateRawTransaction(hex); + }); + }); + + it('should throw for invalid hex', function () { + assert.throws(() => { + builder.validateRawTransaction('notvalidhex!!'); + }, /Invalid raw Kaspa/); + }); + }); + + describe('validateValue', function () { + it('should not throw for non-negative value', function () { + const BigNumber = require('bignumber.js').default ?? require('bignumber.js'); + assert.doesNotThrow(() => { + builder.validateValue(new BigNumber(0)); + }); + assert.doesNotThrow(() => { + builder.validateValue(new BigNumber(1000000)); + }); + }); + + it('should throw for negative value', function () { + const BigNumber = require('bignumber.js').default ?? require('bignumber.js'); + assert.throws(() => { + builder.validateValue(new BigNumber(-1)); + }, /negative/); + }); + }); + + describe('build', function () { + it('should build a valid transaction from inputs + outputs', async function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await builder.build()) as Transaction; + + assert.ok(tx instanceof Transaction); + assert.equal(tx.txData.version, 0); + assert.equal(tx.txData.inputs.length, 1); + assert.equal(tx.txData.outputs.length, 1); + assert.equal(tx.txData.outputs[0].amount, '99998000'); + assert.equal(tx.txData.fee, '2000'); + }); + + it('should build a multi-input transaction', async function () { + builder.addInputs([UTXOS.simple, UTXOS.second]).to(ADDRESSES.recipient, '299998000').fee('2000'); + const tx = (await builder.build()) as Transaction; + + assert.equal(tx.txData.inputs.length, 2); + assert.equal(tx.txData.outputs.length, 1); + }); + + it('should include all standard fields', async function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000'); + const tx = (await builder.build()) as Transaction; + + assert.equal(tx.txData.version, 0); + assert.equal(tx.txData.lockTime, '0'); + assert.equal(tx.txData.subnetworkId, '0000000000000000000000000000000000000000'); + }); + }); + + describe('from (rebuild from hex)', function () { + it('should reconstruct a builder from a serialized transaction', async function () { + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await builder.build()) as Transaction; + const hex = tx.toHex(); + + const newBuilder = new TransactionBuilder(coinConfig); + newBuilder.from(hex); + const rebuilt = (await newBuilder.build()) as Transaction; + + assert.deepEqual(rebuilt.toJson(), tx.toJson()); + }); + }); +}); + +describe('Kaspa TransactionBuilderFactory', function () { + let factory: TransactionBuilderFactory; + + beforeEach(function () { + factory = new TransactionBuilderFactory(coinConfig); + }); + + describe('getBuilder', function () { + it('should return a new TransactionBuilder', function () { + const builder = factory.getBuilder(); + assert.ok(builder instanceof TransactionBuilder); + }); + }); + + describe('from', function () { + it('should reconstruct a builder from a serialized transaction hex', async function () { + const originalBuilder = factory.getBuilder(); + originalBuilder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await originalBuilder.build()) as Transaction; + const hex = tx.toHex(); + + const rebuiltBuilder = factory.from(hex); + const rebuiltTx = (await rebuiltBuilder.build()) as Transaction; + + assert.deepEqual(rebuiltTx.toJson(), tx.toJson()); + }); + }); +}); 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..2c1717e0c0 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/transactionFlow.test.ts @@ -0,0 +1,134 @@ +/** + * Kaspa End-to-End Transaction Flow + * + * Covers: build → sign → serialize → deserialize → verify + * All operations are offline (no live RPC calls). + */ + +import assert from 'assert'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilder } from '../../src/lib/transactionBuilder'; +import { Transaction } from '../../src/lib/transaction'; +import { KEYS, ADDRESSES, UTXOS } from '../fixtures/kas.fixtures'; + +const coinConfig = coins.get('kas'); +const PRV_KEY_BUF = Buffer.from(KEYS.prv, 'hex'); + +describe('Kaspa — End-to-End Transaction Flow', function () { + it('should build, sign, serialize, deserialize, and verify a simple transaction', async function () { + // Step 1: Build + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + + const tx = (await builder.build()) as Transaction; + assert.ok(tx instanceof Transaction, 'build() should return a Transaction'); + assert.equal(tx.txData.inputs.length, 1, 'should have 1 input'); + assert.equal(tx.txData.outputs.length, 1, 'should have 1 output'); + assert.equal(tx.txData.outputs[0].amount, '99998000'); + + // Step 2: Sign + tx.sign(PRV_KEY_BUF); + const sigs = tx.signature; + assert.ok(sigs.length > 0, 'should have signatures after signing'); + assert.ok(sigs[0].length > 0, 'signature should be non-empty'); + + // Step 3: Verify signature with public key + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.ok(tx.verifySignature(pubKey, 0), 'signature should verify against correct pubkey'); + + // Step 4: Serialize to broadcast format (JSON string for Kaspa) + const broadcastPayload = tx.toBroadcastFormat(); + assert.ok(broadcastPayload, 'toBroadcastFormat should return a non-empty string'); + assert.doesNotThrow(() => JSON.parse(broadcastPayload), 'broadcast format should be valid JSON'); + + // Step 5: Serialize to hex (for storage/transport) + const hex = tx.toHex(); + assert.ok(/^[0-9a-fA-F]+$/.test(hex), 'toHex should return valid hex'); + + // Step 6: Deserialize and verify round-trip + const reloaded = Transaction.fromHex(coinConfig.name, hex); + assert.deepEqual(reloaded.txData.inputs, tx.txData.inputs); + assert.deepEqual(reloaded.txData.outputs, tx.txData.outputs); + assert.equal(reloaded.txData.fee, tx.txData.fee); + + // Step 7: Explain the transaction + const explanation = tx.explainTransaction(); + assert.ok(explanation.outputs.length > 0, 'explanation should have outputs'); + assert.equal(explanation.outputs[0].amount, '99998000'); + assert.ok(explanation.fee, 'explanation should have fee'); + }); + + it('should NOT be considered fully signed when unsigned', async function () { + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + + const tx = (await builder.build()) as Transaction; + const sigs = tx.signature; + + // Unsigned: all signatureScript fields should be empty + assert.ok( + sigs.every((s) => s === ''), + 'unsigned transaction should have empty signature scripts' + ); + + // toBroadcastFormat still works but produces an unsigned payload + const payload = tx.toBroadcastFormat(); + const parsed = JSON.parse(payload); + assert.ok(!parsed.inputs[0].signatureScript, 'inputs should not have signatureScript when unsigned'); + }); + + it('should sign multiple inputs independently', async function () { + const builder = new TransactionBuilder(coinConfig); + builder.addInputs([UTXOS.simple, UTXOS.second]).to(ADDRESSES.recipient, '299998000').fee('2000'); + + const tx = (await builder.build()) as Transaction; + tx.sign(PRV_KEY_BUF); + + const pubKey = Buffer.from(KEYS.pub, 'hex'); + assert.equal(tx.txData.inputs.length, 2); + assert.ok(tx.verifySignature(pubKey, 0), 'first input signature should verify'); + assert.ok(tx.verifySignature(pubKey, 1), 'second input signature should verify'); + }); + + it('should rebuild from serialized hex and produce identical broadcast format', async function () { + // Build and sign + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await builder.build()) as Transaction; + tx.sign(PRV_KEY_BUF); + + const originalHex = tx.toHex(); + const originalPayload = tx.toBroadcastFormat(); + + // Reload and re-serialize + const rebuilt = Transaction.fromHex(coinConfig.name, originalHex); + const rebuiltPayload = rebuilt.toBroadcastFormat(); + + assert.equal(rebuiltPayload, originalPayload, 'serialization should be deterministic'); + }); + + it('should produce a valid RPC-submittable JSON broadcast payload', async function () { + const builder = new TransactionBuilder(coinConfig); + builder.addInput(UTXOS.simple).to(ADDRESSES.recipient, '99998000').fee('2000'); + const tx = (await builder.build()) as Transaction; + tx.sign(PRV_KEY_BUF); + + const payload = tx.toBroadcastFormat(); + + assert.ok(payload.length > 0, 'payload must be non-empty'); + const parsed = JSON.parse(payload); + + // Required Kaspa transaction fields + assert.ok(parsed.version !== undefined, 'must have version'); + assert.ok(Array.isArray(parsed.inputs), 'must have inputs array'); + assert.ok(Array.isArray(parsed.outputs), 'must have outputs array'); + assert.ok(parsed.inputs.length > 0, 'must have at least one input'); + assert.ok(parsed.outputs.length > 0, 'must have at least one output'); + + // Each signed input must have a signatureScript + for (const input of parsed.inputs) { + assert.ok(input.signatureScript, 'signed input must have signatureScript'); + assert.equal(input.signatureScript.length, 130, 'Schnorr sig should be 65 bytes = 130 hex chars'); + } + }); +}); 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..5907269905 --- /dev/null +++ b/modules/sdk-coin-kas/test/unit/utils.test.ts @@ -0,0 +1,202 @@ +import assert from 'assert'; +import { + isValidKaspaAddress, + isValidMainnetAddress, + isValidTestnetAddress, + isValidPublicKey, + isValidPrivateKey, + isValidTransactionId, + pubKeyToKaspaAddress, + Utils, +} from '../../src/lib/utils'; +import { KeyPair } from '../../src/lib/keyPair'; +import { KEYS, ADDRESSES, UTXOS } from '../fixtures/kas.fixtures'; + +describe('Kaspa Utils', function () { + describe('isValidKaspaAddress', function () { + it('should accept a valid mainnet address', function () { + assert.ok(isValidKaspaAddress(ADDRESSES.valid)); + }); + + it('should accept a valid testnet address', function () { + assert.ok(isValidKaspaAddress(ADDRESSES.testnet)); + }); + + it('should reject an empty string', function () { + assert.equal(isValidKaspaAddress(''), false); + }); + + it('should reject a non-kaspa address format', function () { + assert.equal(isValidKaspaAddress('bitcoin:qp9dksrqz9'), false); + }); + + it('should reject a plain invalid string', function () { + assert.equal(isValidKaspaAddress('notanaddress'), false); + }); + + it('should reject null-like input', function () { + assert.equal(isValidKaspaAddress(null as unknown as string), false); + }); + }); + + describe('isValidMainnetAddress', function () { + it('should accept a valid mainnet address', function () { + assert.ok(isValidMainnetAddress(ADDRESSES.valid)); + }); + + it('should reject a testnet address', function () { + assert.equal(isValidMainnetAddress(ADDRESSES.testnet), false); + }); + + it('should reject an empty string', function () { + assert.equal(isValidMainnetAddress(''), false); + }); + }); + + describe('isValidTestnetAddress', function () { + it('should accept a valid testnet address', function () { + assert.ok(isValidTestnetAddress(ADDRESSES.testnet)); + }); + + it('should reject a mainnet address', function () { + assert.equal(isValidTestnetAddress(ADDRESSES.valid), false); + }); + + it('should reject an empty string', function () { + assert.equal(isValidTestnetAddress(''), false); + }); + }); + + describe('pubKeyToKaspaAddress', function () { + it('should derive a valid mainnet address from compressed public key', function () { + const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); + const address = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspa'); + assert.ok(address.startsWith('kaspa:')); + assert.ok(isValidKaspaAddress(address)); + }); + + it('should derive a valid testnet address from compressed public key', function () { + const pubKeyBuffer = Buffer.from(KEYS.pub, 'hex'); + const address = pubKeyToKaspaAddress(pubKeyBuffer, 'kaspatest'); + assert.ok(address.startsWith('kaspatest:')); + assert.ok(isValidKaspaAddress(address)); + }); + + it('should accept hex string public key', function () { + const address = pubKeyToKaspaAddress(KEYS.pub, 'kaspa'); + assert.ok(address.startsWith('kaspa:')); + }); + + it('should throw for non-33-byte public key', function () { + assert.throws(() => { + pubKeyToKaspaAddress(Buffer.from('0102030405', 'hex'), 'kaspa'); + }); + }); + + it('should produce same address as KeyPair.getAddress', function () { + const kp = new KeyPair({ pub: KEYS.pub }); + const fromKp = kp.getAddress('mainnet'); + const fromUtil = pubKeyToKaspaAddress(Buffer.from(KEYS.pub, 'hex'), 'kaspa'); + assert.equal(fromUtil, fromKp); + }); + }); + + describe('isValidPublicKey', function () { + it('should accept a valid compressed 33-byte public key', function () { + assert.ok(isValidPublicKey(KEYS.pub)); + }); + + it('should reject an empty string', function () { + assert.equal(isValidPublicKey(''), false); + }); + + it('should reject a string that is not hex', function () { + assert.equal(isValidPublicKey('not_a_key'), false); + }); + + it('should reject incorrect key length', function () { + assert.equal(isValidPublicKey('0102030405'), false); + }); + + it('should accept uncompressed 65-byte public key', function () { + // Construct a fake 65-byte uncompressed key starting with 0x04 + const fakeUncompressed = '04' + 'ab'.repeat(64); + assert.ok(isValidPublicKey(fakeUncompressed)); + }); + + it('should reject a null-like value', function () { + assert.equal(isValidPublicKey(null as unknown as string), false); + }); + }); + + describe('isValidPrivateKey', function () { + it('should accept a valid 32-byte hex private key', function () { + assert.ok(isValidPrivateKey(KEYS.prv)); + }); + + it('should reject an empty string', function () { + assert.equal(isValidPrivateKey(''), false); + }); + + it('should reject a non-hex string', function () { + assert.equal(isValidPrivateKey('not_a_key'), false); + }); + + it('should reject null-like value', function () { + assert.equal(isValidPrivateKey(null as unknown as string), false); + }); + }); + + describe('isValidTransactionId', function () { + it('should accept a valid 64-char hex transaction ID', function () { + assert.ok(isValidTransactionId(UTXOS.simple.transactionId)); + }); + + it('should reject short hex strings', function () { + assert.equal(isValidTransactionId('abcdef1234'), false); + }); + + it('should reject non-hex strings', function () { + assert.equal(isValidTransactionId('xyz' + 'a'.repeat(61)), false); + }); + + it('should reject empty string', function () { + assert.equal(isValidTransactionId(''), false); + }); + }); + + describe('Utils class', function () { + const utils = new Utils(); + + it('isValidAddress should delegate to isValidKaspaAddress', function () { + assert.ok(utils.isValidAddress(ADDRESSES.valid)); + assert.equal(utils.isValidAddress('invalid'), false); + }); + + it('isValidBlockId should accept 64-char hex', function () { + assert.ok(utils.isValidBlockId(UTXOS.simple.transactionId)); + assert.equal(utils.isValidBlockId('short'), false); + }); + + it('isValidPrivateKey should accept valid key', function () { + assert.ok(utils.isValidPrivateKey(KEYS.prv)); + }); + + it('isValidPublicKey should accept valid key', function () { + assert.ok(utils.isValidPublicKey(KEYS.pub)); + }); + + it('isValidSignature should accept 128-char hex', function () { + const sig = 'ab'.repeat(64); + assert.ok(utils.isValidSignature(sig)); + }); + + it('isValidSignature should reject short strings', function () { + assert.equal(utils.isValidSignature('abcd'), false); + }); + + it('isValidTransactionId should accept 64-char hex', function () { + assert.ok(utils.isValidTransactionId(UTXOS.simple.transactionId)); + }); + }); +}); 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..ec4315e13f 100644 --- a/modules/sdk-core/src/bitgo/environments.ts +++ b/modules/sdk-core/src/bitgo/environments.ts @@ -78,6 +78,7 @@ interface EnvironmentTemplate { sgbExplorerApiToken?: string; icpNodeUrl: string; hyperLiquidNodeUrl: string; + kasNodeUrl?: string; wemixExplorerBaseUrl?: string; wemixExplorerApiToken?: string; monExplorerBaseUrl?: string; @@ -350,6 +351,7 @@ const mainnetBase: EnvironmentTemplate = { }, icpNodeUrl: 'https://ic0.app', hyperLiquidNodeUrl: 'https://api.hyperliquid.xyz', + kasNodeUrl: 'wss://mainnet.kaspa.green', worldExplorerBaseUrl: 'https://worldscan.org/', somniaExplorerBaseUrl: 'https://mainnet.somnia.w3us.site/', soneiumExplorerBaseUrl: 'https://soneium.blockscout.com', @@ -428,6 +430,7 @@ const testnetBase: EnvironmentTemplate = { sgbExplorerBaseUrl: 'https://coston-explorer.flare.network', icpNodeUrl: 'https://ic0.app', hyperLiquidNodeUrl: 'https://api.hyperliquid-testnet.xyz', + kasNodeUrl: 'wss://testnet-10.kaspa.green', monExplorerBaseUrl: 'https://api.etherscan.io/v2', worldExplorerBaseUrl: 'https://sepolia.worldscan.org/', somniaExplorerBaseUrl: 'https://shannon-explorer.somnia.network/', diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index 7c19cd4ee9..fb1a862b0d 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -75,7 +75,9 @@ import { jettonTokens } from './coins/jettonTokens'; import { polyxTokens } from './coins/polyxTokens'; import { cantonTokens } from './coins/cantonTokens'; import { flrp } from './flrp'; +import { kas } from './kas'; import { hypeEvm } from './hypeevm'; +import { kas } from './kas'; import { ACCOUNT_COIN_DEFAULT_FEATURES_EXCLUDE_SINGAPORE_AND_MENA_FZE, ADA_FEATURES, @@ -187,6 +189,8 @@ export const allCoinsAndTokens = [ Networks.test.flrP, UnderlyingAsset.FLRP ), + kas('a7c52b3e-1f4d-4e8a-9d2b-6c3a5f8e1b9c', 'kas', 'Kaspa', Networks.main.kaspa, UnderlyingAsset.KAS), + kas('b8d63c4f-2e5e-5f9b-ae3c-7d4b6f9e2c0d', 'tkas', 'Testnet Kaspa', Networks.test.kaspa, UnderlyingAsset.KAS), ada( 'fd4d125e-f14f-414b-bd17-6cb1393265f0', 'ada', diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 647aa15870..4685edbafd 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -78,8 +78,10 @@ export enum CoinFamily { ISLM = 'islm', JOVAYETH = 'jovayeth', KAIA = 'kaia', + KAS = 'kas', KAVACOSMOS = 'kavacosmos', KAVAEVM = 'kavaevm', + KAS = 'kas', LNBTC = 'lnbtc', LTC = 'ltc', MANTLE = 'mantle', @@ -3815,6 +3817,7 @@ export enum BaseUnit { TAO = 'rao', ICP = 'e8s', HYPE = 'hype', + KAS = 'sompi', MANTRA = 'uom', POLYX = 'micropolyx', CRONOS = 'basecro', @@ -3826,6 +3829,7 @@ export enum BaseUnit { TASI = 'atestfet', CANTON = 'canton', USDC = 'usdc', + KAS = 'sompi', } export interface BaseCoinConstructorOptions { diff --git a/modules/statics/src/coins/erc20Coins.ts b/modules/statics/src/coins/erc20Coins.ts index 962bdd869d..0d66a7cc0a 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..16da3d3fab --- /dev/null +++ b/modules/statics/src/kas.ts @@ -0,0 +1,90 @@ +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; +} + +export class KASCoin extends BaseCoin { + public static readonly DEFAULT_FEATURES = [ + CoinFeature.UNSPENT_MODEL, + 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, + CoinFeature.MULTISIG_COLD, + CoinFeature.MULTISIG, + ]; + + public readonly network: KaspaNetwork; + + constructor(options: KASConstructorOptions) { + super({ + ...options, + kind: CoinKind.CRYPTO, + isToken: false, + decimalPlaces: 8, + 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 + * @param fullName Complete human-readable name of the coin + * @param network Network object for this coin + * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. + * @param features? Features of this coin. Defaults to the DEFAULT_FEATURES defined in `KASCoin` + * @param prefix? Optional coin prefix. Defaults to empty string + * @param suffix? Optional coin suffix. Defaults to coin name. + * @param primaryKeyCurve The elliptic curve for this chain/token + */ +export function kas( + id: string, + name: string, + fullName: string, + network: KaspaNetwork, + asset: UnderlyingAsset, + features: CoinFeature[] = KASCoin.DEFAULT_FEATURES, + prefix = '', + suffix: string = name.toUpperCase(), + /** Kaspa uses secp256k1 with Schnorr signatures **/ + 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..76b4a366de 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -124,6 +124,15 @@ export interface AccountNetwork extends BaseNetwork { readonly blockExplorerUrl?: string; } +/** + * Kaspa network interface — UTXO-based BlockDAG (GHOSTDAG / Proof-of-Work). + */ +export interface KaspaNetwork extends BaseNetwork { + readonly hrp: string; // Bech32 human-readable part: 'kaspa' | 'kaspatest' + readonly accountExplorerUrl: string; + readonly txFee: string; // Minimum fee in sompi +} + export interface CosmosNetwork extends AccountNetwork { readonly addressPrefix: string; readonly validatorPrefix: string; @@ -192,6 +201,15 @@ export interface StacksNetwork extends AccountNetwork { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OfcNetwork extends BaseNetwork {} +export interface KaspaNetwork extends BaseNetwork { + name: string; + family: CoinFamily; + explorerUrl: string; + accountExplorerUrl: string; + /** Human-readable part for bech32 address encoding (e.g., 'kaspa' for mainnet) */ + hrp: string; +} + abstract class Mainnet extends BaseNetwork { type = NetworkType.MAINNET; } @@ -1698,6 +1716,22 @@ class KavaEVM extends Mainnet implements EthereumNetwork { nativeCoinOperationHashPrefix = '2222'; } +export class Kaspa extends Mainnet implements KaspaNetwork { + name = 'Kaspa'; + family = CoinFamily.KAS; + explorerUrl = 'https://explorer.kaspa.org/txs/'; + accountExplorerUrl = 'https://explorer.kaspa.org/addresses/'; + hrp = 'kaspa'; +} + +export class KaspaTestnet extends Testnet implements KaspaNetwork { + name = 'KaspaTestnet'; + family = CoinFamily.KAS; + explorerUrl = 'https://explorer-tn10.kaspa.org/txs/'; + accountExplorerUrl = 'https://explorer-tn10.kaspa.org/addresses/'; + hrp = 'kaspatest'; +} + class LineaETH extends Mainnet implements EthereumNetwork { name = 'Linea Ethereum Mainnet'; family = CoinFamily.LINEAETH; @@ -1974,6 +2008,24 @@ class KaiaTestnet extends Testnet implements EthereumNetwork { walletImplementationAddress = '0x944fef03af368414f29dc31a72061b8d64f568d2'; } +class Kaspa extends Mainnet implements KaspaNetwork { + name = 'Kaspa'; + family = CoinFamily.KAS; + explorerUrl = 'https://explorer.kaspa.org/txs/'; + accountExplorerUrl = 'https://explorer.kaspa.org/addresses/'; + hrp = 'kaspa'; + txFee = '1000'; // minimum fee in sompi +} + +class KaspaTestnet extends Testnet implements KaspaNetwork { + name = 'KaspaTestnet'; + family = CoinFamily.KAS; + explorerUrl = 'https://explorer-tn10.kaspa.org/txs/'; + accountExplorerUrl = 'https://explorer-tn10.kaspa.org/addresses/'; + hrp = 'kaspatest'; + txFee = '1000'; // minimum fee in sompi +} + class Irys extends Mainnet implements EthereumNetwork { name = 'Irys'; family = CoinFamily.IRYS; @@ -2704,6 +2756,7 @@ export const Networks = { islm: Object.freeze(new Islm()), jovayeth: Object.freeze(new JovayETH()), kaia: Object.freeze(new Kaia()), + kaspa: Object.freeze(new Kaspa()), kavacosmos: Object.freeze(new KavaCosmos()), kavaevm: Object.freeze(new KavaEVM()), lnbtc: Object.freeze(new LightningBitcoin()), @@ -2827,6 +2880,7 @@ export const Networks = { irys: Object.freeze(new IrysTestnet()), islm: Object.freeze(new IslmTestnet()), jovayeth: Object.freeze(new JovayETHTestnet()), + kaspa: Object.freeze(new KaspaTestnet()), kavacosmos: Object.freeze(new KavaCosmosTestnet()), kavaevm: Object.freeze(new KavaEVMTestnet()), kovan: Object.freeze(new Kovan()), From 0caea8b88d86891d77ef87769069e59ed5a73f18 Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Tue, 7 Apr 2026 13:33:57 +0530 Subject: [PATCH 2/8] fix(sdk-coin-kas): resolve duplicate definitions and signature validation - Remove duplicate CoinFamily.KAS and BaseUnit.KAS enum entries in base.ts - Remove duplicate KaspaNetwork interface declaration in networks.ts - Remove duplicate non-exported Kaspa/KaspaTestnet class definitions - Add missing txFee field to exported Kaspa/KaspaTestnet classes - Remove duplicate kas import in allCoinsAndTokens.ts - Fix signature validation to require 65 bytes (64 Schnorr + 1 sighash type) Co-Authored-By: Claude Sonnet 4.6 --- modules/sdk-coin-kas/src/lib/transaction.ts | 2 +- modules/statics/src/allCoinsAndTokens.ts | 1 - modules/statics/src/base.ts | 2 -- modules/statics/src/networks.ts | 27 ++------------------- 4 files changed, 3 insertions(+), 29 deletions(-) diff --git a/modules/sdk-coin-kas/src/lib/transaction.ts b/modules/sdk-coin-kas/src/lib/transaction.ts index fe886cb03d..b90801b27c 100644 --- a/modules/sdk-coin-kas/src/lib/transaction.ts +++ b/modules/sdk-coin-kas/src/lib/transaction.ts @@ -60,7 +60,7 @@ export class Transaction extends BaseTransaction { return false; } const sigBytes = Buffer.from(input.signatureScript, 'hex'); - if (sigBytes.length < 64) { + if (sigBytes.length < 65) { return false; } const sig = sigBytes.slice(0, 64); diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index fb1a862b0d..146a981498 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -77,7 +77,6 @@ import { cantonTokens } from './coins/cantonTokens'; import { flrp } from './flrp'; import { kas } from './kas'; import { hypeEvm } from './hypeevm'; -import { kas } from './kas'; import { ACCOUNT_COIN_DEFAULT_FEATURES_EXCLUDE_SINGAPORE_AND_MENA_FZE, ADA_FEATURES, diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 4685edbafd..26e097a057 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -81,7 +81,6 @@ export enum CoinFamily { KAS = 'kas', KAVACOSMOS = 'kavacosmos', KAVAEVM = 'kavaevm', - KAS = 'kas', LNBTC = 'lnbtc', LTC = 'ltc', MANTLE = 'mantle', @@ -3829,7 +3828,6 @@ export enum BaseUnit { TASI = 'atestfet', CANTON = 'canton', USDC = 'usdc', - KAS = 'sompi', } export interface BaseCoinConstructorOptions { diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 76b4a366de..0220655ad0 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -201,14 +201,6 @@ export interface StacksNetwork extends AccountNetwork { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OfcNetwork extends BaseNetwork {} -export interface KaspaNetwork extends BaseNetwork { - name: string; - family: CoinFamily; - explorerUrl: string; - accountExplorerUrl: string; - /** Human-readable part for bech32 address encoding (e.g., 'kaspa' for mainnet) */ - hrp: string; -} abstract class Mainnet extends BaseNetwork { type = NetworkType.MAINNET; @@ -1722,6 +1714,7 @@ export class Kaspa extends Mainnet implements KaspaNetwork { explorerUrl = 'https://explorer.kaspa.org/txs/'; accountExplorerUrl = 'https://explorer.kaspa.org/addresses/'; hrp = 'kaspa'; + txFee = '1000'; } export class KaspaTestnet extends Testnet implements KaspaNetwork { @@ -1730,6 +1723,7 @@ export class KaspaTestnet extends Testnet implements KaspaNetwork { explorerUrl = 'https://explorer-tn10.kaspa.org/txs/'; accountExplorerUrl = 'https://explorer-tn10.kaspa.org/addresses/'; hrp = 'kaspatest'; + txFee = '1000'; } class LineaETH extends Mainnet implements EthereumNetwork { @@ -2008,23 +2002,6 @@ class KaiaTestnet extends Testnet implements EthereumNetwork { walletImplementationAddress = '0x944fef03af368414f29dc31a72061b8d64f568d2'; } -class Kaspa extends Mainnet implements KaspaNetwork { - name = 'Kaspa'; - family = CoinFamily.KAS; - explorerUrl = 'https://explorer.kaspa.org/txs/'; - accountExplorerUrl = 'https://explorer.kaspa.org/addresses/'; - hrp = 'kaspa'; - txFee = '1000'; // minimum fee in sompi -} - -class KaspaTestnet extends Testnet implements KaspaNetwork { - name = 'KaspaTestnet'; - family = CoinFamily.KAS; - explorerUrl = 'https://explorer-tn10.kaspa.org/txs/'; - accountExplorerUrl = 'https://explorer-tn10.kaspa.org/addresses/'; - hrp = 'kaspatest'; - txFee = '1000'; // minimum fee in sompi -} class Irys extends Mainnet implements EthereumNetwork { name = 'Irys'; From cfa9186dfb2b03e446ea0c75ae3536f1be0c5ae9 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 7 Apr 2026 14:19:28 +0530 Subject: [PATCH 3/8] feat(sdk-coin-kas): add Kaspa SDK reference and update dependencies --- modules/account-lib/tsconfig.json | 3 +++ modules/sdk-coin-kas/package.json | 8 +++--- .../sdk-coin-kas/test/fixtures/kasFixtures.ts | 25 ++++++------------- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/modules/account-lib/tsconfig.json b/modules/account-lib/tsconfig.json index 4b6b79cabf..98b5f4ed45 100644 --- a/modules/account-lib/tsconfig.json +++ b/modules/account-lib/tsconfig.json @@ -79,6 +79,9 @@ { "path": "../sdk-coin-islm" }, + { + "path": "../sdk-coin-kas" + }, { "path": "../sdk-coin-mon" }, diff --git a/modules/sdk-coin-kas/package.json b/modules/sdk-coin-kas/package.json index dc62e9bb38..c6967ea5f8 100644 --- a/modules/sdk-coin-kas/package.json +++ b/modules/sdk-coin-kas/package.json @@ -40,12 +40,12 @@ ] }, "devDependencies": { - "@bitgo/sdk-api": "^1.76.0", - "@bitgo/sdk-test": "^9.1.34" + "@bitgo/sdk-api": "^1.76.1", + "@bitgo/sdk-test": "^9.1.35" }, "dependencies": { - "@bitgo/sdk-core": "^36.36.0", - "@bitgo/secp256k1": "^1.10.0", + "@bitgo/sdk-core": "^36.37.0", + "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.32.0", "bech32": "^2.0.0", "blakejs": "^1.2.1", diff --git a/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts b/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts index 534daba1fb..315442e6fb 100644 --- a/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts +++ b/modules/sdk-coin-kas/test/fixtures/kasFixtures.ts @@ -19,10 +19,7 @@ export const testUtxo = { outputIndex: 0, address: '', // will be set in tests based on derived address amount: '200000000', // 2 KAS in sompi - scriptPublicKey: { - version: 0, - script: '21' + '03d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35' + 'ac', - }, + scriptPublicKey: '21' + '03d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35' + 'ac', }; // Test transaction data @@ -30,30 +27,24 @@ export const testTransactionData: KaspaTransactionData = { version: 0, inputs: [ { - previousOutpoint: { - transactionId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - index: 0, - }, + transactionId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transactionIndex: 0, + amount: '200000000', + scriptPublicKey: '2103d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35ac', signatureScript: '', sequence: '18446744073709551615', sigOpCount: 1, - utxoEntry: { - amount: '200000000', - scriptPublicKey: { version: 0, script: '2103d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35ac' }, - blockDaaScore: '0', - isCoinbase: false, - }, }, ], outputs: [ { - value: '100000000', // 1 KAS - scriptPublicKey: { version: 0, script: '2103d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35ac' }, + address: '', + amount: '100000000', // 1 KAS + scriptPublicKey: '2103d6bfe100d1600c0d8f769501676fc74c3809500bd131c8a549f88cf616c21f35ac', }, ], lockTime: '0', subnetworkId: '0000000000000000000000000000000000000000', - gas: '0', payload: '', }; From 4924e2fdb00a59fa424e92b65f99ee5caab9c853 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 7 Apr 2026 14:29:54 +0530 Subject: [PATCH 4/8] feat(sdk-coin-kas): add Kaspa SDK reference and update dependencies --- CODEOWNERS | 1 + Dockerfile | 1 + modules/account-lib/package.json | 1 + modules/account-lib/src/index.ts | 5 +++++ modules/sdk-coin-kas/src/index.ts | 1 + tsconfig.packages.json | 3 +++ 6 files changed, 12 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index c117c16963..296c80a3a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -76,6 +76,7 @@ /modules/sdk-coin-icp/ @BitGo/ethalt-team /modules/sdk-coin-initia/ @BitGo/ethalt-team /modules/sdk-coin-iota/ @BitGo/ethalt-team +/modules/sdk-coin-kas/ @BitGo/ethalt-team /modules/sdk-coin-mon/ @BitGo/ethalt-team /modules/sdk-coin-mantra/ @BitGo/ethalt-team /modules/sdk-coin-near/ @BitGo/ethalt-team diff --git a/Dockerfile b/Dockerfile index 49e234d06d..0f715ebba3 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/ diff --git a/modules/account-lib/package.json b/modules/account-lib/package.json index da470a6d2f..388e60fd5f 100644 --- a/modules/account-lib/package.json +++ b/modules/account-lib/package.json @@ -59,6 +59,7 @@ "@bitgo/sdk-coin-initia": "^2.6.0", "@bitgo/sdk-coin-injective": "^3.7.0", "@bitgo/sdk-coin-islm": "^2.6.0", + "@bitgo/sdk-coin-kas": "^1.0.0", "@bitgo/sdk-coin-mon": "^1.8.0", "@bitgo/sdk-coin-near": "^2.17.0", "@bitgo/sdk-coin-oas": "^2.7.0", diff --git a/modules/account-lib/src/index.ts b/modules/account-lib/src/index.ts index 9e20042797..0890804326 100644 --- a/modules/account-lib/src/index.ts +++ b/modules/account-lib/src/index.ts @@ -95,6 +95,9 @@ export { Injective }; import * as Islm from '@bitgo/sdk-coin-islm'; export { Islm }; +import * as Kas from '@bitgo/sdk-coin-kas'; +export { Kas }; + import * as Zeta from '@bitgo/sdk-coin-zeta'; export { Zeta }; @@ -299,6 +302,8 @@ const coinBuilderMap = { ttempo: Tempo.Tip20TransactionBuilder, icp: Icp.TransactionBuilder, ticp: Icp.TransactionBuilder, + kas: Kas.TransactionBuilderFactory, + tkas: Kas.TransactionBuilderFactory, baby: Baby.TransactionBuilder, tbaby: Baby.TransactionBuilder, cronos: Cronos.TransactionBuilder, diff --git a/modules/sdk-coin-kas/src/index.ts b/modules/sdk-coin-kas/src/index.ts index 67a06f1ffa..5787d4f9f9 100644 --- a/modules/sdk-coin-kas/src/index.ts +++ b/modules/sdk-coin-kas/src/index.ts @@ -1,4 +1,5 @@ export * from './kas'; export * from './tkas'; +export * from './lib'; export * as KasLib from './lib'; export * from './register'; diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 63617d8625..f61ea5dec9 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -166,6 +166,9 @@ { "path": "./modules/sdk-coin-islm" }, + { + "path": "./modules/sdk-coin-kas" + }, { "path": "./modules/sdk-coin-lnbtc" }, From 5b976f71fa645e1b0af65ace3e06ec6a7bd3167e Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 7 Apr 2026 14:34:02 +0530 Subject: [PATCH 5/8] feat(sdk-coin-kas): add yarn link for Kaspa SDK module in Dockerfile --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0f715ebba3..3d948637ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -196,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 && \ @@ -299,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 && \ From 2f63afd118145724c943228fdbbf4fa26dd2969e Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 7 Apr 2026 14:43:16 +0530 Subject: [PATCH 6/8] feat(sdk-coin-kas): add .prettierignore and update dependencies in package.json --- modules/sdk-coin-kas/.prettierignore | 3 +++ modules/sdk-coin-kas/package.json | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 modules/sdk-coin-kas/.prettierignore diff --git a/modules/sdk-coin-kas/.prettierignore b/modules/sdk-coin-kas/.prettierignore new file mode 100644 index 0000000000..c429bd99a8 --- /dev/null +++ b/modules/sdk-coin-kas/.prettierignore @@ -0,0 +1,3 @@ +.nyc_output/ +dist/.nyc_output/ +dist/ diff --git a/modules/sdk-coin-kas/package.json b/modules/sdk-coin-kas/package.json index c6967ea5f8..7f6d64ae78 100644 --- a/modules/sdk-coin-kas/package.json +++ b/modules/sdk-coin-kas/package.json @@ -47,11 +47,8 @@ "@bitgo/sdk-core": "^36.37.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.32.0", - "bech32": "^2.0.0", - "blakejs": "^1.2.1", - "bs58": "^6.0.0", - "create-hash": "^1.2.0", - "safe-buffer": "^5.2.1" + "bignumber.js": "^9.1.1", + "blakejs": "^1.2.1" }, "files": [ "dist" From da4827b494027a8987f7baef1e47c0f4b2db5784 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 7 Apr 2026 14:52:28 +0530 Subject: [PATCH 7/8] refactor(sdk-coin-kas): remove unused devDependencies and clean up tsconfig references --- modules/sdk-coin-kas/package.json | 4 ---- modules/sdk-coin-kas/tsconfig.json | 6 ------ modules/statics/src/networks.ts | 2 -- 3 files changed, 12 deletions(-) diff --git a/modules/sdk-coin-kas/package.json b/modules/sdk-coin-kas/package.json index 7f6d64ae78..7e45dc8d57 100644 --- a/modules/sdk-coin-kas/package.json +++ b/modules/sdk-coin-kas/package.json @@ -39,10 +39,6 @@ ".ts" ] }, - "devDependencies": { - "@bitgo/sdk-api": "^1.76.1", - "@bitgo/sdk-test": "^9.1.35" - }, "dependencies": { "@bitgo/sdk-core": "^36.37.0", "@bitgo/secp256k1": "^1.11.0", diff --git a/modules/sdk-coin-kas/tsconfig.json b/modules/sdk-coin-kas/tsconfig.json index 64aeea134a..de6bead3f6 100644 --- a/modules/sdk-coin-kas/tsconfig.json +++ b/modules/sdk-coin-kas/tsconfig.json @@ -10,17 +10,11 @@ "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules"], "references": [ - { - "path": "../sdk-api" - }, { "path": "../sdk-core" }, { "path": "../statics" - }, - { - "path": "../sdk-test" } ] } diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index 0220655ad0..2de6a67e0e 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -201,7 +201,6 @@ export interface StacksNetwork extends AccountNetwork { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OfcNetwork extends BaseNetwork {} - abstract class Mainnet extends BaseNetwork { type = NetworkType.MAINNET; } @@ -2002,7 +2001,6 @@ class KaiaTestnet extends Testnet implements EthereumNetwork { walletImplementationAddress = '0x944fef03af368414f29dc31a72061b8d64f568d2'; } - class Irys extends Mainnet implements EthereumNetwork { name = 'Irys'; family = CoinFamily.IRYS; From 4554385cf3fb10003583b9735e33bbdab8c33e71 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Tue, 7 Apr 2026 15:10:30 +0530 Subject: [PATCH 8/8] feat(sdk-coin-kas): update KASCoin default features and add 'kas' to expectedColdFeatures --- modules/statics/src/kas.ts | 5 +++-- modules/statics/test/unit/fixtures/expectedColdFeatures.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/statics/src/kas.ts b/modules/statics/src/kas.ts index 16da3d3fab..b868185cc5 100644 --- a/modules/statics/src/kas.ts +++ b/modules/statics/src/kas.ts @@ -16,14 +16,15 @@ export interface KASConstructorOptions { 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, - CoinFeature.MULTISIG_COLD, - CoinFeature.MULTISIG, ]; public readonly network: KaspaNetwork; 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',