|
| 1 | +import assert from 'assert/strict'; |
| 2 | +import { createHash } from 'crypto'; |
| 3 | + |
| 4 | +import { pox5 } from '@bitgo/utxo-descriptors'; |
| 5 | +import { Psbt, type Descriptor } from '@bitgo/wasm-utxo'; |
| 6 | +import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils'; |
| 7 | + |
| 8 | +import { |
| 9 | + assertPox5EarlyExitSpend, |
| 10 | + assertPox5LocktimeSpend, |
| 11 | + classifyPox5Spend, |
| 12 | + POX5_MAX_UNLOCK_HEIGHT, |
| 13 | + preparePox5EarlyExit, |
| 14 | +} from '../../../src/pox5'; |
| 15 | + |
| 16 | +type Pox5InputMatch = pox5.Pox5InputMatch; |
| 17 | + |
| 18 | +const UNLOCK_HEIGHT = 840_000; |
| 19 | + |
| 20 | +function sha256(value: Uint8Array): Buffer { |
| 21 | + return createHash('sha256').update(value).digest(); |
| 22 | +} |
| 23 | + |
| 24 | +function createPox5RecoveryPsbt( |
| 25 | + lockTime: number, |
| 26 | + sequence = 0xfffffffe, |
| 27 | + unlockHeight = UNLOCK_HEIGHT |
| 28 | +): { |
| 29 | + psbt: Psbt; |
| 30 | + match: Pox5InputMatch; |
| 31 | + principalPreimage: Buffer; |
| 32 | +} { |
| 33 | + const [user, backup, bitgo] = getKeyTriple('utxo-staking-pox5-recovery'); |
| 34 | + const earlyExit = getKey('utxo-staking-pox5-recovery-early-exit'); |
| 35 | + const principalPreimage = Buffer.alloc(32, 0x42); |
| 36 | + const descriptor = pox5.createPox5LockupDescriptor({ |
| 37 | + unlockHeight, |
| 38 | + stakerCommitment: sha256(principalPreimage), |
| 39 | + earlyExitKey: Buffer.from(earlyExit.publicKey), |
| 40 | + stakerKeys: [Buffer.from(user.publicKey), Buffer.from(backup.publicKey), Buffer.from(bitgo.publicKey)], |
| 41 | + }); |
| 42 | + const concreteDescriptor = descriptor as Descriptor; |
| 43 | + const psbt = Psbt.create(2, lockTime); |
| 44 | + psbt.addInput('01'.repeat(32), 0, 100_000n, concreteDescriptor.scriptPubkey(), sequence); |
| 45 | + psbt.addOutput(concreteDescriptor.scriptPubkey(), 90_000n); |
| 46 | + psbt.updateInputWithDescriptor(0, concreteDescriptor); |
| 47 | + |
| 48 | + const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', concreteDescriptor]])); |
| 49 | + assert.ok(match); |
| 50 | + return { psbt, match: match as Pox5InputMatch, principalPreimage }; |
| 51 | +} |
| 52 | + |
| 53 | +describe('PoX-5 spend policy', function () { |
| 54 | + it('classifies locktime and early-exit branches from native transaction data', function () { |
| 55 | + const locktimeSpend = createPox5RecoveryPsbt(UNLOCK_HEIGHT); |
| 56 | + const earlyExitSpend = createPox5RecoveryPsbt(0); |
| 57 | + |
| 58 | + assert.equal(classifyPox5Spend(locktimeSpend.psbt, locktimeSpend.match), 'locktime'); |
| 59 | + assert.equal(classifyPox5Spend(earlyExitSpend.psbt, earlyExitSpend.match), 'early-exit'); |
| 60 | + assert.doesNotThrow(() => assertPox5LocktimeSpend(locktimeSpend.psbt, [locktimeSpend.match])); |
| 61 | + assert.doesNotThrow(() => assertPox5EarlyExitSpend(earlyExitSpend.psbt, earlyExitSpend.match)); |
| 62 | + assert.throws(() => assertPox5EarlyExitSpend(locktimeSpend.psbt, locktimeSpend.match), /not an early-exit spend/); |
| 63 | + }); |
| 64 | + |
| 65 | + it('enforces the block-height and unlock-height boundaries', function () { |
| 66 | + const atHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT); |
| 67 | + const aboveHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT + 1); |
| 68 | + const belowHeight = createPox5RecoveryPsbt(UNLOCK_HEIGHT - 1); |
| 69 | + const timestampLocktime = createPox5RecoveryPsbt(POX5_MAX_UNLOCK_HEIGHT); |
| 70 | + |
| 71 | + assert.doesNotThrow(() => assertPox5LocktimeSpend(atHeight.psbt, [atHeight.match])); |
| 72 | + assert.doesNotThrow(() => assertPox5LocktimeSpend(aboveHeight.psbt, [aboveHeight.match])); |
| 73 | + assert.throws(() => assertPox5LocktimeSpend(belowHeight.psbt, [belowHeight.match]), /at least/); |
| 74 | + assert.throws( |
| 75 | + () => assertPox5LocktimeSpend(timestampLocktime.psbt, [timestampLocktime.match]), |
| 76 | + /block height below/ |
| 77 | + ); |
| 78 | + }); |
| 79 | + |
| 80 | + it('requires non-final sequences for locktime spends', function () { |
| 81 | + const final = createPox5RecoveryPsbt(UNLOCK_HEIGHT, 0xffffffff); |
| 82 | + const nonFinal = createPox5RecoveryPsbt(UNLOCK_HEIGHT, 0xfffffffe); |
| 83 | + |
| 84 | + assert.throws(() => assertPox5LocktimeSpend(final.psbt, [final.match]), /non-final sequences/); |
| 85 | + assert.doesNotThrow(() => assertPox5LocktimeSpend(nonFinal.psbt, [nonFinal.match])); |
| 86 | + }); |
| 87 | + |
| 88 | + it('adds a validated principal preimage through the native PSBT API', function () { |
| 89 | + const earlyExitSpend = createPox5RecoveryPsbt(0); |
| 90 | + |
| 91 | + preparePox5EarlyExit(earlyExitSpend.psbt, 0, earlyExitSpend.match, earlyExitSpend.principalPreimage); |
| 92 | + |
| 93 | + const records = earlyExitSpend.psbt |
| 94 | + .getInputKeyValues(0) |
| 95 | + .filter((record) => record.type === 'known' && record.key === 'PSBT_IN_SHA256'); |
| 96 | + assert.equal(records.length, 1); |
| 97 | + const [record] = records; |
| 98 | + assert.deepStrictEqual(Buffer.from(record.keyData), sha256(earlyExitSpend.principalPreimage)); |
| 99 | + assert.deepStrictEqual(Buffer.from(record.value), earlyExitSpend.principalPreimage); |
| 100 | + }); |
| 101 | +}); |
0 commit comments