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(btc): support including multi-origin UTXOs #228

Merged
merged 3 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 9 additions & 0 deletions .changeset/orange-mice-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@rgbpp-sdk/btc": minor
---

Support including multi-origin UTXOs in the same transaction

- Add `pubkeyMap` option in the sendUtxos(), sendRgbppUtxos() and sendRbf() API
- Rename `inputsPubkey` option to `pubkeyMap` in the sendRbf() API
- Delete `onlyProvableUtxos` option from the sendRgbppUtxos() API
22 changes: 22 additions & 0 deletions packages/btc/src/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export enum AddressType {
UNKNOWN,
}

/**
* Type: Record<Address, Pubkey>
*
* The map of address and pubkey, usually for recognizing the P2TR inputs in the transaction.
*/
export type AddressToPubkeyMap = Record<string, string>;

/**
* Check weather the address is supported as a from address.
* Currently, only P2WPKH and P2TR addresses are supported.
Expand Down Expand Up @@ -210,3 +217,18 @@ function getAddressTypeDust(addressType: AddressType) {
return 546;
}
}

/**
* Add address/pubkey pair to a Record<address, pubkey> map
*/
export function addAddressToPubkeyMap(
pubkeyMap: AddressToPubkeyMap,
address: string,
pubkey?: string,
): Record<string, string> {
const newMap = { ...pubkeyMap };
if (pubkey) {
newMap[address] = pubkey;
}
return newMap;
}
17 changes: 3 additions & 14 deletions packages/btc/src/api/sendRbf.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { BaseOutput, Utxo } from '../transaction/utxo';
import { DataSource } from '../query/source';
import { AddressToPubkeyMap } from '../address';
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 {
Expand All @@ -23,7 +23,7 @@ export interface SendRbfProps {
requireGreaterFeeAndRate?: boolean;

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

export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
Expand All @@ -43,18 +43,6 @@ export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
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);
}

Expand Down Expand Up @@ -151,6 +139,7 @@ export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
from: props.from,
source: props.source,
feeRate: props.feeRate,
pubkeyMap: props.pubkeyMap,
fromPubkey: props.fromPubkey,
minUtxoSatoshi: props.minUtxoSatoshi,
onlyConfirmedUtxos: props.onlyConfirmedUtxos ?? true,
Expand Down
22 changes: 6 additions & 16 deletions packages/btc/src/api/sendRgbppUtxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Collector, checkCkbTxInputsCapacitySufficient } from '@rgbpp-sdk/ckb';
import { isRgbppLockCell, isBtcTimeLockCell, calculateCommitment } from '@rgbpp-sdk/ckb';
import { bitcoin } from '../bitcoin';
import { BaseOutput, Utxo } from '../transaction/utxo';
import { AddressToPubkeyMap } from '../address';
import { DataSource } from '../query/source';
import { NetworkType } from '../preset/types';
import { ErrorCodes, TxBuildError } from '../error';
Expand Down Expand Up @@ -31,7 +32,7 @@ export interface SendRgbppUtxosProps {
excludeUtxos?: BaseOutput[];

// EXPERIMENTAL: the below props are unstable and can be altered at any time
onlyProvableUtxos?: boolean;
pubkeyMap?: AddressToPubkeyMap;
}

