Skip to content

Commit

Permalink
feat: RBF
Browse files Browse the repository at this point in the history
  • Loading branch information
limpbrains committed Aug 21, 2024
1 parent 0e89330 commit 56e8c55
Show file tree
Hide file tree
Showing 11 changed files with 432 additions and 51 deletions.
28 changes: 28 additions & 0 deletions .github/workflows/lint-check.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .github/workflows/type-check.yml
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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: {}
1 change: 1 addition & 0 deletions docker/electrs.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
auth = "polaruser:polarpass"
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
164 changes: 123 additions & 41 deletions src/transaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<ISendTransaction>> {
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<Result<ISendTransaction>> {
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<ISendTransaction> = {
...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);
}
}
1 change: 1 addition & 0 deletions src/types/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ export interface IRbfData {
fee: number; // Total fee in sats.
inputs: IUtxo[];
message: string;
changeAddress: string;
}

export interface IBoostedTransaction {
Expand Down
Loading

0 comments on commit 56e8c55

Please sign in to comment.