Skip to content

Commit

Permalink
Merge pull request #5128 from BitGo/BTC-1579
Browse files Browse the repository at this point in the history
Create CoreDao staking tx
  • Loading branch information
davidkaplanbitgo authored Nov 13, 2024
2 parents 91cab7c + 73b33bc commit 7bf48fd
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 12 deletions.
1 change: 1 addition & 0 deletions modules/utxo-staking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
]
},
"dependencies": {
"@bitgo/unspents": "^0.47.12",
"@bitgo/utxo-lib": "^11.0.1",
"@bitgo/wasm-miniscript": "^2.0.0-beta.2"
}
Expand Down
1 change: 1 addition & 0 deletions modules/utxo-staking/src/coreDao/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './opReturn';
export * from './descriptor';
export * from './transaction';
2 changes: 1 addition & 1 deletion modules/utxo-staking/src/coreDao/opReturn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type BaseParams = {
fee: number;
};

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

/**
* Create a CoreDAO OP_RETURN output script
Expand Down
36 changes: 36 additions & 0 deletions modules/utxo-staking/src/coreDao/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createCoreDaoOpReturnOutputScript, OpReturnParams } from './opReturn';
import { Descriptor } from '@bitgo/wasm-miniscript';

/**
* Create the staking outputs for a CoreDAO staking transaction. This is the ordering
* in which to add into the transaction.
* @param stakingParams how to create the timelocked stake output
* @param stakingParams.descriptor if stakingParams.index is not provided, then this is assumed to be a `definite` descriptor.
* If stakingParams.index is provided, then this is assumed to be a `derivable` descriptor.
* @param opReturnParams to create the OP_RETURN output
*/
export function createStakingOutputs(
stakingParams: {
value: bigint;
descriptor: string;
index?: number;
},
opReturnParams: OpReturnParams
): { script: Buffer; value: bigint }[] {
const descriptor = Descriptor.fromString(
stakingParams.descriptor,
stakingParams.index === undefined ? 'definite' : 'derivable'
);

const outputScript = Buffer.from(
stakingParams.index === undefined
? descriptor.scriptPubkey()
: descriptor.atDerivationIndex(stakingParams.index).scriptPubkey()
);
const opReturnScript = createCoreDaoOpReturnOutputScript(opReturnParams);

return [
{ script: outputScript, value: stakingParams.value },
{ script: opReturnScript, value: BigInt(0) },
];
}
2 changes: 2 additions & 0 deletions modules/utxo-staking/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * as coreDao from './coreDao';

export * from './transaction';
100 changes: 100 additions & 0 deletions modules/utxo-staking/src/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as utxolib from '@bitgo/utxo-lib';
import { Dimensions } from '@bitgo/unspents';

/**
* Build a staking transaction for a wallet that assumes 2-of-3 multisig for the inputs
*
* Given the inputs and the staking outputs, we will create the PSBT with the desired fee rate.
* We always add the change address as the last output.
*
* @param rootWalletKeys
* @param unspents
* @param createStakingOutputs
* @param changeAddressInfo
* @param feeRateSatKB
* @param network
*/
export function buildFixedWalletStakingPsbt({
rootWalletKeys,
unspents,
outputs,
changeAddressInfo,
feeRateSatKB,
network,
skipNonWitnessUtxo,
dustAmount = BigInt(0),
}: {
rootWalletKeys: utxolib.bitgo.RootWalletKeys;
unspents: utxolib.bitgo.WalletUnspent<bigint>[];
outputs: {
script: Buffer;
value: bigint;
}[];
changeAddressInfo: {
chain: utxolib.bitgo.ChainCode;
index: number;
address: string;
};
feeRateSatKB: number;
network: utxolib.Network;
skipNonWitnessUtxo?: boolean;
dustAmount?: bigint;
}): utxolib.bitgo.UtxoPsbt {
if (feeRateSatKB < 1000) {
throw new Error('Fee rate must be at least 1 sat/vbyte');
}
if (unspents.length === 0 || outputs.length === 0) {
throw new Error('Must have at least one input and one output');
}

// Check the change address info
const changeScript = utxolib.bitgo.outputScripts.createOutputScript2of3(
rootWalletKeys.deriveForChainAndIndex(changeAddressInfo.chain, changeAddressInfo.index).publicKeys,
utxolib.bitgo.scriptTypeForChain(changeAddressInfo.chain),
network
).scriptPubKey;
if (!changeScript.equals(utxolib.addressFormat.toOutputScriptTryFormats(changeAddressInfo.address, network))) {
throw new Error('Change address info does not match the derived change script');
}

const psbt = utxolib.bitgo.createPsbtForNetwork({ network });
utxolib.bitgo.addXpubsToPsbt(psbt, rootWalletKeys);

const inputAmount = unspents.reduce((sum, unspent) => sum + unspent.value, BigInt(0));
const outputAmount = outputs.reduce((sum, output) => sum + output.value, BigInt(0));

unspents.forEach((unspent) =>
utxolib.bitgo.addWalletUnspentToPsbt(psbt, unspent, rootWalletKeys, 'user', 'bitgo', {
isReplaceableByFee: true,
skipNonWitnessUtxo,
})
);
outputs.forEach((output) => psbt.addOutput(output));

const fee = Math.ceil(
(Dimensions.fromPsbt(psbt)
.plus(Dimensions.fromOutput({ script: changeScript }))
.getVSize() *
feeRateSatKB) /
1000
);

const changeAmount = inputAmount - (outputAmount + BigInt(fee));
if (changeAmount < BigInt(0)) {
throw new Error(
`Input amount ${inputAmount.toString()} cannot cover the staking amount ${outputAmount} and the fee: ${fee}`
);
}

if (changeAmount > dustAmount) {
utxolib.bitgo.addWalletOutputToPsbt(
psbt,
rootWalletKeys,
changeAddressInfo.chain,
changeAddressInfo.index,
changeAmount
);
}

return psbt;
}
109 changes: 109 additions & 0 deletions modules/utxo-staking/test/unit/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as assert from 'assert';

