Skip to content

Commit

Permalink
feat(btc): support full RBF (#150)
Browse files Browse the repository at this point in the history
  • Loading branch information
Flouse authored Jun 14, 2024
2 parents 8c5b4fd + 08200c9 commit 26e7070
Show file tree
Hide file tree
Showing 17 changed files with 965 additions and 141 deletions.
9 changes: 9 additions & 0 deletions .changeset/spicy-cups-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@rgbpp-sdk/btc": minor
---

Support Full-RBF feature with the sendRbf() and createSendRbfBuilder() API

- Add `excludeUtxos`, `skipInputsValidation` options in the `sendUtxos()` API to support the RBF feature
- Add `onlyProvableUtxos` option in the `sendRgbppUtxos()` API for future update supports
- Add `changeIndex` in the return type of the BTC Builder APIs
102 changes: 88 additions & 14 deletions packages/btc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,35 @@ const psbt = await sendRgbppUtxos({
minUtxoSatoshi: config.btcUtxoDustLimit, // optional, default to 1000 on the testnet, 1,0000 on the mainnet
rgbppMinUtxoSatoshi: config.rgbppUtxoDustLimit, // optional, default to 546 on both testnet/mainnet
onlyConfirmedUtxos: false, // optional, default to false, only confirmed utxos are allowed in the transaction
onlyProvableUtxos: true, // optional, default to true, only utxos that satisfy (utxo.address == from) are allowed
feeRate: 1, // optional, default to 1 on the testnet, and it is a floating number on the mainnet
});
```

### Construct a Full-RBF transaction

```typescript
import { sendRbf, networkTypeToConfig, DataSource, Collector, NetworkType } from '@rgbpp-sdk/btc';
import { BtcAssetsApi } from '@rgbpp-sdk/service';

const networkType = NetworkType.TESTNET;
const config = networkTypeToConfig(networkType);

const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token');
const source = new DataSource(service, networkType);

const psbt = await sendRbf({
txHex: 'your_original_transaction_hex',
from: account.address,
feeRate: 40, // the feeRate should be greater than the feeRate of the original transaction
changeIndex: 1, // optional, return change to outputs[changeIndex], will create a new output if not specified
changeAddress: 'address_to_return_change', // optional, where should the change satoshi be returned to
requireValidOutputsValue: false, // optional, default to false, require each output's value to be >= minUtxoSatoshi
requireGreaterFeeAndRate: true, // optional, default to true, require the fee rate&amount to be greater than the original transction
source,
});
```

## Types

### Transaction
Expand All @@ -238,8 +263,9 @@ declare function sendBtc(props: SendBtcProps): Promise<bitcoin.Psbt>;
```typescript
declare function createSendBtcBuilder(props: SendBtcProps): Promise<{
builder: TxBuilder;
feeRate: number;
fee: number;
feeRate: number;
changeIndex: number;
}>;
```

Expand All @@ -265,8 +291,9 @@ declare function sendUtxos(props: SendUtxosProps): Promise<bitcoin.Psbt>;
```typescript
declare function createSendUtxosBuilder(props: SendUtxosProps): Promise<{
builder: TxBuilder;
feeRate: number;
fee: number;
feeRate: number;
changeIndex: number;
}>;
```

Expand All @@ -281,6 +308,10 @@ interface SendUtxosProps {
changeAddress?: string;
minUtxoSatoshi?: number;
onlyConfirmedUtxos?: boolean;
excludeUtxos?: BaseOutput[];

// EXPERIMENTAL: the below props are unstable and can be altered at any time
skipInputsValidation?: boolean;
}
```

Expand All @@ -293,8 +324,9 @@ declare function sendRgbppUtxos(props: SendRgbppUtxosProps): Promise<bitcoin.Psb
```typescript
declare function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): Promise<{
builder: TxBuilder;
feeRate: number;
fee: number;
feeRate: number;
changeIndex: number;
}>;
```

Expand All @@ -317,6 +349,44 @@ interface SendRgbppUtxosProps {
changeAddress?: string;
minUtxoSatoshi?: number;
onlyConfirmedUtxos?: boolean;
excludeUtxos?: BaseOutput[];

// EXPERIMENTAL: the below props are experimental and can be altered at any time
onlyProvableUtxos?: boolean;
}
```

#### sendRbf / createSendRbfBuilder / SendRbfProps

```typescript
declare function sendRbf(props: SendRbfProps): Promise<bitcoin.Psbt>;
```

```typescript
declare function createSendRbfBuilder(props: SendRbfProps): Promise<{
builder: TxBuilder;
fee: number;
feeRate: number;
changeIndex: number;
}>;
```

```typescript
interface SendRbfProps {
from: string;
txHex: string;
source: DataSource;
feeRate?: number;
fromPubkey?: string;
changeIndex?: number;
changeAddress?: string;
minUtxoSatoshi?: number;
onlyConfirmedUtxos?: boolean;
requireValidOutputsValue?: boolean;
requireGreaterFeeAndRate?: boolean;

// EXPERIMENTAL: the below props are experimental and can be altered at any time
inputsPubkey?: Record<string, string>; // Record<address, pubkey>
}
```

Expand All @@ -329,27 +399,27 @@ type InitOutput = TxAddressOutput | TxDataOutput | TxScriptOutput;
#### TxAddressOutput / TxDataOutput / TxScriptOutput

```typescript
interface TxAddressOutput extends BaseOutput {
interface TxAddressOutput extends TxBaseOutput {
address: string;
}
```

```typescript
interface TxDataOutput extends BaseOutput {
interface TxDataOutput extends TxBaseOutput {
data: Buffer | string;
}
```

```typescript
interface TxScriptOutput extends BaseOutput {
interface TxScriptOutput extends TxBaseOutput {
script: Buffer;
}
```

#### BaseOutput
#### TxBaseOutput

```typescript
interface BaseOutput {
interface TxBaseOutput {
value: number;
fixed?: boolean;
protected?: boolean;
Expand All @@ -374,10 +444,7 @@ interface DataSource {
onlyConfirmedUtxos?: boolean;
noAssetsApiCache?: boolean;
internalCacheKey?: string;
excludeUtxos?: {
txid: string;
vout: number;
}[];
excludeUtxos?: BaseOutput[];
}): Promise<{
utxos: Utxo[];
satoshi: number;
Expand All @@ -399,16 +466,23 @@ interface FeesRecommended {

### Basic

#### Utxo / Output
#### BaseOutput / Output / Utxo

```typescript
interface Output {
interface BaseOutput {
txid: string;
vout: number;
}
```

```typescript
interface Output extends BaseOutput {
value: number;
scriptPk: string;
}
```

```typescript
interface Utxo extends Output {
addressType: AddressType;
address: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/btc/src/api/sendBtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export interface SendBtcProps {

export async function createSendBtcBuilder(props: SendBtcProps): Promise<{
builder: TxBuilder;
feeRate: number;
fee: number;
feeRate: number;
changeIndex: number;
}> {
// By default, all outputs in the sendBtc() API are fixed
const outputs = props.tos.map((to) => ({
Expand Down
174 changes: 174 additions & 0 deletions packages/btc/src/api/sendRbf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { BaseOutput, Utxo } from '../transaction/utxo';
import { DataSource } from '../query/source';
import { ErrorCodes, TxBuildError } from '../error';
import { InitOutput, TxBuilder } from '../transaction/build';
import { isOpReturnScriptPubkey } from '../transaction/embed';
import { networkTypeToNetwork } from '../preset/network';
import { networkTypeToConfig } from '../preset/config';
import { createSendUtxosBuilder } from './sendUtxos';
import { isP2trScript } from '../script';
import { bitcoin } from '../bitcoin';

export interface SendRbfProps {
from: string;
txHex: string;
source: DataSource;
feeRate?: number;
fromPubkey?: string;
changeIndex?: number;
changeAddress?: string;
minUtxoSatoshi?: number;
onlyConfirmedUtxos?: boolean;
requireValidOutputsValue?: boolean;
requireGreaterFeeAndRate?: boolean;

// EXPERIMENTAL: the below props are unstable and can be altered at any time
inputsPubkey?: Record<string, string>; // Record<address, pubkey>
}

export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
builder: TxBuilder;
fee: number;
feeRate: number;
changeIndex: number;
}> {
const previousTx = bitcoin.Transaction.fromHex(props.txHex);
const network = networkTypeToNetwork(props.source.networkType);

// Rebuild inputs
const inputs: Utxo[] = [];
for (const input of previousTx.ins) {
const hash = Buffer.from(input.hash).reverse().toString('hex');
const utxo = await props.source.getUtxo(hash, input.index);
if (!utxo) {
throw TxBuildError.withComment(ErrorCodes.CANNOT_FIND_UTXO, `hash: ${hash}, index: ${input.index}`);
}

// Ensure each P2TR input has a corresponding pubkey
const fromPubkey = utxo.address === props.from ? props.fromPubkey : undefined;
const inputPubkey = props.inputsPubkey?.[utxo.address];
const pubkey = inputPubkey ?? fromPubkey;
if (pubkey) {
utxo.pubkey = pubkey;
}
if (isP2trScript(utxo.scriptPk) && !utxo.pubkey) {
throw TxBuildError.withComment(ErrorCodes.MISSING_PUBKEY, utxo.address);
}

inputs.push(utxo);
}

// Rebuild outputs
const requireValidOutputsValue = props.requireValidOutputsValue ?? false;
const outputs: InitOutput[] = previousTx.outs.map((output) => {
if (isOpReturnScriptPubkey(output.script)) {
return {
script: output.script,
value: output.value,
fixed: true,
};
} else {
return {
minUtxoSatoshi: requireValidOutputsValue ? undefined : output.value,
address: bitcoin.address.fromOutputScript(output.script, network),
value: output.value,
fixed: true,
};
}
});

// Set change output if specified
let changeAddress: string | undefined = props.changeAddress;
if (props.changeIndex !== undefined) {
const changeOutput = outputs[props.changeIndex];
if (!changeOutput) {
throw TxBuildError.withComment(ErrorCodes.INVALID_CHANGE_OUTPUT, `outputs[${props.changeIndex}] is not found`);
}
const isReturnableOutput = changeOutput && 'address' in changeOutput;
if (!isReturnableOutput) {
throw TxBuildError.withComment(
ErrorCodes.INVALID_CHANGE_OUTPUT,
`outputs[${props.changeIndex}] is not a returnable output for change`,
);
}
const changeOutputAddress = changeOutput.address;
if (changeOutputAddress && changeAddress && changeAddress !== changeOutputAddress) {
throw TxBuildError.withComment(
ErrorCodes.INVALID_CHANGE_OUTPUT,
`The address of outputs[${props.changeIndex}] does not match the specified changeAddress, expected: ${changeAddress}, actual: ${changeOutputAddress}`,
);
}
if (changeOutputAddress && !changeAddress) {
changeAddress = changeOutputAddress;
}
const isLastOutput = outputs.length === props.changeIndex + 1;
if (isLastOutput) {
outputs.pop();
} else {
const config = networkTypeToConfig(props.source.networkType);
const minUtxoSatoshi = props.minUtxoSatoshi ?? config.btcUtxoDustLimit;
changeOutput.minUtxoSatoshi = minUtxoSatoshi;
changeOutput.value = minUtxoSatoshi;
changeOutput.protected = true;
changeOutput.fixed = false;
}
}

// Fee rate
const requireGreaterFeeAndRate = props.requireGreaterFeeAndRate ?? true;
let feeRate: number | undefined = props.feeRate;
if (requireGreaterFeeAndRate && !feeRate) {
const feeRates = await props.source.service.getBtcRecommendedFeeRates();
feeRate = feeRates.fastestFee;
}

// The RBF transaction should offer a higher fee rate
const previousInsValue = inputs.reduce((sum, input) => sum + input.value, 0);
const previousOutsValue = previousTx.outs.reduce((sum, output) => sum + output.value, 0);
const previousFee = previousInsValue - previousOutsValue;
const previousFeeRate = Math.floor(previousFee / previousTx.virtualSize());
if (requireGreaterFeeAndRate && feeRate !== undefined && feeRate <= previousFeeRate) {
throw TxBuildError.withComment(
ErrorCodes.INVALID_FEE_RATE,
`RBF should offer a higher fee rate, previous: ${previousFeeRate}, current: ${feeRate}`,
);
}

// Exclude all outputs of the previous transaction during the collection
// TODO: also exclude all outputs of the previous transaction's children transactions
const previousTxId = previousTx.getId();
const excludeUtxos: BaseOutput[] = previousTx.outs.map((_, index) => ({
txid: previousTxId,
vout: index,
}));

// Build RBF transaction
const res = await createSendUtxosBuilder({
inputs,
outputs,
excludeUtxos,
changeAddress,
from: props.from,
source: props.source,
feeRate: props.feeRate,
fromPubkey: props.fromPubkey,
minUtxoSatoshi: props.minUtxoSatoshi,
onlyConfirmedUtxos: props.onlyConfirmedUtxos ?? true,
skipInputsValidation: true,
});

// The RBF transaction should offer a higher fee amount
if (requireGreaterFeeAndRate && res.fee <= previousFee) {
throw TxBuildError.withComment(
ErrorCodes.INVALID_FEE_RATE,
`RBF should offer a higher fee amount, previous: ${previousFee}, current: ${res.fee}`,
);
}

return res;
}

export async function sendRbf(props: SendRbfProps): Promise<bitcoin.Psbt> {
const { builder } = await createSendRbfBuilder(props);
return builder.toPsbt();
}
Loading

1 comment on commit 26e7070

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New snapshot version of the rgbpp-sdk packages have been released:

Name Version
@rgbpp-sdk/btc 0.0.0-snap-20240614121054

Please sign in to comment.