Skip to content

Commit b955ea9

Browse files
feat(utxo-staking): add PoX-5 recovery policy
Move native PoX-5 branch classification, locktime and sequence policy, preimage preparation, and witness finalization into @bitgo/utxo-staking. Refs: WAL-2022
1 parent a5978a6 commit b955ea9

5 files changed

Lines changed: 208 additions & 52 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './witness';
2+
export * from './recovery';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Psbt, Transaction } from '@bitgo/wasm-utxo';
2+
import { pox5 } from '@bitgo/utxo-descriptors';
3+
4+
export const POX5_MAX_UNLOCK_HEIGHT = 500_000_000;
5+
6+
export type Pox5SpendBranch = 'locktime' | 'early-exit';
7+
8+
export type Pox5SpendInput = pox5.Pox5InputMatch | pox5.Pox5DescriptorInfo;
9+
10+
function getPox5DescriptorInfo(input: Pox5SpendInput): pox5.Pox5DescriptorInfo {
11+
return 'info' in input ? input.info : input;
12+
}
13+
14+
function assertPox5UnlockHeight(unlockHeight: number): void {
15+
if (!Number.isSafeInteger(unlockHeight) || unlockHeight <= 0 || unlockHeight >= POX5_MAX_UNLOCK_HEIGHT) {
16+
throw new Error(`PoX-5 unlock height must be a positive block height below ${POX5_MAX_UNLOCK_HEIGHT}`);
17+
}
18+
}
19+
20+
function assertPox5BlockHeightLocktime(lockTime: number): void {
21+
if (!Number.isSafeInteger(lockTime) || lockTime < 0 || lockTime >= POX5_MAX_UNLOCK_HEIGHT) {
22+
throw new Error(`PoX-5 nLockTime must be a block height below ${POX5_MAX_UNLOCK_HEIGHT}`);
23+
}
24+
}
25+
26+
function hasFinalInput(psbt: Psbt): boolean {
27+
return Transaction.fromBytes(psbt.getUnsignedTx())
28+
.getInputs()
29+
.some((input) => input.sequence === 0xffffffff);
30+
}
31+
32+
/** Classify a canonical PoX-5 input by the transaction branch it can spend. */
33+
export function classifyPox5Spend(psbt: Psbt, input: pox5.Pox5InputMatch): Pox5SpendBranch {
34+
const { unlockHeight } = input.info;
35+
assertPox5UnlockHeight(unlockHeight);
36+
const lockTime = psbt.lockTime();
37+
assertPox5BlockHeightLocktime(lockTime);
38+
return lockTime >= unlockHeight ? 'locktime' : 'early-exit';
39+
}
40+
41+
/** Validate the post-CLTV policy for all canonical PoX-5 inputs in a recovery PSBT. */
42+
export function assertPox5LocktimeSpend(psbt: Psbt, inputs: readonly Pox5SpendInput[]): void {
43+
if (inputs.length === 0) {
44+
throw new Error('PoX-5 lockup descriptor match is required');
45+
}
46+
47+
const unlockHeights = inputs.map((input) => {
48+
const { unlockHeight } = getPox5DescriptorInfo(input);
49+
assertPox5UnlockHeight(unlockHeight);
50+
return unlockHeight;
51+
});
52+
const lockTime = psbt.lockTime();
53+
assertPox5BlockHeightLocktime(lockTime);
54+
const requiredLockTime = Math.max(...unlockHeights);
55+
if (lockTime < requiredLockTime) {
56+
throw new Error(`PoX-5 nLockTime must be at least ${requiredLockTime}`);
57+
}
58+
if (hasFinalInput(psbt)) {
59+
throw new Error('PoX-5 locktime spend inputs must use non-final sequences');
60+
}
61+
}
62+
63+
/** Validate that a canonical PoX-5 input uses the principal-preimage branch. */
64+
export function assertPox5EarlyExitSpend(psbt: Psbt, input: pox5.Pox5InputMatch): void {
65+
if (classifyPox5Spend(psbt, input) !== 'early-exit') {
66+
throw new Error('PoX-5 input is not an early-exit spend');
67+
}
68+
}
69+
70+
/** Add validated principal-preimage metadata for an early-exit spend. */
71+
export function preparePox5EarlyExit(
72+
psbt: Psbt,
73+
inputIndex: number,
74+
input: pox5.Pox5InputMatch,
75+
principalPreimage: Uint8Array
76+
): void {
77+
assertPox5EarlyExitSpend(psbt, input);
78+
pox5.assertPox5PrincipalPreimage(input.info, principalPreimage);
79+
psbt.addSha256Preimage(inputIndex, principalPreimage);
80+
}
Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,29 @@
1-
import { createHash } from 'crypto';
2-
3-
import { ast, Descriptor, Psbt } from '@bitgo/wasm-utxo';
1+
import { Psbt } from '@bitgo/wasm-utxo';
42
import { pox5 } from '@bitgo/utxo-descriptors';
53

6-
type Pox5Descriptor = Descriptor | ast.DescriptorNode;
4+
import { assertPox5EarlyExitSpend, assertPox5LocktimeSpend, preparePox5EarlyExit } from './recovery';
75

86
export type Pox5FinalizerParams = {
9-
/** A definite or derivation-indexed canonical PoX-5 descriptor. */
10-
descriptor: Pox5Descriptor;
11-
/** The derived user, backup, and BitGo keys in descriptor order. */
12-
stakerKeys: [Buffer, Buffer, Buffer];
7+
/** The canonical descriptor match for the input being finalized. */
8+
match: pox5.Pox5InputMatch;
139
};
1410

