diff --git a/modules/abstract-utxo/package.json b/modules/abstract-utxo/package.json index 570920bb046..1477e221ff4 100644 --- a/modules/abstract-utxo/package.json +++ b/modules/abstract-utxo/package.json @@ -66,6 +66,7 @@ "@bitgo/utxo-core": "^1.41.5", "@bitgo/utxo-descriptors": "^2.1.0", "@bitgo/utxo-ord": "^1.34.5", + "@bitgo/utxo-staking": "^2.0.1", "@bitgo/wasm-utxo": "^5.5.1", "@types/lodash": "^4.14.121", "@types/superagent": "4.1.15", diff --git a/modules/abstract-utxo/src/impl/btc/tbtcstx.ts b/modules/abstract-utxo/src/impl/btc/tbtcstx.ts index a8a92bcbef2..8acf7f5006a 100644 --- a/modules/abstract-utxo/src/impl/btc/tbtcstx.ts +++ b/modules/abstract-utxo/src/impl/btc/tbtcstx.ts @@ -1,6 +1,6 @@ import { BitGoBase } from '@bitgo/sdk-core'; -import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; +import { UtxoCoinName } from '../../names'; import { Tbtc } from './tbtc'; @@ -14,8 +14,4 @@ export class Tbtcstx extends Tbtc { static createInstance(bitgo: BitGoBase): Tbtcstx { return new Tbtcstx(bitgo); } - - override get wasmName(): WasmUtxoCoinName { - return 'tbtcreg'; - } } diff --git a/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts b/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts index 8b41370c12d..cdb83b65824 100644 --- a/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts +++ b/modules/abstract-utxo/src/impl/btc/tbtcstxsignet.ts @@ -1,6 +1,6 @@ import { BitGoBase } from '@bitgo/sdk-core'; -import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; +import { UtxoCoinName } from '../../names'; import { Tbtc } from './tbtc'; @@ -14,8 +14,4 @@ export class Tbtcstxsignet extends Tbtc { static createInstance(bitgo: BitGoBase): Tbtcstxsignet { return new Tbtcstxsignet(bitgo); } - - override get wasmName(): WasmUtxoCoinName { - return 'tbtcsig'; - } } diff --git a/modules/abstract-utxo/src/names.ts b/modules/abstract-utxo/src/names.ts index b6b564e34cb..c262fd54d1a 100644 --- a/modules/abstract-utxo/src/names.ts +++ b/modules/abstract-utxo/src/names.ts @@ -34,10 +34,20 @@ export type UtxoCoinNameTestnet = export type UtxoCoinName = UtxoCoinNameMainnet | UtxoCoinNameTestnet; export function toWasmUtxoCoinName(coinName: UtxoCoinName | WasmUtxoCoinName): WasmUtxoCoinName { - if (!isCoinName(coinName)) { - throw new Error(`coin ${coinName} is not supported by wasm-utxo`); + if (isCoinName(coinName)) { + return coinName; + } + // Stacks PoX coins share a wasm coin with their base chain for script and + // address codecs (see the `wasmName` of the coin classes) + switch (coinName) { + case 'tbtcstx': + case 'tbtcstxprivate1': + return 'tbtcreg'; + case 'tbtcstxsignet': + return 'tbtcsig'; + default: + throw new Error(`coin ${coinName} is not supported by wasm-utxo`); } - return coinName; } export function isUtxoCoinNameMainnet(coinName: string): coinName is UtxoCoinNameMainnet { diff --git a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts index 2b5daa4fb3b..551cf4486d9 100644 --- a/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts +++ b/modules/abstract-utxo/src/offlineVault/TransactionExplanation.ts @@ -17,6 +17,8 @@ export interface TransactionExplanation { payGoFeeString: string | number | undefined; payGoFeeAddress: string | undefined; }; + /* PSBT nLockTime — the operator must verify it against the PoX-5 unlock height before co-signing a CLTV spend */ + locktime: number; } export function getTransactionExplanation(coinName: UtxoCoinName, tx: unknown): TransactionExplanation { diff --git a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts index ae4f17768f8..5cc22e93d72 100644 --- a/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts +++ b/modules/abstract-utxo/src/offlineVault/descriptor/transaction.ts @@ -1,10 +1,13 @@ import * as t from 'io-ts'; +import { pox5 } from '@bitgo/utxo-descriptors'; +import { pox5 as pox5Staking } from '@bitgo/utxo-staking'; import { bip32, Psbt } from '@bitgo/wasm-utxo'; import { DescriptorMap, NamedDescriptor } from '../../descriptor'; import { OfflineVaultSignable, toKeyTriple } from '../OfflineVaultSignable'; import { getValidatorOneOfTemplates, + getValidatorPox5Lockup, getValidatorSignedByUserKey, getValidatorSome, toDescriptorMapValidate, @@ -20,21 +23,58 @@ export const DescriptorTransaction = t.intersection( export type DescriptorTransaction = t.TypeOf; +const descriptorTemplates = ['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']; + export function getDescriptorsFromDescriptorTransaction(tx: DescriptorTransaction): DescriptorMap { const { descriptors, xpubsWithDerivationPath } = tx; const pubkeys = toKeyTriple(xpubsWithDerivationPath); const policy = getValidatorSome([ // allow all 2-of-3-ish descriptors where the keys match the wallet keys - getValidatorOneOfTemplates(['Wsh2Of3', 'Wsh2Of3CltvDrop', 'ShWsh2Of3CltvDrop']), + getValidatorOneOfTemplates(descriptorTemplates), + // allow PoX-5 staking groups: canonical lockup descriptors with wallet staker + // keys alongside the wallet's external/internal descriptors + getValidatorPox5Lockup(descriptorTemplates), // allow all descriptors signed by the user key getValidatorSignedByUserKey(), ]); return toDescriptorMapValidate(descriptors, pubkeys, policy); } +function getPox5InputMatches(psbt: Psbt, descriptorMap: DescriptorMap): pox5.Pox5InputMatch[] { + const matches: pox5.Pox5InputMatch[] = []; + for (let vin = 0; vin < psbt.inputCount(); vin++) { + const match = pox5.matchPox5Input(psbt, vin, descriptorMap); + if (match) { + matches.push(match); + } + } + return matches; +} + +/** + * Apply the BitGoJS PoX-5 staking policy (WAL-2022) before half-signing a late + * exit: the spend must use the post-CLTV branch over exactly one lockup input. + * Early exits (principal preimage branch) are out of scope for the offline vault. + */ +function assertPox5LateExitSpend(psbt: Psbt, descriptorMap: DescriptorMap): void { + const matches = getPox5InputMatches(psbt, descriptorMap); + if (matches.length === 0) { + return; + } + if (matches.length > 1) { + throw new Error(`PoX-5 late exit must spend exactly one lockup input, found ${matches.length}`); + } + const [match] = matches; + if (pox5Staking.classifyPox5Spend(psbt, match) !== 'locktime') { + throw new Error('PoX-5 early exit spends are not supported by the offline vault'); + } + pox5Staking.assertPox5LocktimeSpend(psbt, matches); +} + export function getHalfSignedPsbt(tx: DescriptorTransaction, prv: bip32.BIP32Interface, coinName: UtxoCoinName): Psbt { const psbt = Psbt.deserialize(Buffer.from(tx.coinSpecific.txHex, 'hex')); const descriptorMap = getDescriptorsFromDescriptorTransaction(tx); + assertPox5LateExitSpend(psbt, descriptorMap); signPsbt(psbt, descriptorMap, prv, { onUnknownInput: 'throw' }); return psbt; } @@ -56,5 +96,6 @@ export function getTransactionExplanationFromPsbt( payGoFeeString: undefined, payGoFeeAddress: undefined, }, + locktime: psbt.lockTime(), }; } diff --git a/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json b/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json index 0fe5e59f98d..0bfb6b6027a 100644 --- a/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json +++ b/modules/abstract-utxo/test/unit/offlineVault/fixtures/Wsh2Of3.buildAdmin.custodial.explanation.json @@ -16,5 +16,6 @@ ], "fee": { "fee": "4421" - } + }, + "locktime": 0 } diff --git a/modules/abstract-utxo/test/unit/offlineVault/pox5.ts b/modules/abstract-utxo/test/unit/offlineVault/pox5.ts new file mode 100644 index 00000000000..d9d27628fa1 --- /dev/null +++ b/modules/abstract-utxo/test/unit/offlineVault/pox5.ts @@ -0,0 +1,258 @@ +import assert from 'assert'; +import { createHash } from 'crypto'; + +import { pox5 } from '@bitgo/utxo-descriptors'; +import { BIP32, bip32, Descriptor, Psbt } from '@bitgo/wasm-utxo'; +import * as testutils from '@bitgo/wasm-utxo/testutils'; + +import { getDescriptorFromBuilder } from '../../../src/descriptor/builder'; +import { NamedDescriptor } from '../../../src/descriptor'; +import { deriveKeyWithSeed } from '../../../src/deriveKeyWithSeed'; +import { createHalfSigned, getTransactionExplanation } from '../../../src/offlineVault'; +import { DescriptorTransaction } from '../../../src/offlineVault/descriptor'; +import { UtxoCoinName } from '../../../src/names'; + +type BIP32Interface = bip32.BIP32Interface; + +const UNLOCK_HEIGHT = 840_000; +const SEED = 'pox5-offline-vault'; +const PRINCIPAL_PREIMAGE = Buffer.alloc(32, 0x42); +const LOCKUP_VALUE = 100_000n; +const EXTERNAL_VALUE = 50_000n; +const CHANGE_VALUE = 45_000n; + +function sha256(value: Uint8Array): Buffer { + return createHash('sha256').update(value).digest(); +} + +// the offline vault derives the user signing key from a root key and a seed +const rootUser = testutils.getKey('abstract-utxo-pox5-root'); +const backupKey = testutils.getKey('abstract-utxo-pox5-backup'); +const bitgoKey = testutils.getKey('abstract-utxo-pox5-bitgo'); +const userKey = deriveKeyWithSeed(BIP32.fromBase58(rootUser.toBase58()), SEED).key; +const walletKeys: [BIP32Interface, BIP32Interface, BIP32Interface] = [userKey, backupKey, bitgoKey]; + +const EARLY_EXIT_KEY = Buffer.from(userKey.derive(9).publicKey); +const EXTERNAL_SCRIPT = Buffer.from( + Descriptor.fromString( + `wpkh(${Buffer.from(testutils.getKey('abstract-utxo-pox5-recipient').publicKey).toString('hex')})`, + 'definite' + ).scriptPubkey() +); + +function getLockupDescriptor(stakerKeys: [BIP32Interface, BIP32Interface, BIP32Interface] = walletKeys): Descriptor { + return pox5.createPox5LockupDescriptor({ + unlockHeight: UNLOCK_HEIGHT, + stakerCommitment: sha256(PRINCIPAL_PREIMAGE), + earlyExitKey: EARLY_EXIT_KEY, + stakerKeys, + }); +} + +function getExternalDescriptor(): Descriptor { + return getDescriptorFromBuilder({ name: 'Wsh2Of3', keys: walletKeys, path: '0/*' }); +} + +function getInternalDescriptor(): Descriptor { + return getDescriptorFromBuilder({ name: 'Wsh2Of3', keys: walletKeys, path: '1/*' }); +} + +function getLockupName(): string { + return `pox5-lockup-u${UNLOCK_HEIGHT}-${sha256(PRINCIPAL_PREIMAGE).toString('hex').slice(0, 16)}`; +} + +function toNamedDescriptor(name: string, descriptor: Descriptor): NamedDescriptor { + return { name, value: descriptor.toString() }; +} + +function assertInputHasValidSignatures(psbt: Psbt, inputIndex: number): void { + assert(psbt.hasPartialSignatures(inputIndex)); + const partialSigs = psbt.getPartialSignatures(inputIndex); + assert(partialSigs.length > 0); + for (const sig of partialSigs) { + assert(psbt.validateSignatureAtInput(inputIndex, sig.pubkey)); + } +} + +function assertHalfSigned(tx: DescriptorTransaction, coinName: UtxoCoinName): void { + const halfSigned = createHalfSigned(coinName, rootUser, SEED, tx); + const psbt = Psbt.deserialize(Buffer.from(halfSigned.halfSigned.txHex, 'hex')); + assertInputHasValidSignatures(psbt, 0); +} + +type CreateDescriptorTransactionOptions = { + /* descriptors funding the inputs, spent at derivation index 0 */ + input: Descriptor; + /* change descriptor, paid at derivation index 0 */ + change: Descriptor; + descriptors: NamedDescriptor[]; + lockTime?: number; + sequence?: number; + /* spend a second lockup input */ + extraLockupInput?: boolean; + /* commit a principal preimage to mark an early exit */ + earlyExit?: boolean; +}; + +function createDescriptorTransaction(opts: CreateDescriptorTransactionOptions): DescriptorTransaction { + const input = opts.input.atDerivationIndex(0); + const change = opts.change.atDerivationIndex(0); + const sequence = opts.sequence ?? 0xfffffffe; + const psbt = Psbt.create(2, opts.lockTime ?? UNLOCK_HEIGHT); + psbt.addInput('01'.repeat(32), 0, LOCKUP_VALUE, Buffer.from(input.scriptPubkey()), sequence); + psbt.updateInputWithDescriptor(0, input); + if (opts.extraLockupInput) { + psbt.addInput('02'.repeat(32), 0, LOCKUP_VALUE, Buffer.from(input.scriptPubkey()), sequence); + psbt.updateInputWithDescriptor(1, input); + } + psbt.addOutput(Buffer.from(change.scriptPubkey()), CHANGE_VALUE); + psbt.updateOutputWithDescriptor(0, change); + psbt.addOutput(EXTERNAL_SCRIPT, EXTERNAL_VALUE); + if (opts.earlyExit) { + psbt.addSha256Preimage(0, PRINCIPAL_PREIMAGE); + } + return { + xpubsWithDerivationPath: { + user: { xpub: walletKeys[0].neutered().toBase58(), derivedFromParentWithSeed: SEED }, + backup: { xpub: walletKeys[1].neutered().toBase58() }, + bitgo: { xpub: walletKeys[2].neutered().toBase58() }, + }, + coinSpecific: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, + descriptors: opts.descriptors, + }; +} + +function createLateExitTransaction( + opts: Partial> & { + input?: Descriptor; + } = {} +): DescriptorTransaction { + const input = opts.input ?? getLockupDescriptor(); + return createDescriptorTransaction({ + ...opts, + input, + change: getInternalDescriptor(), + descriptors: [ + toNamedDescriptor('external', getExternalDescriptor()), + toNamedDescriptor('internal', getInternalDescriptor()), + toNamedDescriptor(getLockupName(), input), + ], + }); +} + +describe('OfflineVault PoX-5 late exit', function () { + for (const coinName of ['btc', 'tbtcstxsignet'] as const) { + describe(coinName, function () { + it('explains a late exit and includes the locktime', function () { + const tx = createLateExitTransaction(); + const explanation = getTransactionExplanation(coinName, tx); + assert.strictEqual(explanation.locktime, UNLOCK_HEIGHT); + assert.strictEqual(explanation.outputs.length, 1); + assert.strictEqual(explanation.outputs[0].amount, EXTERNAL_VALUE.toString()); + assert.strictEqual(explanation.changeOutputs.length, 1); + assert.strictEqual(explanation.changeOutputs[0].amount, CHANGE_VALUE.toString()); + assert.strictEqual(explanation.fee.fee, (LOCKUP_VALUE - EXTERNAL_VALUE - CHANGE_VALUE).toString()); + }); + + it('half-signs a late exit', function () { + assertHalfSigned(createLateExitTransaction(), coinName); + }); + + it('rejects lockup descriptors with foreign staker keys', function () { + const foreignKeys: [BIP32Interface, BIP32Interface, BIP32Interface] = [ + testutils.getKey('abstract-utxo-pox5-foreign-1'), + testutils.getKey('abstract-utxo-pox5-foreign-2'), + testutils.getKey('abstract-utxo-pox5-foreign-3'), + ]; + const tx = createLateExitTransaction({ input: getLockupDescriptor(foreignKeys) }); + assert.throws(() => getTransactionExplanation(coinName, tx), /does not match policy/); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /does not match policy/); + }); + + it('rejects malformed lockup descriptors', function () { + const malformed = Descriptor.fromString( + getLockupDescriptor().toString().split('#')[0].replace('multi(2,', 'multi(3,'), + 'derivable' + ); + const tx = createLateExitTransaction({ input: malformed }); + assert.throws(() => getTransactionExplanation(coinName, tx), /does not match policy/); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /does not match policy/); + }); + + it('rejects non-canonical lockup descriptors', function () { + const nonCanonical = Descriptor.fromString(`wsh(pk(${walletKeys[0].neutered().toBase58()}/*))`, 'derivable'); + const tx = createLateExitTransaction({ input: nonCanonical }); + assert.throws(() => getTransactionExplanation(coinName, tx), /does not match policy/); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /does not match policy/); + }); + + it('explains but does not half-sign before the unlock height', function () { + const tx = createLateExitTransaction({ lockTime: UNLOCK_HEIGHT - 1 }); + assert.strictEqual(getTransactionExplanation(coinName, tx).locktime, UNLOCK_HEIGHT - 1); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /nLockTime must be at least/); + }); + + it('explains but does not half-sign a timestamp locktime', function () { + const tx = createLateExitTransaction({ lockTime: 500_000_000 }); + assert.strictEqual(getTransactionExplanation(coinName, tx).locktime, 500_000_000); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /block height below/); + }); + + it('does not half-sign a final lockup input sequence', function () { + const tx = createLateExitTransaction({ sequence: 0xffffffff }); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /non-final sequence/); + }); + + it('does not half-sign early exits', function () { + const tx = createLateExitTransaction({ earlyExit: true }); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /early exit/); + }); + + it('does not half-sign multiple lockup inputs', function () { + const tx = createLateExitTransaction({ extraLockupInput: true }); + assert.throws(() => createHalfSigned(coinName, rootUser, SEED, tx), /exactly one lockup input/); + }); + }); + } +}); + +describe('OfflineVault descriptor transaction regressions', function () { + it('explains and half-signs Wsh2Of3 descriptor transactions', function () { + const external = getExternalDescriptor(); + const internal = getInternalDescriptor(); + const tx = createDescriptorTransaction({ + input: external, + change: internal, + descriptors: [toNamedDescriptor('external', external), toNamedDescriptor('internal', internal)], + lockTime: 0, + sequence: 0xffffffff, + }); + const explanation = getTransactionExplanation('btc', tx); + assert.strictEqual(explanation.locktime, 0); + assert.strictEqual(explanation.outputs.length, 1); + assertHalfSigned(tx, 'btc'); + }); + + it('explains and half-signs Wsh2Of3CltvDrop descriptor transactions', function () { + const external = getDescriptorFromBuilder({ + name: 'Wsh2Of3CltvDrop', + keys: walletKeys, + path: '0/*', + locktime: 1_500_000, + }); + const internal = getDescriptorFromBuilder({ + name: 'Wsh2Of3CltvDrop', + keys: walletKeys, + path: '1/*', + locktime: 1_500_000, + }); + const tx = createDescriptorTransaction({ + input: external, + change: internal, + descriptors: [toNamedDescriptor('external', external), toNamedDescriptor('internal', internal)], + }); + const explanation = getTransactionExplanation('btc', tx); + assert.strictEqual(explanation.outputs.length, 1); + assertHalfSigned(tx, 'btc'); + }); +});