From cceee873ab652ea88f17beab9bd1d968a5f3f8b8 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Thu, 7 May 2026 11:26:04 +0530 Subject: [PATCH] feat(utxo-descriptors): add sBTC taproot descriptors Ticket: CSHLD-744 --- CODEOWNERS | 1 + modules/utxo-descriptors/.eslintignore | 2 + modules/utxo-descriptors/.eslintrc.js | 7 + modules/utxo-descriptors/.gitignore | 3 + modules/utxo-descriptors/.mocharc.js | 4 + modules/utxo-descriptors/.npmignore | 10 + modules/utxo-descriptors/.prettierignore | 4 + modules/utxo-descriptors/.prettierrc.yml | 3 + modules/utxo-descriptors/README.md | 1 + modules/utxo-descriptors/package.json | 66 +++++ modules/utxo-descriptors/src/index.ts | 1 + .../utxo-descriptors/src/sbtc/constants.ts | 32 +++ .../src/sbtc/depositAddress.ts | 46 ++++ .../utxo-descriptors/src/sbtc/descriptor.ts | 116 ++++++++ modules/utxo-descriptors/src/sbtc/index.ts | 3 + .../fixtures/sbtc/descriptor/default-ast.json | 50 ++++ .../fixtures/sbtc/descriptor/default-spk.hex | 1 + .../sbtc/descriptor/default-string.txt | 1 + .../test/unit/sbtc/descriptor.ts | 251 ++++++++++++++++++ modules/utxo-descriptors/tsconfig.esm.json | 17 ++ modules/utxo-descriptors/tsconfig.json | 24 ++ yarn.lock | 7 +- 22 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 modules/utxo-descriptors/.eslintignore create mode 100644 modules/utxo-descriptors/.eslintrc.js create mode 100644 modules/utxo-descriptors/.gitignore create mode 100644 modules/utxo-descriptors/.mocharc.js create mode 100644 modules/utxo-descriptors/.npmignore create mode 100644 modules/utxo-descriptors/.prettierignore create mode 100644 modules/utxo-descriptors/.prettierrc.yml create mode 100644 modules/utxo-descriptors/README.md create mode 100644 modules/utxo-descriptors/package.json create mode 100644 modules/utxo-descriptors/src/index.ts create mode 100644 modules/utxo-descriptors/src/sbtc/constants.ts create mode 100644 modules/utxo-descriptors/src/sbtc/depositAddress.ts create mode 100644 modules/utxo-descriptors/src/sbtc/descriptor.ts create mode 100644 modules/utxo-descriptors/src/sbtc/index.ts create mode 100644 modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-ast.json create mode 100644 modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-spk.hex create mode 100644 modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-string.txt create mode 100644 modules/utxo-descriptors/test/unit/sbtc/descriptor.ts create mode 100644 modules/utxo-descriptors/tsconfig.esm.json create mode 100644 modules/utxo-descriptors/tsconfig.json diff --git a/CODEOWNERS b/CODEOWNERS index fb4104259a..d315e00a30 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -27,6 +27,7 @@ /modules/utxo-lib/ @BitGo/btc-team /modules/utxo-ord/ @BitGo/btc-team /modules/utxo-staking/ @BitGo/btc-team +/modules/utxo-descriptors/ @BitGo/btc-team @BitGo/ethalt-team /modules/babylonlabs-io-btc-staking-ts @BitGo/btc-team /modules/bitgo/test/v2/unit/coins/abstractUtxoCoin.ts @BitGo/btc-team /modules/bitgo/test/v2/unit/coins/payGoPSBTHexFixture/psbtHexProof.ts @BitGo/btc-team diff --git a/modules/utxo-descriptors/.eslintignore b/modules/utxo-descriptors/.eslintignore new file mode 100644 index 0000000000..de4d1f007d --- /dev/null +++ b/modules/utxo-descriptors/.eslintignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/modules/utxo-descriptors/.eslintrc.js b/modules/utxo-descriptors/.eslintrc.js new file mode 100644 index 0000000000..7b07ce3cfb --- /dev/null +++ b/modules/utxo-descriptors/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc.json'], + rules: { + 'import/order': ['error', { 'newlines-between': 'always' }], + 'import/no-internal-modules': 'off', + }, +}; diff --git a/modules/utxo-descriptors/.gitignore b/modules/utxo-descriptors/.gitignore new file mode 100644 index 0000000000..d62464f3e6 --- /dev/null +++ b/modules/utxo-descriptors/.gitignore @@ -0,0 +1,3 @@ +/dist +/node_modules +/.nyc_output diff --git a/modules/utxo-descriptors/.mocharc.js b/modules/utxo-descriptors/.mocharc.js new file mode 100644 index 0000000000..007ef573be --- /dev/null +++ b/modules/utxo-descriptors/.mocharc.js @@ -0,0 +1,4 @@ +module.exports = { + require: 'tsx', + extension: ['.js', '.ts'], +}; diff --git a/modules/utxo-descriptors/.npmignore b/modules/utxo-descriptors/.npmignore new file mode 100644 index 0000000000..85677ec29f --- /dev/null +++ b/modules/utxo-descriptors/.npmignore @@ -0,0 +1,10 @@ +**/*.ts +!**/*.d.ts +src +test +tsconfig.json +tslint.json +.gitignore +.eslintignore +.mocharc.yml +.prettierignore diff --git a/modules/utxo-descriptors/.prettierignore b/modules/utxo-descriptors/.prettierignore new file mode 100644 index 0000000000..e8f5216bdf --- /dev/null +++ b/modules/utxo-descriptors/.prettierignore @@ -0,0 +1,4 @@ +.nyc_output/ +dist +node_modules +test/fixtures diff --git a/modules/utxo-descriptors/.prettierrc.yml b/modules/utxo-descriptors/.prettierrc.yml new file mode 100644 index 0000000000..5198f5e019 --- /dev/null +++ b/modules/utxo-descriptors/.prettierrc.yml @@ -0,0 +1,3 @@ +printWidth: 120 +singleQuote: true +trailingComma: es5 diff --git a/modules/utxo-descriptors/README.md b/modules/utxo-descriptors/README.md new file mode 100644 index 0000000000..ef8f08c163 --- /dev/null +++ b/modules/utxo-descriptors/README.md @@ -0,0 +1 @@ +# BitGo UTXO descriptors diff --git a/modules/utxo-descriptors/package.json b/modules/utxo-descriptors/package.json new file mode 100644 index 0000000000..14e7206b81 --- /dev/null +++ b/modules/utxo-descriptors/package.json @@ -0,0 +1,66 @@ +{ + "name": "@bitgo/utxo-descriptors", + "version": "1.39.1", + "description": "BitGo SDK for building UTXO descriptors", + "main": "./dist/cjs/src/index.js", + "module": "./dist/esm/index.js", + "browser": "./dist/esm/index.js", + "types": "./dist/cjs/src/index.d.ts", + "files": [ + "dist/cjs", + "dist/esm" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/src/index.d.ts", + "default": "./dist/cjs/src/index.js" + } + } + }, + "scripts": { + "build": "npm run build:cjs && npm run build:esm", + "build:cjs": "yarn tsc --build --incremental --verbose .", + "build:esm": "yarn tsc --project tsconfig.esm.json", + "fmt": "prettier --write .", + "check-fmt": "prettier --check '**/*.{ts,js,json}'", + "clean": "rm -r ./dist", + "lint": "eslint --quiet .", + "prepare": "npm run build", + "coverage": "nyc -- npm run unit-test", + "unit-test": "mocha --recursive \"test/**/*.ts\"" + }, + "author": "BitGo SDK Team ", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "https://github.com/BitGo/BitGoJS.git", + "directory": "modules/utxo-descriptors" + }, + "lint-staged": { + "*.{js,ts}": [ + "yarn prettier --write", + "yarn eslint --fix" + ] + }, + "publishConfig": { + "access": "public" + }, + "nyc": { + "extension": [ + ".ts" + ] + }, + "dependencies": { + "@bitgo/utxo-core": "^1.37.1", + "@bitgo/utxo-lib": "^11.22.1", + "@bitgo/wasm-utxo": "^4.11.0" + } +} diff --git a/modules/utxo-descriptors/src/index.ts b/modules/utxo-descriptors/src/index.ts new file mode 100644 index 0000000000..7e309b49c7 --- /dev/null +++ b/modules/utxo-descriptors/src/index.ts @@ -0,0 +1 @@ +export * as sbtc from './sbtc'; diff --git a/modules/utxo-descriptors/src/sbtc/constants.ts b/modules/utxo-descriptors/src/sbtc/constants.ts new file mode 100644 index 0000000000..ec600a7884 --- /dev/null +++ b/modules/utxo-descriptors/src/sbtc/constants.ts @@ -0,0 +1,32 @@ +/** + * Well-known unspendable Taproot internal key. + * + * Nothing-up-my-sleeve point on secp256k1 with no known private key. Using it + * as a Taproot internal key guarantees the output cannot be spent via the + * key path — only via one of the script-path leaves. + */ +export const UNSPENDABLE_INTERNAL_KEY = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; + +/** + * Default reclaim relative timelock — number of Bitcoin blocks the depositor + * must wait before they can reclaim their BTC if the sBTC signers fail to + * process the deposit. + */ +export const DEFAULT_RECLAIM_LOCK_TIME = 950; + +/** + * Default max signer fee in satoshis. Encoded as a big-endian u64 (8 bytes) + * inside the deposit-leaf metadata payload. + */ +export const DEFAULT_MAX_SIGNER_FEE = 80_000; + +/** Length of the encoded sBTC max-fee field, in bytes. */ +export const MAX_FEE_BYTE_LENGTH = 8; + +/** + * Length of the Stacks recipient field inside the deposit payload — 22 bytes: + * byte 0 = Clarity principal type (0x05 standard, 0x06 contract) + * byte 1 = Stacks address version (e.g. 0x16 mainnet, 0x1a testnet) + * bytes 2..21 = 20-byte hash160 of the principal + */ +export const STACKS_RECIPIENT_BYTE_LENGTH = 22; diff --git a/modules/utxo-descriptors/src/sbtc/depositAddress.ts b/modules/utxo-descriptors/src/sbtc/depositAddress.ts new file mode 100644 index 0000000000..5e9ae73dec --- /dev/null +++ b/modules/utxo-descriptors/src/sbtc/depositAddress.ts @@ -0,0 +1,46 @@ +import { BIP32Interface } from '@bitgo/utxo-lib'; +import { Descriptor } from '@bitgo/wasm-utxo'; + +import { createSbtcDepositDescriptor, SbtcDepositDescriptorParams } from './descriptor'; + +/** + * Compile the sBTC deposit Taproot scriptPubKey. + * + * If `params.walletKeys` contains BIP32 xpubs, the descriptor is derivable and + * `derivationIndex` selects which child keys go into the reclaim leaf. If + * `params.walletKeys` contains raw 32-byte x-only Buffers, the descriptor is + * definite and `derivationIndex` is ignored. + * + * We can't use `Descriptor.fromStringDetectType()` here: that path doesn't + * enable the `drop` ExtParams flag, so it rejects `r:older` and `payload_drop` + * fragments. We branch on the input shape instead — that's the same signal + * the descriptor library would use, just observable to us before parsing. + */ +export function createSbtcDepositScriptPubKey(params: SbtcDepositDescriptorParams, derivationIndex = 0): Buffer { + const descString = createSbtcDepositDescriptor(params); + const isDefinite = params.walletKeys.every(Buffer.isBuffer); + if (isDefinite) { + return Buffer.from(Descriptor.fromString(descString, 'definite').scriptPubkey()); + } + const desc = Descriptor.fromString(descString, 'derivable'); + return Buffer.from(desc.atDerivationIndex(derivationIndex).scriptPubkey()); +} + +/** + * Derive the three concrete x-only reclaim pubkeys at the given derivation + * index from a triple of BIP32 xpubs. + * + * The descriptor library performs this derivation internally when computing + * `scriptPubkey()`; this helper exposes the same derivation for callers that + * need the keys directly (e.g., constructing a witness for the reclaim leaf). + */ +export function deriveReclaimKeys( + walletKeys: [BIP32Interface, BIP32Interface, BIP32Interface], + index: number +): [Buffer, Buffer, Buffer] { + const [k1, k2, k3] = walletKeys.map((k) => { + const child = k.derive(index); + return Buffer.from(child.publicKey.subarray(1)); + }); + return [k1, k2, k3]; +} diff --git a/modules/utxo-descriptors/src/sbtc/descriptor.ts b/modules/utxo-descriptors/src/sbtc/descriptor.ts new file mode 100644 index 0000000000..cc2c399147 --- /dev/null +++ b/modules/utxo-descriptors/src/sbtc/descriptor.ts @@ -0,0 +1,116 @@ +import { BIP32Interface } from '@bitgo/utxo-lib'; +import { ast } from '@bitgo/wasm-utxo'; + +import { MAX_FEE_BYTE_LENGTH, STACKS_RECIPIENT_BYTE_LENGTH, UNSPENDABLE_INTERNAL_KEY } from './constants'; + +/** + * A reclaim key entry — either a BIP32 xpub/xprv (derivable) or a concrete + * 32-byte x-only public key (definite). + */ +export type SbtcReclaimKey = BIP32Interface | Buffer; + +export type SbtcDepositDescriptorParams = { + /** + * The three reclaim keys (user, backup, bitgo). Used in the reclaim leaf as + * a 2-of-3 Tapscript multisig (`multi_a`). If a key is a `BIP32Interface`, + * it is rendered as `/*` and the descriptor is derivable. If a key is + * a 32-byte `Buffer`, it is rendered as a concrete x-only hex key and the + * descriptor is definite. + */ + walletKeys: [SbtcReclaimKey, SbtcReclaimKey, SbtcReclaimKey]; + /** + * Number of Bitcoin blocks the depositor must wait (relative timelock) before + * the reclaim leaf becomes spendable. + */ + lockTime: number; + /** Max satoshis the sBTC signers may take. Encoded big-endian u64 (8 bytes). */ + maxFee: number | bigint; + /** + * Stacks recipient bytes — 22 bytes: + * byte 0 = Clarity principal type (0x05 standard, 0x06 contract) + * byte 1 = Stacks address version (e.g. 0x16 mainnet, 0x1a testnet) + * bytes 2..21 = 20-byte hash160 of the principal + */ + stacksRecipient: Buffer; + /** 32-byte x-only sBTC signers' aggregate pubkey. */ + signersAggregateKey: Buffer; +}; + +function asDescriptorKey(key: SbtcReclaimKey, neutered: boolean): string { + if (Buffer.isBuffer(key)) { + if (key.length !== 32) { + throw new Error(`reclaim key buffer must be 32 bytes x-only (got ${key.length})`); + } + return key.toString('hex'); + } + return (neutered ? key.neutered() : key).toBase58() + '/*'; +} + +/** + * Encode the deposit-leaf metadata that the sBTC signers parse from the + * witness: max-fee (u64 big-endian, 8 bytes) followed by the 22-byte + * Stacks recipient. Total: 30 bytes. + * + * The result is the single `payload_drop` argument inside the deposit leaf. + */ +export function encodeDepositPayload(maxFee: number | bigint, stacksRecipient: Buffer): Buffer { + if (stacksRecipient.length !== STACKS_RECIPIENT_BYTE_LENGTH) { + throw new Error(`stacksRecipient must be ${STACKS_RECIPIENT_BYTE_LENGTH} bytes (got ${stacksRecipient.length})`); + } + const fee = typeof maxFee === 'bigint' ? maxFee : BigInt(maxFee); + if (fee < 0n || fee > 0xffffffffffffffffn) { + throw new Error(`maxFee (${maxFee}) does not fit in unsigned 64 bits`); + } + const feeBuf = Buffer.alloc(MAX_FEE_BYTE_LENGTH); + feeBuf.writeBigUInt64BE(fee); + return Buffer.concat([feeBuf, stacksRecipient]); +} + +/** + * Build the sBTC peg-in deposit Taproot descriptor as a single all-miniscript + * `tr()` with two leaves: + * + * tr(, + * { + * c:and_v(payload_drop(), pk_k()), + * and_v(r:older(), multi_a(2, xpub1/*, xpub2/*, xpub3/*)) + * } + * ) + * + * - Deposit leaf compiles to: `<30B-payload> OP_DROP OP_CHECKSIG` + * - Reclaim leaf compiles to: ` OP_CSV OP_DROP OP_CHECKSIG OP_CHECKSIGADD OP_CHECKSIGADD OP_2 OP_NUMEQUAL` + * + * Both fragments are valid Bitcoin miniscript via the `payload_drop` and + * `r:older` extensions added in `@bitgo/wasm-utxo` 4.11.0 + * (BitGoWASM PR #272). + * + * The descriptor is derivable: the reclaim xpubs use `/*` and the descriptor + * library resolves them to concrete x-only keys at each derivation index. + * + * @returns descriptor string (without checksum) + */ +export function createSbtcDepositDescriptor(params: SbtcDepositDescriptorParams, neutered = true): string { + if (params.lockTime <= 0) { + throw new Error(`lockTime (${params.lockTime}) must be greater than 0`); + } + if (params.signersAggregateKey.length !== 32) { + throw new Error(`signersAggregateKey must be 32 bytes x-only (got ${params.signersAggregateKey.length})`); + } + + const payloadHex = encodeDepositPayload(params.maxFee, params.stacksRecipient).toString('hex'); + const reclaimKeys = params.walletKeys.map((k) => asDescriptorKey(k, neutered)); + + // `payload_drop` is not yet in the public MiniscriptNode TS union, so cast + // the deposit leaf — the formatter is generic and renders it correctly. + const depositLeaf = { + 'c:and_v': [{ payload_drop: payloadHex }, { pk_k: params.signersAggregateKey.toString('hex') }], + } as unknown as ast.MiniscriptNode; + + const reclaimLeaf: ast.MiniscriptNode = { + and_v: [{ 'r:older': params.lockTime }, { multi_a: [2, ...reclaimKeys] }], + }; + + return ast.formatNode({ + tr: [UNSPENDABLE_INTERNAL_KEY, [depositLeaf, reclaimLeaf]], + }); +} diff --git a/modules/utxo-descriptors/src/sbtc/index.ts b/modules/utxo-descriptors/src/sbtc/index.ts new file mode 100644 index 0000000000..8e54e9ece3 --- /dev/null +++ b/modules/utxo-descriptors/src/sbtc/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './descriptor'; +export * from './depositAddress'; diff --git a/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-ast.json b/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-ast.json new file mode 100644 index 0000000000..27c422899b --- /dev/null +++ b/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-ast.json @@ -0,0 +1,50 @@ +{ + "Tr": [ + { + "Single": "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + }, + { + "Tree": [ + { + "Check": { + "AndV": [ + { + "PayloadDrop": "000000000001388005166d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d" + }, + { + "PkK": { + "Single": "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421" + } + } + ] + } + }, + { + "AndV": [ + { + "Drop": { + "Older": { + "relLockTime": 950 + } + } + }, + { + "MultiA": [ + 2, + { + "XPub": "xpub661MyMwAqRbcGZfgedgcQiBJpkHoZ37k6uotUVLoC6Px4Y46Yrdmy1CUogcJMoAsosY381gPjhGe9jFx1uYAcxy2gTHh9YFP32tUWycqHnV/*" + }, + { + "XPub": "xpub661MyMwAqRbcF8FoYWukS8eTn2gVEojzwf5DYETpB8uqC8t5sqDCEFnuJ39DaPjLRerBDK9QqSMvSYpT4WSugCVbUK5HEevSKAu1wUkVWsS/*" + }, + { + "XPub": "xpub661MyMwAqRbcFdScyinA4JpCViqkKsd37MQ6fwuZQQ4shdaGRRX9a8bWvR9QC1AFqKongweJJfyrm7uCoWmCw7UixwGZkZnCT2mchFr7cQb/*" + } + ] + } + ] + } + ] + } + ] +} diff --git a/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-spk.hex b/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-spk.hex new file mode 100644 index 0000000000..3827bd7ff4 --- /dev/null +++ b/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-spk.hex @@ -0,0 +1 @@ +51206762539ced3f9dc474b91ea31c8f8a22ed65c3c08efeac2663459992892a670b \ No newline at end of file diff --git a/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-string.txt b/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-string.txt new file mode 100644 index 0000000000..97ad7b173c --- /dev/null +++ b/modules/utxo-descriptors/test/fixtures/sbtc/descriptor/default-string.txt @@ -0,0 +1 @@ +tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,{c:and_v(payload_drop(000000000001388005166d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d6d),pk_k(c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421)),and_v(r:older(950),multi_a(2,xpub661MyMwAqRbcGZfgedgcQiBJpkHoZ37k6uotUVLoC6Px4Y46Yrdmy1CUogcJMoAsosY381gPjhGe9jFx1uYAcxy2gTHh9YFP32tUWycqHnV/*,xpub661MyMwAqRbcF8FoYWukS8eTn2gVEojzwf5DYETpB8uqC8t5sqDCEFnuJ39DaPjLRerBDK9QqSMvSYpT4WSugCVbUK5HEevSKAu1wUkVWsS/*,xpub661MyMwAqRbcFdScyinA4JpCViqkKsd37MQ6fwuZQQ4shdaGRRX9a8bWvR9QC1AFqKongweJJfyrm7uCoWmCw7UixwGZkZnCT2mchFr7cQb/*))}) \ No newline at end of file diff --git a/modules/utxo-descriptors/test/unit/sbtc/descriptor.ts b/modules/utxo-descriptors/test/unit/sbtc/descriptor.ts new file mode 100644 index 0000000000..3afab2f04e --- /dev/null +++ b/modules/utxo-descriptors/test/unit/sbtc/descriptor.ts @@ -0,0 +1,251 @@ +import * as assert from 'assert'; + +import * as utxolib from '@bitgo/utxo-lib'; +import { Descriptor } from '@bitgo/wasm-utxo'; +import { getFixture } from '@bitgo/utxo-core/testutil'; + +import { + createSbtcDepositDescriptor, + createSbtcDepositScriptPubKey, + DEFAULT_MAX_SIGNER_FEE, + DEFAULT_RECLAIM_LOCK_TIME, + deriveReclaimKeys, + encodeDepositPayload, + SbtcDepositDescriptorParams, + STACKS_RECIPIENT_BYTE_LENGTH, + UNSPENDABLE_INTERNAL_KEY, +} from '../../../src/sbtc'; + +const FIXTURE_BASE = 'test/fixtures/sbtc/descriptor/'; + +// Deterministic test inputs. +const SIGNERS_AGGREGATE_KEY = Buffer.from('c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421', 'hex'); +const STACKS_RECIPIENT = Buffer.from( + // 1-byte Clarity principal type (0x05 standard) + 1-byte address version + // (0x16 mainnet) + 20-byte hash160 of the principal + '05' + '16' + '6d'.repeat(20), + 'hex' +); + +function buildParams(overrides: Partial = {}): SbtcDepositDescriptorParams { + const triple = utxolib.testutil.getDefaultWalletKeys().triple; + return { + walletKeys: [triple[0], triple[1], triple[2]], + lockTime: DEFAULT_RECLAIM_LOCK_TIME, + maxFee: DEFAULT_MAX_SIGNER_FEE, + stacksRecipient: STACKS_RECIPIENT, + signersAggregateKey: SIGNERS_AGGREGATE_KEY, + ...overrides, + }; +} + +describe('encodeDepositPayload', function () { + it('encodes max fee as 8-byte big-endian followed by the 22-byte recipient', function () { + const payload = encodeDepositPayload(DEFAULT_MAX_SIGNER_FEE, STACKS_RECIPIENT); + assert.strictEqual(payload.length, 8 + STACKS_RECIPIENT_BYTE_LENGTH); + // 80_000 == 0x13880; left-padded into 8 bytes big-endian + assert.strictEqual(payload.subarray(0, 8).toString('hex'), '0000000000013880'); + assert.deepStrictEqual(payload.subarray(8), STACKS_RECIPIENT); + }); + + it('accepts bigint fees up to 2^64 - 1', function () { + const max = 0xffffffffffffffffn; + const payload = encodeDepositPayload(max, STACKS_RECIPIENT); + assert.strictEqual(payload.subarray(0, 8).toString('hex'), 'f'.repeat(16)); + }); + + it('rejects fees outside the unsigned 64-bit range', function () { + assert.throws(() => encodeDepositPayload(-1, STACKS_RECIPIENT)); + assert.throws(() => encodeDepositPayload(0x10000000000000000n, STACKS_RECIPIENT)); + }); + + it('rejects recipients of the wrong length', function () { + assert.throws(() => encodeDepositPayload(0, Buffer.alloc(STACKS_RECIPIENT_BYTE_LENGTH - 1))); + assert.throws(() => encodeDepositPayload(0, Buffer.alloc(STACKS_RECIPIENT_BYTE_LENGTH + 1))); + }); +}); + +describe('createSbtcDepositDescriptor', function () { + describe('argument validation', function () { + it('rejects a non-positive locktime', function () { + assert.throws(() => createSbtcDepositDescriptor(buildParams({ lockTime: 0 }))); + assert.throws(() => createSbtcDepositDescriptor(buildParams({ lockTime: -1 }))); + }); + + it('rejects a signers key that is not 32 bytes (x-only)', function () { + assert.throws(() => createSbtcDepositDescriptor(buildParams({ signersAggregateKey: Buffer.alloc(33) }))); + assert.throws(() => createSbtcDepositDescriptor(buildParams({ signersAggregateKey: Buffer.alloc(31) }))); + }); + + it('rejects a recipient that is not 22 bytes', function () { + assert.throws(() => createSbtcDepositDescriptor(buildParams({ stacksRecipient: Buffer.alloc(21) }))); + }); + }); + + describe('default parameters', function () { + const fixturePath = FIXTURE_BASE + 'default'; + + it('emits the expected descriptor string', async function () { + const descriptorString = createSbtcDepositDescriptor(buildParams()); + assert.strictEqual( + descriptorString, + await getFixture(fixturePath + '-string.txt', descriptorString), + descriptorString + ); + }); + + it('uses the unspendable internal key for the tr() and contains both leaves as miniscript', function () { + const descriptorString = createSbtcDepositDescriptor(buildParams()); + assert.ok(descriptorString.startsWith(`tr(${UNSPENDABLE_INTERNAL_KEY},{`)); + // No raw() leaf inside the tr() tree. + assert.ok(!/raw\(/.test(descriptorString), `descriptor must not contain raw(): ${descriptorString}`); + // Both leaves expressed via miniscript fragments. + assert.ok(descriptorString.includes('payload_drop(')); + assert.ok(descriptorString.includes('r:older(')); + assert.ok(descriptorString.includes('multi_a(')); + }); + + it('parses as a derivable descriptor', function () { + const descriptor = Descriptor.fromString(createSbtcDepositDescriptor(buildParams()), 'derivable'); + assert.ok(descriptor); + }); + + it('matches the expected AST', async function () { + const descriptor = Descriptor.fromString(createSbtcDepositDescriptor(buildParams()), 'derivable'); + const node = descriptor.node(); + assert.deepStrictEqual(node, await getFixture(fixturePath + '-ast.json', node)); + }); + + it('matches the expected scriptPubKey at derivation index 0', async function () { + const spk = createSbtcDepositScriptPubKey(buildParams(), 0); + assert.strictEqual(spk.length, 34, 'P2TR scriptPubKey is OP_1 + 32-byte tweaked key'); + assert.strictEqual(spk[0], 0x51, 'first byte is OP_1'); + assert.strictEqual(spk[1], 0x20, 'second byte is push-32'); + assert.deepStrictEqual(spk, await getFixture(fixturePath + '-spk.hex', spk)); + }); + }); + + describe('changing parameters changes the address', function () { + it('different lockTime → different scriptPubKey', function () { + const a = createSbtcDepositScriptPubKey(buildParams({ lockTime: 100 }), 0); + const b = createSbtcDepositScriptPubKey(buildParams({ lockTime: 200 }), 0); + assert.notDeepStrictEqual(a, b); + }); + + it('different maxFee → different scriptPubKey', function () { + const a = createSbtcDepositScriptPubKey(buildParams({ maxFee: 1 }), 0); + const b = createSbtcDepositScriptPubKey(buildParams({ maxFee: 2 }), 0); + assert.notDeepStrictEqual(a, b); + }); + + it('different recipient → different scriptPubKey', function () { + const r1 = Buffer.alloc(STACKS_RECIPIENT_BYTE_LENGTH, 0x11); + const r2 = Buffer.alloc(STACKS_RECIPIENT_BYTE_LENGTH, 0x22); + const a = createSbtcDepositScriptPubKey(buildParams({ stacksRecipient: r1 }), 0); + const b = createSbtcDepositScriptPubKey(buildParams({ stacksRecipient: r2 }), 0); + assert.notDeepStrictEqual(a, b); + }); + + it('different derivation index → different scriptPubKey', function () { + const a = createSbtcDepositScriptPubKey(buildParams(), 0); + const b = createSbtcDepositScriptPubKey(buildParams(), 1); + assert.notDeepStrictEqual(a, b); + }); + }); +}); + +describe('definite descriptor (concrete x-only reclaim keys)', function () { + // Bitcoin regtest network: same byte-prefixes as testnet, but uses the `bcrt` + // bech32 HRP. Mirrors `bitcoinRegtest` from + // modules/utxo-bin/src/args/parseNetwork.ts. + const regtest: utxolib.Network = { ...utxolib.networks.testnet, bech32: 'bcrt' }; + + // Reference vectors — match the on-chain leaves the user gave. + // Deposit leaf: 1e <30B-payload> 75 20 ac + // Reclaim leaf: 51 b2 75 20 ac 20 ba 20 ba 52 9c + const reclaimKeys: [Buffer, Buffer, Buffer] = [ + Buffer.from('4d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1e', 'hex'), + Buffer.from('639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943', 'hex'), + Buffer.from('d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2', 'hex'), + ]; + const stacksRecipient = Buffer.from('051ad206838b7981a116c334e8cb1b950afb73eb54a5', 'hex'); + const signersAggregateKey = Buffer.from('c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421', 'hex'); + const params: SbtcDepositDescriptorParams = { + walletKeys: reclaimKeys, + lockTime: 1, + maxFee: 80_000, + stacksRecipient, + signersAggregateKey, + }; + + // Reference vectors from the on-chain regtest deposit + // (txid 7db56e1e6705ea4f2ee68cb075b27a2c80d44aca1f9ffd48e6f196e578a911e0). + const expectedDescriptor = + 'tr(50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0,' + + '{c:and_v(' + + 'payload_drop(0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5),' + + 'pk_k(c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421)),' + + 'and_v(r:older(1),multi_a(2,' + + '4d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1e,' + + '639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943,' + + 'd1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2))})'; + const expectedTweakedKey = 'f3b3930e1e7103753b62e5cfee821b5bfa942eacb868e1d625243df606882dff'; + const expectedScriptPubKey = '5120' + expectedTweakedKey; + const expectedAddress = 'bcrt1p7weexrs7wyph2wmzuh87aqsmt0afgt4vhp5wr439ys7lvp5g9hlsjq4cvx'; + + it('emits the BIP-380 reference descriptor exactly', function () { + const desc = createSbtcDepositDescriptor(params); + assert.strictEqual(desc, expectedDescriptor); + // Sanity: the descriptor is definite (no wildcards) and contains both leaves as miniscript. + assert.ok(!desc.includes('/*')); + assert.ok(!/raw\(/.test(desc)); + assert.ok(desc.includes('payload_drop(')); + assert.ok(desc.includes('r:older(')); + assert.ok(desc.includes('multi_a(')); + }); + + it('produces the reference 34-byte P2TR scriptPubKey (5120 + tweaked x-only key)', function () { + const spk = createSbtcDepositScriptPubKey(params); + assert.strictEqual(spk.toString('hex'), expectedScriptPubKey); + assert.strictEqual(spk.length, 34); + assert.strictEqual(spk[0], 0x51, 'OP_1 (Taproot witness version)'); + assert.strictEqual(spk[1], 0x20, 'push-32'); + assert.strictEqual(spk.subarray(2).toString('hex'), expectedTweakedKey); + }); + + it('derives the expected regtest address bcrt1p7wee...sjq4cvx', function () { + const spk = createSbtcDepositScriptPubKey(params); + const address = utxolib.address.fromOutputScript(spk, regtest); + assert.strictEqual(address, expectedAddress); + }); + + it('rejects raw reclaim key buffers that are not 32 bytes', function () { + const bad: SbtcDepositDescriptorParams = { + ...params, + walletKeys: [Buffer.alloc(31), reclaimKeys[1], reclaimKeys[2]], + }; + assert.throws(() => createSbtcDepositDescriptor(bad)); + }); +}); + +describe('deriveReclaimKeys', function () { + it('returns three 32-byte x-only keys derived at the given index', function () { + const triple = utxolib.testutil.getDefaultWalletKeys().triple; + const [k1, k2, k3] = deriveReclaimKeys([triple[0], triple[1], triple[2]], 0); + for (const k of [k1, k2, k3]) { + assert.strictEqual(k.length, 32, `expected 32-byte x-only key, got ${k.length}`); + } + }); + + it('produces keys consistent with the descriptor library', function () { + const triple = utxolib.testutil.getDefaultWalletKeys().triple; + const bip32Keys: [utxolib.BIP32Interface, utxolib.BIP32Interface, utxolib.BIP32Interface] = [ + triple[0], + triple[1], + triple[2], + ]; + const [k1, k2, k3] = deriveReclaimKeys(bip32Keys, 0); + const derivedFromXpub = bip32Keys.map((k) => Buffer.from(k.neutered().derive(0).publicKey.subarray(1))); + assert.deepStrictEqual([k1, k2, k3], derivedFromXpub); + }); +}); diff --git a/modules/utxo-descriptors/tsconfig.esm.json b/modules/utxo-descriptors/tsconfig.esm.json new file mode 100644 index 0000000000..87b6e94d86 --- /dev/null +++ b/modules/utxo-descriptors/tsconfig.esm.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm", + "rootDir": "./src", + "module": "ES2020", + "target": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "test", "dist", "scripts"], + "references": [] +} diff --git a/modules/utxo-descriptors/tsconfig.json b/modules/utxo-descriptors/tsconfig.json new file mode 100644 index 0000000000..f1b7d6618c --- /dev/null +++ b/modules/utxo-descriptors/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist/cjs", + "rootDir": ".", + "typeRoots": ["./node_modules/@types", "../../node_modules/@types"], + "allowJs": false, + "strict": true, + "useUnknownInCatchVariables": false, + "moduleResolution": "node16", + "module": "node16", + "esModuleInterop": true + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules"], + "references": [ + { + "path": "../utxo-lib" + }, + { + "path": "../utxo-core" + } + ] +} diff --git a/yarn.lock b/yarn.lock index 58f73d91f8..b410565a01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1041,6 +1041,11 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-ton/-/wasm-ton-1.1.1.tgz" integrity sha512-Y4x2V2ZcYWlmx42v7dlrKDtT2DuUt8smk8E98mh7RhpiifJhLk2v5RmXDwBl0A3v9TzUOU6qMOnSS/iZ8Pq52w== +"@bitgo/wasm-utxo@^4.11.0": + version "4.11.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-4.11.0.tgz#52869d01637f34b8d3c1f6fb14a970050a51e6cb" + integrity sha512-U7OC42t+lm72kByRRjaZBYvIy3wsqbIxUga5Jo63ubxXNhzXe4ZyMUI+wzGnPo3IWyFPIkK96ABNKkzFIBrH3A== + "@bitgo/wasm-utxo@^4.8.0": version "4.8.0" resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-4.8.0.tgz#744b20d239e5430402d61bd4ba7b1fbd77cc9ff3" @@ -7605,7 +7610,7 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -axios@0.25.0, axios@0.27.2, axios@1.15.2, axios@1.7.4, axios@^0.21.2, axios@^0.26.1, axios@^1.15.2, axios@^1.6.0, axios@^1.8.3: +axios@0.25.0, axios@0.27.2, axios@1.15.2, axios@1.7.4, axios@^0.21.2, axios@^0.26.1, axios@^1.6.0, axios@^1.8.3: version "1.15.2" resolved "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==