Skip to content

Commit 7bf48fd

Browse files
Merge pull request #5128 from BitGo/BTC-1579
Create CoreDao staking tx
2 parents 91cab7c + 73b33bc commit 7bf48fd

File tree

8 files changed

+261
-12
lines changed

8 files changed

+261
-12
lines changed

modules/utxo-staking/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
]
4141
},
4242
"dependencies": {
43+
"@bitgo/unspents": "^0.47.12",
4344
"@bitgo/utxo-lib": "^11.0.1",
4445
"@bitgo/wasm-miniscript": "^2.0.0-beta.2"
4546
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './opReturn';
22
export * from './descriptor';
3+
export * from './transaction';

modules/utxo-staking/src/coreDao/opReturn.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type BaseParams = {
2828
fee: number;
2929
};
3030

31-
type OpReturnParams = BaseParams & ({ redeemScript: Buffer } | { timelock: number });
31+
export type OpReturnParams = BaseParams & ({ redeemScript: Buffer } | { timelock: number });
3232

3333
/**
3434
* Create a CoreDAO OP_RETURN output script
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createCoreDaoOpReturnOutputScript, OpReturnParams } from './opReturn';
2+
import { Descriptor } from '@bitgo/wasm-miniscript';
3+
4+
/**
5+
* Create the staking outputs for a CoreDAO staking transaction. This is the ordering
6+
* in which to add into the transaction.
7+
* @param stakingParams how to create the timelocked stake output
8+
* @param stakingParams.descriptor if stakingParams.index is not provided, then this is assumed to be a `definite` descriptor.
9+
* If stakingParams.index is provided, then this is assumed to be a `derivable` descriptor.
10+
* @param opReturnParams to create the OP_RETURN output
11+
*/
12+
export function createStakingOutputs(
13+
stakingParams: {
14+
value: bigint;
15+
descriptor: string;
16+
index?: number;
17+
},
18+
opReturnParams: OpReturnParams
19+
): { script: Buffer; value: bigint }[] {
20+
const descriptor = Descriptor.fromString(
21+
stakingParams.descriptor,
22+
stakingParams.index === undefined ? 'definite' : 'derivable'
23+
);
24+
25+
const outputScript = Buffer.from(
26+
stakingParams.index === undefined
27+
? descriptor.scriptPubkey()
28+
: descriptor.atDerivationIndex(stakingParams.index).scriptPubkey()
29+
);
30+
const opReturnScript = createCoreDaoOpReturnOutputScript(opReturnParams);
31+
32+
return [
33+
{ script: outputScript, value: stakingParams.value },
34+
{ script: opReturnScript, value: BigInt(0) },
35+
];
36+
}

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+
});

yarn.lock

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9646,9 +9646,9 @@ ejs@^3.1.7, ejs@^3.1.8:
96469646
jake "^10.8.5"
96479647

96489648
electron-to-chromium@^1.5.41:
9649-
version "1.5.56"
9650-
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.56.tgz#3213f369efc3a41091c3b2c05bc0f406108ac1df"
9651-
integrity sha512-7lXb9dAvimCFdvUMTyucD4mnIndt/xhRKFAlky0CyFogdnNmdPQNoHI23msF/2V4mpTxMzgMdjK4+YRlFlRQZw==
9649+
version "1.5.57"
9650+
resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.57.tgz#cb43af8784166bca24565b3418bf5f775a6b1c86"
9651+
integrity sha512-xS65H/tqgOwUBa5UmOuNSLuslDo7zho0y/lgQw35pnrqiZh7UOWHCeL/Bt6noJATbA6tpQJGCifsFsIRZj1Fqg==
96529652

96539653
96549654
version "6.5.4"
@@ -12268,10 +12268,10 @@ ignore@^5.0.4, ignore@^5.1.8, ignore@^5.2.0, ignore@^5.2.4:
1226812268
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
1226912269
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
1227012270

12271-
immutable@^4.0.0:
12272-
version "4.3.7"
12273-
resolved "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
12274-
integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
12271+
immutable@^5.0.2:
12272+
version "5.0.2"
12273+
resolved "https://registry.npmjs.org/immutable/-/immutable-5.0.2.tgz#bb8a987349a73efbe6b3b292a9cbaf1b530d296b"
12274+
integrity sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==
1227512275

1227612276
import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0:
1227712277
version "3.3.0"
@@ -17457,12 +17457,12 @@ sass-loader@^11.0.1:
1745717457
neo-async "^2.6.2"
1745817458

1745917459
sass@^1.32.12:
17460-
version "1.80.6"
17461-
resolved "https://registry.npmjs.org/sass/-/sass-1.80.6.tgz#5d0aa55763984effe41e40019c9571ab73e6851f"
17462-
integrity sha512-ccZgdHNiBF1NHBsWvacvT5rju3y1d/Eu+8Ex6c21nHp2lZGLBEtuwc415QfiI1PJa1TpCo3iXwwSRjRpn2Ckjg==
17460+
version "1.80.7"
17461+
resolved "https://registry.npmjs.org/sass/-/sass-1.80.7.tgz#7569334c39220f8ca62fcea38dce60f809ba345c"
17462+
integrity sha512-MVWvN0u5meytrSjsU7AWsbhoXi1sc58zADXFllfZzbsBT1GHjjar6JwBINYPRrkx/zqnQ6uqbQuHgE95O+C+eQ==
1746317463
dependencies:
1746417464
chokidar "^4.0.0"
17465-
immutable "^4.0.0"
17465+
immutable "^5.0.2"
1746617466
source-map-js ">=0.6.2 <2.0.0"
1746717467
optionalDependencies:
1746817468
"@parcel/watcher" "^2.4.1"

0 commit comments

Comments
 (0)