-
Notifications
You must be signed in to change notification settings - Fork 276
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(utxo-staking): build staking transaction
TICKET: BTC-1579
- Loading branch information
1 parent
6b5ca4b
commit 73b33bc
Showing
3 changed files
with
211 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
export * as coreDao from './coreDao'; | ||
|
||
export * from './transaction'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |