Skip to content

Commit 73b33bc

Browse files
feat(utxo-staking): build staking transaction
TICKET: BTC-1579
1 parent 6b5ca4b commit 73b33bc

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-0
lines changed

modules/utxo-staking/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export * as coreDao from './coreDao';
2+
3+
export * from './transaction';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { Dimensions } from '@bitgo/unspents';
3+
4+
/**
5+
* Build a staking transaction for a wallet that assumes 2-of-3 multisig for the inputs
6+
*
7+
* Given the inputs and the staking outputs, we will create the PSBT with the desired fee rate.
8+
* We always add the change address as the last output.
9+
*
10+
* @param rootWalletKeys
11+
* @param unspents
12+
* @param createStakingOutputs
13+
* @param changeAddressInfo
14+
* @param feeRateSatKB
15+
* @param network
16+
*/
17+
export function buildFixedWalletStakingPsbt({
18+
rootWalletKeys,
19+
unspents,
20+
outputs,
21+
changeAddressInfo,
22+
feeRateSatKB,
23+
network,
24+
skipNonWitnessUtxo,
25+
dustAmount = BigInt(0),
26+
}: {
27+
rootWalletKeys: utxolib.bitgo.RootWalletKeys;
28+
unspents: utxolib.bitgo.WalletUnspent<bigint>[];
29+
outputs: {
30+
script: Buffer;
31+
value: bigint;
32+
}[];
33+
changeAddressInfo: {
34+
chain: utxolib.bitgo.ChainCode;
35+
index: number;
36+
address: string;
37+
};
38+
feeRateSatKB: number;
39+
network: utxolib.Network;
40+
skipNonWitnessUtxo?: boolean;
41+
dustAmount?: bigint;
42+
}): utxolib.bitgo.UtxoPsbt {
43+
if (feeRateSatKB < 1000) {
44+
throw new Error('Fee rate must be at least 1 sat/vbyte');
45+
}
46+
if (unspents.length === 0 || outputs.length === 0) {
47+
throw new Error('Must have at least one input and one output');
48+
}
49+
50+
// Check the change address info
51+
const changeScript = utxolib.bitgo.outputScripts.createOutputScript2of3(
52+
rootWalletKeys.deriveForChainAndIndex(changeAddressInfo.chain, changeAddressInfo.index).publicKeys,
53+
utxolib.bitgo.scriptTypeForChain(changeAddressInfo.chain),
54+
network
55+
).scriptPubKey;
56+
if (!changeScript.equals(utxolib.addressFormat.toOutputScriptTryFormats(changeAddressInfo.address, network))) {
57+
throw new Error('Change address info does not match the derived change script');
58+
}
59+
60+
const psbt = utxolib.bitgo.createPsbtForNetwork({ network });
61+
utxolib.bitgo.addXpubsToPsbt(psbt, rootWalletKeys);
62+
63+
const inputAmount = unspents.reduce((sum, unspent) => sum + unspent.value, BigInt(0));
64+
const outputAmount = outputs.reduce((sum, output) => sum + output.value, BigInt(0));
65+
66+
unspents.forEach((unspent) =>
67+
utxolib.bitgo.addWalletUnspentToPsbt(psbt, unspent, rootWalletKeys, 'user', 'bitgo', {
68+
isReplaceableByFee: true,
69+
skipNonWitnessUtxo,
70+
})
71+
);
72+
outputs.forEach((output) => psbt.addOutput(output));
73+
74+
const fee = Math.ceil(
75+
(Dimensions.fromPsbt(psbt)
76+
.plus(Dimensions.fromOutput({ script: changeScript }))
77+
.getVSize() *
78+
feeRateSatKB) /
79+
1000
80+
);
81+
82+
const changeAmount = inputAmount - (outputAmount + BigInt(fee));
83+
if (changeAmount < BigInt(0)) {
84+
throw new Error(
85+
`Input amount ${inputAmount.toString()} cannot cover the staking amount ${outputAmount} and the fee: ${fee}`
86+
);
87+
}
88+
89+
if (changeAmount > dustAmount) {
90+
utxolib.bitgo.addWalletOutputToPsbt(
91+
psbt,
92+
rootWalletKeys,
93+
changeAddressInfo.chain,
94+
changeAddressInfo.index,
95+
changeAmount
96+
);
97+
}
98+
99+
return psbt;
100+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as assert from 'assert';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
import { buildFixedWalletStakingPsbt } from '../../src';
5+
6+
describe('transactions', function () {
7+
describe('fixed wallets', function () {
8+
const rootWalletKeys = utxolib.testutil.getDefaultWalletKeys();
9+
const network = utxolib.networks.bitcoin;
10+
const chain = 20;
11+
const index = 0;
12+
const changeAddress = utxolib.address.fromOutputScript(
13+
utxolib.bitgo.outputScripts.createOutputScript2of3(
14+
rootWalletKeys.deriveForChainAndIndex(chain, index).publicKeys,
15+
'p2wsh',
16+
network
17+
).scriptPubKey,
18+
network
19+
);
20+
const changeAddressInfo = { chain: chain as utxolib.bitgo.ChainCode, index, address: changeAddress };
21+
22+
const unspents = utxolib.testutil.mockUnspents(
23+
rootWalletKeys,
24+
['p2sh', 'p2wsh'],
25+
BigInt(1e8),
26+
network
27+
) as utxolib.bitgo.WalletUnspent<bigint>[];
28+
29+
const outputs = [
30+
{
31+
script: utxolib.bitgo.outputScripts.createOutputScript2of3(
32+
rootWalletKeys.deriveForChainAndIndex(40, 0).publicKeys,
33+
'p2trMusig2',
34+
network
35+
).scriptPubKey,
36+
value: BigInt(1e7),
37+
},
38+
];
39+
40+
it('should fail if fee rate is negative', function () {
41+
assert.throws(() => {
42+
buildFixedWalletStakingPsbt({
43+
rootWalletKeys,
44+
unspents,
45+
outputs,
46+
changeAddressInfo,
47+
feeRateSatKB: 999,
48+
network,
49+
});
50+
}, /Fee rate must be at least 1 sat\/vbyte/);
51+
});
52+
53+
it('should fail if the changeAddressInfo does not match the derived change script', function () {
54+
assert.throws(() => {
55+
buildFixedWalletStakingPsbt({
56+
rootWalletKeys,
57+
unspents,
58+
outputs,
59+
changeAddressInfo: { ...changeAddressInfo, index: 1 },
60+
feeRateSatKB: 1000,
61+
network,
62+
});
63+
}, /Change address info does not match the derived change script/);
64+
});
65+
66+
it('should fail if there are no unspents or outputs', function () {
67+
assert.throws(() => {
68+
buildFixedWalletStakingPsbt({
69+
rootWalletKeys,
70+
unspents: [],
71+
outputs: [],
72+
changeAddressInfo,
73+
feeRateSatKB: 1000,
74+
network,
75+
});
76+
}, /Must have at least one input and one output/);
77+
});
78+
79+
it('should fail if the input amount cannot cover the staking amount and the fee', function () {
80+
assert.throws(() => {
81+
buildFixedWalletStakingPsbt({
82+
rootWalletKeys,
83+
unspents: unspents.slice(0, 1),
84+
outputs: [
85+
{ script: outputs[0].script, value: unspents.reduce((sum, unspent) => sum + unspent.value, BigInt(0)) },
86+
],
87+
changeAddressInfo,
88+
feeRateSatKB: 1000,
89+
network,
90+
});
91+
}, /Input amount \d+ cannot cover the staking amount \d+ and the fee: \d+/);
92+
});
93+
94+
it('should be able to create a psbt for a fixed wallet', function () {
95+
const psbt = buildFixedWalletStakingPsbt({
96+
rootWalletKeys,
97+
unspents,
98+
outputs,
99+
changeAddressInfo,
100+
feeRateSatKB: 1000,
101+
network,
102+
});
103+
104+
assert.deepStrictEqual(psbt.data.inputs.length, 2);
105+
assert.deepStrictEqual(psbt.data.outputs.length, 2);
106+
assert.deepStrictEqual(psbt.txOutputs[0].script, outputs[0].script);
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)