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(rgbpp-btc): P2TR basic support #20

Merged
merged 4 commits into from
Mar 13, 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
5 changes: 5 additions & 0 deletions .changeset/smart-items-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rgbpp-sdk/btc": patch
---

Add basic support of P2TR as the from address type in the sendBtc() API
24 changes: 17 additions & 7 deletions packages/btc/src/address.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import bitcoin from './bitcoin';
import { bitcoin } from './bitcoin';
import { AddressType } from './types';
import { NetworkType, toPsbtNetwork } from './network';
import { ErrorCodes, TxBuildError } from './error';
import { removeHexPrefix, toXOnly } from './utils';

/**
* Check weather the address is supported as a from address.
* Currently, only P2WPKH and P2TR addresses are supported.
*/
export function isSupportedFromAddress(address: string) {
const { addressType } = decodeAddress(address);
return addressType === AddressType.P2WPKH || addressType === AddressType.P2TR;
}

/**
* Convert public key to bitcoin payment object.
Expand All @@ -12,23 +22,23 @@ export function publicKeyToPayment(publicKey: string, addressType: AddressType,
}

const network = toPsbtNetwork(networkType);
const pubkey = Buffer.from(publicKey, 'hex');
const pubkey = Buffer.from(removeHexPrefix(publicKey), 'hex');

if (addressType === AddressType.P2PKH) {
return bitcoin.payments.p2pkh({
pubkey,
network,
});
}
if (addressType === AddressType.P2WPKH || addressType === AddressType.M44_P2WPKH) {
if (addressType === AddressType.P2WPKH) {
return bitcoin.payments.p2wpkh({
pubkey,
network,
});
}
if (addressType === AddressType.P2TR || addressType === AddressType.M44_P2TR) {
if (addressType === AddressType.P2TR) {
return bitcoin.payments.p2tr({
internalPubkey: pubkey.slice(1, 33),
internalPubkey: toXOnly(pubkey),
network,
});
}
Expand Down Expand Up @@ -178,9 +188,9 @@ export function decodeAddress(address: string): {
}

function getAddressTypeDust(addressType: AddressType) {
if (addressType === AddressType.P2WPKH || addressType === AddressType.M44_P2WPKH) {
if (addressType === AddressType.P2WPKH) {
return 294;
} else if (addressType === AddressType.P2TR || addressType === AddressType.M44_P2TR) {
} else if (addressType === AddressType.P2TR) {
return 330;
} else {
return 546;
Expand Down
15 changes: 13 additions & 2 deletions packages/btc/src/api/sendBtc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import bitcoin from '../bitcoin';
import { bitcoin } from '../bitcoin';
import { NetworkType } from '../network';
import { DataSource } from '../query/source';
import { TxBuilder, TxTo } from '../transaction/build';
import { isSupportedFromAddress } from '../address';
import { ErrorCodes, TxBuildError } from '../error';

export async function sendBtc(props: {
from: string;
Expand All @@ -10,8 +12,13 @@ export async function sendBtc(props: {
networkType: NetworkType;
minUtxoSatoshi?: number;
changeAddress?: string;
fromPubkey?: string;
feeRate?: number;
}): Promise<bitcoin.Psbt> {
if (!isSupportedFromAddress(props.from)) {
throw new TxBuildError(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE);
}

const tx = new TxBuilder({
source: props.source,
networkType: props.networkType,
Expand All @@ -24,6 +31,10 @@ export async function sendBtc(props: {
tx.addTo(to);
});

await tx.collectInputsAndPayFee(props.from);
await tx.collectInputsAndPayFee({
address: props.from,
pubkey: props.fromPubkey,
});

return tx.toPsbt();
}
11 changes: 10 additions & 1 deletion packages/btc/src/bitcoin.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
export * as default from 'bitcoinjs-lib';
import ECPairFactory from 'ecpair';
import ecc from '@bitcoinerlab/secp256k1';
import * as bitcoin from 'bitcoinjs-lib';
import { isTaprootInput } from 'bitcoinjs-lib/src/psbt/bip371';

bitcoin.initEccLib(ecc);

const ECPair = ECPairFactory(ecc);

export { ecc, ECPair, bitcoin, isTaprootInput };
7 changes: 0 additions & 7 deletions packages/btc/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1 @@
import ecc from '@bitcoinerlab/secp256k1';
import ECPairFactory from 'ecpair';
import bitcoin from './bitcoin';

bitcoin.initEccLib(ecc);
export const ECPair = ECPairFactory(ecc);

export const MIN_COLLECTABLE_SATOSHI = 546;
2 changes: 2 additions & 0 deletions packages/btc/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum ErrorCodes {
UNKNOWN,
MISSING_PUBKEY,
INSUFFICIENT_UTXO,
UNSUPPORTED_OUTPUT,
UNSUPPORTED_ADDRESS_TYPE,
Expand All @@ -12,6 +13,7 @@ export enum ErrorCodes {

export const ErrorMessages = {
[ErrorCodes.UNKNOWN]: 'Unknown error',
[ErrorCodes.MISSING_PUBKEY]: 'Missing a pubkey corresponding to the UTXO',
[ErrorCodes.INSUFFICIENT_UTXO]: 'Insufficient UTXO',
[ErrorCodes.UNSUPPORTED_OUTPUT]: 'Unsupported output format',
[ErrorCodes.UNSUPPORTED_ADDRESS_TYPE]: 'Unsupported address type',
Expand Down
2 changes: 1 addition & 1 deletion packages/btc/src/network.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import bitcoin from './bitcoin';
import { bitcoin } from './bitcoin';

export enum NetworkType {
MAINNET,
Expand Down
52 changes: 26 additions & 26 deletions packages/btc/src/transaction/build.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import clone from 'lodash/clone';
import bitcoin from '../bitcoin';
import { bitcoin } from '../bitcoin';
import { DataSource } from '../query/source';
import { ErrorCodes, TxBuildError } from '../error';
import { AddressType, UnspentOutput } from '../types';
import { NetworkType, toPsbtNetwork } from '../network';
import { MIN_COLLECTABLE_SATOSHI } from '../constants';
import { addressToScriptPublicKeyHex, getAddressType } from '../address';
import { removeHexPrefix } from '../utils';
import { removeHexPrefix, toXOnly } from '../utils';
import { dataToOpReturnScriptPubkey } from './embed';
import { FeeEstimator } from './fee';

Expand Down Expand Up @@ -88,10 +88,13 @@ export class TxBuilder {
throw new TxBuildError(ErrorCodes.UNSUPPORTED_OUTPUT);
}

async collectInputsAndPayFee(address: string, fee?: number, extraChange?: number): Promise<void> {
extraChange = extraChange ?? 0;
fee = fee ?? 0;

async collectInputsAndPayFee(props: {
address: string;
pubkey?: string;
fee?: number;
extraChange?: number;
}): Promise<void> {
const { address, pubkey, fee = 0, extraChange = 0 } = props;
const outputAmount = this.outputs.reduce((acc, out) => acc + out.value, 0);
const targetAmount = outputAmount + fee + extraChange;

Expand All @@ -106,20 +109,15 @@ export class TxBuilder {

const originalInputs = clone(this.inputs);
utxos.forEach((utxo) => {
this.addInput(utxo);
this.addInput({
...utxo,
pubkey,
});
});

console.log('----');
console.log(
`collecting satoshi: ${targetAmount} = outputAmount(${outputAmount}) + fee(${fee}) + + extraChange(${extraChange})`,
);

const originalOutputs = clone(this.outputs);
const changeSatoshi = exceedSatoshi + extraChange;
const requireChangeUtxo = changeSatoshi > 0;
console.log(
`collected satoshi: ${satoshi}, collected utxos: [${this.inputs.map((u) => u.utxo.value)}], returning change: ${changeSatoshi}`,
);
if (requireChangeUtxo) {
this.addOutput({
address: this.changedAddress,
Expand All @@ -129,7 +127,6 @@ export class TxBuilder {

const addressType = getAddressType(address);
const estimatedFee = await this.calculateFee(addressType);
console.log(`expected fee: ${estimatedFee}`);
if (estimatedFee > fee || changeSatoshi < this.minUtxoSatoshi) {
this.inputs = originalInputs;
this.outputs = originalOutputs;
Expand All @@ -144,17 +141,18 @@ export class TxBuilder {
return 0;
})();

console.log(`extra collecting satoshi: ${nextExtraChange}`);
return await this.collectInputsAndPayFee(address, estimatedFee, nextExtraChange);
return await this.collectInputsAndPayFee({
address,
pubkey,
fee: estimatedFee,
extraChange: nextExtraChange,
});
}
}

async calculateFee(addressType: AddressType): Promise<number> {
const psbt = await this.createEstimatedPsbt(addressType);
const vSize = psbt.extractTransaction(true).virtualSize();
console.log(
`calculating vSize, vSize: ${vSize}, feeRate: ${this.feeRate}, rawFee: ${vSize * this.feeRate}, ceilFee: ${Math.ceil(vSize * this.feeRate)}`,
);
return Math.ceil(vSize * this.feeRate);
}

Expand Down Expand Up @@ -190,7 +188,7 @@ export class TxBuilder {
toPsbt(): bitcoin.Psbt {
const network = toPsbtNetwork(this.networkType);
const psbt = new bitcoin.Psbt({ network });
this.inputs.forEach((input, index) => {
this.inputs.forEach((input) => {
psbt.data.addInput(input.data);
});
this.outputs.forEach((output) => {
Expand All @@ -207,7 +205,7 @@ export function utxoToInput(utxo: UnspentOutput): TxInput {
index: utxo.vout,
witnessUtxo: {
value: utxo.value,
script: Buffer.from(utxo.scriptPk, 'hex'),
script: Buffer.from(removeHexPrefix(utxo.scriptPk), 'hex'),
},
};

Expand All @@ -217,15 +215,17 @@ export function utxoToInput(utxo: UnspentOutput): TxInput {
};
}
if (utxo.addressType === AddressType.P2TR) {
if (!utxo.pubkey) {
throw new TxBuildError(ErrorCodes.MISSING_PUBKEY);
}
const data = {
hash: utxo.txid,
index: utxo.vout,
witnessUtxo: {
value: utxo.value,
script: Buffer.from(utxo.scriptPk, 'hex'),
script: Buffer.from(removeHexPrefix(utxo.scriptPk), 'hex'),
},
// TODO: how to obtain pubkey from the utxo directly?
// tapInternalKey: toXOnly(Buffer.from(utxo.pubkey, "hex")),
tapInternalKey: toXOnly(Buffer.from(removeHexPrefix(utxo.pubkey), 'hex')),
};
return {
data,
Expand Down
2 changes: 1 addition & 1 deletion packages/btc/src/transaction/embed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import bitcoin from 'bitcoinjs-lib';
import { bitcoin } from '../bitcoin';
import { ErrorCodes, TxBuildError } from '../error';

/**
Expand Down
21 changes: 14 additions & 7 deletions packages/btc/src/transaction/fee.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import bitcoin from '../bitcoin';
import { bitcoin, ECPair, isTaprootInput } from '../bitcoin';
import { ECPairInterface } from 'ecpair';
import { ECPair } from '../constants';
import { AddressType } from '../types';
import { publicKeyToAddress } from '../address';
import { NetworkType, toPsbtNetwork } from '../network';
import { toXOnly } from '../utils';
import { toXOnly, tweakSigner } from '../utils';

export class FeeEstimator {
public networkType: NetworkType;
Expand Down Expand Up @@ -35,9 +34,9 @@ export class FeeEstimator {
}

async signPsbt(psbt: bitcoin.Psbt): Promise<bitcoin.Psbt> {
psbt.data.inputs.forEach((v, index) => {
psbt.data.inputs.forEach((v) => {
const isNotSigned = !(v.finalScriptSig || v.finalScriptWitness);
const isP2TR = this.addressType === AddressType.P2TR || this.addressType === AddressType.M44_P2TR;
const isP2TR = this.addressType === AddressType.P2TR;
const lostInternalPubkey = !v.tapInternalKey;
// Special measures taken for compatibility with certain applications.
if (isNotSigned && isP2TR && lostInternalPubkey) {
Expand All @@ -52,8 +51,16 @@ export class FeeEstimator {
}
});

// TODO: support multiple from addresses
psbt.signAllInputs(this.keyPair);
psbt.data.inputs.forEach((input, index) => {
if (isTaprootInput(input)) {
const tweakedSigner = tweakSigner(this.keyPair, {
network: this.network,
});
psbt.signInput(index, tweakedSigner);
} else {
psbt.signInput(index, this.keyPair);
}
});

psbt.finalizeAllInputs();
return psbt;
Expand Down
2 changes: 0 additions & 2 deletions packages/btc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ export enum AddressType {
P2WPKH,
P2TR,
P2SH_P2WPKH,
M44_P2WPKH, // deprecated
M44_P2TR, // deprecated
P2WSH,
P2SH,
UNKNOWN,
Expand Down
33 changes: 32 additions & 1 deletion packages/btc/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
import { bitcoin, ecc, ECPair } from './bitcoin';

export function toXOnly(pubKey: Buffer): Buffer {
return pubKey.length === 32 ? pubKey : pubKey.slice(1, 33);
return pubKey.length === 32 ? pubKey : pubKey.subarray(1, 33);
}

function tapTweakHash(publicKey: Buffer, hash: Buffer | undefined): Buffer {
return bitcoin.crypto.taggedHash('TapTweak', Buffer.concat(hash ? [publicKey, hash] : [publicKey]));
}

export function tweakSigner<T extends bitcoin.Signer>(
signer: T,
options?: {
network?: bitcoin.Network;
tweakHash?: Buffer;
},
): bitcoin.Signer {
let privateKey: Uint8Array | undefined = (signer as any).privateKey;
if (!privateKey) {
throw new Error('Private key is required for tweaking signer!');
}
if (signer.publicKey[0] === 3) {
privateKey = ecc.privateNegate(privateKey);
}

const tweakedPrivateKey = ecc.privateAdd(privateKey, tapTweakHash(toXOnly(signer.publicKey), options?.tweakHash));
if (!tweakedPrivateKey) {
throw new Error('Invalid tweaked private key!');
}

return ECPair.fromPrivateKey(Buffer.from(tweakedPrivateKey), {
network: options?.network,
});
}

/**
Expand Down
Loading
Loading