diff --git a/Dockerfile b/Dockerfile index 491a65fc0b..5eef249584 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,7 @@ COPY --from=builder /tmp/bitgo/modules/argon2 /var/modules/argon2/ COPY --from=builder /tmp/bitgo/modules/sdk-hmac /var/modules/sdk-hmac/ COPY --from=builder /tmp/bitgo/modules/unspents /var/modules/unspents/ COPY --from=builder /tmp/bitgo/modules/utxo-core /var/modules/utxo-core/ +COPY --from=builder /tmp/bitgo/modules/utxo-descriptors /var/modules/utxo-descriptors/ COPY --from=builder /tmp/bitgo/modules/utxo-ord /var/modules/utxo-ord/ COPY --from=builder /tmp/bitgo/modules/account-lib /var/modules/account-lib/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-ada /var/modules/sdk-coin-ada/ @@ -163,6 +164,7 @@ cd /var/modules/argon2 && yarn link && \ cd /var/modules/sdk-hmac && yarn link && \ cd /var/modules/unspents && yarn link && \ cd /var/modules/utxo-core && yarn link && \ +cd /var/modules/utxo-descriptors && yarn link && \ cd /var/modules/utxo-ord && yarn link && \ cd /var/modules/account-lib && yarn link && \ cd /var/modules/sdk-coin-ada && yarn link && \ @@ -269,6 +271,7 @@ RUN cd /var/bitgo-express && \ yarn link @bitgo/sdk-hmac && \ yarn link @bitgo/unspents && \ yarn link @bitgo/utxo-core && \ + yarn link @bitgo/utxo-descriptors && \ yarn link @bitgo/utxo-ord && \ yarn link @bitgo/account-lib && \ yarn link @bitgo/sdk-coin-ada && \ diff --git a/modules/abstract-utxo/package.json b/modules/abstract-utxo/package.json index c89d298513..bf17671f51 100644 --- a/modules/abstract-utxo/package.json +++ b/modules/abstract-utxo/package.json @@ -64,9 +64,10 @@ "@bitgo/sdk-api": "^1.81.0", "@bitgo/sdk-core": "^37.2.0", "@bitgo/utxo-core": "^1.38.0", + "@bitgo/utxo-descriptors": "^1.2.0", "@bitgo/utxo-lib": "^11.22.1", "@bitgo/utxo-ord": "^1.31.0", - "@bitgo/wasm-utxo": "^4.13.0", + "@bitgo/wasm-utxo": "^4.14.1", "@types/lodash": "^4.14.121", "@types/superagent": "4.1.15", "bignumber.js": "^9.0.2", diff --git a/modules/abstract-utxo/src/descriptor/builder/builder.ts b/modules/abstract-utxo/src/descriptor/builder/builder.ts index 090b524820..c3caec11b7 100644 --- a/modules/abstract-utxo/src/descriptor/builder/builder.ts +++ b/modules/abstract-utxo/src/descriptor/builder/builder.ts @@ -1,3 +1,4 @@ +import { sbtc } from '@bitgo/utxo-descriptors'; import { bip32, Descriptor } from '@bitgo/wasm-utxo'; type DescriptorWithKeys = { @@ -14,7 +15,24 @@ export type DescriptorBuilder = * relative locktime with an OP_DROP (requiring a miniscript extension). * It is basically what is used in CoreDao staking transactions. */ - | (DescriptorWithKeys<'ShWsh2Of3CltvDrop' | 'Wsh2Of3CltvDrop'> & { locktime: number }); + | (DescriptorWithKeys<'ShWsh2Of3CltvDrop' | 'Wsh2Of3CltvDrop'> & { locktime: number }) + /* + * sBTC peg-in deposit Taproot descriptor: + * tr(, + * { + * c:and_v(payload_drop(), pk_k()), + * and_v(r:older(), multi_a(2, k1/*, k2/*, k3/*)) + * } + * ) + * + * `keys` are the three reclaim keys used in the reclaim-leaf multi_a. + */ + | (DescriptorWithKeys<'SbtcDeposit'> & { + lockTime: number; + maxFee: bigint; + stacksRecipient: Buffer; + signersAggregateKey: Buffer; + }); function toXPub(k: bip32.BIP32Interface | string): string { if (typeof k === 'string') { @@ -44,6 +62,22 @@ function getDescriptorString(builder: DescriptorBuilder): string { return `wsh(and_v(r:after(${builder.locktime}),${multi(2, 3, builder.keys, builder.path)}))`; case 'ShWsh2Of3CltvDrop': return `sh(${getDescriptorString({ ...builder, name: 'Wsh2Of3CltvDrop' })})`; + case 'SbtcDeposit': + // The reclaim leaf always uses `/*` wildcard derivation; `createSbtcDepositDescriptor` + // hardcodes that suffix when given BIP32 keys, so reject any other path here. + if (builder.path !== '*') { + throw new Error(`SbtcDeposit path must be '*', got '${builder.path}'`); + } + if (builder.keys.length !== 3) { + throw new Error(`SbtcDeposit needs exactly 3 reclaim keys, got ${builder.keys.length}`); + } + return sbtc.createSbtcDepositDescriptor({ + walletKeys: [builder.keys[0], builder.keys[1], builder.keys[2]], + lockTime: builder.lockTime, + maxFee: builder.maxFee, + stacksRecipient: builder.stacksRecipient, + signersAggregateKey: builder.signersAggregateKey, + }); } throw new Error(`Unknown descriptor template: ${builder}`); } diff --git a/modules/abstract-utxo/src/descriptor/builder/parse.ts b/modules/abstract-utxo/src/descriptor/builder/parse.ts index 685b6ea354..bdd9ca54ae 100644 --- a/modules/abstract-utxo/src/descriptor/builder/parse.ts +++ b/modules/abstract-utxo/src/descriptor/builder/parse.ts @@ -1,3 +1,4 @@ +import { sbtc } from '@bitgo/utxo-descriptors'; import { BIP32, bip32, Descriptor } from '@bitgo/wasm-utxo'; import { DescriptorBuilder, getDescriptorFromBuilder } from './builder'; @@ -62,7 +63,7 @@ function parseWshMulti(node: unknown): DescriptorBuilder | undefined { const wshMsMulti = unwrapNode(node, ['Wsh', 'Ms', 'Multi']); if (wshMsMulti) { const { threshold, keys, path } = parseMulti(wshMsMulti); - let name; + let name: 'Wsh2Of2' | 'Wsh2Of3'; if (threshold === 2 && keys.length === 2) { name = 'Wsh2Of2'; } else if (threshold === 2 && keys.length === 3) { @@ -78,6 +79,38 @@ function parseWshMulti(node: unknown): DescriptorBuilder | undefined { } } +function parseSbtcDeposit(descriptor: Descriptor): DescriptorBuilder | undefined { + const parsed = sbtc.parseSbtcDepositDescriptor(descriptor); + if (!parsed) { + return; + } + + // For wallet descriptors the reclaim leaf is a derivable `multi_a` with + // `xpub.../*` entries; split each into xpub + path. Definite descriptors + // produce raw hex strings here, which `BIP32.fromBase58` rejects — that + // matches our intent, since a definite descriptor isn't a wallet descriptor. + const keyWithPath = parsed.reclaimKeyStrings.map((k) => { + const [xpub, ...rest] = k.split('/'); + return { xpub, path: rest.join('/') }; + }); + const paths = keyWithPath.map((k) => k.path); + paths.forEach((p) => { + if (p !== paths[0]) { + throw new Error(`Expected all multi_a paths to be the same: ${p} !== ${paths[0]}`); + } + }); + + return { + name: 'SbtcDeposit', + keys: keyWithPath.map((k) => BIP32.fromBase58(k.xpub)), + path: paths[0], + lockTime: parsed.lockTime, + maxFee: parsed.maxFee, + stacksRecipient: parsed.stacksRecipient, + signersAggregateKey: parsed.signersAggregateKey, + }; +} + function parseCltvDrop( node: unknown, name: 'Wsh2Of3CltvDrop' | 'ShWsh2Of3CltvDrop', @@ -120,7 +153,9 @@ export function parseDescriptorNode(node: unknown): DescriptorBuilder { } export function parseDescriptor(descriptor: Descriptor): DescriptorBuilder { - const builder = parseDescriptorNode(descriptor.node()); + // sBTC peg-in uses a Taproot `tr(...)` descriptor that the raw-node parsers + // don't recognize; match it against the ast form first. + const builder = parseSbtcDeposit(descriptor) ?? parseDescriptorNode(descriptor.node()); if (getDescriptorFromBuilder(builder).toString() !== descriptor.toString()) { throw new Error('Failed to parse descriptor'); } diff --git a/modules/abstract-utxo/src/descriptor/validatePolicy.ts b/modules/abstract-utxo/src/descriptor/validatePolicy.ts index 0f39c5cf8d..1d3d01e714 100644 --- a/modules/abstract-utxo/src/descriptor/validatePolicy.ts +++ b/modules/abstract-utxo/src/descriptor/validatePolicy.ts @@ -104,6 +104,8 @@ export function getPolicyForEnv(env: EnvironmentName): DescriptorValidationPolic getValidatorDescriptorTemplate('Wsh2Of3'), // allow descriptor groups where all keys match the wallet keys plus OP_DROP (coredao staking) getValidatorDescriptorTemplate('Wsh2Of3CltvDrop'), + // allow sBTC peg-in deposit descriptors where the reclaim keys match the wallet keys + getValidatorDescriptorTemplate('SbtcDeposit'), // allow all descriptors signed by the user key getValidatorSignedByUserKey(), ]); diff --git a/modules/abstract-utxo/test/unit/descriptor/builder.ts b/modules/abstract-utxo/test/unit/descriptor/builder.ts index a10b852995..50aec8888d 100644 --- a/modules/abstract-utxo/test/unit/descriptor/builder.ts +++ b/modules/abstract-utxo/test/unit/descriptor/builder.ts @@ -4,6 +4,12 @@ import * as testutils from '@bitgo/wasm-utxo/testutils'; import { parseDescriptor, DescriptorBuilder, getDescriptorFromBuilder } from '../../../src/descriptor/builder'; +const SBTC_SIGNERS_AGGREGATE_KEY = Buffer.from( + 'c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421', + 'hex' +); +const SBTC_STACKS_RECIPIENT = Buffer.from('05' + '16' + '6d'.repeat(20), 'hex'); + function getDescriptorBuilderForType(name: DescriptorBuilder['name']): DescriptorBuilder { const keys = testutils.getKeyTriple('default').map((k) => k.neutered()); switch (name) { @@ -22,6 +28,17 @@ function getDescriptorBuilderForType(name: DescriptorBuilder['name']): Descripto path: '0/*', locktime: 1, }; + case 'SbtcDeposit': + return { + name, + keys, + // sBTC reclaim leaf uses bare `/*` wildcard (no chain prefix). + path: '*', + lockTime: 950, + maxFee: 80000n, + stacksRecipient: SBTC_STACKS_RECIPIENT, + signersAggregateKey: SBTC_SIGNERS_AGGREGATE_KEY, + }; } } @@ -46,3 +63,4 @@ describeForName('Wsh2Of2'); describeForName('Wsh2Of3'); describeForName('Wsh2Of3CltvDrop'); describeForName('ShWsh2Of3CltvDrop'); +describeForName('SbtcDeposit'); diff --git a/modules/abstract-utxo/tsconfig.json b/modules/abstract-utxo/tsconfig.json index d616948f8d..332fd692e2 100644 --- a/modules/abstract-utxo/tsconfig.json +++ b/modules/abstract-utxo/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../utxo-core" }, + { + "path": "../utxo-descriptors" + }, { "path": "../utxo-ord" } diff --git a/modules/utxo-bin/package.json b/modules/utxo-bin/package.json index ad74ff1aa0..acbf7ad8f5 100644 --- a/modules/utxo-bin/package.json +++ b/modules/utxo-bin/package.json @@ -31,7 +31,7 @@ "@bitgo/unspents": "^0.51.4", "@bitgo/utxo-core": "^1.38.0", "@bitgo/utxo-lib": "^11.22.1", - "@bitgo/wasm-utxo": "^4.13.0", + "@bitgo/wasm-utxo": "^4.14.1", "@noble/curves": "1.8.1", "archy": "^1.0.0", "bech32": "^2.0.0", diff --git a/modules/utxo-core/package.json b/modules/utxo-core/package.json index 0b50aeb308..9f239bbc28 100644 --- a/modules/utxo-core/package.json +++ b/modules/utxo-core/package.json @@ -81,7 +81,7 @@ "@bitgo/secp256k1": "^1.11.0", "@bitgo/unspents": "^0.51.4", "@bitgo/utxo-lib": "^11.22.1", - "@bitgo/wasm-utxo": "^4.13.0", + "@bitgo/wasm-utxo": "^4.14.1", "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", "fast-sha256": "^1.3.0" }, diff --git a/modules/utxo-descriptors/package.json b/modules/utxo-descriptors/package.json index 8e2f15538e..47c1763d25 100644 --- a/modules/utxo-descriptors/package.json +++ b/modules/utxo-descriptors/package.json @@ -60,6 +60,6 @@ }, "dependencies": { "@bitgo/utxo-core": "^1.38.0", - "@bitgo/wasm-utxo": "^4.13.0" + "@bitgo/wasm-utxo": "^4.14.1" } } diff --git a/modules/utxo-descriptors/src/sbtc/parseDescriptor.ts b/modules/utxo-descriptors/src/sbtc/parseDescriptor.ts index b88a1f6f07..6d56176ca3 100644 --- a/modules/utxo-descriptors/src/sbtc/parseDescriptor.ts +++ b/modules/utxo-descriptors/src/sbtc/parseDescriptor.ts @@ -34,6 +34,14 @@ export type ParsedSbtcDepositDescriptor = { depositMiniscriptNode: ast.MiniscriptNode; /** Raw miniscript AST for the reclaim leaf (second tap-tree leaf). */ reclaimMiniscriptNode: ast.MiniscriptNode; + /** + * The three raw `multi_a` key entries from the reclaim leaf, exactly as + * they appear in the descriptor — `xpub.../*` for derivable descriptors and + * 64-hex-char x-only keys for definite descriptors. Useful for callers that + * need the original xpub form (e.g. to round-trip back to a descriptor + * builder) and don't want the resolved `reclaimKeys` buffers. + */ + reclaimKeyStrings: [string, string, string]; }; function asString(v: unknown, field: string): string { @@ -218,6 +226,11 @@ export function parseSbtcDepositDescriptor( const { signersAggregateKey, maxFee, stacksRecipient } = parseDepositLeaf(depositMiniscriptNode, matcher); const { lockTime, reclaimKeyStrings } = parseReclaimLeaf(reclaimMiniscriptNode, matcher); + const reclaimKeyStringsTuple: [string, string, string] = [ + reclaimKeyStrings[0], + reclaimKeyStrings[1], + reclaimKeyStrings[2], + ]; const reclaimKeys = reclaimKeysFromStrings(reclaimKeyStrings); if (reclaimKeys) { @@ -237,6 +250,7 @@ export function parseSbtcDepositDescriptor( stacksRecipient, lockTime, reclaimKeys, + reclaimKeyStrings: reclaimKeyStringsTuple, depositMiniscriptNode, reclaimMiniscriptNode, }; diff --git a/modules/utxo-descriptors/test/unit/sbtc/parseDescriptor.ts b/modules/utxo-descriptors/test/unit/sbtc/parseDescriptor.ts index 63cf09097b..c92ef762da 100644 --- a/modules/utxo-descriptors/test/unit/sbtc/parseDescriptor.ts +++ b/modules/utxo-descriptors/test/unit/sbtc/parseDescriptor.ts @@ -75,6 +75,12 @@ describe('parseSbtcDepositDescriptor', function () { assert.deepStrictEqual(parsed.reclaimKeys, sortedInput); }); + it('exposes raw hex reclaimKeyStrings for definite inputs', function () { + const parsed = parseSbtcDepositDescriptor(descriptor); + assert.ok(parsed); + assert.deepStrictEqual(parsed.reclaimKeyStrings, [...reclaimKeyHex].sort()); + }); + it('returns miniscript AST nodes for both leaves', function () { const parsed = parseSbtcDepositDescriptor(descriptor); assert.ok(parsed); @@ -110,6 +116,15 @@ describe('parseSbtcDepositDescriptor', function () { assert.strictEqual(parsed.reclaimKeys, undefined); }); + it('exposes the raw xpub.../* reclaimKeyStrings for derivable inputs', function () { + const parsed = parseSbtcDepositDescriptor(descriptor); + assert.ok(parsed); + assert.strictEqual(parsed.reclaimKeyStrings.length, 3); + for (const k of parsed.reclaimKeyStrings) { + assert.match(k, /^xpub[1-9A-HJ-NP-Za-km-z]+\/\*$/); + } + }); + it('resolves to concrete reclaim keys after atDerivationIndex(0) and compiles to a tap Miniscript', function () { assert.strictEqual(descriptor.hasWildcard(), true); const derivedZero = descriptor.atDerivationIndex(0); diff --git a/modules/utxo-ord/package.json b/modules/utxo-ord/package.json index 67f94c6981..eaa7696b11 100644 --- a/modules/utxo-ord/package.json +++ b/modules/utxo-ord/package.json @@ -45,7 +45,7 @@ "directory": "modules/utxo-ord" }, "dependencies": { - "@bitgo/wasm-utxo": "^4.13.0" + "@bitgo/wasm-utxo": "^4.14.1" }, "devDependencies": { "@bitgo/utxo-lib": "^11.22.1" diff --git a/modules/utxo-staking/package.json b/modules/utxo-staking/package.json index 879551431e..09de6182a4 100644 --- a/modules/utxo-staking/package.json +++ b/modules/utxo-staking/package.json @@ -63,7 +63,7 @@ "@bitgo/babylonlabs-io-btc-staking-ts": "^3.5.0", "@bitgo/utxo-core": "^1.38.0", "@bitgo/utxo-lib": "^11.22.1", - "@bitgo/wasm-utxo": "^4.13.0", + "@bitgo/wasm-utxo": "^4.14.1", "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", "bip322-js": "^2.0.0", "bitcoinjs-lib": "^6.1.7", diff --git a/yarn.lock b/yarn.lock index 9e47216ef1..39c497fc83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1094,10 +1094,10 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-ton/-/wasm-ton-1.1.1.tgz" integrity sha512-Y4x2V2ZcYWlmx42v7dlrKDtT2DuUt8smk8E98mh7RhpiifJhLk2v5RmXDwBl0A3v9TzUOU6qMOnSS/iZ8Pq52w== -"@bitgo/wasm-utxo@^4.13.0": - version "4.13.0" - resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-4.13.0.tgz#95efb853e40b30c6525cbf99a05f1a4db4161609" - integrity sha512-/jejFe7o/KJ08sivfWNKNWoNt1iBJ7tL7MZ7IbhVGeC2TLWo7ZF6UuE1A/E3rNyAM7egJ6BrtGuTumKVXDeibQ== +"@bitgo/wasm-utxo@^4.14.1": + version "4.14.1" + resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-4.14.1.tgz#bf709a98078f10f9c52900bcba24a6c6a9d0256e" + integrity sha512-cHGJzCNlPMpVXp/ydFIHQ0/GSRJLYG+cqzVYa6u7x2bbnKDzIqgn34hpWX1w/FfZ626U0S47HGo8ujmoWoUn2Q== "@brandonblack/musig@^0.0.1-alpha.0": version "0.0.1-alpha.1"