diff --git a/Makefile b/Makefile index 47941eb..b3beaa1 100644 --- a/Makefile +++ b/Makefile @@ -17,11 +17,21 @@ run-demo-regtest-replenisher-very-small-coins: packages/fastbtc-node/version.jso .PHONY: run-demo-regtest-replenisher-limits run-demo-regtest-replenisher-limits: packages/fastbtc-node/version.json @export TEST_REPLENISHER_LIMITS=true && make run-demo-regtest +.PHONY: run-demo-regtest-cpfp +run-demo-regtest-cpfp: packages/fastbtc-node/version.json + @export TEST_CPFP=true && make run-demo-regtest .PHONY: show-node-logs show-node-logs: @docker-compose -f docker-compose-base.yml -f docker-compose-regtest.yml logs -f node1 node2 node3 +# This is very hacky :P We grep for a message only sent by the initiator and parse the node id from there +.PHONY: show-initiator-logs +show-initiator-logs: + docker-compose -f docker-compose-base.yml -f docker-compose-regtest.yml logs -f $$( \ + docker-compose -f docker-compose-base.yml -f docker-compose-regtest.yml logs --no-color \ + | grep -e 'stored batches in total' | tail -1 | cut -d_ -f 1) + .PHONY: build-regtest-bitcoin build-regtest-bitcoin: @(cd integration_test/bitcoind-regtest \ diff --git a/README.md b/README.md index 0887875..1779b11 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,37 @@ $ make test-transfers-big-amounts Observe the output, quit with Ctrl-C if wanted (though it quits automatically on success). +#### Automatic bumping of slow transfers with CPFP + +This test tests the case when a bitcoin transfer is slow and requires a CPFP (child-pays-for-parent) +transaction to speed it up. + +This is notoriously hard to test in a regtest environment. The test case will validate +that the code for creating a CPFP transaction works, but it will not actually test that +it will actually bump the parent transaction. + +``` +# In one tab: +$ make run-demo-regtest-cpfp +# In another tab +$ make test-transfers +``` + +Observe the output, quit with Ctrl-C if wanted (though it quits automatically on success). + +You should see the lines: +``` +TEST_CPFP is true, only sleeping for 100 ms +``` +and +``` +CPFP transaction successfully sent to bitcoin +``` +in the output. + +This test will also take ~10 minutes to run. + + ### Advanced details The test setup (launched with `make run-demo-regtest`) will expose the Hardhat RPC server at `http://localhost:18545` diff --git a/docker-compose-regtest.yml b/docker-compose-regtest.yml index 582a1e8..5c77b9c 100644 --- a/docker-compose-regtest.yml +++ b/docker-compose-regtest.yml @@ -29,6 +29,7 @@ services: # Extra test vars - TEST_VERY_SMALL_REPLENISHER_COINS - TEST_REPLENISHER_LIMITS + - TEST_CPFP ports: - 18443:18443 @@ -55,6 +56,7 @@ services: - FASTBTC_RSK_RPC_URL=http://hardhat:8545 - FASTBTC_BTC_RPC_URL=http://bitcoin-regtest:18443/wallet/multisig - FASTBTC_REPLENISHER_RPC_URL=http://bitcoin-regtest:18443/wallet/replenisher + - TEST_CPFP depends_on: - bitcoin-regtest - hardhat @@ -64,6 +66,7 @@ services: - FASTBTC_RSK_RPC_URL=http://hardhat:8545 - FASTBTC_BTC_RPC_URL=http://bitcoin-regtest:18443/wallet/multisig - FASTBTC_REPLENISHER_RPC_URL=http://bitcoin-regtest:18443/wallet/replenisher + - TEST_CPFP depends_on: - bitcoin-regtest - hardhat @@ -81,6 +84,7 @@ services: - FASTBTC_RSK_RPC_URL=http://hardhat:8545 - FASTBTC_BTC_RPC_URL=http://bitcoin-regtest:18443/wallet/multisig - FASTBTC_REPLENISHER_RPC_URL=http://bitcoin-regtest:18443/wallet/replenisher + - TEST_CPFP depends_on: - bitcoin-regtest - hardhat diff --git a/integration_test/bitcoind-regtest/docker-entrypoint.sh b/integration_test/bitcoind-regtest/docker-entrypoint.sh index 1d15502..d5b8c26 100755 --- a/integration_test/bitcoind-regtest/docker-entrypoint.sh +++ b/integration_test/bitcoind-regtest/docker-entrypoint.sh @@ -60,11 +60,15 @@ echo "done" # triggered (as long as the number matches the one configured in the backend) -- and the rest to the replenisher # wallet. This is meant to test the case where a new TransferBatch cannot be created because the multisig doesn't # have enough funds, but the replenisher doesn't trigger either because the balance is over the threshold. +# - If TEST_CPFP is true, we wait a crapton of time between mining new blocks. This is very slow, but +# it enables us to actually send a CPFP transaction and test that the code does not fail. +# Caveat: It does not actually test that the CPFP transaction does a CPFP. # # Ugh. echo "Test settings:" echo "TEST_VERY_SMALL_REPLENISHER_COINS=$TEST_VERY_SMALL_REPLENISHER_COINS" echo "TEST_REPLENISHER_LIMITS=$TEST_REPLENISHER_LIMITS" +echo "TEST_CPFP=$TEST_CPFP" # We need a temporary address for both of these cases, because we need to send amounts smaller than the block reward. if [[ "$TEST_VERY_SMALL_REPLENISHER_COINS" = "true" || "$TEST_REPLENISHER_LIMITS" = "true" ]] @@ -107,6 +111,12 @@ then echo "Sending 5.5 BTC to the multisig (should be just over the threshold)..." bitcoin-cli -rpcwallet=temporary sendtoaddress "$MULTISIG_ADDRESS" 5.5 > /dev/null else + if [[ "$TEST_CPFP" = "true" ]] + then + echo "Generating 101 blocks (sending balance to multisig because we are testing CPFP)..." + bitcoin-cli -rpcwallet=replenisher generatetoaddress 101 "$MULTISIG_ADDRESS" > /dev/null + fi + echo "Generating 101+ blocks (sending balance to replenisher wallet, not directly to multisig)..." echo "Init replenisher funds" for i in $(bitcoin-cli deriveaddresses "$REPLENISHER_SOURCE_DESCRIPTOR" '[5,10]'|cut -f 2 -d '"'|grep bc) @@ -147,7 +157,13 @@ do # sending to replenisher here, not multisig bitcoin-cli -rpcwallet=replenisher generatetoaddress 1 "$i" > /dev/null fi - sleep 1 + + if [[ "$TEST_CPFP" = "true" ]] + then + sleep 300 + else + sleep 1 + fi done done diff --git a/packages/fastbtc-contracts/hardhat.config.ts b/packages/fastbtc-contracts/hardhat.config.ts index ce5a67c..fffa5c4 100644 --- a/packages/fastbtc-contracts/hardhat.config.ts +++ b/packages/fastbtc-contracts/hardhat.config.ts @@ -84,6 +84,39 @@ task("show-transfer", "Show transfer details") }); + +task("ispaused", "Check if the bridge is paused") + .addPositionalParam('btcAddressOrTransferId') + .addOptionalPositionalParam('nonce') + .addOptionalParam("bridgeAddress", "FastBTCBridge contract address (if empty, use deployment)") + .setAction(async ({ btcAddressOrTransferId, nonce, bridgeAddress }, hre) => { + const contract = await hre.ethers.getContractAt( + 'FastBTCBridge', + await getDeploymentAddress(bridgeAddress, hre, 'FastBTCBridge'), + ); + + let transferId; + if (nonce === undefined) { + console.log('Nonce not given, treat', btcAddressOrTransferId, 'as transferId'); + transferId = btcAddressOrTransferId; + } else { + console.log('Nonce given, treat', btcAddressOrTransferId, 'as btcAddress'); + transferId = await contract.getTransferId(btcAddressOrTransferId, nonce); + } + + console.log('transferId', transferId); + + const transfer = await contract.getTransferByTransferId(transferId); + for (let [key, value] of transfer.entries()) { + console.log( + key, + BigNumber.isBigNumber(value) ? value.toString() : value + ); + } + console.log(transfer); + + }); + task("free-money", "Sends free money to address") .addPositionalParam("address", "Address to send free money to") .addPositionalParam("rbtcAmount", "RBTC amount to send", "1.0") @@ -276,7 +309,7 @@ task('roles', 'Manage roles') await getDeploymentAddress(undefined, hre, 'FastBTCAccessControl'), signer, ); - + console.log('fastBTCBridge address', await getDeploymentAddress(undefined, hre, 'FastBTCBridge')); console.log(`${action} role ${role}`, account ? `for ${account}` : ''); const roleHash = await accessControl[`ROLE_${role}`](); console.log('role hash:', roleHash); diff --git a/packages/fastbtc-node/src/btc/multisig.ts b/packages/fastbtc-node/src/btc/multisig.ts index 4cb2bf4..795d94b 100644 --- a/packages/fastbtc-node/src/btc/multisig.ts +++ b/packages/fastbtc-node/src/btc/multisig.ts @@ -2,7 +2,19 @@ * Bitcoin multisig signature logic, Bitcoin transaction sending and reading data from the Bitcoin network */ import {inject, injectable} from 'inversify'; -import {bip32, ECPair, Network, networks, Payment, payments, Psbt, script} from "bitcoinjs-lib"; +import { + bip32, + ECPair, + Network, + networks, + Payment, + payments, + Psbt, + script, + address as bitcoinjsAddress, + Transaction as RawTransaction, + PsbtTxOutput, +} from "bitcoinjs-lib"; import {normalizeKey, xprvToPublic} from './utils'; import getByteCount from './bytecount'; import BitcoinNodeWrapper, {IBitcoinNodeWrapper} from './nodewrapper'; @@ -21,6 +33,11 @@ export interface PartiallySignedBitcoinTransaction { requiredSignatures: number; derivationPaths: string[]; noChange: boolean; + isCpfp?: boolean; + // cumulativeByteCount includes the transaction's byte count, and the byte count + // of all CPFP'd transactions in the chain + cumulativeByteCount?: number; + feeSatsPerVB?: number; } export interface BtcTransfer { @@ -29,6 +46,15 @@ export interface BtcTransfer { nonce: number; } +export interface CPFPOpts { + feeMultiplier?: number; + signSelf?: boolean; +} + +export class CPFPValidationError extends Error { + isValidationError = true; +} + // https://developer.bitcoin.org/reference/rpc/gettransaction.html // this is only partially reflected here because we don't need everything export interface BitcoinRPCGetTransactionResponse { @@ -293,12 +319,19 @@ export class BitcoinMultisig { response = withDerivationPaths; - const amountSatoshi: BigNumber = transfers.map(t => t.amountSatoshi).reduce( + const totalPayoutValueSat: BigNumber = transfers.map(t => t.amountSatoshi).reduce( (a, b) => a.add(b), BigNumber.from(0), ); + const minChangeSat = ( + noChange + ? BigNumber.from(0) + : BigNumber.from(1e7) // 0.1 BTC to ensure enough change for cpfp + ); + const totalPayoutValueWithMinChangeSat = totalPayoutValueSat.add(minChangeSat); + const psbt = new Psbt({network}); - let totalSum = BigNumber.from(0); + let totalInputValueSat = BigNumber.from(0); let outputCounts = { 'P2WSH': 2, // OP_RETURN data + change address; this actually always exceeds the byte size of the OP_RETURN }; @@ -307,8 +340,14 @@ export class BitcoinMultisig { }; const derivationPaths: Set = new Set(); - let fee = BigNumber.from(0); + let feeSat = BigNumber.from(0); let totalInputCount = 0; + let cumulativeByteCount = getByteCount( + inputCounts, + outputCounts, + transfers.map(t => t.btcAddress), + this.network + ); for (const utxo of response) { const tx = await this.getRawTx(utxo.txid); @@ -326,20 +365,20 @@ export class BitcoinMultisig { psbt.addInput(input); inputCounts[inputType]++; totalInputCount++; - totalSum = totalSum.add(BigNumber.from(Math.round(utxo.amount * 1e8))); + totalInputValueSat = totalInputValueSat.add(BigNumber.from(Math.round(utxo.amount * 1e8))); - fee = BigNumber.from( + cumulativeByteCount = getByteCount( + inputCounts, + outputCounts, + transfers.map(t => t.btcAddress), + this.network + ); + feeSat = BigNumber.from( Math.round( - getByteCount( - inputCounts, - outputCounts, - transfers.map(t => t.btcAddress), - this.network - ) - * feeRate + cumulativeByteCount * feeRate ) ); - if (totalSum.gte(amountSatoshi.add(fee))) { + if (totalInputValueSat.gte(totalPayoutValueWithMinChangeSat.add(feeSat))) { break; } if (maxInputs && totalInputCount >= maxInputs) { @@ -348,18 +387,18 @@ export class BitcoinMultisig { } } - const transferSumIncludingFee = amountSatoshi.add(fee); - if (totalSum.lt(transferSumIncludingFee)) { - if (maxInputs && noChange && !totalSum.isZero()) { + const transferSumIncludingFee = totalPayoutValueWithMinChangeSat.add(feeSat); + if (totalInputValueSat.lt(transferSumIncludingFee)) { + if (maxInputs && noChange && !totalInputValueSat.isZero()) { this.logger.warning( `Number of inputs is capped at ${maxInputs} -- can only send ` + - `${totalSum.toString()} satoshi out of ${transferSumIncludingFee.toString()} satoshi wanted. ` + + `${totalInputValueSat.toString()} satoshi out of ${transferSumIncludingFee.toString()} satoshi wanted. ` + `(But that's ok for a replenish tx.)` ) } else { throw new Error( - `balance is too low (can only send up to ${totalSum.toString()} satoshi out of ` + + `balance is too low (can only send up to ${totalInputValueSat.toString()} satoshi out of ` + `${transferSumIncludingFee.toString()} required)` ); } @@ -387,7 +426,7 @@ export class BitcoinMultisig { // change money! psbt.addOutput({ address: this.changePayment.address!, - value: totalSum.sub(fee).sub(amountSatoshi).toNumber(), + value: totalInputValueSat.sub(feeSat).sub(totalPayoutValueSat).toNumber(), }); } else { @@ -398,7 +437,7 @@ export class BitcoinMultisig { psbt.addOutput({ address: transfers[0].btcAddress, - value: totalSum.sub(fee).toNumber() + value: totalInputValueSat.sub(feeSat).toNumber() }); } @@ -408,6 +447,9 @@ export class BitcoinMultisig { requiredSignatures: this.cosigners, derivationPaths: [...derivationPaths], noChange: Boolean(noChange), + isCpfp: false, + cumulativeByteCount, + feeSatsPerVB: feeRate, }; if (signSelf) { ret = this.signTransaction(ret); @@ -417,6 +459,169 @@ export class BitcoinMultisig { return ret; } + /** + * Create a child-pays-for-parent (CPFP) transaction that bumps `bumpedTx`. + * + * @param bumpedTx + * @param opts + */ + async createPartiallySignedCpfpTransaction( + bumpedTx: PartiallySignedBitcoinTransaction, + opts?: CPFPOpts, + ): Promise { + const { + feeMultiplier = 2, // TODO: experiment with this + signSelf = false, + } = (opts ?? {}); + if (!this.changePayment.address || !this.changePayment.redeem) { + throw new Error("no change address"); + } + if (!bumpedTx.cumulativeByteCount || !bumpedTx.feeSatsPerVB) { + throw new Error("bumpedTx.cumulativeByteCount and bumpedTx.feeSatsPerVB are required"); + } + + const bumpedPsbt = Psbt.fromBase64(bumpedTx.serializedTransaction, { network: this.network }); + bumpedPsbt.validateSignaturesOfAllInputs(); + bumpedPsbt.finalizeAllInputs(); + const rawBumpedTx = bumpedPsbt.extractTransaction(); + + this.logger.info( + `Attempting to CPFP ${rawBumpedTx.getId()} with fee multiplier ${feeMultiplier}` + ); + + const feeBtcPerKB = await this.feeEstimator.estimateFeeBtcPerKB(); + let feeSatsPerVB = feeMultiplier * feeBtcPerKB / 1000 * 1e8; + feeSatsPerVB = Math.max(feeSatsPerVB, bumpedTx.feeSatsPerVB); + + const outputCounts = { + 'P2WSH': 1, + }; + const inputType = `MULTISIG-P2WSH:${this.cosigners}-${this.masterPublicKeys.length}`; + const inputCounts = { + [inputType]: 1, + }; + + // we add the original cumulativeByteCount to this to ensure the network gets enough fee per vB + const cumulativeByteCount = getByteCount( + inputCounts, + outputCounts, + [this.changePayment.address], + this.network + ) + bumpedTx.cumulativeByteCount; + const fee = Math.round( + cumulativeByteCount * feeSatsPerVB + ); + + this.logger.info( + `Creating CPFP transaction with feeSatsPerVB=${feeSatsPerVB}, ` + + `cumulativeByteCount=${cumulativeByteCount}. ` + + `Original tx (${rawBumpedTx.getId()}): feeSatsPerVB=${bumpedTx.feeSatsPerVB} ` + + `cumulativeByteCount=${bumpedTx.cumulativeByteCount}.` + ); + + const maxFeeBtc = 0.01; + if (fee >= maxFeeBtc * 1e8) { + throw new Error(`yeah, we're not paying over ${maxFeeBtc} for CPFP`); + } + + const [ changeOutput, changeOutputVout ] = this.getChangeOutputAndVout(bumpedPsbt); + if (!changeOutput) { + throw new Error("bumpedTx has no change output"); + } + + if (fee >= changeOutput.value) { + throw new Error(`fee ${fee} is greater or equal than change output value ${changeOutput.value}`); + } + + const cpfpPsbt = new Psbt({network: this.network}); + cpfpPsbt.addInput({ + hash: rawBumpedTx.getId(), + index: changeOutputVout, + nonWitnessUtxo: rawBumpedTx.toBuffer(), + witnessScript: this.changePayment.redeem.output, + }); + cpfpPsbt.addOutput({ + address: this.changePayment.address, + value: changeOutput.value - fee, + }); + const derivationPath = this.keyDerivationPath; // probably correct + let ret: PartiallySignedBitcoinTransaction = { + serializedTransaction: cpfpPsbt.toBase64(), + signedPublicKeys: [], + requiredSignatures: this.cosigners, + derivationPaths: [derivationPath], + noChange: false, + isCpfp: true, + cumulativeByteCount, + feeSatsPerVB, + }; + if (signSelf) { + ret = this.signTransaction(ret); + } + return ret; + } + + validatePartiallySignedCpfpTransaction( + bumpedTx: PartiallySignedBitcoinTransaction, + cpfpTx: PartiallySignedBitcoinTransaction, + ): void { + if (!cpfpTx.isCpfp) { + throw new CPFPValidationError("cpfpTx is not a CPFP transaction"); + } + if (!this.changePayment.address || !this.changePayment.redeem) { + throw new Error("no change address -- cannot validate"); + } + const cpfpPsbt = Psbt.fromBase64(cpfpTx.serializedTransaction, { network: this.network }); + const bumpedPsbt = Psbt.fromBase64(bumpedTx.serializedTransaction, { network: this.network }); + bumpedPsbt.validateSignaturesOfAllInputs(); + bumpedPsbt.finalizeAllInputs(); + + const rawBumpedTx = bumpedPsbt.extractTransaction(); + const [ changeOutput, changeOutputVout ] = this.getChangeOutputAndVout(bumpedPsbt); + if (!changeOutput) { + throw new CPFPValidationError("bumpedTx has no change output"); + } + if (cpfpPsbt.txInputs.length !== 1) { + throw new CPFPValidationError("cpfpTx must have exactly one input"); + } + if (cpfpPsbt.txOutputs.length !== 1) { + throw new CPFPValidationError("cpfpTx must have exactly one output"); + } + + const cpfpInput = cpfpPsbt.txInputs[0]; + const cpfpInputHash = cpfpInput.hash.toString('hex'); + const rawBumpedTxHash = rawBumpedTx.getHash().toString('hex'); + if (cpfpInputHash !== rawBumpedTxHash) { + throw new CPFPValidationError( + `cpfpTx input hash ${cpfpInputHash} does not match bumpedTx hash ${rawBumpedTxHash}` + ); + } + if (cpfpInput.index !== changeOutputVout) { + throw new CPFPValidationError( + `cpfpTx input index ${cpfpInput.index} does not match change output vout ${changeOutputVout}` + ); + } + // TODO: could maybe validate the witnessScript and nonWitnessUtxo + + const cpfpOutput = cpfpPsbt.txOutputs[0]; + const cpfpOutputAddress = bitcoinjsAddress.fromOutputScript(cpfpOutput.script, this.network); + if (cpfpOutputAddress !== this.changePayment.address) { + throw new CPFPValidationError( + `cpfpTx output address ${cpfpOutputAddress} does not match change address ${this.changePayment.address}` + ); + } + } + + private getChangeOutputAndVout(psbt: Psbt): [PsbtTxOutput|undefined, number] { + for (let i = 0; i < psbt.txOutputs.length; i++) { + const output = psbt.txOutputs[i]; + if (output.address === this.changePayment.address) { + return [output, i]; + } + } + return [undefined, -1]; + } + getTransactionTransfers(tx: PartiallySignedBitcoinTransaction): BtcTransfer[] { const psbtUnserialized = Psbt.fromBase64(tx.serializedTransaction, {network: this.network}); let end; @@ -430,8 +635,9 @@ export class BitcoinMultisig { `The partial no-change transaction does not have enough outputs, ` + `should have at least 2 outputs, has ${transferLength + 1}`); } - } - else { + } else if (tx.isCpfp) { + throw new Error('this method is not implemented for CPFP transactions') + } else { end = -1; transferLength = psbtUnserialized.txOutputs.length - 2; if (transferLength < 1) { @@ -531,10 +737,13 @@ export class BitcoinMultisig { requiredSignatures: tx.requiredSignatures, derivationPaths: [...tx.derivationPaths], noChange: Boolean(tx.noChange), + isCpfp: Boolean(tx.isCpfp), + cumulativeByteCount: tx.cumulativeByteCount, + feeSatsPerVB: tx.feeSatsPerVB, } } - async combine(txs: PartiallySignedBitcoinTransaction[]): Promise { + combine(txs: PartiallySignedBitcoinTransaction[]): PartiallySignedBitcoinTransaction { if (! txs.length) { throw new Error('Cannot combine zero transactions'); } @@ -566,6 +775,9 @@ export class BitcoinMultisig { requiredSignatures: tx.requiredSignatures, derivationPaths: [...tx.derivationPaths], noChange: Boolean(tx.noChange), + isCpfp: Boolean(tx.isCpfp), + cumulativeByteCount: tx.cumulativeByteCount, + feeSatsPerVB: tx.feeSatsPerVB, } } return result; diff --git a/packages/fastbtc-node/src/config.ts b/packages/fastbtc-node/src/config.ts index 37a6625..81c72a8 100644 --- a/packages/fastbtc-node/src/config.ts +++ b/packages/fastbtc-node/src/config.ts @@ -30,6 +30,7 @@ export interface Config { btcRpcUrl: string; btcRpcUsername: string; btcKeyDerivationPath: string; + btcWaitedBlocksBeforeCpfp: number; statsdUrl?: string; secrets: () => ConfigSecrets; replenisherConfig: ReplenisherConfig|undefined; @@ -154,6 +155,7 @@ export const envConfigProviderFactory = async ( btcRpcUrl: env.FASTBTC_BTC_RPC_URL!, btcRpcUsername: env.FASTBTC_BTC_RPC_USERNAME ?? '', btcKeyDerivationPath: env.FASTBTC_BTC_KEY_DERIVATION_PATH ?? 'm/0/0/0', + btcWaitedBlocksBeforeCpfp: parseInt(env.FASTBTC_BTC_WAITED_BLOCKS_BEFORE_CPFP ?? '5'), statsdUrl: env.FASTBTC_STATSD_URL, secrets: () => ( { diff --git a/packages/fastbtc-node/src/core/cpfp.ts b/packages/fastbtc-node/src/core/cpfp.ts new file mode 100644 index 0000000..a800b62 --- /dev/null +++ b/packages/fastbtc-node/src/core/cpfp.ts @@ -0,0 +1,200 @@ +import {inject, injectable} from 'inversify'; +import Logger from '../logger'; +import {Network, P2PNetwork} from '../p2p/network'; +import {BitcoinMultisig, CPFPValidationError, PartiallySignedBitcoinTransaction} from '../btc/multisig'; +import {BitcoinTransferService, TransferBatch, TransferBatchDTO, TransferBatchValidator} from './transfers'; +import {Config} from '../config'; +import {setExtend, setIntersection} from '../utils/sets'; +import {sleep} from '../utils'; +import {MessageUnion} from 'ataraxia'; + +export interface CPFPBumperConfig { + numRequiredSigners: number; +} + +interface RequestCPFPSignatureMessage { + transferBatchDto: TransferBatchDTO; + cpfpTransaction: PartiallySignedBitcoinTransaction + requestId: number; +} +interface CPFPSignatureResponseMessage { + cpfpTransaction: PartiallySignedBitcoinTransaction + requestId: number; +} +interface CPFPBumperMessage { + 'fastbtc:cpfp-bumper:request-signature': RequestCPFPSignatureMessage; + 'fastbtc:cpfp-bumper:signature-response': CPFPSignatureResponseMessage; +} + + +/** + * Service for bumping transactions with CPFP + */ +@injectable() +export class CPFPBumper { + readonly MAX_SIGNATURE_WAIT_TIME_MS = 1000 * 60 * 2; + readonly SLEEP_TIME_MS = 1000; + + private logger = new Logger('cpfp-bumper') + private signatureRequestId = 0; + private responseListeners: ((msg: MessageUnion) => void)[] = []; + + constructor( + @inject(Config) private config: CPFPBumperConfig, + @inject(P2PNetwork) private network: Network, + @inject(BitcoinTransferService) private bitcoinTransferService: BitcoinTransferService, + @inject(TransferBatchValidator) private transferBatchValidator: TransferBatchValidator, + @inject(BitcoinMultisig) private btcMultisig: BitcoinMultisig, + ) { + network.onMessage(this.onMessage); + } + + public async addCpfpTransaction(transferBatch: TransferBatch): Promise { + await this.transferBatchValidator.validateForAddingCpfpTransaction(transferBatch); + + const bumpedTransaction = transferBatch.signedBtcTransaction; + if (!bumpedTransaction) { + throw new Error('Cannot add CPFP to a transfer batch without a signed transaction'); + } + + const initialCpfpTransaction = await this.btcMultisig.createPartiallySignedCpfpTransaction(bumpedTransaction); + const signedCpfpTransaction = await this.requestCpfpSignatures(transferBatch, initialCpfpTransaction); + transferBatch = await this.bitcoinTransferService.addCpfpTransaction(transferBatch, signedCpfpTransaction); + return transferBatch; + } + + private async requestCpfpSignatures( + transferBatch: TransferBatch, + initialCpfpTransaction: PartiallySignedBitcoinTransaction + ): Promise { + if (initialCpfpTransaction.signedPublicKeys.length > 0) { + throw new Error('initialCpfpTransaction should not have any signatures'); + } + let signedCpfpTransaction = this.btcMultisig.signTransaction(initialCpfpTransaction); + const seenPublicKeys = new Set(signedCpfpTransaction.signedPublicKeys); + + const requestId = this.signatureRequestId++; + let gatheredPsbts: PartiallySignedBitcoinTransaction[] = []; + + const listener = async (msg: MessageUnion) => { + if (msg.type !== 'fastbtc:cpfp-bumper:signature-response') { + return; + } + const { requestId: responseRequestId, cpfpTransaction } = msg.data; + if (responseRequestId !== requestId) { + return; + } + this.logger.info(`Received CPFP signature response from ${msg.source.id}`); + try { + this.validateCpfpTransaction(transferBatch, signedCpfpTransaction); + gatheredPsbts.push(cpfpTransaction); + } catch (e: any) { + if (e.isValidationError) { + this.logger.warn(`Invalid CPFP signature response: ${e.message}`); + } else { + this.logger.exception(e, 'Error processing CPFP signature response'); + } + } + } + + // We don't use this.network.onMessage(listener) because I cannot find a way to remove that + // without removing EVERY onMessage listener -_- + this.responseListeners.push(listener); + + const maxIterations = this.MAX_SIGNATURE_WAIT_TIME_MS / this.SLEEP_TIME_MS; + try { + for (let iteration = 0; iteration < maxIterations; iteration++) { + await this.network.broadcast('fastbtc:cpfp-bumper:request-signature', { + transferBatchDto: transferBatch.getDto(), + cpfpTransaction: initialCpfpTransaction, + requestId, + }); + await sleep(this.SLEEP_TIME_MS) + + const newPsbts = [...gatheredPsbts]; + gatheredPsbts = []; + for (const psbt of newPsbts) { + if (seenPublicKeys.size == this.config.numRequiredSigners) { + break; + } + + const seenIntersection = setIntersection(seenPublicKeys, new Set(psbt.signedPublicKeys)); + if (seenIntersection.size) { + this.logger.info(`public keys ${[...seenIntersection]} have already signed the CPFP tx`); + continue; + } + + setExtend(seenPublicKeys, psbt.signedPublicKeys); + signedCpfpTransaction = this.btcMultisig.combine([signedCpfpTransaction, psbt]); + } + + if (seenPublicKeys.size === signedCpfpTransaction.requiredSignatures) { + return signedCpfpTransaction; + } + } + throw new Error('Timed out waiting for CPFP signatures'); + } finally { + // Remove the listener + this.responseListeners = this.responseListeners.filter(l => l !== listener); + } + } + + private onMessage = async (message: MessageUnion) => { + try { + if (message.type === 'fastbtc:cpfp-bumper:signature-response') { + for (const listener of this.responseListeners) { + await listener(message); + } + } else if (message.type === 'fastbtc:cpfp-bumper:request-signature') { + const {transferBatchDto, cpfpTransaction, requestId} = message.data; + if (cpfpTransaction.signedPublicKeys.indexOf(this.btcMultisig.getThisNodePublicKey()) !== -1) { + this.logger.info('CPFP already signed by this node') + return; + } + + const transferBatch = await this.bitcoinTransferService.loadFromDto(transferBatchDto) + if (!transferBatch) { + this.logger.warn('TransferBatch not found'); + return; + } + + await this.transferBatchValidator.validateForSigningCpfpTransaction(transferBatch); + this.validateCpfpTransaction(transferBatch, cpfpTransaction); + + this.logger.info('Signing CPFP with requestId %s', requestId) + const signedCpfpTransaction = this.btcMultisig.signTransaction(cpfpTransaction); + await message.source.send('fastbtc:cpfp-bumper:signature-response', { + cpfpTransaction: signedCpfpTransaction, + requestId, + }); + } + } catch (e: any) { + if (e.isValidationError) { + this.logger.warn( + 'Validation error while processing CPFP message %s with data %s: %s', + message.type, + message.data, + e.message + ); + } else { + this.logger.exception( + e, + `Error processing CPFP message %s with data %s`, + message.type, + message.data + ); + } + } + } + + private validateCpfpTransaction( + transferBatch: TransferBatch, + cpfpTransaction: PartiallySignedBitcoinTransaction + ): void { + const bumpedTransaction = transferBatch.signedBtcTransaction; + if (!bumpedTransaction) { + throw new CPFPValidationError('no signed transaction'); + } + this.btcMultisig.validatePartiallySignedCpfpTransaction(bumpedTransaction, cpfpTransaction); + } +} diff --git a/packages/fastbtc-node/src/core/index.ts b/packages/fastbtc-node/src/core/index.ts index fc8e3ac..08d17d1 100644 --- a/packages/fastbtc-node/src/core/index.ts +++ b/packages/fastbtc-node/src/core/index.ts @@ -2,6 +2,7 @@ import {interfaces} from 'inversify'; import {FastBTCNode} from './node'; import {BitcoinTransferService, TransferBatchValidator} from './transfers'; import StatusChecker from './statuschecker'; +import {CPFPBumper} from './cpfp'; import Container = interfaces.Container; export function setupInversify(container: Container) { @@ -9,4 +10,5 @@ export function setupInversify(container: Container) { container.bind(BitcoinTransferService).toSelf().inSingletonScope(); container.bind(TransferBatchValidator).toSelf().inSingletonScope(); container.bind(StatusChecker).toSelf().inSingletonScope(); + container.bind(CPFPBumper).toSelf().inSingletonScope(); } diff --git a/packages/fastbtc-node/src/core/node.ts b/packages/fastbtc-node/src/core/node.ts index 0968910..e1e7fba 100644 --- a/packages/fastbtc-node/src/core/node.ts +++ b/packages/fastbtc-node/src/core/node.ts @@ -15,6 +15,7 @@ import {StatsD} from "hot-shots"; import {TYPES} from "../stats"; import StatusChecker from './statuschecker'; import {BitcoinReplenisher} from '../replenisher/replenisher'; +import {CPFPBumper} from './cpfp'; type FastBTCNodeConfig = Pick< Config, @@ -115,6 +116,7 @@ export class FastBTCNode { @inject(TYPES.StatsD) private statsd: StatsD, @inject(StatusChecker) private statusChecker: StatusChecker, @inject(BitcoinReplenisher) private replenisher: BitcoinReplenisher, + @inject(CPFPBumper) private cpfpBumper: CPFPBumper, ) { this.networkUtil = new NetworkUtil(network, this.logger); network.onNodeAvailable(this.onNodeAvailable); @@ -292,7 +294,14 @@ export class FastBTCNode { transferBatchDto: transferBatch.getDto(), } ); - await this.bitcoinTransferService.sendToBitcoin(transferBatch); + const wasMined = await this.bitcoinTransferService.sendToBitcoin(transferBatch); + if (!wasMined) { + this.logger.info( + 'TransferBatch was not sent in due time, initiating CPFP' + ); + this.statsd.increment('fastbtc.pegout.cpfp_initiated'); + transferBatch = await this.cpfpBumper.addCpfpTransaction(transferBatch); + } return; } @@ -559,7 +568,16 @@ export class FastBTCNode { return; } - await callback(transferBatch, message); + try { + await callback(transferBatch, message); + } catch (e: any) { + if (e.isValidationError) { + this.logger.warn(`Validation error: ${e.message}`); + } else { + this.logger.exception(e, 'Error processing a message from the initiator'); + } + throw e; + } } onRskSendingSignatureResponse = async (data: RSKSendingSignatureResponseMessage, source: Node) => { diff --git a/packages/fastbtc-node/src/core/transfers.ts b/packages/fastbtc-node/src/core/transfers.ts index 72cbd4e..ff53531 100644 --- a/packages/fastbtc-node/src/core/transfers.ts +++ b/packages/fastbtc-node/src/core/transfers.ts @@ -14,6 +14,7 @@ import {sleep} from '../utils'; import {setExtend, setIntersection} from "../utils/sets"; import {toNumber} from '../rsk/utils'; import {Satoshis} from '../btc/types'; +import {deepcopy} from '../utils/copy'; // For lack of a better place, just have these here export const MAX_BTC_IN_BATCH = 5.0; @@ -34,6 +35,7 @@ export interface TransferBatchDTO { signedBtcTransaction?: PartiallySignedBitcoinTransaction; rskMinedSignatures: string[]; rskMinedSigners: string[]; + signedCpfpTransactions?: PartiallySignedBitcoinTransaction[]; } export interface TransferBatchEnvironment { @@ -44,6 +46,7 @@ export interface TransferBatchEnvironment { requiredBitcoinConfirmations: number; requiredRskConfirmations: number; bitcoinOnChainTransaction?: BitcoinRPCGetTransactionResponse; + bitcoinOnChainCpfpTransactions: (BitcoinRPCGetTransactionResponse|undefined)[]; } /** @@ -60,6 +63,7 @@ export class TransferBatch { public signedBtcTransaction: PartiallySignedBitcoinTransaction|undefined, public rskMinedSignatures: string[], public rskMinedSigners: string[], + public signedCpfpTransactions?: PartiallySignedBitcoinTransaction[], ) { } @@ -81,6 +85,7 @@ export class TransferBatch { signedBtcTransaction: this.signedBtcTransaction, rskMinedSignatures: this.rskMinedSignatures, rskMinedSigners: this.rskMinedSigners, + signedCpfpTransactions: this.signedCpfpTransactions, } } @@ -188,6 +193,14 @@ export class TransferBatch { if (!chainTx) { return false; } + for (let cpfpTx of this.environment.bitcoinOnChainCpfpTransactions) { + if (!cpfpTx) { + return false; + } + if (cpfpTx.confirmations < this.environment.requiredBitcoinConfirmations) { + return false; + } + } return chainTx.confirmations >= this.environment.requiredBitcoinConfirmations; } @@ -294,8 +307,12 @@ export class BitcoinTransferService { transfers.push(transfer); } + const cpfpTxHashes = dto.signedCpfpTransactions?.map( + tx => this.btcMultisig.getBitcoinTransactionHash(tx) + ) ?? []; + return new TransferBatch( - await this.getTransferBatchEnvironment(dto.bitcoinTransactionHash), + await this.getTransferBatchEnvironment(dto.bitcoinTransactionHash, cpfpTxHashes), transfers, dto.rskSendingSignatures, dto.rskSendingSigners, @@ -304,6 +321,7 @@ export class BitcoinTransferService { dto.signedBtcTransaction, dto.rskMinedSignatures, dto.rskMinedSigners, + dto.signedCpfpTransactions, ); }); } @@ -530,6 +548,25 @@ export class BitcoinTransferService { return transferBatch; } + async addCpfpTransaction( + transferBatch: TransferBatch, + cpfpTransaction: PartiallySignedBitcoinTransaction + ): Promise { + await this.validator.validateForAddingCpfpTransaction(transferBatch); + if (!transferBatch.signedBtcTransaction) { + throw new Error('Cannot add cpfp transaction to unsigned transaction'); + } + this.btcMultisig.validatePartiallySignedCpfpTransaction(transferBatch.signedBtcTransaction, cpfpTransaction); + transferBatch = transferBatch.copy(); + if(!transferBatch.signedCpfpTransactions) { + transferBatch.signedCpfpTransactions = []; + } + transferBatch.signedCpfpTransactions.push(cpfpTransaction); + await this.validator.validateCpfpTransactions(transferBatch); + await this.updateStoredTransferBatch(transferBatch); + return transferBatch; + } + async markAsSendingInRsk(transferBatch: TransferBatch): Promise { if (!transferBatch.hasEnoughRskSendingSignatures()) { throw new Error('TransferBatch does not have enough signatures to be marked as sending'); @@ -615,34 +652,61 @@ export class BitcoinTransferService { return this.btcMultisig.signTransaction(transferBatch.initialBtcTransaction); } - async sendToBitcoin(transferBatch: TransferBatch): Promise { + async sendToBitcoin(transferBatch: TransferBatch): Promise { this.logger.info("Sending TransferBatch to bitcoin"); await this.validator.validateForSendingToBitcoin(transferBatch); if (transferBatch.isSentToBitcoin()) { this.logger.info("TransferBatch is already sent to bitcoin"); - return; + return true; } if (!transferBatch.signedBtcTransaction) { throw new Error("TransferBatch doesn't have signedBtcTransaction"); } await this.btcMultisig.submitTransaction(transferBatch.signedBtcTransaction); this.logger.info("TransferBatch successfully sent to bitcoin"); + if (transferBatch.signedCpfpTransactions) { + for (const cpfpTransaction of transferBatch.signedCpfpTransactions) { + await this.btcMultisig.submitTransaction(cpfpTransaction); + this.logger.info("CPFP transaction successfully sent to bitcoin"); + } + } // This method should be idempotent, but we will still wait for the required number of confirmations. + // TODO: wait for N blocks, not N time const requiredConfirmations = this.config.btcRequiredConfirmations; const maxIterations = 200; const avgBlockTimeMs = 10 * 60 * 1000; const overheadMultiplier = 2; - const sleepTimeMs = Math.round((avgBlockTimeMs * requiredConfirmations * overheadMultiplier) / maxIterations); + let sleepTimeMs: number; + if (process.env.TEST_CPFP === 'true') { + // hack for testing cpfp + this.logger.info("TEST_CPFP is true, only sleeping for 100 ms"); + sleepTimeMs = 100; + } else { + sleepTimeMs = Math.round((avgBlockTimeMs * requiredConfirmations * overheadMultiplier) / maxIterations); + } this.logger.info(`Waiting for ${requiredConfirmations} confirmations`); for (let i = 0; i < maxIterations; i++) { const chainTx = await this.btcMultisig.getTransaction(transferBatch.bitcoinTransactionHash); const confirmations = chainTx ? chainTx.confirmations : 0; if (confirmations >= requiredConfirmations) { - break; + let isConfirmed = true; + if (transferBatch.signedCpfpTransactions) { + for (const cpfpPsbt of transferBatch.signedCpfpTransactions) { + const cpfpTxHash = this.btcMultisig.getBitcoinTransactionHash(cpfpPsbt); + const cpfpTx = await this.btcMultisig.getTransaction(cpfpTxHash); + if (!cpfpTx || cpfpTx.confirmations < requiredConfirmations) { + isConfirmed = false; + break; + } + } + } + return isConfirmed; } await sleep(sleepTimeMs); } + // not mined + return false; } async signRskMinedUpdate(transferBatch: TransferBatch): Promise<{signature: string, address: string}> { @@ -663,7 +727,7 @@ export class BitcoinTransferService { initialSignedBtcTransaction ); return new TransferBatch( - await this.getTransferBatchEnvironment(bitcoinTxHash), // could also null here since it is not in blockchain + await this.getTransferBatchEnvironment(bitcoinTxHash, []), // could also null here since it is not in blockchain transfers, [], [], @@ -675,15 +739,19 @@ export class BitcoinTransferService { ); } - private async getTransferBatchEnvironment(bitcoinTransactionHash: string|null): Promise { + private async getTransferBatchEnvironment(bitcoinTransactionHash: string|null, cpfpTransactionHashes: string[]): Promise { const currentBlockNumber = await this.ethersProvider.getBlockNumber(); let bitcoinOnChainTransaction = undefined; if (bitcoinTransactionHash) { bitcoinOnChainTransaction = await this.btcMultisig.getTransaction(bitcoinTransactionHash); } + const bitcoinOnChainCpfpTransactions = await Promise.all( + cpfpTransactionHashes.map(h => this.btcMultisig.getTransaction(h)) + ); return { currentBlockNumber, bitcoinOnChainTransaction, + bitcoinOnChainCpfpTransactions, requiredBitcoinConfirmations: this.config.btcRequiredConfirmations, requiredRskConfirmations: this.config.rskRequiredConfirmations, numRequiredSigners: this.config.numRequiredSigners, @@ -852,6 +920,32 @@ export class TransferBatchValidator { await this.validateTransferBatch(transferBatch, null, true); } + async validateForSigningCpfpTransaction(transferBatch: TransferBatch): Promise { + await this.validateForAddingCpfpTransaction(transferBatch); + } + + async validateForAddingCpfpTransaction(transferBatch: TransferBatch): Promise { + if ( + transferBatch.transfers.length == 0 || + //!transferBatch.hasEnoughRskSendingSignatures() || + !transferBatch.hasEnoughBitcoinSignatures() || + !transferBatch.isMarkedAsSendingInRsk() || + !transferBatch.signedBtcTransaction + ) { + throw new TransferBatchValidationError('TransferBatch is not sendable to bitcoin'); + } + if (transferBatch.isSentToBitcoin()) { + throw new TransferBatchValidationError('TransferBatch is already sent to bitcoin'); + } + if (transferBatch.signedCpfpTransactions?.length) { + // TODO: get rid of this when we add support for more than one CPFP transaction + throw new TransferBatchValidationError( + `Only one CPFP transaction is currently supported, got ${transferBatch.signedCpfpTransactions.length}` + ); + } + await this.validateTransferBatch(transferBatch, TransferStatus.Sending, true); + } + async validateCompleteTransferBatch(transferBatch: TransferBatch): Promise { if ( transferBatch.transfers.length == 0 || @@ -929,7 +1023,7 @@ export class TransferBatchValidator { private async validateTransferBatch( transferBatch: TransferBatch, expectedStatus: TransferStatus|null, - requireSignedBtcTransaction: boolean + requireSignedBtcTransaction: boolean, ): Promise { if (!transferBatch.hasValidTransferState()) { throw new TransferBatchValidationError( @@ -946,6 +1040,7 @@ export class TransferBatchValidator { 'TransferBatch is missing signedBtcTransaction' ); } + await this.validateCpfpTransactions(transferBatch); } private async validateRskSignatures(transferBatch: TransferBatch): Promise { @@ -1076,6 +1171,36 @@ export class TransferBatchValidator { } } + async validateCpfpTransactions(transferBatch: TransferBatch) { + if (!transferBatch.signedCpfpTransactions?.length) { + return; + } + if (!transferBatch.signedBtcTransaction) { + throw new TransferBatchValidationError( + 'TransferBatch must have a signed BTC transaction to have CPFP transactions' + ); + } + if (transferBatch.signedCpfpTransactions.length > 1) { + // TODO: get rid of this when we add support for more than one CPFP transaction + throw new TransferBatchValidationError( + `Only one CPFP transaction is currently supported, got ${transferBatch.signedCpfpTransactions.length}` + ); + } + let bumpedTx = transferBatch.signedBtcTransaction; + for (const cpfp of transferBatch.signedCpfpTransactions) { + // We should maybe validate that signed public keys match signers... But 1) it doesn't look like they are + // validated for the "normal" BTC transactions either and 2) any federator can put anything in the + // signed public keys as it's not derived from the PSBT + if (cpfp.signedPublicKeys.length !== cpfp.requiredSignatures) { + throw new TransferBatchValidationError( + `TransferBatch has a CPFP tx with ${cpfp.signedPublicKeys.length} signatures but requires ${cpfp.requiredSignatures}` + ); + } + await this.btcMultisig.validatePartiallySignedCpfpTransaction(bumpedTx, cpfp); + bumpedTx = cpfp; + } + } + private async fetchRskTransferInfo(btcPaymentAddress: string, nonce: number): Promise { const currentBlock = await this.ethersProvider.getBlockNumber(); const transferData = await this.fastBtcBridge.getTransfer(btcPaymentAddress, nonce); @@ -1135,7 +1260,3 @@ async function getUpdateHashForMined(fastBtcBridge: Contract, transferBatch: Tra TransferStatus.Mined ); } - -function deepcopy(thing: T): T { - return JSON.parse(JSON.stringify(thing)); -} diff --git a/packages/fastbtc-node/src/utils/copy.ts b/packages/fastbtc-node/src/utils/copy.ts new file mode 100644 index 0000000..5ba898b --- /dev/null +++ b/packages/fastbtc-node/src/utils/copy.ts @@ -0,0 +1,3 @@ +export function deepcopy(thing: T): T { + return JSON.parse(JSON.stringify(thing)); +}