import * as utxolib from '@bitgo/utxo-lib';
import { buildFixedWalletStakingPsbt } from '../../src';

describe('transactions', function () {
describe('fixed wallets', function () {
const rootWalletKeys = utxolib.testutil.getDefaultWalletKeys();
const network = utxolib.networks.bitcoin;
const chain = 20;
const index = 0;
const changeAddress = utxolib.address.fromOutputScript(
utxolib.bitgo.outputScripts.createOutputScript2of3(
rootWalletKeys.deriveForChainAndIndex(chain, index).publicKeys,
'p2wsh',
network
).scriptPubKey,
network
);
const changeAddressInfo = { chain: chain as utxolib.bitgo.ChainCode, index, address: changeAddress };

const unspents = utxolib.testutil.mockUnspents(
rootWalletKeys,
['p2sh', 'p2wsh'],
BigInt(1e8),
network
) as utxolib.bitgo.WalletUnspent<bigint>[];

const outputs = [
{
script: utxolib.bitgo.outputScripts.createOutputScript2of3(
rootWalletKeys.deriveForChainAndIndex(40, 0).publicKeys,
'p2trMusig2',
network
).scriptPubKey,
value: BigInt(1e7),
},
];

it('should fail if fee rate is negative', function () {
assert.throws(() => {
buildFixedWalletStakingPsbt({
rootWalletKeys,
unspents,
outputs,
changeAddressInfo,
feeRateSatKB: 999,
network,
});
}, /Fee rate must be at least 1 sat\/vbyte/);
});

it('should fail if the changeAddressInfo does not match the derived change script', function () {
assert.throws(() => {
buildFixedWalletStakingPsbt({
rootWalletKeys,
unspents,
outputs,
changeAddressInfo: { ...changeAddressInfo, index: 1 },
feeRateSatKB: 1000,
network,
});
}, /Change address info does not match the derived change script/);
});

it('should fail if there are no unspents or outputs', function () {
assert.throws(() => {
buildFixedWalletStakingPsbt({
rootWalletKeys,
unspents: [],
outputs: [],
changeAddressInfo,
feeRateSatKB: 1000,
network,
});
}, /Must have at least one input and one output/);
});

it('should fail if the input amount cannot cover the staking amount and the fee', function () {
assert.throws(() => {
buildFixedWalletStakingPsbt({
rootWalletKeys,
unspents: unspents.slice(0, 1),
outputs: [
{ script: outputs[0].script, value: unspents.reduce((sum, unspent) => sum + unspent.value, BigInt(0)) },
],
changeAddressInfo,
feeRateSatKB: 1000,
network,
});
}, /Input amount \d+ cannot cover the staking amount \d+ and the fee: \d+/);
});

it('should be able to create a psbt for a fixed wallet', function () {
const psbt = buildFixedWalletStakingPsbt({
rootWalletKeys,
unspents,
outputs,
changeAddressInfo,
feeRateSatKB: 1000,
network,
});

assert.deepStrictEqual(psbt.data.inputs.length, 2);
assert.deepStrictEqual(psbt.data.outputs.length, 2);
assert.deepStrictEqual(psbt.txOutputs[0].script, outputs[0].script);
});
});
});
22 changes: 11 additions & 11 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9646,9 +9646,9 @@ ejs@^3.1.7, ejs@^3.1.8:
jake "^10.8.5"

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

[email protected]:
version "6.5.4"
Expand Down Expand Up @@ -12268,10 +12268,10 @@ ignore@^5.0.4, ignore@^5.1.8, ignore@^5.2.0, ignore@^5.2.4:
resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==

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

import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0:
version "3.3.0"
Expand Down Expand Up @@ -17457,12 +17457,12 @@ sass-loader@^11.0.1:
neo-async "^2.6.2"

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

0 comments on commit 7bf48fd

Please sign in to comment.