-
Notifications
You must be signed in to change notification settings - Fork 302
feat(utxo-descriptors): add sBTC taproot descriptors #8706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
veetragjain
wants to merge
1
commit into
master
Choose a base branch
from
veetragjain/cshld-744-create-utxo-descriptors-package-for-sbtc-taproot-descriptors
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| dist | ||
| node_modules |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| /dist | ||
| /node_modules | ||
| /.nyc_output |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| module.exports = { | ||
| require: 'tsx', | ||
| extension: ['.js', '.ts'], | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| .nyc_output/ | ||
| dist | ||
| node_modules | ||
| test/fixtures |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| printWidth: 120 | ||
| singleQuote: true | ||
| trailingComma: es5 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| { | ||
| "name": "@bitgo/utxo-descriptors", | ||
| "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" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * as sbtc from './sbtc'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]], | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export * from './constants'; | ||
| export * from './descriptor'; | ||
| export * from './depositAddress'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 firstThere was a problem hiding this comment.
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
There was a problem hiding this comment.
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 theprivate: trueagainThere was a problem hiding this comment.
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
is failing
There was a problem hiding this comment.
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-kaspaThere was a problem hiding this comment.
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: trueanyway so we can get a green buildThere was a problem hiding this comment.
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.