Skip to content

Commit

Permalink
feat: support pubkeyMap option in all BTC builder APIs to support mul…
Browse files Browse the repository at this point in the history
…ti-origin inputs other than the from address, originally named inputsPubkey
  • Loading branch information
ShookLyngs committed Jun 13, 2024
1 parent f46984d commit 3df991c
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 55 deletions.
15 changes: 15 additions & 0 deletions packages/btc/src/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,18 @@ function getAddressTypeDust(addressType: AddressType) {
return 546;
}
}

/**
* Add address/pubkey pair to a Record<address, pubkey> map
*/
export function addAddressToPubkeyMap(
pubkeyMap: Record<string, string>,
address: string,
pubkey?: string,
): Record<string, string> {
const newMap = { ...pubkeyMap };
if (pubkey) {
newMap[address] = pubkey;
}
return newMap;
}
16 changes: 2 additions & 14 deletions packages/btc/src/api/sendRbf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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 +22,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?: Record<string, string>; // Record<address, pubkey>
}

export async function createSendRbfBuilder(props: SendRbfProps): Promise<{
Expand All @@ -43,18 +42,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 @@ -146,6 +133,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
21 changes: 5 additions & 16 deletions packages/btc/src/api/sendRgbppUtxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface SendRgbppUtxosProps {
excludeUtxos?: BaseOutput[];

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

/**
Expand All @@ -43,8 +43,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 All @@ -60,33 +58,23 @@ export async function createSendRgbppUtxosBuilder(props: SendRgbppUtxosProps): P
const ckbLiveCell = await props.ckbCollector.getLiveCell(ckbInput.previousOutput!);
const isRgbppLock = isRgbppLockCell(ckbLiveCell.output, isCkbMainnet);

// 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 = unpackRgbppLockArgs(ckbLiveCell.output.lock.args);
const utxo = await props.source.getUtxo(args.btcTxid, args.outIndex, props.onlyConfirmedUtxos);
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 @@ -153,6 +141,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 { 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?: Record<string, string>; // Record<address, pubkey>
}

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
61 changes: 60 additions & 1 deletion packages/btc/src/transaction/utxo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import cloneDeep from 'lodash/cloneDeep';
import { ErrorCodes, TxBuildError } from '../error';
import { DataSource } from '../query/source';
import { AddressType } from '../address';
import { TxInput } from './build';
import { remove0x, toXOnly } from '../utils';
import { isP2trScript } from '../script';

export interface BaseOutput {
txid: string;
Expand Down Expand Up @@ -37,7 +40,7 @@ export function utxoToInput(utxo: Utxo): TxInput {
}
if (utxo.addressType === AddressType.P2TR) {
if (!utxo.pubkey) {
throw new TxBuildError(ErrorCodes.MISSING_PUBKEY);
throw TxBuildError.withComment(ErrorCodes.MISSING_PUBKEY, utxo.address);
}
const data = {
hash: utxo.txid,
Expand All @@ -56,3 +59,59 @@ export function utxoToInput(utxo: Utxo): TxInput {

throw new TxBuildError(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE);
}

/**
* Fill pubkey for P2TR UTXO, and optionally throw an error if pubkey is missing
*/
export function fillUtxoPubkey(
utxo: Utxo,
pubkeyMap: Record<string, string>, // Record<address, pubkey>
options?: {
requirePubkey?: boolean;
},
): Utxo {
const newUtxo = cloneDeep(utxo);
if (isP2trScript(newUtxo.scriptPk) && !newUtxo.pubkey) {
const pubkey = pubkeyMap[newUtxo.address];
if (options?.requirePubkey && !pubkey) {
throw TxBuildError.withComment(ErrorCodes.MISSING_PUBKEY, newUtxo.address);
}
if (pubkey) {
newUtxo.pubkey = pubkey;
}
}

return newUtxo;
}

/**
* Prepare and validate UTXOs for transaction building:
* 1. Fill pubkey for P2TR UTXOs, and optionally throw an error if pubkey is missing
* 2. Optionally check if the UTXOs are confirmed, and throw an error if not
*/
export async function prepareUtxoInputs(props: {
utxos: Utxo[];
source: DataSource;
requirePubkey?: boolean;
requireConfirmed?: boolean;
pubkeyMap?: Record<string, string>; // Record<address, pubkey>
}): Promise<Utxo[]> {
const pubkeyMap = props.pubkeyMap ?? {};
const utxos = props.utxos.map((utxo) => {
return fillUtxoPubkey(utxo, pubkeyMap, {
requirePubkey: props.requirePubkey,
});
});

if (props.requireConfirmed) {
for (const utxo of utxos) {
// TODO: refactor with p-limit after rebase upon the develop branch
const confirmed = await props.source.isTransactionConfirmed(utxo.txid);
if (!confirmed) {
throw TxBuildError.withComment(ErrorCodes.UNCONFIRMED_UTXO, `hash: ${utxo.txid}, index: ${utxo.vout}`);
}
}
}

return utxos;
}
105 changes: 105 additions & 0 deletions packages/btc/tests/Transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,111 @@ describe('Transaction', () => {
// console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`);
});

it('Transfer P2TR, pay fee with P2WPKH', async () => {
const p2trUtxos = await source.getUtxos(accounts.charlie.p2tr.address, {
min_satoshi: config.btcUtxoDustLimit,
only_confirmed: true,
});

const psbt = await sendUtxos({
inputs: [p2trUtxos[0]],
outputs: [
{
address: accounts.charlie.p2tr.address,
value: p2trUtxos[0].value,
fixed: true,
},
],
from: accounts.charlie.p2wpkh.address,
feeRate: STATIC_FEE_RATE,
pubkeyMap: {
[accounts.charlie.p2tr.address]: accounts.charlie.publicKey,
},
source,
});

// Sign & finalize inputs
await signAndBroadcastPsbt({
psbt,
account: accounts.charlie,
feeRate: STATIC_FEE_RATE,
send: false,
});
});
it('Transfer P2WPKH, pay fee with P2TR', async () => {
const p2wpkhUtxos = await source.getUtxos(accounts.charlie.p2wpkh.address, {
min_satoshi: config.btcUtxoDustLimit,
only_confirmed: true,
});

const psbt = await sendUtxos({
inputs: [p2wpkhUtxos[0]],
outputs: [
{
address: accounts.charlie.p2wpkh.address,
value: p2wpkhUtxos[0].value,
fixed: true,
},
],
from: accounts.charlie.p2tr.address,
fromPubkey: accounts.charlie.publicKey,
feeRate: STATIC_FEE_RATE,
source,
});

// Sign & finalize inputs
await signAndBroadcastPsbt({
psbt,
account: accounts.charlie,
feeRate: STATIC_FEE_RATE,
send: false,
});
});
it('Try mixed transfer, without pubkeyMap', async () => {
const p2trUtxos = await source.getUtxos(accounts.charlie.p2tr.address, {
min_satoshi: config.btcUtxoDustLimit,
only_confirmed: true,
});

await expect(() =>
sendUtxos({
inputs: [p2trUtxos[0]],
outputs: [
{
address: accounts.charlie.p2tr.address,
value: p2trUtxos[0].value,
fixed: true,
},
],
from: accounts.charlie.p2wpkh.address,
feeRate: STATIC_FEE_RATE,
source,
}),
).rejects.toHaveProperty('code', ErrorCodes.MISSING_PUBKEY);
});
it('Try mixed transfer, pay fee with P2TR without fromPubkey', async () => {
const p2wpkhUtxos = await source.getUtxos(accounts.charlie.p2wpkh.address, {
min_satoshi: config.btcUtxoDustLimit,
only_confirmed: true,
});

await expect(() =>
sendUtxos({
inputs: [p2wpkhUtxos[0]],
outputs: [
{
address: accounts.charlie.p2wpkh.address,
value: p2wpkhUtxos[0].value,
fixed: true,
},
],
from: accounts.charlie.p2tr.address,
feeRate: STATIC_FEE_RATE,
source,
}),
).rejects.toHaveProperty('code', ErrorCodes.MISSING_PUBKEY);
});

it('Try transfer non-existence UTXO', async () => {
await expect(() =>
sendUtxos({
Expand Down
Loading

0 comments on commit 3df991c

Please sign in to comment.