Skip to content

Commit

Permalink
feat(utxo-staking): build staking transaction
Browse files Browse the repository at this point in the history
TICKET: BTC-1579
  • Loading branch information
davidkaplanbitgo committed Nov 13, 2024
1 parent 6b5ca4b commit 73b33bc
Show file tree
Hide file tree
Showing 3 changed files with 211 additions and 0 deletions.
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);
});
});
});

0 comments on commit 73b33bc

Please sign in to comment.