/**
Expand All @@ -45,8 +46,6 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
feeRate: number;
changeIndex: number;
}> {
const onlyProvableUtxos = props.onlyProvableUtxos ?? true;

const btcInputs: Utxo[] = [];
const btcOutputs: InitOutput[] = [];
let lastCkbTypeOutputIndex = -1;
Expand Down Expand Up @@ -86,33 +85,23 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
for (let i = 0; i < ckbVirtualTx.inputs.length; i++) {
const { lockArgs, isRgbppLock } = ckbLiveCells[i];

// If input.lock == RgbppLock, add to inputs if:
// Add to inputs if all the following conditions are met:
// 1. input.lock.args can be unpacked to RgbppLockArgs
// 2. utxo can be found via the DataSource.getUtxo() API
// 3. utxo.scriptPk == addressToScriptPk(props.from)
// 4. utxo is not duplicated in the inputs
// 3. utxo is not duplicated in the inputs
if (isRgbppLock) {
const args = lockArgs!;
const utxo = btcUtxos[i];
if (!utxo) {
throw TxBuildError.withComment(ErrorCodes.CANNOT_FIND_UTXO, `hash: ${args.btcTxid}, index: ${args.outIndex}`);
}
if (onlyProvableUtxos && utxo.address !== props.from) {
throw TxBuildError.withComment(
ErrorCodes.REFERENCED_UNPROVABLE_UTXO,
`hash: ${args.btcTxid}, index: ${args.outIndex}`,
);
}

const foundInInputs = btcInputs.some((v) => v.txid === utxo.txid && v.vout === utxo.vout);
if (foundInInputs) {
continue;
}

btcInputs.push({
...utxo,
pubkey: props.fromPubkey, // For P2TR addresses, a pubkey is required
});
btcInputs.push(utxo);
}
}

Expand Down Expand Up @@ -179,6 +168,7 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
minUtxoSatoshi: props.minUtxoSatoshi,
onlyConfirmedUtxos: props.onlyConfirmedUtxos,
excludeUtxos: props.excludeUtxos,
pubkeyMap: props.pubkeyMap,
});
}

Expand Down
24 changes: 17 additions & 7 deletions packages/btc/src/api/sendUtxos.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { bitcoin } from '../bitcoin';
import { DataSource } from '../query/source';
import { BaseOutput, Utxo } from '../transaction/utxo';
import { TxBuilder, InitOutput } from '../transaction/build';
import { BaseOutput, Utxo, prepareUtxoInputs } from '../transaction/utxo';
import { AddressToPubkeyMap, addAddressToPubkeyMap } from '../address';

export interface SendUtxosProps {
inputs: Utxo[];
Expand All @@ -17,6 +18,7 @@ export interface SendUtxosProps {

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

export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{
Expand All @@ -32,16 +34,24 @@ export async function createSendUtxosBuilder(props: SendUtxosProps): Promise<{
onlyConfirmedUtxos: props.onlyConfirmedUtxos,
});

tx.addInputs(props.inputs);
tx.addOutputs(props.outputs);
// Prepare the UTXO inputs:
// 1. Fill pubkey for each P2TR UTXO, and throw if the corresponding pubkey is not found
// 2. Throw if unconfirmed UTXOs are found (if onlyConfirmedUtxos == true && skipInputsValidation == false)
const pubkeyMap = addAddressToPubkeyMap(props.pubkeyMap ?? {}, props.from, props.fromPubkey);
const inputs = await prepareUtxoInputs({
utxos: props.inputs,
source: props.source,
requireConfirmed: props.onlyConfirmedUtxos && !props.skipInputsValidation,
requirePubkey: true,
pubkeyMap,
});

if (props.onlyConfirmedUtxos && !props.skipInputsValidation) {
await tx.validateInputs();
}
tx.addInputs(inputs);
tx.addOutputs(props.outputs);

const paid = await tx.payFee({
address: props.from,
publicKey: props.fromPubkey,
publicKey: pubkeyMap[props.from],
changeAddress: props.changeAddress,
excludeUtxos: props.excludeUtxos,
});
Expand Down
24 changes: 10 additions & 14 deletions packages/btc/src/transaction/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { bitcoin } from '../bitcoin';
import { DataSource } from '../query/source';
import { ErrorCodes, TxBuildError } from '../error';
import { NetworkType, RgbppBtcConfig } from '../preset/types';
import { AddressType, addressToScriptPublicKeyHex, getAddressType, isSupportedFromAddress } from '../address';
import { isSupportedFromAddress } from '../address';
import { dataToOpReturnScriptPubkey, isOpReturnScriptPubkey } from './embed';
import { networkTypeToConfig } from '../preset/config';
import { BaseOutput, Utxo, utxoToInput } from './utxo';
Expand Down Expand Up @@ -213,8 +213,7 @@ export class TxBuilder {
}

// Calculate network fee
const addressType = getAddressType(address);
currentFee = await this.calculateFee(addressType, currentFeeRate);
currentFee = await this.calculateFee(currentFeeRate);

// If (fee = previousFee ±1), the fee is considered acceptable/expected.
isFeeExpected = [-1, 0, 1].includes(currentFee - previousFee);
Expand Down Expand Up @@ -471,14 +470,14 @@ export class TxBuilder {
});
}

async calculateFee(addressType: AddressType, feeRate?: number): Promise<number> {
async calculateFee(feeRate?: number): Promise<number> {
if (!feeRate && !this.feeRate) {
throw TxBuildError.withComment(ErrorCodes.INVALID_FEE_RATE, `${feeRate ?? this.feeRate}`);
}

const currentFeeRate = feeRate ?? this.feeRate!;

const psbt = await this.createEstimatedPsbt(addressType);
const psbt = await this.createEstimatedPsbt();
const tx = psbt.extractTransaction(true);

const inputs = tx.ins.length;
Expand All @@ -490,20 +489,17 @@ export class TxBuilder {
return Math.ceil(virtualSize * currentFeeRate);
}

async createEstimatedPsbt(addressType: AddressType): Promise<bitcoin.Psbt> {
const estimate = FeeEstimator.fromRandom(addressType, this.networkType);
const estimateScriptPk = addressToScriptPublicKeyHex(estimate.address, this.networkType);
async createEstimatedPsbt(): Promise<bitcoin.Psbt> {
const estimator = FeeEstimator.fromRandom(this.networkType);

const tx = this.clone();
const utxos = tx.inputs.map((input) => input.utxo);
tx.inputs = utxos.map((utxo) => {
utxo.scriptPk = estimateScriptPk;
utxo.pubkey = estimate.publicKey;
return utxoToInput(utxo);
tx.inputs = tx.inputs.map((input) => {
const replacedUtxo = estimator.replaceUtxo(input.utxo);
return utxoToInput(replacedUtxo);
});

const psbt = tx.toPsbt();
await estimate.signPsbt(psbt);
await estimator.signPsbt(psbt);
return psbt;
}

Expand Down
Loading
Loading