15-
function getParsedDescriptor(params: Pox5FinalizerParams) {
16-
const parsed = pox5.parsePox5LockupDescriptor(params.descriptor);
17-
if (!parsed || !parsed.stakerKeys) {
18-
throw new Error('descriptor must be a definite or derivation-indexed canonical PoX-5 descriptor');
19-
}
20-
if (!parsed.stakerKeys.every((key, index) => key.equals(params.stakerKeys[index]))) {
21-
throw new Error('stakerKeys must match the canonical descriptor order');
22-
}
23-
return parsed;
24-
}
25-
26-
function getDescriptor(descriptor: Pox5Descriptor): Descriptor {
27-
return descriptor instanceof Descriptor ? descriptor : Descriptor.fromString(ast.formatNode(descriptor), 'definite');
28-
}
29-
30-
function prepareInput(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams) {
31-
const parsed = getParsedDescriptor(params);
32-
psbt.updateInputWithDescriptor(inputIndex, getDescriptor(params.descriptor));
33-
return parsed;
34-
}
35-
3611
/** Finalize the post-CLTV 2-of-3 PoX-5 spend branch. */
3712
export function finalizePox5LocktimePath(psbt: Psbt, inputIndex: number, params: Pox5FinalizerParams): void {
38-
const parsed = prepareInput(psbt, inputIndex, params);
39-
if (psbt.lockTime() < parsed.unlockHeight) {
40-
throw new Error(`transaction locktime must be at least ${parsed.unlockHeight}`);
41-
}
13+
assertPox5LocktimeSpend(psbt, [params.match]);
14+
psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor);
4215
psbt.finalizeInput(inputIndex);
4316
}
4417

4518
/** Finalize the principal-preimage early-exit 2-of-3 PoX-5 spend branch. */
4619
export function finalizePox5EarlyExitPath(
4720
psbt: Psbt,
4821
inputIndex: number,
49-
params: Pox5FinalizerParams & { principalPreimage: Buffer }
22+
params: Pox5FinalizerParams & { principalPreimage: Uint8Array }
5023
): void {
51-
const parsed = prepareInput(psbt, inputIndex, params);
52-
const preimageHash = createHash('sha256').update(params.principalPreimage).digest();
53-
if (!preimageHash.equals(parsed.stakerCommitment)) {
54-
throw new Error('principalPreimage does not match the descriptor stakerCommitment');
55-
}
56-
psbt.addSha256Preimage(inputIndex, params.principalPreimage);
24+
assertPox5EarlyExitSpend(psbt, params.match);
25+
pox5.assertPox5PrincipalPreimage(params.match.info, params.principalPreimage);
26+
preparePox5EarlyExit(psbt, inputIndex, params.match, params.principalPreimage);
27+
psbt.updateInputWithDescriptor(inputIndex, params.match.descriptor);
5728
psbt.finalizeInput(inputIndex);
5829
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
});

modules/utxo-staking/test/unit/pox5/witness.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { pox5 } from '@bitgo/utxo-descriptors';
55
import { Psbt, type Descriptor } from '@bitgo/wasm-utxo';
66
import { getKey, getKeyTriple } from '@bitgo/wasm-utxo/testutils';
77

8-
import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, Pox5FinalizerParams } from '../../../src/pox5';
8+
import { finalizePox5EarlyExitPath, finalizePox5LocktimePath, type Pox5FinalizerParams } from '../../../src/pox5';
9+
10+
type Pox5InputMatch = pox5.Pox5InputMatch;
911

1012
const UNLOCK_HEIGHT = 840_000;
1113

@@ -29,21 +31,22 @@ function createPox5Psbt(
2931
Buffer,
3032
Buffer
3133
];
32-
const params: Pox5FinalizerParams = {
33-
descriptor: pox5.createPox5LockupDescriptor({
34-
unlockHeight: UNLOCK_HEIGHT,
35-
stakerCommitment: sha256(principalPreimage),
36-
earlyExitKey: Buffer.from(earlyExit.publicKey),
37-
stakerKeys,
38-
}),
34+
const descriptor = pox5.createPox5LockupDescriptor({
35+
unlockHeight: UNLOCK_HEIGHT,
36+
stakerCommitment: sha256(principalPreimage),
37+
earlyExitKey: Buffer.from(earlyExit.publicKey),
3938
stakerKeys,
40-
};
41-
const descriptor = params.descriptor as Descriptor;
42-
const scriptPubKey = descriptor.scriptPubkey();
39+
});
40+
const paramsDescriptor = descriptor as Descriptor;
41+
const scriptPubKey = paramsDescriptor.scriptPubkey();
4342
const psbt = Psbt.create(2, lockTime);
4443
psbt.addInput('01'.repeat(32), 0, 100_000n, scriptPubKey, 0xfffffffe);
4544
psbt.addOutput(scriptPubKey, 90_000n);
46-
psbt.updateInputWithDescriptor(0, descriptor);
45+
psbt.updateInputWithDescriptor(0, paramsDescriptor);
46+
47+
const match = pox5.matchPox5Input(psbt, 0, new Map([['pox5', paramsDescriptor]]));
48+
assert.ok(match);
49+
const params: Pox5FinalizerParams = { match: match as Pox5InputMatch };
4750

4851
for (const key of includeEarlyExitSignature ? [user, backup, earlyExit] : [user, backup]) {
4952
assert.ok(key.privateKey, 'test key must include private key material');

0 commit comments

Comments
 (0)