diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml new file mode 100644 index 0000000..a769e71 --- /dev/null +++ b/.github/workflows/lint-check.yml @@ -0,0 +1,28 @@ +name: lint-check + +on: + workflow_dispatch: + pull_request: + +jobs: + lint: + name: Run lint check + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Node.js dependencies + run: npm install || npm install + + - name: Lint check + run: npm run lint:check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9dab961 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: tests + +on: + workflow_dispatch: + pull_request: + +jobs: + tests: + name: Run unit tests + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run regtest setup + run: cd docker && docker compose up --quiet-pull -d + + - name: Wait for bitcoind + run: | + sudo apt install wait-for-it + wait-for-it -h 127.0.0.1 -p 43782 -t 60 + + - name: Wait for electrum server + run: wait-for-it -h 127.0.0.1 -p 60001 -t 60 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Node.js dependencies + run: npm install || npm install + + - name: Run Tests + run: npm run test + + - name: Dump docker logs on failure + if: failure() + uses: jwalton/gh-docker-logs@v2 diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml new file mode 100644 index 0000000..4e795ad --- /dev/null +++ b/.github/workflows/type-check.yml @@ -0,0 +1,28 @@ +name: type-check + +on: + workflow_dispatch: + pull_request: + +jobs: + typescript: + name: Run type check + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Node.js dependencies + run: npm install || npm install + + - name: Type check + run: npm run tsc:check diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..e5cf94a --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,77 @@ +services: + bitcoind: + container_name: bitcoin + image: btcpayserver/bitcoin:26.0 + restart: unless-stopped + expose: + - '43782' + - '39388' + ports: + - '43782:43782' + - '39388:39388' + volumes: + - 'bitcoin_home:/home/bitcoin/.bitcoin' + environment: + BITCOIN_NETWORK: ${NBITCOIN_NETWORK:-regtest} + CREATE_WALLET: 'true' + BITCOIN_WALLETDIR: '/walletdata' + BITCOIN_EXTRA_ARGS: | + rpcport=43782 + rpcbind=0.0.0.0:43782 + rpcallowip=0.0.0.0/0 + port=39388 + whitelist=0.0.0.0/0 + maxmempool=500 + rpcauth=polaruser:5e5e98c21f5c814568f8b55d83b23c1c$$066b03f92df30b11de8e4b1b1cd5b1b4281aa25205bd57df9be82caf97a05526 + txindex=1 + fallbackfee=0.00001 + zmqpubrawblock=tcp://0.0.0.0:28334 + zmqpubrawtx=tcp://0.0.0.0:28335 + zmqpubhashblock=tcp://0.0.0.0:28336 + + bitcoinsetup: + image: btcpayserver/bitcoin:26.0 + depends_on: + - bitcoind + restart: 'no' + volumes: + - 'bitcoin_home:/home/bitcoin/.bitcoin' + user: bitcoin + # generate one block so electrs stop complaining + entrypoint: + [ + 'bash', + '-c', + 'sleep 1; while ! bitcoin-cli -rpcconnect=bitcoind -generate 1; do sleep 1; done', + ] + + electrs: + container_name: electrum + image: getumbrel/electrs:v0.10.2 + restart: unless-stopped + depends_on: + - bitcoind + expose: + - '60001' + - '28334' + - '28335' + - '28336' + ports: + - '60001:60001' + # - '28334:28334' + # - '28335:28335' + # - '28336:28336' + volumes: + - './electrs.toml:/data/electrs.toml' + environment: + - ELECTRS_NETWORK=regtest + - ELECTRS_ELECTRUM_RPC_ADDR=electrs:60001 + - ELECTRS_DAEMON_RPC_ADDR=bitcoind:43782 + - ELECTRS_DAEMON_P2P_ADDR=bitcoind:39388 + - ELECTRS_LOG_FILTERS=INFO + + +volumes: + bitcoin_home: + +networks: {} diff --git a/docker/electrs.toml b/docker/electrs.toml new file mode 100644 index 0000000..e65a51c --- /dev/null +++ b/docker/electrs.toml @@ -0,0 +1 @@ +auth = "polaruser:polarpass" diff --git a/package-lock.json b/package-lock.json index 64670a5..50fd505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beignet", - "version": "0.0.44", + "version": "0.0.45", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beignet", - "version": "0.0.44", + "version": "0.0.45", "license": "MIT", "dependencies": { "@bitcoinerlab/secp256k1": "1.0.5", diff --git a/package.json b/package.json index 7b3176f..00bb1b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "beignet", - "version": "0.0.44", + "version": "0.0.45", "description": "A self-custodial, JS Bitcoin wallet management library.", "main": "dist/index.js", "scripts": { diff --git a/src/transaction/index.ts b/src/transaction/index.ts index 6e3326b..0dc4076 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -1173,52 +1173,134 @@ export class Transaction { txid?: string; // txid of utxo to include in the CPFP tx. Undefined will gather all utxo's. satsPerByte?: number; }): Promise> { - await this.resetSendTransaction(); - const setupTransactionRes = await this.setupTransaction({ - inputTxHashes: txid ? [txid] : undefined, - rbf: this._wallet.rbf - }); - if (setupTransactionRes.isErr()) { - return err(setupTransactionRes.error.message); - } - const receiveAddress = await this._wallet.getReceiveAddress({}); - if (receiveAddress.isErr()) { - return err(receiveAddress.error.message); - } + try { + await this.resetSendTransaction(); + const setupTransactionRes = await this.setupTransaction({ + inputTxHashes: txid ? [txid] : undefined, + rbf: this._wallet.rbf + }); + if (setupTransactionRes.isErr()) { + return err(setupTransactionRes.error.message); + } + const receiveAddress = await this._wallet.getReceiveAddress({}); + if (receiveAddress.isErr()) { + return err(receiveAddress.error.message); + } - // try to calculate satsPerByte if not provided. - // child + parent combined fee rate should be higher than fastest. - if (!satsPerByte && txid) { - const parent = this._wallet.data.transactions[txid]; - if (parent) { - const parentVsize = parent.vsize; - const childVsize = 141; // assume segwit 1 input 1 output - const fast = this._wallet.feeEstimates.fast; - const res = Math.ceil( - (fast * (parentVsize + childVsize) - parent.fee) / childVsize - ); - satsPerByte = res; + // try to calculate satsPerByte if not provided. + // child + parent combined fee rate should be higher than fastest. + // TODO: take all possible unconfirmed parent UTXOs into account. + if (!satsPerByte && txid) { + const parent = this._wallet.data.transactions[txid]; + if (parent) { + const parentVsize = parent.vsize; + const childVsize = 141; // assume segwit 1 input 1 output + const fast = this._wallet.feeEstimates.fast; + const res = Math.ceil( + (fast * (parentVsize + childVsize) - parent.fee) / childVsize + ); + satsPerByte = res; + } + } + + // if we still don't have a satsPerByte, use 1.5x fastest. + if (!satsPerByte) { + satsPerByte = Math.ceil(this._wallet.feeEstimates.fast * 1.5); } - } - // if we still don't have a satsPerByte, use 1.5x fastest. - if (!satsPerByte) { - satsPerByte = Math.ceil(this._wallet.feeEstimates.fast * 1.5); + const sendMaxRes = await this.sendMax({ + transaction: { + ...this.data, + ...setupTransactionRes.value, + boostType: EBoostType.cpfp + }, + address: receiveAddress.value, + satsPerByte, + rbf: this._wallet.rbf + }); + if (sendMaxRes.isErr()) { + return err(sendMaxRes.error.message); + } + return ok(this.data); + } catch (e) { + return err(e); } + } - const sendMaxRes = await this.sendMax({ - transaction: { - ...this.data, - ...setupTransactionRes.value, - boostType: EBoostType.cpfp - }, - address: receiveAddress.value, - satsPerByte, - rbf: this._wallet.rbf - }); - if (sendMaxRes.isErr()) { - return err(sendMaxRes.error.message); + /** + * Sets up a transaction for RBF. + * @param {string} txid + */ + async setupRbf({ + txid + }: { + txid: string; + }): Promise> { + try { + await this.resetSendTransaction(); + const setupTransactionRes = await this.setupTransaction({ + rbf: true + }); + if (setupTransactionRes.isErr()) { + return err(setupTransactionRes.error.message); + } + + const response = await this._wallet.getRbfData({ + txHash: { tx_hash: txid } + }); + if (response.isErr()) { + return err(response.error.message); + } + const transaction = response.value; + + const satsPerByte = this._wallet.feeEstimates.fast; + const newFee = this.getTotalFee({ + transaction, + satsPerByte, + message: transaction.message + }); + + // filter out change address, otherwise getTransactionOutputValue will include it + const outputs = transaction.outputs + .filter((output) => output.address !== transaction.changeAddress) + .map((output, index) => ({ ...output, index })); + + const inputTotal = this.getTransactionInputValue({ + inputs: transaction.inputs + }); + // Ensure we have enough funds to perform an RBF transaction. + const outputTotal = this.getTransactionOutputValue({ + outputs + }); + + if (outputTotal + newFee >= inputTotal || newFee >= inputTotal / 2) { + /* + * We could always pull the fee from the output total, + * but this may negatively impact the transaction made by the user. + * (Ex: Reducing the amount paid to the recipient). + * We could always include additional unconfirmed utxo's to cover the fee as well, + * but this may negatively impact the user's privacy by including sensitive utxos. + * Instead of allowing either scenario, we attempt a CPFP instead. + */ + return err('Not enough sats to support an RBF transaction.'); + } + const newTransaction: Partial = { + ...transaction, + outputs, + minFee: satsPerByte, + fee: newFee, + satsPerByte, + rbf: true, + boostType: EBoostType.rbf + }; + + this.updateSendTransaction({ + transaction: newTransaction + }); + + return ok(this.data); + } catch (e) { + return err(e); } - return ok(this.data); } } diff --git a/src/types/wallet.ts b/src/types/wallet.ts index 0ec725a..7d9033d 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -494,6 +494,7 @@ export interface IRbfData { fee: number; // Total fee in sats. inputs: IUtxo[]; message: string; + changeAddress: string; } export interface IBoostedTransaction { diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 5a41e56..74d78b4 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -3367,7 +3367,7 @@ export class Wallet { * Instead of pulling sats from that output to accommodate the higher fee (reducing how much the recipient receives) * suggest a CPFP transaction. */ - return err('cpfp'); + return err('Unable to determine change address.'); } if (outputTotal > inputTotal) { @@ -3396,11 +3396,16 @@ export class Wallet { }: { txid: string; }): Promise { - const transactions = this.data.transactions; + const transactions = this._data.transactions; + const unconfirmed = this._data.unconfirmedTransactions; if (txid in transactions) { delete transactions[txid]; } + if (txid in unconfirmed) { + delete unconfirmed[txid]; + } await this.saveWalletData('transactions', transactions); + await this.saveWalletData('unconfirmedTransactions', unconfirmed); } /** @@ -3425,12 +3430,12 @@ export class Wallet { async addBoostedTransaction({ newTxId, oldTxId, - type = EBoostType.cpfp, + type, fee }: { newTxId: string; oldTxId: string; - type?: EBoostType; + type: EBoostType; fee: number; }): Promise> { try { @@ -3453,6 +3458,12 @@ export class Wallet { ...this.data.boostedTransactions, ...boostedTransaction }; + + // Only delete the old transaction if it was an RBF + if (type === EBoostType.rbf) { + await this.deleteOnChainTransactionById({ txid: oldTxId }); + } + await this.saveWalletData( 'boostedTransactions', this._data.boostedTransactions diff --git a/tests/boost.test.ts b/tests/boost.test.ts index 26515cd..bddc770 100644 --- a/tests/boost.test.ts +++ b/tests/boost.test.ts @@ -47,6 +47,7 @@ beforeEach(async function () { const mnemonic = generateMnemonic(); const res = await Wallet.create({ + rbf: true, mnemonic, // mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', network: EAvailableNetworks.regtest, @@ -62,6 +63,13 @@ beforeEach(async function () { ], net, tls + }, + // reduce gap limit to speed up tests + gapLimitOptions: { + lookAhead: 2, + lookBehind: 2, + lookAheadChange: 2, + lookBehindChange: 2 } }); if (res.isErr()) { @@ -142,11 +150,12 @@ describe('Boost', async function () { if (s1.isErr()) { throw s1.error; } + const oldTxId = s1.value; await wallet.refreshWallet({}); - const b1 = wallet.canBoost(s1.value); + const b1 = wallet.canBoost(oldTxId); expect(b1).to.deep.equal({ canBoost: true, rbf: false, cpfp: true }); - const setup = await wallet.transaction.setupCpfp({ txid: s1.value }); + const setup = await wallet.transaction.setupCpfp({ txid: oldTxId }); if (setup.isErr()) { throw setup.error; } @@ -161,14 +170,115 @@ describe('Boost', async function () { if (createRes.isErr()) { throw createRes.error; } + const newTxId = createRes.value.id; wallet.electrum.broadcastTransaction({ rawTx: createRes.value.hex }); + const addBoost = await wallet.addBoostedTransaction({ + oldTxId, + newTxId, + type: EBoostType.cpfp, + fee: setup.value.fee + }); + if (addBoost.isErr()) { + throw addBoost.error; + } + const boosted = wallet.getBoostedTransactions(); + expect(boosted).to.deep.equal({ + [oldTxId]: { + parentTransactions: [oldTxId], + childTransaction: newTxId, + type: EBoostType.cpfp, + fee: setup.value.fee + } + }); + await wallet.refreshWallet({}); expect(Object.keys(wallet.transactions).length).to.equal(3); // FIXME: Broadcasted tx fee and setup fee should be the same - // expect(wallet.transactions[createRes.value.id].satsPerByte).to.equal( + // expect(wallet.transactions[newTxId].satsPerByte).to.equal( // setup.value.satsPerByte // ); }); + + it('Should generate RBF for send transaction', async () => { + const r = await wallet.getNextAvailableAddress(); + if (r.isErr()) { + throw r.error; + } + const a1 = r.value.addressIndex.address; + await rpc.sendToAddress(a1, '0.0001'); // 10000 sats + await rpc.generateToAddress(1, await rpc.getNewAddress()); + + await waitForElectrum(); + const r1 = await wallet.refreshWallet({}); + if (r1.isErr()) { + throw r1.error; + } + expect(wallet.data.balance).to.equal(10000); + + // create and send original transaction + const s1 = await wallet.send({ + address: 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk', + amount: 1000, + satsPerByte: 1, + rbf: true + }); + if (s1.isErr()) { + throw s1.error; + } + const oldTxId = s1.value; + const r2 = await wallet.refreshWallet({}); + if (r2.isErr()) { + throw r2.error; + } + const b1 = wallet.canBoost(oldTxId); + expect(b1).to.deep.equal({ canBoost: true, rbf: true, cpfp: true }); + + // replace original transaction using RBF + const setup = await wallet.transaction.setupRbf({ txid: oldTxId }); + if (setup.isErr()) { + throw setup.error; + } + expect(setup.value.boostType).to.equal(EBoostType.rbf); + const createRes = await wallet.transaction.createTransaction(); + if (createRes.isErr()) { + throw createRes.error; + } + const newTxId = createRes.value.id; + const broadcastResp = await wallet.electrum.broadcastTransaction({ + rawTx: createRes.value.hex + }); + if (broadcastResp.isErr()) { + throw broadcastResp.error; + } + + const addBoost = await wallet.addBoostedTransaction({ + oldTxId, + newTxId, + type: EBoostType.rbf, + fee: setup.value.fee + }); + if (addBoost.isErr()) { + throw addBoost.error; + } + const boosted = wallet.getBoostedTransactions(); + expect(boosted).to.deep.equal({ + [oldTxId]: { + parentTransactions: [oldTxId], + childTransaction: newTxId, + type: EBoostType.rbf, + fee: setup.value.fee + } + }); + + const r3 = await wallet.refreshWallet({}); + if (r3.isErr()) { + throw r3.error; + } + + expect(Object.keys(wallet.transactions).length).to.equal(2); + expect(wallet.transactions).not.to.have.property(oldTxId); + expect(wallet.transactions).to.have.property(newTxId); + }); });