diff --git a/modules/utxo-staking/package.json b/modules/utxo-staking/package.json index 1fc0614cf9..2f55c8c932 100644 --- a/modules/utxo-staking/package.json +++ b/modules/utxo-staking/package.json @@ -40,6 +40,7 @@ ] }, "dependencies": { + "@bitgo/unspents": "^0.47.12", "@bitgo/utxo-lib": "^11.0.1", "@bitgo/wasm-miniscript": "^2.0.0-beta.2" } diff --git a/modules/utxo-staking/src/coreDao/index.ts b/modules/utxo-staking/src/coreDao/index.ts index d74116baef..79d126e879 100644 --- a/modules/utxo-staking/src/coreDao/index.ts +++ b/modules/utxo-staking/src/coreDao/index.ts @@ -1,2 +1,3 @@ export * from './opReturn'; export * from './descriptor'; +export * from './transaction'; diff --git a/modules/utxo-staking/src/coreDao/opReturn.ts b/modules/utxo-staking/src/coreDao/opReturn.ts index a06d186ace..e549eeafef 100644 --- a/modules/utxo-staking/src/coreDao/opReturn.ts +++ b/modules/utxo-staking/src/coreDao/opReturn.ts @@ -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 diff --git a/modules/utxo-staking/src/coreDao/transaction.ts b/modules/utxo-staking/src/coreDao/transaction.ts new file mode 100644 index 0000000000..ec7ba2bbe2 --- /dev/null +++ b/modules/utxo-staking/src/coreDao/transaction.ts @@ -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) }, + ]; +} diff --git a/modules/utxo-staking/src/index.ts b/modules/utxo-staking/src/index.ts index b8e6119ecd..89fa45b1b7 100644 --- a/modules/utxo-staking/src/index.ts +++ b/modules/utxo-staking/src/index.ts @@ -1 +1,3 @@ export * as coreDao from './coreDao'; + +export * from './transaction'; diff --git a/modules/utxo-staking/src/transaction.ts b/modules/utxo-staking/src/transaction.ts new file mode 100644 index 0000000000..c24255b527 --- /dev/null +++ b/modules/utxo-staking/src/transaction.ts @@ -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[]; + 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; +} diff --git a/modules/utxo-staking/test/unit/transaction.ts b/modules/utxo-staking/test/unit/transaction.ts new file mode 100644 index 0000000000..a131291e38 --- /dev/null +++ b/modules/utxo-staking/test/unit/transaction.ts @@ -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[]; + + 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); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 88bae791b5..84882ca497 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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== elliptic@6.5.4: version "6.5.4" @@ -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" @@ -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"