Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: RBF #76

Merged
merged 1 commit into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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