Skip to content
Open
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
36 changes: 36 additions & 0 deletions modules/utxo-descriptors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# @bitgo/utxo-descriptors

A library for constructing [Bitcoin output descriptors][bip-380] (BIP-380) — descriptor strings that pair on-chain UTXOs with a derivable, parseable key-tracking expression.

This package is the canonical home for descriptor-building logic across BitGo's UTXO codebase. It deliberately stays one layer above [`@bitgo/wasm-utxo`][wasm-utxo]: that package owns descriptor parsing and miniscript compilation. This package owns the high-level _builders_ that emit valid descriptor strings for the protocols BitGo supports.

## Install

```bash
yarn add @bitgo/utxo-descriptors
```

## Module layout

```
src/
├── index.ts # re-exports each protocol module under a namespace
└── sbtc/ # sBTC peg-in deposit descriptors
├── constants.ts
├── descriptor.ts
├── depositAddress.ts
└── index.ts
```

Each new protocol gets its own subdirectory under `src/` and is re-exported as a namespace from [`src/index.ts`](src/index.ts), so consumers import it as `import { sbtc } from '@bitgo/utxo-descriptors'`.

---

## sBTC

The `sbtc` namespace builds descriptors for sBTC peg-in deposits — Bitcoin UTXOs whose output script is a Taproot tree with two leaves:

- a **deposit** leaf, spendable only by the sBTC signers, that commits to the Stacks recipient and the maximum signer fee
- a **reclaim** leaf, spendable by the depositor after a relative timelock, that lets the depositor recover their BTC if the signers fail to act

Both leaves are expressed as **Bitcoin miniscript fragments** inside a single `tr()` descriptor — no raw script bytes, no out-of-band leaf hashing. This is enabled by the `payload_drop(<hex>)` fragment and tap-context `r:older(N)` shipped in `@bitgo/wasm-utxo` 4.11.0 ([BitGoWASM #272](https://github.com/BitGo/BitGoWASM/pull/272)).
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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to set this to { private: true } for now, we need to publish this with a manual step first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought someone from velocity team will help creating the package first based on this thread - https://bitgo.slack.com/archives/C010AEXLLCR/p1772034901293329

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes but I think the sequence is: create package in repo with private: true, then manual publish, then we remove the private: true again

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure it's the reason why

[BitGo SDK / verify-npm-packages (pull_request)]

is failing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it's documented anywhere, but I don't see this being done for some of the recently added packages like sdk-coin-kaspa

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try setting it to private: true anyway so we can get a green build

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, let me try it once.

"version": "1.0.0",
"private": true,
"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/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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@linear add a ticket so we expose this from wasm-utxo where we define it already I believe

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created issue T1-3296


/**
* 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;
47 changes: 47 additions & 0 deletions modules/utxo-descriptors/src/sbtc/depositAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { bip32, Descriptor } from '@bitgo/wasm-utxo';

import { createSbtcDepositDescriptor, SbtcDepositDescriptorParams } from './descriptor';

type BIP32Interface = bip32.BIP32Interface;

/**
* 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];
}
117 changes: 117 additions & 0 deletions modules/utxo-descriptors/src/sbtc/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { ast, bip32 } from '@bitgo/wasm-utxo';

import { MAX_FEE_BYTE_LENGTH, STACKS_RECIPIENT_BYTE_LENGTH, UNSPENDABLE_INTERNAL_KEY } from './constants';

type BIP32Interface = bip32.BIP32Interface;

/**
* 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): 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 key.neutered().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): 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(asDescriptorKey);

// `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';
Loading
Loading