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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions modules/utxo-descriptors/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
node_modules
7 changes: 7 additions & 0 deletions modules/utxo-descriptors/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
extends: ['../../.eslintrc.json'],
rules: {
'import/order': ['error', { 'newlines-between': 'always' }],
'import/no-internal-modules': 'off',
},
};
3 changes: 3 additions & 0 deletions modules/utxo-descriptors/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/dist
/node_modules
/.nyc_output
4 changes: 4 additions & 0 deletions modules/utxo-descriptors/.mocharc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
require: 'tsx',
extension: ['.js', '.ts'],
};
10 changes: 10 additions & 0 deletions modules/utxo-descriptors/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**/*.ts
!**/*.d.ts
src
test
tsconfig.json
tslint.json
.gitignore
.eslintignore
.mocharc.yml
.prettierignore
4 changes: 4 additions & 0 deletions modules/utxo-descriptors/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.nyc_output/
dist
node_modules
test/fixtures
3 changes: 3 additions & 0 deletions modules/utxo-descriptors/.prettierrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
printWidth: 120
singleQuote: true
trailingComma: es5
1 change: 1 addition & 0 deletions modules/utxo-descriptors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# BitGo UTXO descriptors
66 changes: 66 additions & 0 deletions modules/utxo-descriptors/package.json
Original file line number Diff line number Diff line change
@@ -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 <sdkteam@bitgo.com>",
"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"
}
}
1 change: 1 addition & 0 deletions modules/utxo-descriptors/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as sbtc from './sbtc';
32 changes: 32 additions & 0 deletions modules/utxo-descriptors/src/sbtc/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
46 changes: 46 additions & 0 deletions modules/utxo-descriptors/src/sbtc/depositAddress.ts
Original file line number Diff line number Diff line change
@@ -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];
}
116 changes: 116 additions & 0 deletions modules/utxo-descriptors/src/sbtc/descriptor.ts
Original file line number Diff line number Diff line change
@@ -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 `<xpub>/*` 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(<UNSPENDABLE>,
* {
* c:and_v(payload_drop(<feeBE||recipient>), pk_k(<signersKey>)),
* and_v(r:older(<lockTime>), multi_a(2, xpub1/*, xpub2/*, xpub3/*))
* }
* )
*
* - Deposit leaf compiles to: `<30B-payload> OP_DROP <signersKey> OP_CHECKSIG`
* - Reclaim leaf compiles to: `<lockTime> OP_CSV OP_DROP <k1> OP_CHECKSIG <k2> OP_CHECKSIGADD <k3> 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]],
});
}
3 changes: 3 additions & 0 deletions modules/utxo-descriptors/src/sbtc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './constants';
export * from './descriptor';
export * from './depositAddress';
Original file line number Diff line number Diff line change
@@ -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/*"
}
]
}
]
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
51206762539ced3f9dc474b91ea31c8f8a22ed65c3c08efeac2663459992892a670b
Original file line number Diff line number Diff line change
@@ -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/*))})
Loading
Loading