From f78b565bb07a4d748dac6c79dd5a09ccc004b63b Mon Sep 17 00:00:00 2001 From: Shook Date: Sun, 17 Mar 2024 18:06:59 +0800 Subject: [PATCH 01/15] feat!: add sendUtxos() API to support predefined inputs/outputs --- .changeset/fair-impalas-crash.md | 9 + packages/btc/src/address.ts | 4 +- packages/btc/src/api/sendBtc.ts | 21 +- packages/btc/src/api/sendUtxos.ts | 35 ++ packages/btc/src/constants.ts | 13 +- packages/btc/src/index.ts | 1 + packages/btc/src/query/source.ts | 43 +- packages/btc/src/transaction/build.ts | 340 +++++++++++-- packages/btc/src/transaction/embed.ts | 7 +- packages/btc/src/types.ts | 2 +- packages/btc/src/utils.ts | 2 +- packages/btc/tests/Transaction.test.ts | 650 +++++++++++++++++++++---- packages/btc/tests/shared/env.ts | 2 + 13 files changed, 952 insertions(+), 177 deletions(-) create mode 100644 .changeset/fair-impalas-crash.md create mode 100644 packages/btc/src/api/sendUtxos.ts diff --git a/.changeset/fair-impalas-crash.md b/.changeset/fair-impalas-crash.md new file mode 100644 index 00000000..eb89147b --- /dev/null +++ b/.changeset/fair-impalas-crash.md @@ -0,0 +1,9 @@ +--- +"@rgbpp-sdk/btc": patch +--- + +Add sendUtxos() API for building transactions that has wider support for predefined inputs/outputs. +This feature also includes breaking changes, such as: + +- Type/Function/Constant name changes +- Refactor of the fee/satoshi collection process diff --git a/packages/btc/src/address.ts b/packages/btc/src/address.ts index 21a2b7ad..0fa73e31 100644 --- a/packages/btc/src/address.ts +++ b/packages/btc/src/address.ts @@ -2,7 +2,7 @@ import { bitcoin } from './bitcoin'; import { AddressType } from './types'; import { NetworkType, toPsbtNetwork } from './network'; import { ErrorCodes, TxBuildError } from './error'; -import { removeHexPrefix, toXOnly } from './utils'; +import { remove0x, toXOnly } from './utils'; /** * Check weather the address is supported as a from address. @@ -22,7 +22,7 @@ export function publicKeyToPayment(publicKey: string, addressType: AddressType, } const network = toPsbtNetwork(networkType); - const pubkey = Buffer.from(removeHexPrefix(publicKey), 'hex'); + const pubkey = Buffer.from(remove0x(publicKey), 'hex'); if (addressType === AddressType.P2PKH) { return bitcoin.payments.p2pkh({ diff --git a/packages/btc/src/api/sendBtc.ts b/packages/btc/src/api/sendBtc.ts index 549a4a25..7764913f 100644 --- a/packages/btc/src/api/sendBtc.ts +++ b/packages/btc/src/api/sendBtc.ts @@ -1,13 +1,11 @@ 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'; +import { TxBuilder, InitOutput } from '../transaction/build'; export async function sendBtc(props: { from: string; - tos: TxTo[]; + tos: InitOutput[]; source: DataSource; networkType: NetworkType; minUtxoSatoshi?: number; @@ -15,25 +13,24 @@ export async function sendBtc(props: { fromPubkey?: string; feeRate?: number; }): Promise { - if (!isSupportedFromAddress(props.from)) { - throw new TxBuildError(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE); - } - const tx = new TxBuilder({ source: props.source, networkType: props.networkType, - changeAddress: props.changeAddress ?? props.from, minUtxoSatoshi: props.minUtxoSatoshi, feeRate: props.feeRate, }); props.tos.forEach((to) => { - tx.addTo(to); + tx.addOutput({ + fixed: true, + ...to, + }); }); - await tx.collectInputsAndPayFee({ + await tx.payFee({ address: props.from, - pubkey: props.fromPubkey, + publicKey: props.fromPubkey, + changeAddress: props.changeAddress, }); return tx.toPsbt(); diff --git a/packages/btc/src/api/sendUtxos.ts b/packages/btc/src/api/sendUtxos.ts new file mode 100644 index 00000000..021bfbc0 --- /dev/null +++ b/packages/btc/src/api/sendUtxos.ts @@ -0,0 +1,35 @@ +import { bitcoin } from '../bitcoin'; +import { Utxo } from '../types'; +import { NetworkType } from '../network'; +import { DataSource } from '../query/source'; +import { TxBuilder, InitOutput } from '../transaction/build'; + +export async function sendUtxos(props: { + inputs: Utxo[]; + outputs: InitOutput[]; + source: DataSource; + networkType: NetworkType; + from: string; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; +}): Promise { + const tx = new TxBuilder({ + source: props.source, + networkType: props.networkType, + minUtxoSatoshi: props.minUtxoSatoshi, + feeRate: props.feeRate, + }); + + tx.addInputs(props.inputs); + tx.addOutputs(props.outputs); + + await tx.payFee({ + address: props.from, + publicKey: props.fromPubkey, + changeAddress: props.changeAddress, + }); + + return tx.toPsbt(); +} diff --git a/packages/btc/src/constants.ts b/packages/btc/src/constants.ts index e3b1534e..ee381235 100644 --- a/packages/btc/src/constants.ts +++ b/packages/btc/src/constants.ts @@ -1 +1,12 @@ -export const MIN_COLLECTABLE_SATOSHI = 546; +/** + * The minimum satoshi amount that can be declared in a BTC_UTXO. + * BTC_UTXOs with satoshi below this constant are considered dust and will not be collected/created. + * Officially, this constant should be 1,0000, but currently we are using 1,000 for testing purposes. + */ +export const BTC_UTXO_DUST_LIMIT = 1000; + +/** + * The minimum satoshi amount that can be declared in a RGBPP_UTXO. + * RGBPP_UTXOs with satoshi below this constant are considered dust and will not be created. + */ +export const RGBPP_UTXO_DUST_LIMIT = 546; diff --git a/packages/btc/src/index.ts b/packages/btc/src/index.ts index bb0a8a88..a5bbcbe7 100644 --- a/packages/btc/src/index.ts +++ b/packages/btc/src/index.ts @@ -15,3 +15,4 @@ export * from './transaction/embed'; export * from './transaction/fee'; export * from './api/sendBtc'; +export * from './api/sendUtxos'; diff --git a/packages/btc/src/query/source.ts b/packages/btc/src/query/source.ts index d8edb663..c5183f80 100644 --- a/packages/btc/src/query/source.ts +++ b/packages/btc/src/query/source.ts @@ -1,20 +1,20 @@ +import { Utxo } from '../types'; import { NetworkType } from '../network'; -import { UnspentOutput } from '../types'; import { ErrorCodes, TxBuildError } from '../error'; import { addressToScriptPublicKeyHex, getAddressType } from '../address'; import { BtcAssetsApi, BtcAssetsApiUtxoParams } from './service'; export class DataSource { - public source: BtcAssetsApi; + public service: BtcAssetsApi; public networkType: NetworkType; - constructor(source: BtcAssetsApi, networkType: NetworkType) { - this.source = source; + constructor(service: BtcAssetsApi, networkType: NetworkType) { + this.service = service; this.networkType = networkType; } - async getUtxos(address: string, params?: BtcAssetsApiUtxoParams): Promise { - const utxos = await this.source.getUtxos(address, params); + async getUtxos(address: string, params?: BtcAssetsApiUtxoParams): Promise { + const utxos = await this.service.getUtxos(address, params); const scriptPk = addressToScriptPublicKeyHex(address, this.networkType); return utxos @@ -23,7 +23,7 @@ export class DataSource { const bOrder = `${b.status.block_height}${b.vout}`; return Number(aOrder) - Number(bOrder); }) - .map((row): UnspentOutput => { + .map((row): Utxo => { return { address, scriptPk, @@ -35,17 +35,22 @@ export class DataSource { }); } - async collectSatoshi( - address: string, - targetAmount: number, - minSatoshi?: number, - ): Promise<{ - utxos: UnspentOutput[]; + async collectSatoshi(props: { + address: string; + targetAmount: number; + minUtxoSatoshi?: number; + excludeUtxos?: { + txid: string; + vout: number; + }[]; + }): Promise<{ + utxos: Utxo[]; satoshi: number; exceedSatoshi: number; }> { + const { address, targetAmount, minUtxoSatoshi, excludeUtxos = [] } = props; const utxos = await this.getUtxos(address, { - min_satoshi: minSatoshi, + min_satoshi: minUtxoSatoshi, }); const collected = []; @@ -54,9 +59,17 @@ export class DataSource { if (collectedAmount >= targetAmount) { break; } - if (minSatoshi !== void 0 && utxo.value < minSatoshi) { + if (minUtxoSatoshi !== void 0 && utxo.value < minUtxoSatoshi) { continue; } + if (excludeUtxos.length > 0) { + const excluded = excludeUtxos.find((exclude) => { + return exclude.txid === utxo.txid && exclude.vout === utxo.vout; + }); + if (excluded) { + continue; + } + } collected.push(utxo); collectedAmount += utxo.value; } diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts index 0a54aec1..138ee94e 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -1,13 +1,13 @@ -import clone from 'lodash/clone'; +import clone from 'lodash/cloneDeep'; import { bitcoin } from '../bitcoin'; import { DataSource } from '../query/source'; +import { AddressType, Utxo } from '../types'; 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, toXOnly } from '../utils'; +import { addressToScriptPublicKeyHex, getAddressType, isSupportedFromAddress } from '../address'; import { dataToOpReturnScriptPubkey } from './embed'; +import { BTC_UTXO_DUST_LIMIT } from '../constants'; +import { remove0x, toXOnly } from '../utils'; import { FeeEstimator } from './fee'; interface TxInput { @@ -17,23 +17,26 @@ interface TxInput { witnessUtxo: { value: number; script: Buffer }; tapInternalKey?: Buffer; }; - utxo: UnspentOutput; + utxo: Utxo; } export type TxOutput = TxAddressOutput | TxScriptOutput; -export interface TxAddressOutput { - address: string; +export type InitOutput = TxAddressOutput | TxDataOutput | TxScriptOutput; + +export interface BaseOutput { value: number; + fixed?: boolean; + protected?: boolean; + minUtxoSatoshi?: number; } -export interface TxScriptOutput { - script: Buffer; - value: number; +export interface TxAddressOutput extends BaseOutput { + address: string; } - -export type TxTo = TxAddressOutput | TxDataOutput; -export interface TxDataOutput { +export interface TxDataOutput extends BaseOutput { data: Buffer | string; - value: number; +} +export interface TxScriptOutput extends BaseOutput { + script: Buffer; } export class TxBuilder { @@ -42,50 +45,267 @@ export class TxBuilder { source: DataSource; networkType: NetworkType; - changedAddress: string; minUtxoSatoshi: number; feeRate: number; - constructor(props: { - source: DataSource; - networkType: NetworkType; - changeAddress: string; - minUtxoSatoshi?: number; - feeRate?: number; - }) { + constructor(props: { source: DataSource; networkType: NetworkType; minUtxoSatoshi?: number; feeRate?: number }) { this.source = props.source; + this.networkType = props.networkType; this.feeRate = props.feeRate ?? 1; - this.networkType = props.networkType; - this.changedAddress = props.changeAddress; - this.minUtxoSatoshi = props.minUtxoSatoshi ?? MIN_COLLECTABLE_SATOSHI; + this.minUtxoSatoshi = props.minUtxoSatoshi ?? BTC_UTXO_DUST_LIMIT; } - addInput(utxo: UnspentOutput) { + addInput(utxo: Utxo) { utxo = clone(utxo); this.inputs.push(utxoToInput(utxo)); } - addOutput(output: TxOutput) { - output = clone(output); - this.outputs.push(output); + addInputs(utxos: Utxo[]) { + utxos.forEach((utxo) => { + this.addInput(utxo); + }); } - addTo(to: TxTo) { - if ('data' in to) { - const data = typeof to.data === 'string' ? Buffer.from(removeHexPrefix(to.data), 'hex') : to.data; - const scriptPubkey = dataToOpReturnScriptPubkey(data); + addOutput(output: InitOutput) { + let result: TxOutput | undefined; + + if ('data' in output) { + result = { + script: dataToOpReturnScriptPubkey(clone(output.data)), + value: output.value, + fixed: output.fixed, + protected: output.protected, + minUtxoSatoshi: output.minUtxoSatoshi, + }; + } + if ('address' in output || 'script' in output) { + result = clone(output); + } + if (!result) { + throw new TxBuildError(ErrorCodes.UNSUPPORTED_OUTPUT); + } + + this.outputs.push(result); + } + + addOutputs(outputs: InitOutput[]) { + outputs.forEach((output) => { + this.addOutput(output); + }); + } + + getLastFixedOutputIndex(): number { + return this.outputs.reduce((acc, output, index) => { + if (output.fixed) { + return index; + } + return acc; + }, -1); + } + + getLastProtectedOutputIndex(): number { + return this.outputs.reduce((acc, output, index) => { + if (output.protected) { + return index; + } + return acc; + }, -1); + } + + async payFee(props: { address: string; publicKey?: string; changeAddress?: string; deductFromOutputs?: boolean }) { + const { address, publicKey, changeAddress, deductFromOutputs } = props; + const originalInputs = clone(this.inputs); + const originalOutputs = clone(this.outputs); + + let previousFee: number = 0; + while (true) { + const { inputsNeeding, outputsNeeding } = this.summary(); + if (outputsNeeding > 0) { + // If sum(inputs) > sum(outputs), return change while deducting fee + // Note, should not deduct fee from outputs while also returning change at the same time + const returnAmount = outputsNeeding - previousFee; + await this.injectChange({ + address: changeAddress ?? address, + amount: returnAmount, + publicKey, + }); + } else { + const targetAmount = inputsNeeding + previousFee; + await this.injectSatoshi({ + address, + publicKey, + targetAmount, + changeAddress, + deductFromOutputs, + }); + } + + const addressType = getAddressType(address); + const fee = await this.calculateFee(addressType); + if (fee <= previousFee) { + break; + } + + previousFee = fee; + this.inputs = clone(originalInputs); + this.outputs = clone(originalOutputs); + } + } + + async injectSatoshi(props: { + address: string; + publicKey?: string; + targetAmount: number; + changeAddress?: string; + injectCollected?: boolean; + deductFromOutputs?: boolean; + }) { + if (!isSupportedFromAddress(props.address)) { + throw new TxBuildError(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE); + } + + const injectCollected = props.injectCollected ?? false; + const deductFromOutputs = props.deductFromOutputs ?? true; + + let collected = 0; + let changeAmount = 0; + let targetAmount = props.targetAmount; + + const _collect = async (_targetAmount: number, stack?: boolean) => { + if (stack) { + targetAmount += _targetAmount; + } + + const { utxos, satoshi } = await this.source.collectSatoshi({ + address: props.address, + targetAmount: _targetAmount, + minUtxoSatoshi: this.minUtxoSatoshi, + excludeUtxos: this.inputs.map((row) => row.utxo), + }); + utxos.forEach((utxo) => { + this.addInput({ + ...utxo, + pubkey: props.publicKey, + }); + }); + + collected += satoshi; + _updateChangeAmount(); + }; + const _updateChangeAmount = () => { + if (injectCollected) { + changeAmount = collected + targetAmount; + } else { + changeAmount = collected - targetAmount; + } + }; + + // Collect from outputs + if (deductFromOutputs) { + for (let i = 0; i < this.outputs.length; i++) { + const output = this.outputs[i]; + if (output.fixed) { + continue; + } + if (collected >= targetAmount) { + break; + } + + // If output.protected is true, do not destroy the output + // Only collect the satoshi from (output.value - minUtxoSatoshi) + const minUtxoSatoshi = output.minUtxoSatoshi ?? this.minUtxoSatoshi; + const freeAmount = output.value - minUtxoSatoshi; + const remain = targetAmount - collected; + if (output.protected) { + // freeAmount=100, remain=50, collectAmount=50 + // freeAmount=100, remain=150, collectAmount=100 + const collectAmount = Math.min(freeAmount, remain); + output.value -= collectAmount; + collected += collectAmount; + } else { + // output.value=200, freeAmount=100, remain=50, collectAmount=50 + // output.value=200, freeAmount=100, remain=150, collectAmount=100 + // output.value=100, freeAmount=0, remain=150, collectAmount=100 + const collectAmount = output.value > remain ? Math.min(freeAmount, remain) : output.value; + output.value -= collectAmount; + collected += collectAmount; + + if (output.value === 0) { + this.outputs.splice(i, 1); + i--; + } + } + } + } + + // Collect target amount of satoshi from DataSource + if (collected < targetAmount) { + await _collect(targetAmount - collected); + } + + // If 0 < change amount < minUtxoSatoshi, collect one more time + if (changeAmount > 0 && changeAmount < this.minUtxoSatoshi) { + await _collect(this.minUtxoSatoshi - changeAmount, true); + } - return this.addOutput({ - script: scriptPubkey, - value: to.value, + // If not collected enough satoshi, revert to the original state and throw error + const insufficientBalance = collected < targetAmount; + const insufficientForChange = changeAmount > 0 && changeAmount < this.minUtxoSatoshi; + if (insufficientBalance || insufficientForChange) { + throw new TxBuildError(ErrorCodes.INSUFFICIENT_UTXO); + } + + // Return change + let changeIndex: number = -1; + if (changeAmount > 0) { + changeIndex = this.outputs.length; + this.addOutput({ + address: props.changeAddress ?? props.address, + value: changeAmount, }); } - if ('address' in to) { - return this.addOutput(to); + + return { + collected, + changeIndex, + changeAmount, + }; + } + + async injectChange(props: { amount: number; address: string; publicKey?: string }) { + const { address, publicKey, amount } = props; + + for (let i = 0; i < this.outputs.length; i++) { + const output = this.outputs[i]; + if (output.fixed) { + continue; + } + if (!('address' in output) || output.address !== address) { + continue; + } + + output.value += amount; + return; } - throw new TxBuildError(ErrorCodes.UNSUPPORTED_OUTPUT); + if (amount < this.minUtxoSatoshi) { + const { collected } = await this.injectSatoshi({ + address, + publicKey, + targetAmount: amount, + injectCollected: true, + deductFromOutputs: false, + }); + if (collected < amount) { + throw new TxBuildError(ErrorCodes.INSUFFICIENT_UTXO); + } + } else { + this.addOutput({ + address: address, + value: amount, + }); + } } async collectInputsAndPayFee(props: { @@ -93,16 +313,18 @@ export class TxBuilder { pubkey?: string; fee?: number; extraChange?: number; + changeAddress?: string; }): Promise { const { address, pubkey, fee = 0, extraChange = 0 } = props; const outputAmount = this.outputs.reduce((acc, out) => acc + out.value, 0); const targetAmount = outputAmount + fee + extraChange; + const changeAddress = props.changeAddress ?? address; - const { utxos, satoshi, exceedSatoshi } = await this.source.collectSatoshi( + const { utxos, satoshi, exceedSatoshi } = await this.source.collectSatoshi({ address, - targetAmount, - this.minUtxoSatoshi, - ); + targetAmount: targetAmount, + minUtxoSatoshi: this.minUtxoSatoshi, + }); if (satoshi < targetAmount) { throw new TxBuildError(ErrorCodes.INSUFFICIENT_UTXO); } @@ -120,7 +342,7 @@ export class TxBuilder { const requireChangeUtxo = changeSatoshi > 0; if (requireChangeUtxo) { this.addOutput({ - address: this.changedAddress, + address: changeAddress, value: changeSatoshi, }); } @@ -173,15 +395,31 @@ export class TxBuilder { return psbt; } + summary() { + const sumOfInputs = this.inputs.reduce((acc, input) => acc + input.utxo.value, 0); + const sumOfOutputs = this.outputs.reduce((acc, output) => acc + output.value, 0); + + return { + inputsTotal: sumOfInputs, + inputsRemaining: sumOfInputs - sumOfOutputs, + inputsNeeding: sumOfOutputs > sumOfInputs ? sumOfOutputs - sumOfInputs : 0, + outputsTotal: sumOfOutputs, + outputsRemaining: sumOfOutputs - sumOfInputs, + outputsNeeding: sumOfInputs > sumOfOutputs ? sumOfInputs - sumOfOutputs : 0, + }; + } + clone(): TxBuilder { const tx = new TxBuilder({ source: this.source, - networkType: this.networkType, - changeAddress: this.changedAddress, feeRate: this.feeRate, + networkType: this.networkType, + minUtxoSatoshi: this.minUtxoSatoshi, }); + tx.inputs = clone(this.inputs); tx.outputs = clone(this.outputs); + return tx; } @@ -198,14 +436,14 @@ export class TxBuilder { } } -export function utxoToInput(utxo: UnspentOutput): TxInput { +export function utxoToInput(utxo: Utxo): TxInput { if (utxo.addressType === AddressType.P2WPKH) { const data = { hash: utxo.txid, index: utxo.vout, witnessUtxo: { value: utxo.value, - script: Buffer.from(removeHexPrefix(utxo.scriptPk), 'hex'), + script: Buffer.from(remove0x(utxo.scriptPk), 'hex'), }, }; @@ -223,9 +461,9 @@ export function utxoToInput(utxo: UnspentOutput): TxInput { index: utxo.vout, witnessUtxo: { value: utxo.value, - script: Buffer.from(removeHexPrefix(utxo.scriptPk), 'hex'), + script: Buffer.from(remove0x(utxo.scriptPk), 'hex'), }, - tapInternalKey: toXOnly(Buffer.from(removeHexPrefix(utxo.pubkey), 'hex')), + tapInternalKey: toXOnly(Buffer.from(remove0x(utxo.pubkey), 'hex')), }; return { data, diff --git a/packages/btc/src/transaction/embed.ts b/packages/btc/src/transaction/embed.ts index dc3770d1..2be65019 100644 --- a/packages/btc/src/transaction/embed.ts +++ b/packages/btc/src/transaction/embed.ts @@ -1,3 +1,4 @@ +import { remove0x } from '../utils'; import { bitcoin } from '../bitcoin'; import { ErrorCodes, TxBuildError } from '../error'; @@ -10,7 +11,11 @@ import { ErrorCodes, TxBuildError } from '../error'; * const scriptPk = dataToOpReturnScriptPubkey(data); // * const scriptPkHex = scriptPk.toString('hex'); // 6a0401020304 */ -export function dataToOpReturnScriptPubkey(data: Buffer): Buffer { +export function dataToOpReturnScriptPubkey(data: Buffer | string): Buffer { + if (typeof data === 'string') { + data = Buffer.from(remove0x(data), 'hex'); + } + const payment = bitcoin.payments.embed({ data: [data] }); return payment.output!; } diff --git a/packages/btc/src/types.ts b/packages/btc/src/types.ts index a3751d5b..bfdf293c 100644 --- a/packages/btc/src/types.ts +++ b/packages/btc/src/types.ts @@ -1,4 +1,4 @@ -export interface UnspentOutput { +export interface Utxo { txid: string; vout: number; value: number; diff --git a/packages/btc/src/utils.ts b/packages/btc/src/utils.ts index 21622131..efde07b0 100644 --- a/packages/btc/src/utils.ts +++ b/packages/btc/src/utils.ts @@ -47,6 +47,6 @@ export function isDomain(domain: string): boolean { /** * Remove '0x' prefix from a hex string. */ -export function removeHexPrefix(hex: string): string { +export function remove0x(hex: string): string { return hex.startsWith('0x') ? hex.slice(2) : hex; } diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index a94bf39d..61c9ff61 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -1,134 +1,598 @@ import { describe, expect, it } from 'vitest'; import { accounts, network, networkType, service, source } from './shared/env'; -import { bitcoin, ErrorCodes, ErrorMessages, MIN_COLLECTABLE_SATOSHI, sendBtc, toXOnly, tweakSigner } from '../src'; +import { ErrorCodes, AddressType, sendBtc, sendUtxos, tweakSigner } from '../src'; +import { bitcoin, ErrorMessages, BTC_UTXO_DUST_LIMIT, RGBPP_UTXO_DUST_LIMIT } from '../src'; describe('Transaction', () => { - describe('Transfer from Native SegWit (P2WPKH) address', () => { - const addresses = [ - { type: 'Taproot (P2TR)', address: accounts.charlie.p2tr.address }, - { type: 'Native SegWit (P2WPKH)', address: accounts.charlie.p2wpkh.address }, - { type: 'Nested SegWit (P2SH)', address: '2N4gkVAQ1f6bi8BKon8MLKEV1pi85MJWcPV' }, - { type: 'Legacy (P2PKH)', address: 'mqkAgjy8gfrMZh1VqV5Wm1Yi4G9KWLXA1Q' }, - ]; - addresses.forEach((addressInfo, index) => { - it(`Transfer to ${addressInfo.type} address`, async () => { - if (index !== 0) { - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - - const psbt = await sendBtc({ + describe('BTC transfer', () => { + describe('Transfer from Native SegWit (P2WPKH) address', () => { + const addresses = [ + { type: 'Taproot (P2TR)', address: accounts.charlie.p2tr.address }, + { type: 'Native SegWit (P2WPKH)', address: accounts.charlie.p2wpkh.address }, + { type: 'Nested SegWit (P2SH)', address: '2N4gkVAQ1f6bi8BKon8MLKEV1pi85MJWcPV' }, + { type: 'Legacy (P2PKH)', address: 'mqkAgjy8gfrMZh1VqV5Wm1Yi4G9KWLXA1Q' }, + ]; + addresses.forEach((addressInfo, index) => { + it(`Transfer to ${addressInfo.type} address`, async () => { + if (index !== 0) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + address: addressInfo.address, + value: 1000, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }, 10000); + }); + }); + it('Transfer from Taproot (P2TR) address', async () => { + const psbt = await sendBtc({ + from: accounts.charlie.p2tr.address, + fromPubkey: accounts.charlie.publicKey, + tos: [ + { + address: accounts.charlie.p2tr.address, + value: 1000, + }, + ], + networkType, + source, + }); + + // Create a tweaked signer + const tweakedSigner = tweakSigner(accounts.charlie.keyPair, { + network, + }); + + // Sign & finalize inputs + psbt.signAllInputs(tweakedSigner); + psbt.finalizeAllInputs(); + + console.log('fee', psbt.getFee()); + console.log(psbt.txInputs); + console.log(psbt.txOutputs); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + + it('Transfer with an impossible "minUtxoSatoshi" filter', async () => { + const balance = await service.getBalance(accounts.charlie.p2wpkh.address, { + min_satoshi: BTC_UTXO_DUST_LIMIT, + }); + + await expect(() => + sendBtc({ from: accounts.charlie.p2wpkh.address, tos: [ { - address: addressInfo.address, + address: accounts.charlie.p2wpkh.address, value: 1000, }, ], + minUtxoSatoshi: balance.satoshi + 1, networkType, source, - }); + }), + ).rejects.toThrow(ErrorMessages[ErrorCodes.INSUFFICIENT_UTXO]); + }); + it('Transfer with an extra OP_RETURN output', async () => { + const psbt = await sendBtc({ + from: accounts.charlie.p2wpkh.address, + tos: [ + { + data: Buffer.from('00'.repeat(32), 'hex'), + value: 0, + }, + { + address: accounts.charlie.p2wpkh.address, + value: 1000, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + const outputs = psbt.txOutputs; + expect(outputs).toHaveLength(3); + + const opReturnOutput = outputs[0]; + expect(opReturnOutput).toBeDefined(); + expect(opReturnOutput.script).toBeDefined(); - // Sign & finalize inputs - psbt.signAllInputs(accounts.charlie.keyPair); - psbt.finalizeAllInputs(); + const scripts = bitcoin.script.decompile(opReturnOutput.script); + expect(scripts).toBeDefined(); - // Broadcast transaction - // const tx = psbt.extractTransaction(); - // const res = await service.sendTransaction(tx.toHex()); - // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); - }, 10000); + const op = scripts![0]; + expect(op).toBeTypeOf('number'); + expect(op).toBe(bitcoin.opcodes.OP_RETURN); + + const data = scripts![1]; + expect(data).toBeInstanceOf(Buffer); + expect((data as Buffer).toString('hex')).toEqual('00'.repeat(32)); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); }); - it('Transfer from Taproot P2TR address', async () => { - const psbt = await sendBtc({ - from: accounts.charlie.p2tr.address, - fromPubkey: accounts.charlie.publicKey, - tos: [ - { - address: accounts.charlie.p2tr.address, - value: 1000, - }, - ], - networkType, - source, + + describe('UTXO transfer', () => { + it('Transfer fixed UTXO, sum(ins) = sum(outs)', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 1000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 1000, + fixed: true, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txOutputs).toHaveLength(2); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBeGreaterThanOrEqual(208); + expect(fee).toBeLessThanOrEqual(209); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); + it('Transfer fixed UTXO, sum(ins) < sum(outs)', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 1000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 2000, + fixed: true, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txOutputs).toHaveLength(2); - // Create a tweaked signer - const tweakedSigner = tweakSigner(accounts.charlie.keyPair, { - network, + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBeGreaterThanOrEqual(208); + expect(fee).toBeLessThanOrEqual(209); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); + it('Transfer fixed UTXO, sum(ins) > sum(outs)', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 2000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 1500, + fixed: true, + }, + ], + networkType, + source, + }); - // Sign & finalize inputs - psbt.signAllInputs(tweakedSigner); - psbt.finalizeAllInputs(); + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); - // Broadcast transaction - // const tx = psbt.extractTransaction(); - // const res = await service.sendTransaction(tx.toHex()); - // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); - }); - it('Transfer with an impossible "minUtxoSatoshi" filter', async () => { - const balance = await service.getBalance(accounts.charlie.p2wpkh.address, { - min_satoshi: MIN_COLLECTABLE_SATOSHI, + expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txOutputs).toHaveLength(2); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBeGreaterThanOrEqual(208); + expect(fee).toBeLessThanOrEqual(209); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); + it('Transfer fixed UTXO, and the fee is prepaid', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 3000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 1856, + fixed: true, + }, + { + address: accounts.charlie.p2wpkh.address, + value: 1000, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); - await expect(() => - sendBtc({ + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(2); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBe(141); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + + it('Transfer protected UTXO, sum(ins) = sum(outs)', async () => { + const psbt = await sendUtxos({ from: accounts.charlie.p2wpkh.address, - tos: [ + inputs: [ { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 2000, + addressType: AddressType.P2WPKH, address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 2000, + protected: true, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(1); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBe(110); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + it('Transfer protected UTXO, sum(ins) < sum(outs)', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, value: 1000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 2000, + protected: true, }, ], - minUtxoSatoshi: balance.satoshi + 1, networkType, source, - }), - ).rejects.toThrow(ErrorMessages[ErrorCodes.INSUFFICIENT_UTXO]); - }); - it('Transfer with an extra OP_RETURN output', async () => { - const psbt = await sendBtc({ - from: accounts.charlie.p2wpkh.address, - tos: [ - { - data: Buffer.from('00'.repeat(32), 'hex'), - value: 0, - }, - { - address: accounts.charlie.p2wpkh.address, - value: 1000, - }, - ], - networkType, - source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txOutputs).toHaveLength(2); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBeGreaterThanOrEqual(208); + expect(fee).toBeLessThanOrEqual(209); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + it('Transfer protected UTXO, sum(ins) > sum(outs)', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: 2000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + value: 1500, + protected: true, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(1); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBe(110); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + + it('Transfer protected RGBPP_UTXOs, pay with collection', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: RGBPP_UTXO_DUST_LIMIT, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 2, + value: RGBPP_UTXO_DUST_LIMIT, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT, + protected: true, + }, + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT, + protected: true, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs).toHaveLength(3); + expect(psbt.txOutputs).toHaveLength(3); + + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBeGreaterThanOrEqual(307); + expect(fee).toBeLessThanOrEqual(308); + + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); + it('Transfer protected RGBPP_UTXOs, each with free satoshi', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: (RGBPP_UTXO_DUST_LIMIT + 100) * 3, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT + 100, + protected: true, + }, + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT + 100, + protected: true, + }, + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT + 100, + protected: true, + }, + ], + networkType, + source, + }); - const outputs = psbt.txOutputs; - expect(outputs).toHaveLength(3); + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); - const opReturnOutput = outputs[0]; - expect(opReturnOutput).toBeDefined(); - expect(opReturnOutput.script).toBeDefined(); + console.log(psbt.txOutputs); + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(3); + expect(psbt.txOutputs[0].value).toBe(RGBPP_UTXO_DUST_LIMIT); + expect(psbt.txOutputs[1].value).toBe(RGBPP_UTXO_DUST_LIMIT + 28); + expect(psbt.txOutputs[2].value).toBe(RGBPP_UTXO_DUST_LIMIT + 100); - const scripts = bitcoin.script.decompile(opReturnOutput.script); - expect(scripts).toBeDefined(); + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBe(172); - const op = scripts![0]; - expect(op).toBeTypeOf('number'); - expect(op).toBe(bitcoin.opcodes.OP_RETURN); + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + it('Transfer protected RGBPP_UTXOs, with insufficient free satoshi', async () => { + const psbt = await sendUtxos({ + from: accounts.charlie.p2wpkh.address, + inputs: [ + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 1, + value: RGBPP_UTXO_DUST_LIMIT * 2 + 100, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, + ], + outputs: [ + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT + 100, + protected: true, + }, + { + address: accounts.charlie.p2wpkh.address, + minUtxoSatoshi: RGBPP_UTXO_DUST_LIMIT, + value: RGBPP_UTXO_DUST_LIMIT, + protected: true, + }, + ], + networkType, + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); - const data = scripts![1]; - expect(data).toBeInstanceOf(Buffer); - expect((data as Buffer).toString('hex')).toEqual('00'.repeat(32)); + console.log(psbt.txOutputs); + expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txOutputs).toHaveLength(3); + expect(psbt.txOutputs[0].value).toBe(RGBPP_UTXO_DUST_LIMIT); + expect(psbt.txOutputs[1].value).toBe(RGBPP_UTXO_DUST_LIMIT); - // Sign & finalize inputs - psbt.signAllInputs(accounts.charlie.keyPair); - psbt.finalizeAllInputs(); + const fee = psbt.getFee(); + console.log('fee:', fee); + expect(fee).toBe(240); - // Broadcast transaction - // const tx = psbt.extractTransaction(); - // const res = await service.sendTransaction(tx.toHex()); - // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + // Broadcast transaction + // const tx = psbt.extractTransaction(); + // const res = await service.sendTransaction(tx.toHex()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); }); }); diff --git a/packages/btc/tests/shared/env.ts b/packages/btc/tests/shared/env.ts index 8e6adbb6..201e0206 100644 --- a/packages/btc/tests/shared/env.ts +++ b/packages/btc/tests/shared/env.ts @@ -31,11 +31,13 @@ function createAccount(privateKey: string, _network?: bitcoin.Network) { privateKey, publicKey: keyPair.publicKey.toString('hex'), p2wpkh: { + scriptPubkey: p2wpkh.output!, address: p2wpkh.address!, pubkey: p2wpkh.pubkey!, data: p2wpkh.data!, }, p2tr: { + scriptPubkey: p2tr.output!, address: p2tr.address!, pubkey: p2tr.pubkey!, data: p2tr.data!, From b0929c456d1b0a9d76addef12ab809971f29992e Mon Sep 17 00:00:00 2001 From: Shook Date: Sun, 17 Mar 2024 18:31:22 +0800 Subject: [PATCH 02/15] refactor: remove TxBuilder.collectInputsAndPayFee method --- .changeset/new-phones-lick.md | 5 ++ packages/btc/src/transaction/build.ts | 82 --------------------------- 2 files changed, 5 insertions(+), 82 deletions(-) create mode 100644 .changeset/new-phones-lick.md diff --git a/.changeset/new-phones-lick.md b/.changeset/new-phones-lick.md new file mode 100644 index 00000000..d52e6994 --- /dev/null +++ b/.changeset/new-phones-lick.md @@ -0,0 +1,5 @@ +--- +"@rgbpp-sdk/btc": patch +--- + +Remove the "TxBuilder.collectInputsAndPayFee" API as it is deprecated and no longer in use diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts index 138ee94e..e750d32b 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -95,24 +95,6 @@ export class TxBuilder { }); } - getLastFixedOutputIndex(): number { - return this.outputs.reduce((acc, output, index) => { - if (output.fixed) { - return index; - } - return acc; - }, -1); - } - - getLastProtectedOutputIndex(): number { - return this.outputs.reduce((acc, output, index) => { - if (output.protected) { - return index; - } - return acc; - }, -1); - } - async payFee(props: { address: string; publicKey?: string; changeAddress?: string; deductFromOutputs?: boolean }) { const { address, publicKey, changeAddress, deductFromOutputs } = props; const originalInputs = clone(this.inputs); @@ -308,70 +290,6 @@ export class TxBuilder { } } - async collectInputsAndPayFee(props: { - address: string; - pubkey?: string; - fee?: number; - extraChange?: number; - changeAddress?: string; - }): Promise { - const { address, pubkey, fee = 0, extraChange = 0 } = props; - const outputAmount = this.outputs.reduce((acc, out) => acc + out.value, 0); - const targetAmount = outputAmount + fee + extraChange; - const changeAddress = props.changeAddress ?? address; - - const { utxos, satoshi, exceedSatoshi } = await this.source.collectSatoshi({ - address, - targetAmount: targetAmount, - minUtxoSatoshi: this.minUtxoSatoshi, - }); - if (satoshi < targetAmount) { - throw new TxBuildError(ErrorCodes.INSUFFICIENT_UTXO); - } - - const originalInputs = clone(this.inputs); - utxos.forEach((utxo) => { - this.addInput({ - ...utxo, - pubkey, - }); - }); - - const originalOutputs = clone(this.outputs); - const changeSatoshi = exceedSatoshi + extraChange; - const requireChangeUtxo = changeSatoshi > 0; - if (requireChangeUtxo) { - this.addOutput({ - address: changeAddress, - value: changeSatoshi, - }); - } - - const addressType = getAddressType(address); - const estimatedFee = await this.calculateFee(addressType); - if (estimatedFee > fee || changeSatoshi < this.minUtxoSatoshi) { - this.inputs = originalInputs; - this.outputs = originalOutputs; - - const nextExtraChange = (() => { - if (requireChangeUtxo) { - if (changeSatoshi < this.minUtxoSatoshi) { - return this.minUtxoSatoshi; - } - return extraChange; - } - return 0; - })(); - - return await this.collectInputsAndPayFee({ - address, - pubkey, - fee: estimatedFee, - extraChange: nextExtraChange, - }); - } - } - async calculateFee(addressType: AddressType): Promise { const psbt = await this.createEstimatedPsbt(addressType); const vSize = psbt.extractTransaction(true).virtualSize(); From 77e344f4e9ae279e781dd509163fb754840ec214 Mon Sep 17 00:00:00 2001 From: Shook Date: Sun, 17 Mar 2024 19:31:53 +0800 Subject: [PATCH 03/15] docs: sync recent updates to rgbpp-sdk/btc readme --- packages/btc/README.md | 231 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 206 insertions(+), 25 deletions(-) diff --git a/packages/btc/README.md b/packages/btc/README.md index 58b5d0fb..2028c5c5 100644 --- a/packages/btc/README.md +++ b/packages/btc/README.md @@ -1,13 +1,13 @@ # @rgbpp-sdk/btc -This lib provides: +## About -- APIs for constructing simple BTC transactions -- APIs for accessing the [btc-assets-api](https://github.com/ckb-cell/btc-assets-api) service in TypeScript +This is the BTC part of the rgbpp-sdk for: -## Disclaimer +- BTC/RGBPP transaction construction +- Wrapped API of the [BtcAssetsApi](https://github.com/ckb-cell/btc-assets-api) service in Node and browser -- The main logic of the `@rgbpp-sdk/btc` lib is referenced and cut/simplified from the [unisat wallet-sdk](https://github.com/unisat-wallet/wallet-sdk) package to adapt to the specific needs of our own projects. The unisat wallet-sdk is using the [ISC license](https://github.com/unisat-wallet/wallet-sdk/blob/master/LICENSE). If we open-source our project in the future, it would be best to include the appropriate license referencing the unisat wallet-sdk. +This lib is based on the foundation of the [unisat wallet-sdk](https://github.com/unisat-wallet/wallet-sdk) ([license](https://github.com/unisat-wallet/wallet-sdk/blob/master/LICENSE)). We've simplified the logic of transaction construction and fee collection process to adapt to the specific needs of RGBPP. You can refer to the unisat wallet-sdk repo for more difference. ## Getting started @@ -80,7 +80,6 @@ const networkType = NetworkType.TESTNET; const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); const source = new DataSource(service, networkType); -// Create a PSBT const psbt = await sendBtc({ from: account.address, // your P2WPKH address tos: [ @@ -89,7 +88,7 @@ const psbt = await sendBtc({ value: 1000, // transfer satoshi amount }, ], - feeRate: 1, // optional + feeRate: 1, // optional, default to 1 sat/vbyte networkType, source, }); @@ -104,6 +103,45 @@ const res = await service.sendTransaction(tx.toHex()); console.log('txid:', res.txid); ``` +Transfer BTC from a `P2TR` address: + +```typescript +import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; + +const networkType = NetworkType.TESTNET; + +const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); +const source = new DataSource(service, networkType); + +const psbt = await sendBtc({ + from: account.address, // your P2TR address + fromPubkey: account.publicKey, // your public key, this is required for P2TR + tos: [ + { + address: 'to_address', // destination btc address + value: 1000, // transfer satoshi amount + }, + ], + feeRate: 1, // optional, default to 1 sat/vbyte + networkType, + source, +}); + +// Create a tweaked signer +const tweakedSigner = tweakSigner(account.keyPair, { + network, +}); + +// Sign & finalize inputs +psbt.signAllInputs(tweakedSigner); +psbt.finalizeAllInputs(); + +// Broadcast transaction +const tx = psbt.extractTransaction(); +const res = await service.sendTransaction(tx.toHex()); +console.log('txid:', res.txid); +``` + Create an `OP_RETURN` output: ```typescript @@ -123,7 +161,59 @@ const psbt = await sendBtc({ value: 0, // normally the value is 0 }, ], - feeRate: 1, // optional + changeAddress: account.address, // optional, where to send the change + feeRate: 1, // optional, default to 1 sat/vbyte + networkType, + source, +}); + +// Sign & finalize inputs +psbt.signAllInputs(account.keyPair); +psbt.finalizeAllInputs(); + +// Broadcast transaction +const tx = psbt.extractTransaction(); +const res = await service.sendTransaction(tx.toHex()); +console.log('txid:', res.txid); +``` + +Transfer with predefined inputs/outputs: + +```typescript +import { sendUtxos, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; + +const networkType = NetworkType.TESTNET; + +const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); +const source = new DataSource(service, networkType); + +const psbt = await sendUtxos({ + inputs: [ + { + txid: 'txid', + vout: 1, + value: 546, + address: 'btc_address', + addressType: AddressType.P2WPKH, + scriptPk: 'script_publickey_hex', + }, + ], + outputs: [ + { + data: Buffer.from('commentment_hex', 'hex'), // RGBPP commitment + value: 0, + fixed: true, // mark as fixed, so the output.value will not be changed + }, + { + address: 'to_address', + value: 546, + fixed: true, + minUtxoSatoshi: 546, // customize the dust limit of the output + }, + ], + from: account.address, // provide fee to the transaction + changeAddress: account.address, // optional, where to send the change + feeRate: 1, // optional, default to 1 sat/vbyte networkType, source, }); @@ -145,18 +235,95 @@ console.log('txid:', res.txid); #### sendBtc ```typescript -declare function sendBtc(props: { - from: string; - tos: { - address: string; - value: number; - }[]; - source: DataSource; - networkType: NetworkType; +interface sendBtc { + (props: { + from: string; + tos: InitOutput[]; + source: DataSource; + networkType: NetworkType; + minUtxoSatoshi?: number; + changeAddress?: string; + fromPubkey?: string; + feeRate?: number; + }): Promise; +} +``` + +#### sendUtxos + +```typescript +interface sendUtxos { + (props: { + inputs: Utxo[]; + outputs: InitOutput[]; + source: DataSource; + networkType: NetworkType; + from: string; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; + }): Promise; +} +``` + +#### InitOutput + +```typescript +type InitOutput = TxAddressOutput | TxDataOutput | TxScriptOutput; +``` + +#### TxAddressOutput / TxDataOutput / TxScriptOutput + +```typescript +interface TxAddressOutput extends BaseOutput { + address: string; +} +``` + +```typescript +interface TxDataOutput extends BaseOutput { + data: Buffer | string; +} +``` + +```typescript +interface TxScriptOutput extends BaseOutput { + script: Buffer; +} +``` + +#### BaseOutput + +```typescript +interface BaseOutput { + value: number; + fixed?: boolean; + protected?: boolean; minUtxoSatoshi?: number; - changeAddress?: string; - feeRate?: number; -}): Promise; +} +``` + +#### DataSource + +```typescript +interface DataSource { + constructor(service: BtcAssetsApi, networkType: NetworkType): void; + getUtxos(address: string, params?: BtcAssetsApiUtxoParams): Promise; + collectSatoshi(props: { + address: string; + targetAmount: number; + minUtxoSatoshi?: number; + excludeUtxos?: { + txid: string; + vout: number; + }[]; + }): Promise<{ + utxos: Utxo[]; + satoshi: number; + exceedSatoshi: number; + }>; +} ``` ### Service @@ -167,8 +334,8 @@ declare function sendBtc(props: { interface BtcAssetsApi { init(): Promise; generateToken(): Promise; - getBalance(address: string): Promise; - getUtxos(address: string): Promise; + getBalance(address: string, params?: BtcAssetsApiBalanceParams): Promise; + getUtxos(address: string, params?: BtcAssetsApiUtxoParams): Promise; getTransactions(address: string): Promise; getTransaction(txid: string): Promise; sendTransaction(txHex: string): Promise; @@ -183,6 +350,14 @@ interface BtcAssetsApiToken { } ``` +#### BtcAssetsApiBalanceParams + +```typescript +interface BtcAssetsApiBalanceParams { + min_satoshi?: number; +} +``` + #### BtcAssetsApiBalance ```typescript @@ -194,6 +369,14 @@ interface BtcAssetsApiBalance { } ``` +#### BtcAssetsApiUtxoParams + +```typescript +interface BtcAssetsApiUtxoParams { + min_satoshi?: number; +} +``` + #### BtcAssetsApiUtxo ```typescript @@ -262,10 +445,10 @@ interface BtcAssetsApiTransaction { ### Basic -#### UnspentOutput +#### Utxo ```typescript -interface UnspentOutput { +interface Utxo { txid: string; vout: number; value: number; @@ -284,8 +467,6 @@ enum AddressType { P2WPKH, P2TR, P2SH_P2WPKH, - M44_P2WPKH, // deprecated - M44_P2TR, // deprecated P2WSH, P2SH, UNKNOWN, From 0ee3268e9a015535a28715c2e65417855d3e75de Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 19 Mar 2024 11:31:58 +0800 Subject: [PATCH 04/15] feat: add sendRgbppUtxos() for building BTC RGBPP sync transactions --- .changeset/twelve-forks-float.md | 6 + packages/btc/package.json | 2 + packages/btc/src/api/sendRgbppUtxos.ts | 172 ++++++++++++++++++ packages/btc/src/ckb/commitment.ts | 60 +++++++ packages/btc/src/ckb/molecule.ts | 32 ++++ packages/btc/src/ckb/rpc.ts | 27 +++ packages/btc/src/constants.ts | 5 + packages/btc/src/error.ts | 30 +++- packages/btc/src/index.ts | 1 + packages/btc/src/query/source.ts | 20 +++ packages/btc/src/transaction/embed.ts | 2 +- packages/btc/src/utils.ts | 26 ++- packages/btc/tests/Transaction.test.ts | 8 +- pnpm-lock.yaml | 238 ++++++++++++++++++++++++- 14 files changed, 617 insertions(+), 12 deletions(-) create mode 100644 .changeset/twelve-forks-float.md create mode 100644 packages/btc/src/api/sendRgbppUtxos.ts create mode 100644 packages/btc/src/ckb/commitment.ts create mode 100644 packages/btc/src/ckb/molecule.ts create mode 100644 packages/btc/src/ckb/rpc.ts diff --git a/.changeset/twelve-forks-float.md b/.changeset/twelve-forks-float.md new file mode 100644 index 00000000..ba1793d1 --- /dev/null +++ b/.changeset/twelve-forks-float.md @@ -0,0 +1,6 @@ +--- +"@rgbpp-sdk/btc": patch +--- + +Add sendRgbppUtxos() API for building RGBPP sync transactions on the bitcoin side. +Also renamed some errors that are unrelated to the feature implementation. diff --git a/packages/btc/package.json b/packages/btc/package.json index a30a9b37..37984aca 100644 --- a/packages/btc/package.json +++ b/packages/btc/package.json @@ -17,9 +17,11 @@ ], "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", + "@ckb-lumos/lumos": "0.22.0-next.5", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "ecpair": "^2.1.0", + "js-sha256": "^0.11.0", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts new file mode 100644 index 00000000..e85e208e --- /dev/null +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -0,0 +1,172 @@ +import { helpers, Hash, RawTransaction, RPC } from '@ckb-lumos/lumos'; +import { InitOutput, TxAddressOutput } from '../transaction/build'; +import { ErrorCodes, TxBuildError } from '../error'; +import { DataSource } from '../query/source'; +import { NetworkType } from '../network'; +import { Utxo } from '../types'; +import { bitcoin } from '../bitcoin'; +import { RGBPP_UTXO_DUST_LIMIT } from '../constants'; +import { calculateCommitment } from '../ckb/commitment'; +import { unpackRgbppLockArgs } from '../ckb/molecule'; +import { getCellByOutPoint } from '../ckb/rpc'; +import { sendUtxos } from './sendUtxos'; + +export async function sendRgbppUtxos(props: { + ckbVirtualTx: RawTransaction; + paymaster: TxAddressOutput; + commitment: Hash; + tos?: string[]; + + ckbNodeUrl: string; + rgbppLockCodeHash: Hash; + rgbppTimeLockCodeHash: Hash; + rgbppMinUtxoSatoshi?: number; + + from: string; + source: DataSource; + networkType: NetworkType; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; +}): Promise { + const inputs: Utxo[] = []; + const outputs: InitOutput[] = []; + let lastTypeInputIndex = -1; + let lastTypeOutputIndex = -1; + + // Build TransactionSkeleton from CKB VirtualTx + const rpc = new RPC(props.ckbNodeUrl); + const ckbVirtualTx = props.ckbVirtualTx; + const ckbTxSkeleton = await helpers.createTransactionSkeleton(ckbVirtualTx as any, async (outPoint) => { + const result = await getCellByOutPoint(outPoint, rpc); + if (!result.cell || result.status !== 'live') { + throw new TxBuildError(ErrorCodes.CKB_CANNOT_FIND_OUTPOINT); + } + + return result.cell; + }); + + // Handle and check inputs + const inputCells = ckbTxSkeleton.get('inputs'); + for (let i = 0; i < inputCells.size; i++) { + const input = inputCells.get(i)!; + const isRgbppLock = input.cellOutput.lock.codeHash === props.rgbppLockCodeHash; + const isRgbppTimeLock = input.cellOutput.lock.codeHash === props.rgbppTimeLockCodeHash; + + // If input.type !== null, input.lock must be RgbppLock or RgbppTimeLock + if (input.cellOutput.type) { + if (!isRgbppLock && !isRgbppTimeLock) { + throw new TxBuildError(ErrorCodes.CKB_INVALID_CELL_LOCK); + } + + // If input.type !== null,update lastTypeInput + lastTypeInputIndex = i; + } + + // If input.lock == RgbppLock, add to inputs if: + // 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) + if (isRgbppLock || isRgbppTimeLock) { + const args = unpackRgbppLockArgs(input.cellOutput.lock.args); + const utxo = await props.source.getUtxo(args.btcTxId, args.outIndex); + if (!utxo) { + throw new TxBuildError(ErrorCodes.CANNOT_FIND_UTXO); + } + if (utxo.address !== props.from) { + throw new TxBuildError(ErrorCodes.REFERENCED_UNPROVABLE_UTXO); + } + + inputs.push({ + ...utxo, + pubkey: props.fromPubkey, // For P2TR addresses, a pubkey is required + }); + } + } + + // The inputs.length should be >= 1 + if (inputs.length < 1) { + throw new TxBuildError(ErrorCodes.CKB_INVALID_INPUTS); + } + + // Handle and check outputs + for (let i = 0; i < ckbVirtualTx.outputs.length; i++) { + const output = ckbVirtualTx.outputs[i]; + + // If output.type !== null, then the output.lock must be RgbppLock or RgbppTimeLock + if (output.type) { + const isRgbppLock = output.lock.codeHash === props.rgbppLockCodeHash; + const isRgbppTimeLock = output.lock.codeHash === props.rgbppTimeLockCodeHash; + if (!isRgbppLock && !isRgbppTimeLock) { + throw new TxBuildError(ErrorCodes.CKB_INVALID_CELL_LOCK); + } + + // If output.type !== null,update lastTypeInput + lastTypeOutputIndex = i; + } + + // If output.lock == RgbppLock, generate a corresponding output in outputs + if (output.lock.codeHash === props.rgbppLockCodeHash) { + const toAddress = props.tos?.[i]; + outputs.push({ + protected: true, + address: toAddress ?? props.from, + value: props.rgbppMinUtxoSatoshi ?? RGBPP_UTXO_DUST_LIMIT, + }); + } + } + + // By rules, the outputs.length should be >= 1, + // if recipients is provided, the outputs.length should be >= recipients.length + const recipientsLength = props.tos?.length ?? 0; + if (outputs.length < recipientsLength) { + throw new TxBuildError(ErrorCodes.CKB_INVALID_OUTPUTS); + } + + // Verify the provided commitment + const calculatedCommitment = calculateCommitment({ + inputs: [...ckbVirtualTx.inputs].slice(0, lastTypeInputIndex + 1), + outputs: [...ckbVirtualTx.outputs].slice(0, lastTypeOutputIndex + 1), + outputsData: [...ckbVirtualTx.outputsData].slice(0, lastTypeOutputIndex + 1), + }); + if (props.commitment !== calculatedCommitment) { + throw new TxBuildError(ErrorCodes.CKB_UNMATCHED_COMMITMENT); + } + + const mergedOutputs = (() => { + const merged: InitOutput[] = []; + + // Add commitment to the beginning of outputs + merged.push({ + data: props.commitment, + fixed: true, + value: 0, + }); + + // Add outputs + merged.push(...outputs); + + // Add paymaster if provided + if (props.paymaster) { + merged.push({ + ...props.paymaster, + fixed: true, + }); + } + + return merged; + })(); + + return await sendUtxos({ + inputs, + outputs: mergedOutputs, + from: props.from, + source: props.source, + fromPubkey: props.fromPubkey, + networkType: props.networkType, + changeAddress: props.changeAddress, + minUtxoSatoshi: props.minUtxoSatoshi, + feeRate: props.feeRate, + }); +} diff --git a/packages/btc/src/ckb/commitment.ts b/packages/btc/src/ckb/commitment.ts new file mode 100644 index 00000000..4d03ab49 --- /dev/null +++ b/packages/btc/src/ckb/commitment.ts @@ -0,0 +1,60 @@ +import { sha256 } from 'js-sha256'; +import { Input, Output } from '@ckb-lumos/lumos'; +import { blockchain, bytes } from '@ckb-lumos/lumos/codec'; +import { utf8ToBuffer } from '../utils'; +import { RgbppLockArgs, unpackRgbppLockArgs } from './molecule'; +import { BTC_TX_ID_PLACEHOLDER } from '../constants'; + +interface CommitmentCkbRawTx { + inputs: Input[]; + outputs: Output[]; + outputsData: string[]; +} + +/** + * Calculate RGBPP transaction commitment for validation. + */ +export function calculateCommitment(tx: CommitmentCkbRawTx): string { + const hash = sha256.create(); + + // Prefix + hash.update(utf8ToBuffer('RGB++')); + + // Version + // TODO: Support versioning when needed + const version = [0, 0]; + hash.update(version); + + // Length of inputs & outputs + hash.update([tx.inputs.length, tx.outputs.length]); + + // Inputs + for (const input of tx.inputs) { + hash.update(blockchain.OutPoint.pack(input.previousOutput)); + } + + // Outputs + for (let index = 0; index < tx.outputs.length; index++) { + const output = tx.outputs[index]; + const lockArgs = unpackRgbppLockArgs(output.lock.args); + hash.update( + blockchain.CellOutput.pack({ + capacity: output.capacity, + type: output.type, + lock: { + ...output.lock, + args: RgbppLockArgs.pack({ + outIndex: lockArgs.outIndex, + btcTxId: BTC_TX_ID_PLACEHOLDER, // 32-byte placeholder + }), + }, + }), + ); + + const outputData = tx.outputsData[index] ?? '0x'; + hash.update(bytes.bytify(outputData)); + } + + // Double sha256 + return sha256(hash.array()); +} diff --git a/packages/btc/src/ckb/molecule.ts b/packages/btc/src/ckb/molecule.ts new file mode 100644 index 00000000..6341f495 --- /dev/null +++ b/packages/btc/src/ckb/molecule.ts @@ -0,0 +1,32 @@ +import { BytesLike, FixedBytesCodec, ObjectLayoutCodec, UnpackResult } from '@ckb-lumos/lumos/codec'; +import { blockchain, struct, Uint32LE } from '@ckb-lumos/lumos/codec'; +import { BIish } from '@ckb-lumos/lumos'; +import { ErrorCodes, TxBuildError } from '../error'; + +type Fixed = { + readonly __isFixedCodec__: true; + readonly byteLength: number; +}; + +export const RgbppLockArgs: ObjectLayoutCodec<{ + outIndex: FixedBytesCodec; + btcTxId: FixedBytesCodec; +}> & + Fixed = struct( + { + outIndex: Uint32LE, + btcTxId: blockchain.Byte32, + }, + ['outIndex', 'btcTxId'], +); + +/** + * Unpack RgbppLockArgs from a BytesLike (Buffer, Uint8Array, HexString, etc) value. + */ +export function unpackRgbppLockArgs(source: BytesLike): UnpackResult { + try { + return RgbppLockArgs.unpack(source); + } catch { + throw new TxBuildError(ErrorCodes.CKB_RGBPP_LOCK_UNPACK_ERROR); + } +} diff --git a/packages/btc/src/ckb/rpc.ts b/packages/btc/src/ckb/rpc.ts new file mode 100644 index 00000000..213e02cf --- /dev/null +++ b/packages/btc/src/ckb/rpc.ts @@ -0,0 +1,27 @@ +import { Cell, OutPoint, RPC } from '@ckb-lumos/lumos'; +import { CKBComponents } from '@ckb-lumos/lumos/rpc'; + +/** + * Query a specific cell (with status) via CKB RPC.getLiveCell() method. + */ +export async function getCellByOutPoint( + outPoint: OutPoint, + rpc: RPC, +): Promise<{ + cell?: Cell; + status: CKBComponents.CellStatus; +}> { + const liveCell = await rpc.getLiveCell(outPoint, true); + const cell: Cell | undefined = liveCell.cell + ? { + outPoint, + cellOutput: liveCell.cell.output, + data: liveCell.cell.data.content, + } + : void 0; + + return { + cell, + status: liveCell.status, + }; +} diff --git a/packages/btc/src/constants.ts b/packages/btc/src/constants.ts index ee381235..0ef8c945 100644 --- a/packages/btc/src/constants.ts +++ b/packages/btc/src/constants.ts @@ -10,3 +10,8 @@ export const BTC_UTXO_DUST_LIMIT = 1000; * RGBPP_UTXOs with satoshi below this constant are considered dust and will not be created. */ export const RGBPP_UTXO_DUST_LIMIT = 546; + +/** + * An empty placeholder, filled with 0s for the txid of the BTC transaction. + */ +export const BTC_TX_ID_PLACEHOLDER = '0x' + '0'.repeat(64); diff --git a/packages/btc/src/error.ts b/packages/btc/src/error.ts index 19999b99..c6007ebb 100644 --- a/packages/btc/src/error.ts +++ b/packages/btc/src/error.ts @@ -1,27 +1,49 @@ export enum ErrorCodes { UNKNOWN, + MISSING_PUBKEY, + CANNOT_FIND_UTXO, INSUFFICIENT_UTXO, + REFERENCED_UNPROVABLE_UTXO, UNSUPPORTED_OUTPUT, UNSUPPORTED_ADDRESS_TYPE, - INVALID_OP_RETURN_SCRIPT, + UNSUPPORTED_OP_RETURN_SCRIPT, + ASSETS_API_RESPONSE_ERROR, ASSETS_API_UNAUTHORIZED, ASSETS_API_INVALID_PARAM, ASSETS_API_RESPONSE_DECODE_ERROR, + + CKB_CANNOT_FIND_OUTPOINT, + CKB_INVALID_CELL_LOCK, + CKB_INVALID_INPUTS, + CKB_INVALID_OUTPUTS, + CKB_UNMATCHED_COMMITMENT, + CKB_RGBPP_LOCK_UNPACK_ERROR, } export const ErrorMessages = { [ErrorCodes.UNKNOWN]: 'Unknown error', - [ErrorCodes.MISSING_PUBKEY]: 'Missing a pubkey corresponding to the UTXO', - [ErrorCodes.INSUFFICIENT_UTXO]: 'Insufficient UTXO', + + [ErrorCodes.MISSING_PUBKEY]: 'Missing a pubkey that pairs with the address', + [ErrorCodes.CANNOT_FIND_UTXO]: 'Cannot find the UTXO, it may not exist or is not live', + [ErrorCodes.INSUFFICIENT_UTXO]: 'Insufficient UTXO to cover the expected satoshi amount', + [ErrorCodes.REFERENCED_UNPROVABLE_UTXO]: 'Cannot reference a UTXO that does not belongs to "from"', [ErrorCodes.UNSUPPORTED_OUTPUT]: 'Unsupported output format', [ErrorCodes.UNSUPPORTED_ADDRESS_TYPE]: 'Unsupported address type', - [ErrorCodes.INVALID_OP_RETURN_SCRIPT]: 'Invalid OP_RETURN script format', + [ErrorCodes.UNSUPPORTED_OP_RETURN_SCRIPT]: 'Unsupported OP_RETURN script format', + [ErrorCodes.ASSETS_API_UNAUTHORIZED]: 'BtcAssetsAPI unauthorized, please check your token/origin', [ErrorCodes.ASSETS_API_INVALID_PARAM]: 'Invalid param(s) was provided to the BtcAssetsAPI', [ErrorCodes.ASSETS_API_RESPONSE_ERROR]: 'BtcAssetsAPI returned an error', [ErrorCodes.ASSETS_API_RESPONSE_DECODE_ERROR]: 'Failed to decode the response of BtcAssetsAPI', + + [ErrorCodes.CKB_CANNOT_FIND_OUTPOINT]: 'Cannot find CKB cell by OutPoint, it may not exist or is not live', + [ErrorCodes.CKB_INVALID_CELL_LOCK]: 'Invalid CKB cell lock, it should be RgbppLock, RgbppTimeLock or null', + [ErrorCodes.CKB_INVALID_INPUTS]: 'Invalid input(s) found in the CKB VirtualTx', + [ErrorCodes.CKB_INVALID_OUTPUTS]: 'Invalid output(s) found in the CKB VirtualTx', + [ErrorCodes.CKB_UNMATCHED_COMMITMENT]: 'Invalid commitment found in the CKB VirtualTx', + [ErrorCodes.CKB_RGBPP_LOCK_UNPACK_ERROR]: 'Failed to unpack RgbppLockArgs from the CKB cell lock', }; export class TxBuildError extends Error { diff --git a/packages/btc/src/index.ts b/packages/btc/src/index.ts index a5bbcbe7..922d9886 100644 --- a/packages/btc/src/index.ts +++ b/packages/btc/src/index.ts @@ -16,3 +16,4 @@ export * from './transaction/fee'; export * from './api/sendBtc'; export * from './api/sendUtxos'; +export * from './api/sendRgbppUtxos'; diff --git a/packages/btc/src/query/source.ts b/packages/btc/src/query/source.ts index c5183f80..108e6ff3 100644 --- a/packages/btc/src/query/source.ts +++ b/packages/btc/src/query/source.ts @@ -13,6 +13,26 @@ export class DataSource { this.networkType = networkType; } + async getUtxo(hash: string, index: number): Promise { + const tx = await this.service.getTransaction(hash); + if (!tx) { + return void 0; + } + const vout = tx.vout[index]; + if (!vout) { + return void 0; + } + + return { + txid: hash, + vout: index, + value: vout.value, + scriptPk: vout.scriptpubkey, + address: vout.scriptpubkey_address, + addressType: getAddressType(vout.scriptpubkey_address), + }; + } + async getUtxos(address: string, params?: BtcAssetsApiUtxoParams): Promise { const utxos = await this.service.getUtxos(address, params); diff --git a/packages/btc/src/transaction/embed.ts b/packages/btc/src/transaction/embed.ts index 2be65019..e3e0d08c 100644 --- a/packages/btc/src/transaction/embed.ts +++ b/packages/btc/src/transaction/embed.ts @@ -30,7 +30,7 @@ export function dataToOpReturnScriptPubkey(data: Buffer | string): Buffer { */ export function opReturnScriptPubKeyToData(script: Buffer): Buffer { if (!isOpReturnScriptPubkey(script)) { - throw new TxBuildError(ErrorCodes.INVALID_OP_RETURN_SCRIPT); + throw new TxBuildError(ErrorCodes.UNSUPPORTED_OP_RETURN_SCRIPT); } const [_op, data] = bitcoin.script.decompile(script)!; diff --git a/packages/btc/src/utils.ts b/packages/btc/src/utils.ts index efde07b0..916d9a54 100644 --- a/packages/btc/src/utils.ts +++ b/packages/btc/src/utils.ts @@ -1,4 +1,7 @@ import { bitcoin, ecc, ECPair } from './bitcoin'; +import { bytes } from '@ckb-lumos/lumos/codec'; + +const textEncoder = new TextEncoder(); export function toXOnly(pubKey: Buffer): Buffer { return pubKey.length === 32 ? pubKey : pubKey.subarray(1, 33); @@ -36,8 +39,8 @@ export function tweakSigner( /** * Check if target string is a valid domain. * @exmaple - * - Valid: isDomain('google.com') - * - Invalid: isDomain('https://google.com') + * isDomain('google.com') // => true + * isDomain('https://google.com') // => false */ export function isDomain(domain: string): boolean { const regex = /^(?:[-A-Za-z0-9]+\.)+[A-Za-z]{2,}$/; @@ -46,7 +49,26 @@ export function isDomain(domain: string): boolean { /** * Remove '0x' prefix from a hex string. + * @example + * remove0x('0x1234') // => '1234' + * remove0x('1234') // => '1234' */ export function remove0x(hex: string): string { return hex.startsWith('0x') ? hex.slice(2) : hex; } + +/** + * Convert UTF-8 raw text to buffer (binary bytes). + * @example + * utf8ToBuffer('0x1234') // => Uint8Array(2) [ 18, 52 ] + * utf8ToBuffer('1234') // => Uint8Array(4) [ 49, 50, 51, 52 ] + * utf8ToBuffer('hello') // => Uint8Array(5) [ 104, 101, 108, 108, 111 ] + */ +export function utf8ToBuffer(text: string): Uint8Array { + let result = text.trim(); + if (result.startsWith('0x')) { + return bytes.bytify(result); + } + + return textEncoder.encode(result); +} diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index 61c9ff61..8b0e0e15 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -4,7 +4,7 @@ import { ErrorCodes, AddressType, sendBtc, sendUtxos, tweakSigner } from '../src import { bitcoin, ErrorMessages, BTC_UTXO_DUST_LIMIT, RGBPP_UTXO_DUST_LIMIT } from '../src'; describe('Transaction', () => { - describe('BTC transfer', () => { + describe('sendBtc()', () => { describe('Transfer from Native SegWit (P2WPKH) address', () => { const addresses = [ { type: 'Taproot (P2TR)', address: accounts.charlie.p2tr.address }, @@ -140,7 +140,7 @@ describe('Transaction', () => { }); }); - describe('UTXO transfer', () => { + describe('sendUtxos()', () => { it('Transfer fixed UTXO, sum(ins) = sum(outs)', async () => { const psbt = await sendUtxos({ from: accounts.charlie.p2wpkh.address, @@ -595,4 +595,8 @@ describe('Transaction', () => { // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); }); + + describe('sendRgbppUtxos()', () => { + // TODO: fill tests + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6424560d..742eb769 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: '@bitcoinerlab/secp256k1': specifier: ^1.1.1 version: 1.1.1 + '@ckb-lumos/lumos': + specifier: 0.22.0-next.5 + version: 0.22.0-next.5 bip32: specifier: ^4.0.0 version: 4.0.0 @@ -124,6 +127,9 @@ importers: ecpair: specifier: ^2.1.0 version: 2.1.0 + js-sha256: + specifier: ^0.11.0 + version: 0.11.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -617,6 +623,20 @@ packages: lodash.isequal: 4.5.0 dev: false + /@ckb-lumos/base@0.22.0-next.5: + resolution: {integrity: sha512-2I4f/zJm3Bdz/5soXo35vDh98Vffp2clO8n1ZeZRsUtBZUtvAGNbggC4J8/0a/yFw3cStaOJ1Tztla5E0hP+xA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + '@types/blake2b': 2.1.3 + '@types/lodash.isequal': 4.5.8 + blake2b: 2.1.4 + js-xxhash: 1.0.4 + lodash.isequal: 4.5.0 + dev: false + /@ckb-lumos/bi@0.21.1: resolution: {integrity: sha512-6q8uesvu3DAM7GReei9H5seino4tnakTeg8uXtZBPDC6rboMohLCPQvEwhl1iHmsybXvBYVQt4Te1BPPZtuaRw==} engines: {node: '>=12.0.0'} @@ -630,6 +650,21 @@ packages: dependencies: jsbi: 4.3.0 + /@ckb-lumos/ckb-indexer@0.22.0-next.5: + resolution: {integrity: sha512-1UZ0whrn9k18wrvIIvsdAYRCGInNA4qcrMcxsqSV7ADQnPsw8c+ARkIyzQrqaM2bzvPa3O+aVXnZ7g2oTrhtFA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + cross-fetch: 3.1.8 + events: 3.3.0 + transitivePeerDependencies: + - encoding + dev: false + /@ckb-lumos/codec@0.21.1: resolution: {integrity: sha512-z6IUUxVZrx663iC7VM9CmaQZL8jsdM3ybgz0UCS24JgBXTNec+Uz0/Zrl7yeH6fBpVls44C2wObcHKigKaNVAA==} engines: {node: '>=12.0.0'} @@ -643,6 +678,99 @@ packages: dependencies: '@ckb-lumos/bi': 0.22.0-next.5 + /@ckb-lumos/common-scripts@0.22.0-next.5: + resolution: {integrity: sha512-DXGwyc0pmrq0fj+NflDBeBgJSL2bNAVHpULpbUncDUPIBJf9IFyMDSZUgzX6hYjTX0WA07e5CEFo5wwHb24Dmw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/config-manager': 0.22.0-next.5 + '@ckb-lumos/helpers': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + bech32: 2.0.0 + bs58: 5.0.0 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/config-manager@0.22.0-next.5: + resolution: {integrity: sha512-kicoRgPP9DPxA90que/+9R0iQgiIJQNreZv1/EJ+4GDOj/pFb0iBbTEQxF3zXtLKjnZoMTqTQH7e4CAV0ZftSA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@types/deep-freeze-strict': 1.1.2 + deep-freeze-strict: 1.1.1 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/hd@0.22.0-next.5: + resolution: {integrity: sha512-d+Ws/fknuh+P9+aEBhLWiCOxMVE6i78T+2bEj8aWDLoVfrLvuCqFEyVRCspOG22pgEj0h853nMsetrb4pI+fdw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + bn.js: 5.2.1 + elliptic: 6.5.5 + scrypt-js: 3.0.1 + sha3: 2.1.4 + uuid: 8.3.2 + dev: false + + /@ckb-lumos/helpers@0.22.0-next.5: + resolution: {integrity: sha512-fsPr7oTQuKdfEvqdakgfDv9daU4PLPc9g8l13klMhEayaTamBndG3GdhTTASFMBxQaz58E6thTObsig220yVIg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/config-manager': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + bech32: 2.0.0 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/light-client@0.22.0-next.5: + resolution: {integrity: sha512-PbrGQUQAILO7QI93qm8Cke7J8CduCppKNTJd9yP7Y6ldtFKfwev8uYVB+xhUaciQclLZ26kbe1peaxd9xP8pXQ==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/ckb-indexer': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + cross-fetch: 3.1.8 + events: 3.3.0 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/lumos@0.22.0-next.5: + resolution: {integrity: sha512-Dv+MKa664zsD9Nxrnor04Hb52MNcwgtEutgzdlE+qtAB/skp6P0NVnU9uQF7RiYHNpdmUe8PROlkzB4jaKz9NA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/ckb-indexer': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/common-scripts': 0.22.0-next.5 + '@ckb-lumos/config-manager': 0.22.0-next.5 + '@ckb-lumos/hd': 0.22.0-next.5 + '@ckb-lumos/helpers': 0.22.0-next.5 + '@ckb-lumos/light-client': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + '@ckb-lumos/transaction-manager': 0.22.0-next.5 + transitivePeerDependencies: + - encoding + dev: false + /@ckb-lumos/molecule@0.22.0-next.5: resolution: {integrity: sha512-g5EWFRXR/w4EPsBLPbt5vNwFEFyLnruoeHrdiFa5IXgCyWpiToxTUHwOQ8EM4XtoDB8Xf3hTQ/D1g4XZb5EZhA==} engines: {node: '>=12.0.0'} @@ -659,6 +787,18 @@ packages: relative: 3.0.2 dev: true + /@ckb-lumos/rpc@0.22.0-next.5: + resolution: {integrity: sha512-ohH5kj/iiyKaTksgWCc4STwnEON9cCC2tPf3eP7XUaJ9ZO01ahOZ9C85NccBoK2lOqcLrZag3Y0LDQWw+eFbBg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + abort-controller: 3.0.0 + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + dev: false + /@ckb-lumos/toolkit@0.21.1: resolution: {integrity: sha512-awrFos7uQXEVGbqKSv/8Fc8B8XAfxdYoyYak4zFyAAmxxA0NiTTvk9V8TsOA7zVXpxct4Jal22+qUe+4Jg8T/g==} engines: {node: '>=12.0.0'} @@ -666,6 +806,27 @@ packages: '@ckb-lumos/bi': 0.21.1 dev: false + /@ckb-lumos/toolkit@0.22.0-next.5: + resolution: {integrity: sha512-D41GgB6D8WDh94gBNGXQJTxbI2NFFSH/71jE4kdRI6+qqbl/zYlI3nsLdQmY1RajIYyLgYLNAVHiDIWK/KGO/w==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.22.0-next.5 + dev: false + + /@ckb-lumos/transaction-manager@0.22.0-next.5: + resolution: {integrity: sha512-Xs10tTPgxqrB1cUMUqTdAZDDpTs3ESxPLv9HmyR2jLFI6HVJjSbGJipXODYJaJ+iUNPG/qZaehHmuQoGgbBwiw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/ckb-indexer': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1375,6 +1536,10 @@ packages: resolution: {integrity: sha512-MFCdX0MNxFBP/xEILO5Td0kv6nI7+Q2iRWZbTL/yzH2/eDVZS5Wd1LHdsmXClvsCyzqaZfHFzZaN6BUeUCfSDA==} dev: false + /@types/deep-freeze-strict@1.1.2: + resolution: {integrity: sha512-VvMETBojHvhX4f+ocYTySQlXMZfxKV3Jyb7iCWlWaC+exbedkv6Iv2bZZqI736qXjVguH6IH7bzwMBMfTT+zuQ==} + dev: false + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true @@ -1635,6 +1800,13 @@ packages: pretty-format: 29.7.0 dev: true + /abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: false + /acorn-jsx@5.3.2(acorn@8.11.3): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1890,7 +2062,6 @@ packages: /bn.js@5.2.1: resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} - dev: true /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -2293,6 +2464,14 @@ packages: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + /cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} dependencies: @@ -2384,6 +2563,10 @@ packages: type-detect: 4.0.8 dev: true + /deep-freeze-strict@1.1.1: + resolution: {integrity: sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA==} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -2508,7 +2691,6 @@ packages: inherits: 2.0.4 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - dev: true /emoji-regex@10.3.0: resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} @@ -2791,6 +2973,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: false + /eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} dev: true @@ -2798,7 +2985,6 @@ packages: /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - dev: true /evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} @@ -3240,6 +3426,10 @@ packages: engines: {node: '>= 4'} dev: true + /immutable@4.3.5: + resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -3945,6 +4135,18 @@ packages: - babel-plugin-macros dev: false + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: false + /node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} dev: true @@ -4642,6 +4844,10 @@ packages: loose-envify: 1.4.0 dev: false + /scrypt-js@3.0.1: + resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + dev: false + /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -4696,6 +4902,12 @@ packages: inherits: 2.0.4 safe-buffer: 5.2.1 + /sha3@2.1.4: + resolution: {integrity: sha512-S8cNxbyb0UGUM2VhRD4Poe5N58gJnJsLJ5vC7FYWGUmGhcsj4++WaIOBFVDxlG0W3To6xBuiRh+i0Qp2oNCOtg==} + dependencies: + buffer: 6.0.3 + dev: false + /shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -5054,6 +5266,10 @@ packages: is-number: 7.0.0 dev: true + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: false + /trim-newlines@3.0.1: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} @@ -5335,6 +5551,11 @@ packages: which-typed-array: 1.1.14 dev: true + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} dev: true @@ -5487,6 +5708,17 @@ packages: defaults: 1.0.4 dev: true + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: false + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: false + /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: From c5c58fb5203a35959853faca9ffdd0ecbda08b1f Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 19 Mar 2024 11:47:58 +0800 Subject: [PATCH 05/15] fix: add range to the expected fee --- packages/btc/tests/Transaction.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index 8b0e0e15..6aeaf9f4 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -587,7 +587,8 @@ describe('Transaction', () => { const fee = psbt.getFee(); console.log('fee:', fee); - expect(fee).toBe(240); + expect(fee).toBeGreaterThanOrEqual(239); + expect(fee).toBeLessThanOrEqual(240); // Broadcast transaction // const tx = psbt.extractTransaction(); From 5aa45eef35f7e441c99857210050b3b67d8d1120 Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 19 Mar 2024 11:53:11 +0800 Subject: [PATCH 06/15] chore: mark sendRgbppUtxos() test as todo --- packages/btc/tests/Transaction.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index 6aeaf9f4..def6ccfd 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -597,7 +597,7 @@ describe('Transaction', () => { }); }); - describe('sendRgbppUtxos()', () => { + describe.todo('sendRgbppUtxos()', () => { // TODO: fill tests }); }); From a8e814a5671f305f7c88f5663f43ed446cbe7287 Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 19 Mar 2024 19:42:40 +0800 Subject: [PATCH 07/15] refactor: remove networkType prop from APIs --- packages/btc/README.md | 54 ++++++++++++++++---------- packages/btc/src/address.ts | 11 +++++- packages/btc/src/api/sendBtc.ts | 3 -- packages/btc/src/api/sendRgbppUtxos.ts | 3 -- packages/btc/src/api/sendUtxos.ts | 5 +-- packages/btc/src/constants.ts | 2 +- packages/btc/src/transaction/build.ts | 10 ++--- packages/btc/src/transaction/fee.ts | 5 +-- packages/btc/src/types.ts | 12 +----- packages/btc/tsconfig.build.json | 2 +- 10 files changed, 55 insertions(+), 52 deletions(-) diff --git a/packages/btc/README.md b/packages/btc/README.md index 2028c5c5..cc371610 100644 --- a/packages/btc/README.md +++ b/packages/btc/README.md @@ -75,10 +75,8 @@ Transfer BTC from a `P2WPKH` address: ```typescript import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; -const networkType = NetworkType.TESTNET; - const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); -const source = new DataSource(service, networkType); +const source = new DataSource(service, NetworkType.TESTNET); const psbt = await sendBtc({ from: account.address, // your P2WPKH address @@ -89,7 +87,6 @@ const psbt = await sendBtc({ }, ], feeRate: 1, // optional, default to 1 sat/vbyte - networkType, source, }); @@ -108,10 +105,8 @@ Transfer BTC from a `P2TR` address: ```typescript import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; -const networkType = NetworkType.TESTNET; - const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); -const source = new DataSource(service, networkType); +const source = new DataSource(service, NetworkType.TESTNET); const psbt = await sendBtc({ from: account.address, // your P2TR address @@ -123,7 +118,6 @@ const psbt = await sendBtc({ }, ], feeRate: 1, // optional, default to 1 sat/vbyte - networkType, source, }); @@ -147,10 +141,8 @@ Create an `OP_RETURN` output: ```typescript import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; -const networkType = NetworkType.TESTNET; - const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); -const source = new DataSource(service, networkType); +const source = new DataSource(service, NetworkType.TESTNET); // Create a PSBT const psbt = await sendBtc({ @@ -161,9 +153,8 @@ const psbt = await sendBtc({ value: 0, // normally the value is 0 }, ], - changeAddress: account.address, // optional, where to send the change + changeAddress: account.address, // optional, where to return the change feeRate: 1, // optional, default to 1 sat/vbyte - networkType, source, }); @@ -182,10 +173,8 @@ Transfer with predefined inputs/outputs: ```typescript import { sendUtxos, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; -const networkType = NetworkType.TESTNET; - const service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); -const source = new DataSource(service, networkType); +const source = new DataSource(service, NetworkType.TESTNET); const psbt = await sendUtxos({ inputs: [ @@ -212,9 +201,9 @@ const psbt = await sendUtxos({ }, ], from: account.address, // provide fee to the transaction + fromPubkey: account.publicKey, // optional, required if "from" is a P2TR address changeAddress: account.address, // optional, where to send the change feeRate: 1, // optional, default to 1 sat/vbyte - networkType, source, }); @@ -240,10 +229,9 @@ interface sendBtc { from: string; tos: InitOutput[]; source: DataSource; - networkType: NetworkType; - minUtxoSatoshi?: number; - changeAddress?: string; fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; feeRate?: number; }): Promise; } @@ -257,7 +245,6 @@ interface sendUtxos { inputs: Utxo[]; outputs: InitOutput[]; source: DataSource; - networkType: NetworkType; from: string; fromPubkey?: string; changeAddress?: string; @@ -267,6 +254,31 @@ interface sendUtxos { } ``` +#### sendRgbppUtxos + +```typescript +interface sendRgbppUtxos { + (props: { + ckbVirtualTx: RawTransaction; + paymaster: TxAddressOutput; + commitment: Hash; + tos?: string[]; + + ckbNodeUrl: string; + rgbppLockCodeHash: Hash; + rgbppTimeLockCodeHash: Hash; + rgbppMinUtxoSatoshi?: number; + + from: string; + source: DataSource; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; + }): Promise; +} +``` + #### InitOutput ```typescript diff --git a/packages/btc/src/address.ts b/packages/btc/src/address.ts index 0fa73e31..826be50f 100644 --- a/packages/btc/src/address.ts +++ b/packages/btc/src/address.ts @@ -1,9 +1,18 @@ import { bitcoin } from './bitcoin'; -import { AddressType } from './types'; import { NetworkType, toPsbtNetwork } from './network'; import { ErrorCodes, TxBuildError } from './error'; import { remove0x, toXOnly } from './utils'; +export enum AddressType { + P2PKH, + P2WPKH, + P2TR, + P2SH_P2WPKH, + P2WSH, + P2SH, + UNKNOWN, +} + /** * Check weather the address is supported as a from address. * Currently, only P2WPKH and P2TR addresses are supported. diff --git a/packages/btc/src/api/sendBtc.ts b/packages/btc/src/api/sendBtc.ts index 7764913f..79b303c8 100644 --- a/packages/btc/src/api/sendBtc.ts +++ b/packages/btc/src/api/sendBtc.ts @@ -1,5 +1,4 @@ import { bitcoin } from '../bitcoin'; -import { NetworkType } from '../network'; import { DataSource } from '../query/source'; import { TxBuilder, InitOutput } from '../transaction/build'; @@ -7,7 +6,6 @@ export async function sendBtc(props: { from: string; tos: InitOutput[]; source: DataSource; - networkType: NetworkType; minUtxoSatoshi?: number; changeAddress?: string; fromPubkey?: string; @@ -15,7 +13,6 @@ export async function sendBtc(props: { }): Promise { const tx = new TxBuilder({ source: props.source, - networkType: props.networkType, minUtxoSatoshi: props.minUtxoSatoshi, feeRate: props.feeRate, }); diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts index e85e208e..f912ac05 100644 --- a/packages/btc/src/api/sendRgbppUtxos.ts +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -2,7 +2,6 @@ import { helpers, Hash, RawTransaction, RPC } from '@ckb-lumos/lumos'; import { InitOutput, TxAddressOutput } from '../transaction/build'; import { ErrorCodes, TxBuildError } from '../error'; import { DataSource } from '../query/source'; -import { NetworkType } from '../network'; import { Utxo } from '../types'; import { bitcoin } from '../bitcoin'; import { RGBPP_UTXO_DUST_LIMIT } from '../constants'; @@ -24,7 +23,6 @@ export async function sendRgbppUtxos(props: { from: string; source: DataSource; - networkType: NetworkType; fromPubkey?: string; changeAddress?: string; minUtxoSatoshi?: number; @@ -164,7 +162,6 @@ export async function sendRgbppUtxos(props: { from: props.from, source: props.source, fromPubkey: props.fromPubkey, - networkType: props.networkType, changeAddress: props.changeAddress, minUtxoSatoshi: props.minUtxoSatoshi, feeRate: props.feeRate, diff --git a/packages/btc/src/api/sendUtxos.ts b/packages/btc/src/api/sendUtxos.ts index 021bfbc0..cd9cdc31 100644 --- a/packages/btc/src/api/sendUtxos.ts +++ b/packages/btc/src/api/sendUtxos.ts @@ -1,6 +1,5 @@ import { bitcoin } from '../bitcoin'; import { Utxo } from '../types'; -import { NetworkType } from '../network'; import { DataSource } from '../query/source'; import { TxBuilder, InitOutput } from '../transaction/build'; @@ -8,7 +7,6 @@ export async function sendUtxos(props: { inputs: Utxo[]; outputs: InitOutput[]; source: DataSource; - networkType: NetworkType; from: string; fromPubkey?: string; changeAddress?: string; @@ -17,9 +15,8 @@ export async function sendUtxos(props: { }): Promise { const tx = new TxBuilder({ source: props.source, - networkType: props.networkType, - minUtxoSatoshi: props.minUtxoSatoshi, feeRate: props.feeRate, + minUtxoSatoshi: props.minUtxoSatoshi, }); tx.addInputs(props.inputs); diff --git a/packages/btc/src/constants.ts b/packages/btc/src/constants.ts index 0ef8c945..e6bd535e 100644 --- a/packages/btc/src/constants.ts +++ b/packages/btc/src/constants.ts @@ -12,6 +12,6 @@ export const BTC_UTXO_DUST_LIMIT = 1000; export const RGBPP_UTXO_DUST_LIMIT = 546; /** - * An empty placeholder, filled with 0s for the txid of the BTC transaction. + * An empty 32-byte placeholder, filled with 0s for the txid of the BTC transaction. */ export const BTC_TX_ID_PLACEHOLDER = '0x' + '0'.repeat(64); diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts index e750d32b..2671fc11 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -1,7 +1,8 @@ import clone from 'lodash/cloneDeep'; import { bitcoin } from '../bitcoin'; import { DataSource } from '../query/source'; -import { AddressType, Utxo } from '../types'; +import { Utxo } from '../types'; +import { AddressType } from '../address'; import { ErrorCodes, TxBuildError } from '../error'; import { NetworkType, toPsbtNetwork } from '../network'; import { addressToScriptPublicKeyHex, getAddressType, isSupportedFromAddress } from '../address'; @@ -48,9 +49,9 @@ export class TxBuilder { minUtxoSatoshi: number; feeRate: number; - constructor(props: { source: DataSource; networkType: NetworkType; minUtxoSatoshi?: number; feeRate?: number }) { + constructor(props: { source: DataSource; minUtxoSatoshi?: number; feeRate?: number }) { this.source = props.source; - this.networkType = props.networkType; + this.networkType = this.source.networkType; this.feeRate = props.feeRate ?? 1; this.minUtxoSatoshi = props.minUtxoSatoshi ?? BTC_UTXO_DUST_LIMIT; @@ -72,7 +73,7 @@ export class TxBuilder { if ('data' in output) { result = { - script: dataToOpReturnScriptPubkey(clone(output.data)), + script: dataToOpReturnScriptPubkey(output.data), value: output.value, fixed: output.fixed, protected: output.protected, @@ -331,7 +332,6 @@ export class TxBuilder { const tx = new TxBuilder({ source: this.source, feeRate: this.feeRate, - networkType: this.networkType, minUtxoSatoshi: this.minUtxoSatoshi, }); diff --git a/packages/btc/src/transaction/fee.ts b/packages/btc/src/transaction/fee.ts index 21c50740..9eea35aa 100644 --- a/packages/btc/src/transaction/fee.ts +++ b/packages/btc/src/transaction/fee.ts @@ -1,7 +1,6 @@ -import { bitcoin, ECPair, isTaprootInput } from '../bitcoin'; import { ECPairInterface } from 'ecpair'; -import { AddressType } from '../types'; -import { publicKeyToAddress } from '../address'; +import { bitcoin, ECPair, isTaprootInput } from '../bitcoin'; +import { AddressType, publicKeyToAddress } from '../address'; import { NetworkType, toPsbtNetwork } from '../network'; import { toXOnly, tweakSigner } from '../utils'; diff --git a/packages/btc/src/types.ts b/packages/btc/src/types.ts index bfdf293c..ec31442b 100644 --- a/packages/btc/src/types.ts +++ b/packages/btc/src/types.ts @@ -1,3 +1,5 @@ +import { AddressType } from './address'; + export interface Utxo { txid: string; vout: number; @@ -7,13 +9,3 @@ export interface Utxo { scriptPk: string; pubkey?: string; } - -export enum AddressType { - P2PKH, - P2WPKH, - P2TR, - P2SH_P2WPKH, - P2WSH, - P2SH, - UNKNOWN, -} diff --git a/packages/btc/tsconfig.build.json b/packages/btc/tsconfig.build.json index aad55aa0..5902f288 100644 --- a/packages/btc/tsconfig.build.json +++ b/packages/btc/tsconfig.build.json @@ -6,5 +6,5 @@ "outDir": "lib", "noEmit": false }, - "exclude": ["tests"] + "exclude": ["tests", "lib"] } From 671a0c127bc50dba9b58b81cab1d6a0f1bb130b0 Mon Sep 17 00:00:00 2001 From: Shook Date: Tue, 19 Mar 2024 20:09:33 +0800 Subject: [PATCH 08/15] fix: remove deprecated networkType usage from tests --- packages/btc/tests/Embed.test.ts | 2 +- packages/btc/tests/Transaction.test.ts | 16 +--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/btc/tests/Embed.test.ts b/packages/btc/tests/Embed.test.ts index fd1e47af..b392851a 100644 --- a/packages/btc/tests/Embed.test.ts +++ b/packages/btc/tests/Embed.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { dataToOpReturnScriptPubkey, opReturnScriptPubKeyToData } from '../src/transaction/embed'; +import { dataToOpReturnScriptPubkey, opReturnScriptPubKeyToData } from '../src'; describe('Embed', () => { it('Encode UTF-8 data to OP_RETURN script pubkey', () => { diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index def6ccfd..194b36cd 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { accounts, network, networkType, service, source } from './shared/env'; +import { accounts, network, service, source } from './shared/env'; import { ErrorCodes, AddressType, sendBtc, sendUtxos, tweakSigner } from '../src'; import { bitcoin, ErrorMessages, BTC_UTXO_DUST_LIMIT, RGBPP_UTXO_DUST_LIMIT } from '../src'; @@ -26,7 +26,6 @@ describe('Transaction', () => { value: 1000, }, ], - networkType, source, }); @@ -51,7 +50,6 @@ describe('Transaction', () => { value: 1000, }, ], - networkType, source, }); @@ -89,7 +87,6 @@ describe('Transaction', () => { }, ], minUtxoSatoshi: balance.satoshi + 1, - networkType, source, }), ).rejects.toThrow(ErrorMessages[ErrorCodes.INSUFFICIENT_UTXO]); @@ -107,7 +104,6 @@ describe('Transaction', () => { value: 1000, }, ], - networkType, source, }); @@ -161,7 +157,6 @@ describe('Transaction', () => { fixed: true, }, ], - networkType, source, }); @@ -202,7 +197,6 @@ describe('Transaction', () => { fixed: true, }, ], - networkType, source, }); @@ -243,7 +237,6 @@ describe('Transaction', () => { fixed: true, }, ], - networkType, source, }); @@ -288,7 +281,6 @@ describe('Transaction', () => { value: 1000, }, ], - networkType, source, }); @@ -329,7 +321,6 @@ describe('Transaction', () => { protected: true, }, ], - networkType, source, }); @@ -369,7 +360,6 @@ describe('Transaction', () => { protected: true, }, ], - networkType, source, }); @@ -410,7 +400,6 @@ describe('Transaction', () => { protected: true, }, ], - networkType, source, }); @@ -466,7 +455,6 @@ describe('Transaction', () => { protected: true, }, ], - networkType, source, }); @@ -520,7 +508,6 @@ describe('Transaction', () => { protected: true, }, ], - networkType, source, }); @@ -571,7 +558,6 @@ describe('Transaction', () => { protected: true, }, ], - networkType, source, }); From eaa35a846b82a3c4b2bf4af5f36259a43c54dcf9 Mon Sep 17 00:00:00 2001 From: Shook Date: Wed, 20 Mar 2024 16:14:21 +0800 Subject: [PATCH 09/15] fix: potential issue when sorting utxos --- packages/btc/src/query/source.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/btc/src/query/source.ts b/packages/btc/src/query/source.ts index 108e6ff3..4dc31fe1 100644 --- a/packages/btc/src/query/source.ts +++ b/packages/btc/src/query/source.ts @@ -39,9 +39,12 @@ export class DataSource { const scriptPk = addressToScriptPublicKeyHex(address, this.networkType); return utxos .sort((a, b) => { - const aOrder = `${a.status.block_height}${a.vout}`; - const bOrder = `${b.status.block_height}${b.vout}`; - return Number(aOrder) - Number(bOrder); + const aBlockHeight = a.status.block_height; + const bBlockHeight = b.status.block_height; + if (aBlockHeight !== bBlockHeight) { + return aBlockHeight - bBlockHeight; + } + return a.vout - b.vout; }) .map((row): Utxo => { return { From 9482f6c0d93e4424bcb7518846edd8945fd2c798 Mon Sep 17 00:00:00 2001 From: Shook Date: Thu, 21 Mar 2024 11:53:06 +0800 Subject: [PATCH 10/15] fix: fee estimation and related tests --- packages/btc/src/constants.ts | 6 ++ packages/btc/src/transaction/build.ts | 19 +++-- packages/btc/tests/Transaction.test.ts | 104 +++++++++++++------------ packages/btc/tests/shared/utils.ts | 28 +++++++ 4 files changed, 100 insertions(+), 57 deletions(-) create mode 100644 packages/btc/tests/shared/utils.ts diff --git a/packages/btc/src/constants.ts b/packages/btc/src/constants.ts index e6bd535e..47bf4989 100644 --- a/packages/btc/src/constants.ts +++ b/packages/btc/src/constants.ts @@ -15,3 +15,9 @@ export const RGBPP_UTXO_DUST_LIMIT = 546; * An empty 32-byte placeholder, filled with 0s for the txid of the BTC transaction. */ export const BTC_TX_ID_PLACEHOLDER = '0x' + '0'.repeat(64); + +/** + * The minimum fee rate that can be declared in a BTC transaction, in satoshi per byte. + * Note this value can be different in different networks. + */ +export const FEE_RATE = 1; diff --git a/packages/btc/src/transaction/build.ts b/packages/btc/src/transaction/build.ts index 2671fc11..8a783140 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -7,7 +7,7 @@ import { ErrorCodes, TxBuildError } from '../error'; import { NetworkType, toPsbtNetwork } from '../network'; import { addressToScriptPublicKeyHex, getAddressType, isSupportedFromAddress } from '../address'; import { dataToOpReturnScriptPubkey } from './embed'; -import { BTC_UTXO_DUST_LIMIT } from '../constants'; +import { BTC_UTXO_DUST_LIMIT, FEE_RATE } from '../constants'; import { remove0x, toXOnly } from '../utils'; import { FeeEstimator } from './fee'; @@ -53,7 +53,7 @@ export class TxBuilder { this.source = props.source; this.networkType = this.source.networkType; - this.feeRate = props.feeRate ?? 1; + this.feeRate = props.feeRate ?? FEE_RATE; this.minUtxoSatoshi = props.minUtxoSatoshi ?? BTC_UTXO_DUST_LIMIT; } @@ -126,7 +126,7 @@ export class TxBuilder { const addressType = getAddressType(address); const fee = await this.calculateFee(addressType); - if (fee <= previousFee) { + if ([-1, 0, 1].includes(fee - previousFee)) { break; } @@ -229,7 +229,7 @@ export class TxBuilder { // If 0 < change amount < minUtxoSatoshi, collect one more time if (changeAmount > 0 && changeAmount < this.minUtxoSatoshi) { - await _collect(this.minUtxoSatoshi - changeAmount, true); + await _collect(this.minUtxoSatoshi - changeAmount); } // If not collected enough satoshi, revert to the original state and throw error @@ -293,8 +293,15 @@ export class TxBuilder { async calculateFee(addressType: AddressType): Promise { const psbt = await this.createEstimatedPsbt(addressType); - const vSize = psbt.extractTransaction(true).virtualSize(); - return Math.ceil(vSize * this.feeRate); + const tx = psbt.extractTransaction(true); + + const inputs = tx.ins.length; + const weightWithWitness = tx.byteLength(true); + const weightWithoutWitness = tx.byteLength(false); + + const weight = weightWithoutWitness * 3 + weightWithWitness + inputs; + const virtualSize = Math.ceil(weight / 4); + return Math.ceil(virtualSize * this.feeRate); } async createEstimatedPsbt(addressType: AddressType): Promise { diff --git a/packages/btc/tests/Transaction.test.ts b/packages/btc/tests/Transaction.test.ts index 194b36cd..f4261fca 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { accounts, network, service, source } from './shared/env'; import { ErrorCodes, AddressType, sendBtc, sendUtxos, tweakSigner } from '../src'; import { bitcoin, ErrorMessages, BTC_UTXO_DUST_LIMIT, RGBPP_UTXO_DUST_LIMIT } from '../src'; +import { accounts, network, service, source } from './shared/env'; +import { expectPsbtFeeInRange } from './shared/utils'; describe('Transaction', () => { describe('sendBtc()', () => { @@ -33,6 +34,9 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); + // Broadcast transaction // const tx = psbt.extractTransaction(); // const res = await service.sendTransaction(tx.toHex()); @@ -62,10 +66,12 @@ describe('Transaction', () => { psbt.signAllInputs(tweakedSigner); psbt.finalizeAllInputs(); - console.log('fee', psbt.getFee()); console.log(psbt.txInputs); console.log(psbt.txOutputs); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); + // Broadcast transaction // const tx = psbt.extractTransaction(); // const res = await service.sendTransaction(tx.toHex()); @@ -129,6 +135,9 @@ describe('Transaction', () => { expect(data).toBeInstanceOf(Buffer); expect((data as Buffer).toString('hex')).toEqual('00'.repeat(32)); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); + // Broadcast transaction // const tx = psbt.extractTransaction(); // const res = await service.sendTransaction(tx.toHex()); @@ -164,13 +173,11 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBeGreaterThanOrEqual(208); - expect(fee).toBeLessThanOrEqual(209); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -204,13 +211,11 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBeGreaterThanOrEqual(208); - expect(fee).toBeLessThanOrEqual(209); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -244,13 +249,11 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBeGreaterThanOrEqual(208); - expect(fee).toBeLessThanOrEqual(209); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -291,9 +294,8 @@ describe('Transaction', () => { expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(2); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBe(141); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -313,6 +315,14 @@ describe('Transaction', () => { address: accounts.charlie.p2wpkh.address, scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), }, + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 2, + value: 2000, + addressType: AddressType.P2WPKH, + address: accounts.charlie.p2wpkh.address, + scriptPk: accounts.charlie.p2wpkh.scriptPubkey.toString('hex'), + }, ], outputs: [ { @@ -328,12 +338,13 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - expect(psbt.txInputs).toHaveLength(1); + console.log(psbt.data.inputs.map((row) => row.finalScriptWitness!.byteLength)); + + expect(psbt.txInputs).toHaveLength(2); expect(psbt.txOutputs).toHaveLength(1); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBe(110); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -367,20 +378,18 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(2); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBeGreaterThanOrEqual(208); - expect(fee).toBeLessThanOrEqual(209); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); // const res = await service.sendTransaction(tx.toHex()); // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); - it('Transfer protected UTXO, sum(ins) > sum(outs)', async () => { + it('Transfer protected UTXO, sum(ins) > sum(outs), change to outs[0]', async () => { const psbt = await sendUtxos({ from: accounts.charlie.p2wpkh.address, inputs: [ @@ -410,9 +419,8 @@ describe('Transaction', () => { expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(1); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBe(110); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -420,7 +428,7 @@ describe('Transaction', () => { // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); - it('Transfer protected RGBPP_UTXOs, pay with collection', async () => { + it('Transfer protected RGBPP_UTXOs, sum(ins) = sum(outs)', async () => { const psbt = await sendUtxos({ from: accounts.charlie.p2wpkh.address, inputs: [ @@ -462,20 +470,18 @@ describe('Transaction', () => { psbt.signAllInputs(accounts.charlie.keyPair); psbt.finalizeAllInputs(); - expect(psbt.txInputs).toHaveLength(3); + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(3); expect(psbt.txOutputs).toHaveLength(3); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBeGreaterThanOrEqual(307); - expect(fee).toBeLessThanOrEqual(308); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); // const res = await service.sendTransaction(tx.toHex()); // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); }); - it('Transfer protected RGBPP_UTXOs, each with free satoshi', async () => { + it('Transfer protected RGBPP_UTXOs, each has free satoshi', async () => { const psbt = await sendUtxos({ from: accounts.charlie.p2wpkh.address, inputs: [ @@ -518,13 +524,11 @@ describe('Transaction', () => { console.log(psbt.txOutputs); expect(psbt.txInputs).toHaveLength(1); expect(psbt.txOutputs).toHaveLength(3); - expect(psbt.txOutputs[0].value).toBe(RGBPP_UTXO_DUST_LIMIT); - expect(psbt.txOutputs[1].value).toBe(RGBPP_UTXO_DUST_LIMIT + 28); - expect(psbt.txOutputs[2].value).toBe(RGBPP_UTXO_DUST_LIMIT + 100); + expect(psbt.txOutputs[0].value).toBeLessThan(psbt.txOutputs[1].value); + expect(psbt.txOutputs[1].value).toBeLessThan(psbt.txOutputs[2].value); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBe(172); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); @@ -566,15 +570,13 @@ describe('Transaction', () => { psbt.finalizeAllInputs(); console.log(psbt.txOutputs); - expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); expect(psbt.txOutputs).toHaveLength(3); expect(psbt.txOutputs[0].value).toBe(RGBPP_UTXO_DUST_LIMIT); expect(psbt.txOutputs[1].value).toBe(RGBPP_UTXO_DUST_LIMIT); - const fee = psbt.getFee(); - console.log('fee:', fee); - expect(fee).toBeGreaterThanOrEqual(239); - expect(fee).toBeLessThanOrEqual(240); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); // Broadcast transaction // const tx = psbt.extractTransaction(); diff --git a/packages/btc/tests/shared/utils.ts b/packages/btc/tests/shared/utils.ts new file mode 100644 index 00000000..1eb3e3f4 --- /dev/null +++ b/packages/btc/tests/shared/utils.ts @@ -0,0 +1,28 @@ +import { expect } from 'vitest'; +import { bitcoin, FEE_RATE } from '../../src'; + +/** + * Estimate a network fee of a PSBT. + */ +export function calculatePsbtFee(psbt: bitcoin.Psbt, feeRate?: number) { + if (!feeRate) { + feeRate = FEE_RATE; + } + + const tx = psbt.extractTransaction(false); + const virtualSize = tx.virtualSize(); + return Math.ceil(virtualSize * feeRate); +} + +/** + * Expect the paid fee of the PSBT to be in ±1 range of the estimated fee. + */ +export function expectPsbtFeeInRange(psbt: bitcoin.Psbt, feeRate?: number) { + const estimated = calculatePsbtFee(psbt, feeRate); + const paid = psbt.getFee(); + + expect([0, 1].includes(paid - estimated)).eq( + true, + `paid fee should be ${estimated}±1 satoshi, but paid ${paid} satoshi`, + ); +} From 473fee95d4756c0f621b6183fcfcec9d1024492a Mon Sep 17 00:00:00 2001 From: Shook Date: Thu, 21 Mar 2024 12:39:45 +0800 Subject: [PATCH 11/15] refactor: remove inner ckb implementations in the btc lib --- packages/btc/package.json | 2 +- packages/btc/src/api/sendRgbppUtxos.ts | 56 +++++++++++------------- packages/btc/src/ckb/commitment.ts | 60 -------------------------- packages/btc/src/ckb/molecule.ts | 26 ++--------- packages/btc/src/ckb/rpc.ts | 27 ------------ packages/btc/src/network.ts | 7 +++ pnpm-lock.yaml | 6 +-- 7 files changed, 39 insertions(+), 145 deletions(-) delete mode 100644 packages/btc/src/ckb/commitment.ts delete mode 100644 packages/btc/src/ckb/rpc.ts diff --git a/packages/btc/package.json b/packages/btc/package.json index 37984aca..19d5afc0 100644 --- a/packages/btc/package.json +++ b/packages/btc/package.json @@ -18,10 +18,10 @@ "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", "@ckb-lumos/lumos": "0.22.0-next.5", + "@rgbpp-sdk/ckb": "workspace:^", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "ecpair": "^2.1.0", - "js-sha256": "^0.11.0", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts index f912ac05..9e651f22 100644 --- a/packages/btc/src/api/sendRgbppUtxos.ts +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -1,14 +1,14 @@ -import { helpers, Hash, RawTransaction, RPC } from '@ckb-lumos/lumos'; -import { InitOutput, TxAddressOutput } from '../transaction/build'; -import { ErrorCodes, TxBuildError } from '../error'; -import { DataSource } from '../query/source'; -import { Utxo } from '../types'; +import { Collector, getBtcTimeLockScript, getRgbppLockScript, calculateCommitment } from '@rgbpp-sdk/ckb'; +import { Hash, RawTransaction } from '@ckb-lumos/lumos'; import { bitcoin } from '../bitcoin'; -import { RGBPP_UTXO_DUST_LIMIT } from '../constants'; -import { calculateCommitment } from '../ckb/commitment'; +import { Utxo } from '../types'; +import { DataSource } from '../query/source'; +import { ErrorCodes, TxBuildError } from '../error'; +import { InitOutput, TxAddressOutput } from '../transaction/build'; import { unpackRgbppLockArgs } from '../ckb/molecule'; -import { getCellByOutPoint } from '../ckb/rpc'; +import { toIsCkbMainnet } from '../network'; import { sendUtxos } from './sendUtxos'; +import { RGBPP_UTXO_DUST_LIMIT } from '../constants'; export async function sendRgbppUtxos(props: { ckbVirtualTx: RawTransaction; @@ -16,9 +16,7 @@ export async function sendRgbppUtxos(props: { commitment: Hash; tos?: string[]; - ckbNodeUrl: string; - rgbppLockCodeHash: Hash; - rgbppTimeLockCodeHash: Hash; + ckbCollector: Collector; rgbppMinUtxoSatoshi?: number; from: string; @@ -33,27 +31,21 @@ export async function sendRgbppUtxos(props: { let lastTypeInputIndex = -1; let lastTypeOutputIndex = -1; - // Build TransactionSkeleton from CKB VirtualTx - const rpc = new RPC(props.ckbNodeUrl); const ckbVirtualTx = props.ckbVirtualTx; - const ckbTxSkeleton = await helpers.createTransactionSkeleton(ckbVirtualTx as any, async (outPoint) => { - const result = await getCellByOutPoint(outPoint, rpc); - if (!result.cell || result.status !== 'live') { - throw new TxBuildError(ErrorCodes.CKB_CANNOT_FIND_OUTPOINT); - } - - return result.cell; - }); + const isCkbMainnet = toIsCkbMainnet(props.source.networkType); + const rgbppLock = getRgbppLockScript(isCkbMainnet); + const rgbppTimeLock = getBtcTimeLockScript(isCkbMainnet); // Handle and check inputs - const inputCells = ckbTxSkeleton.get('inputs'); - for (let i = 0; i < inputCells.size; i++) { - const input = inputCells.get(i)!; - const isRgbppLock = input.cellOutput.lock.codeHash === props.rgbppLockCodeHash; - const isRgbppTimeLock = input.cellOutput.lock.codeHash === props.rgbppTimeLockCodeHash; + for (let i = 0; i < ckbVirtualTx.inputs.length; i++) { + const input = ckbVirtualTx.inputs[i]; + + const cell = await props.ckbCollector.getLiveCell(input.previousOutput); + const isRgbppLock = cell.output.lock.codeHash === rgbppLock.codeHash; + const isRgbppTimeLock = cell.output.lock.codeHash === rgbppLock.codeHash; // If input.type !== null, input.lock must be RgbppLock or RgbppTimeLock - if (input.cellOutput.type) { + if (cell.output.type) { if (!isRgbppLock && !isRgbppTimeLock) { throw new TxBuildError(ErrorCodes.CKB_INVALID_CELL_LOCK); } @@ -67,8 +59,8 @@ export async function sendRgbppUtxos(props: { // 2. utxo can be found via the DataSource.getUtxo() API // 3. utxo.scriptPk == addressToScriptPk(props.from) if (isRgbppLock || isRgbppTimeLock) { - const args = unpackRgbppLockArgs(input.cellOutput.lock.args); - const utxo = await props.source.getUtxo(args.btcTxId, args.outIndex); + const args = unpackRgbppLockArgs(cell.output.lock.args); + const utxo = await props.source.getUtxo(args.btcTxid, args.outIndex); if (!utxo) { throw new TxBuildError(ErrorCodes.CANNOT_FIND_UTXO); } @@ -91,11 +83,11 @@ export async function sendRgbppUtxos(props: { // Handle and check outputs for (let i = 0; i < ckbVirtualTx.outputs.length; i++) { const output = ckbVirtualTx.outputs[i]; + const isRgbppLock = output.lock.codeHash === rgbppLock.codeHash; + const isRgbppTimeLock = output.lock.codeHash === rgbppTimeLock.codeHash; // If output.type !== null, then the output.lock must be RgbppLock or RgbppTimeLock if (output.type) { - const isRgbppLock = output.lock.codeHash === props.rgbppLockCodeHash; - const isRgbppTimeLock = output.lock.codeHash === props.rgbppTimeLockCodeHash; if (!isRgbppLock && !isRgbppTimeLock) { throw new TxBuildError(ErrorCodes.CKB_INVALID_CELL_LOCK); } @@ -105,7 +97,7 @@ export async function sendRgbppUtxos(props: { } // If output.lock == RgbppLock, generate a corresponding output in outputs - if (output.lock.codeHash === props.rgbppLockCodeHash) { + if (isRgbppLock) { const toAddress = props.tos?.[i]; outputs.push({ protected: true, diff --git a/packages/btc/src/ckb/commitment.ts b/packages/btc/src/ckb/commitment.ts deleted file mode 100644 index 4d03ab49..00000000 --- a/packages/btc/src/ckb/commitment.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { sha256 } from 'js-sha256'; -import { Input, Output } from '@ckb-lumos/lumos'; -import { blockchain, bytes } from '@ckb-lumos/lumos/codec'; -import { utf8ToBuffer } from '../utils'; -import { RgbppLockArgs, unpackRgbppLockArgs } from './molecule'; -import { BTC_TX_ID_PLACEHOLDER } from '../constants'; - -interface CommitmentCkbRawTx { - inputs: Input[]; - outputs: Output[]; - outputsData: string[]; -} - -/** - * Calculate RGBPP transaction commitment for validation. - */ -export function calculateCommitment(tx: CommitmentCkbRawTx): string { - const hash = sha256.create(); - - // Prefix - hash.update(utf8ToBuffer('RGB++')); - - // Version - // TODO: Support versioning when needed - const version = [0, 0]; - hash.update(version); - - // Length of inputs & outputs - hash.update([tx.inputs.length, tx.outputs.length]); - - // Inputs - for (const input of tx.inputs) { - hash.update(blockchain.OutPoint.pack(input.previousOutput)); - } - - // Outputs - for (let index = 0; index < tx.outputs.length; index++) { - const output = tx.outputs[index]; - const lockArgs = unpackRgbppLockArgs(output.lock.args); - hash.update( - blockchain.CellOutput.pack({ - capacity: output.capacity, - type: output.type, - lock: { - ...output.lock, - args: RgbppLockArgs.pack({ - outIndex: lockArgs.outIndex, - btcTxId: BTC_TX_ID_PLACEHOLDER, // 32-byte placeholder - }), - }, - }), - ); - - const outputData = tx.outputsData[index] ?? '0x'; - hash.update(bytes.bytify(outputData)); - } - - // Double sha256 - return sha256(hash.array()); -} diff --git a/packages/btc/src/ckb/molecule.ts b/packages/btc/src/ckb/molecule.ts index 6341f495..5cd00627 100644 --- a/packages/btc/src/ckb/molecule.ts +++ b/packages/btc/src/ckb/molecule.ts @@ -1,31 +1,13 @@ -import { BytesLike, FixedBytesCodec, ObjectLayoutCodec, UnpackResult } from '@ckb-lumos/lumos/codec'; -import { blockchain, struct, Uint32LE } from '@ckb-lumos/lumos/codec'; -import { BIish } from '@ckb-lumos/lumos'; +import { BytesLike, UnpackResult } from '@ckb-lumos/lumos/codec'; +import { RGBPPLock } from '@rgbpp-sdk/ckb'; import { ErrorCodes, TxBuildError } from '../error'; -type Fixed = { - readonly __isFixedCodec__: true; - readonly byteLength: number; -}; - -export const RgbppLockArgs: ObjectLayoutCodec<{ - outIndex: FixedBytesCodec; - btcTxId: FixedBytesCodec; -}> & - Fixed = struct( - { - outIndex: Uint32LE, - btcTxId: blockchain.Byte32, - }, - ['outIndex', 'btcTxId'], -); - /** * Unpack RgbppLockArgs from a BytesLike (Buffer, Uint8Array, HexString, etc) value. */ -export function unpackRgbppLockArgs(source: BytesLike): UnpackResult { +export function unpackRgbppLockArgs(source: BytesLike): UnpackResult { try { - return RgbppLockArgs.unpack(source); + return RGBPPLock.unpack(source); } catch { throw new TxBuildError(ErrorCodes.CKB_RGBPP_LOCK_UNPACK_ERROR); } diff --git a/packages/btc/src/ckb/rpc.ts b/packages/btc/src/ckb/rpc.ts deleted file mode 100644 index 213e02cf..00000000 --- a/packages/btc/src/ckb/rpc.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Cell, OutPoint, RPC } from '@ckb-lumos/lumos'; -import { CKBComponents } from '@ckb-lumos/lumos/rpc'; - -/** - * Query a specific cell (with status) via CKB RPC.getLiveCell() method. - */ -export async function getCellByOutPoint( - outPoint: OutPoint, - rpc: RPC, -): Promise<{ - cell?: Cell; - status: CKBComponents.CellStatus; -}> { - const liveCell = await rpc.getLiveCell(outPoint, true); - const cell: Cell | undefined = liveCell.cell - ? { - outPoint, - cellOutput: liveCell.cell.output, - data: liveCell.cell.data.content, - } - : void 0; - - return { - cell, - status: liveCell.status, - }; -} diff --git a/packages/btc/src/network.ts b/packages/btc/src/network.ts index 8f280482..35eb8736 100644 --- a/packages/btc/src/network.ts +++ b/packages/btc/src/network.ts @@ -31,3 +31,10 @@ export function toNetworkType(network: bitcoin.Network): NetworkType { return NetworkType.REGTEST; } } + +/** + * The rgbpp-sdk/ckb accepts a "isMainnet" property to indicate the network type. + */ +export function toIsCkbMainnet(networkType: NetworkType): boolean { + return networkType === NetworkType.MAINNET; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 742eb769..e7837c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: '@ckb-lumos/lumos': specifier: 0.22.0-next.5 version: 0.22.0-next.5 + '@rgbpp-sdk/ckb': + specifier: workspace:^ + version: link:../ckb bip32: specifier: ^4.0.0 version: 4.0.0 @@ -127,9 +130,6 @@ importers: ecpair: specifier: ^2.1.0 version: 2.1.0 - js-sha256: - specifier: ^0.11.0 - version: 0.11.0 lodash: specifier: ^4.17.21 version: 4.17.21 From 65a2ab6886a8794df03d338cb6321cf1316ef430 Mon Sep 17 00:00:00 2001 From: Shook Date: Thu, 21 Mar 2024 12:43:41 +0800 Subject: [PATCH 12/15] chore: add build process in the test workflow --- .github/workflows/test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5c8b8da1..1f305b8b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,6 +42,9 @@ jobs: - name: Install dependencies run: pnpm i + + - name: Build packages + run: pnpm run build:packages - name: Run tests for packages run: pnpm run test:packages From 3e2e9d381ab5967cd91ee53118cb1d5e9c5e7b8d Mon Sep 17 00:00:00 2001 From: Shook Date: Thu, 21 Mar 2024 13:11:51 +0800 Subject: [PATCH 13/15] fix: vitest shouldn't find tests in lib --- packages/btc/package.json | 2 +- packages/btc/vitest.config.ts | 8 ++++++++ packages/ckb/package.json | 2 +- packages/ckb/vitest.config.ts | 8 ++++++++ turbo.json | 3 ++- 5 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 packages/btc/vitest.config.ts create mode 100644 packages/ckb/vitest.config.ts diff --git a/packages/btc/package.json b/packages/btc/package.json index 19d5afc0..06543eac 100644 --- a/packages/btc/package.json +++ b/packages/btc/package.json @@ -2,7 +2,7 @@ "name": "@rgbpp-sdk/btc", "version": "0.1.0", "scripts": { - "test": "vitest --watch=false", + "test": "vitest", "build": "tsc -p tsconfig.build.json", "lint": "prettier --check '{src,tests}/**/*.{js,jsx,ts,tsx}'", "lint:fix": "prettier --write '{src,tests}/**/*.{js,jsx,ts,tsx}'", diff --git a/packages/btc/vitest.config.ts b/packages/btc/vitest.config.ts new file mode 100644 index 00000000..dfe64973 --- /dev/null +++ b/packages/btc/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + exclude: ['lib', 'node_modules'], + }, +}); diff --git a/packages/ckb/package.json b/packages/ckb/package.json index 8232b947..b4906959 100644 --- a/packages/ckb/package.json +++ b/packages/ckb/package.json @@ -2,7 +2,7 @@ "name": "@rgbpp-sdk/ckb", "version": "0.1.0", "scripts": { - "test": "vitest --watch=false", + "test": "vitest", "build": "tsc -p tsconfig.build.json", "lint": "prettier --check 'src/**/*.{js,jsx,ts,tsx}'", "lint:fix": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'", diff --git a/packages/ckb/vitest.config.ts b/packages/ckb/vitest.config.ts new file mode 100644 index 00000000..dfe64973 --- /dev/null +++ b/packages/ckb/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + exclude: ['lib', 'node_modules'], + }, +}); diff --git a/turbo.json b/turbo.json index 0dcd8a4f..ccd378fa 100644 --- a/turbo.json +++ b/turbo.json @@ -14,7 +14,8 @@ }, "test": { "outputs": ["coverage/**"], - "dependsOn": [] + "dependsOn": ["^build"], + "cache": false }, "lint:fix": { "cache": false From 2c7bb1df3193a7c3b0d18416e0fc8a195e41da2c Mon Sep 17 00:00:00 2001 From: Shook Date: Thu, 21 Mar 2024 13:16:45 +0800 Subject: [PATCH 14/15] fix: add include to tsconfig.json in btc lib --- packages/btc/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/btc/tsconfig.json b/packages/btc/tsconfig.json index 0d422d0c..35666b8a 100644 --- a/packages/btc/tsconfig.json +++ b/packages/btc/tsconfig.json @@ -15,5 +15,6 @@ "resolveJsonModule": true, "isolatedModules": true }, + "include": ["src"], "exclude": ["node_modules", "lib"] } From b93177be98021874bb7be6c4a74d3813fafe2954 Mon Sep 17 00:00:00 2001 From: Shook Date: Thu, 21 Mar 2024 14:30:05 +0800 Subject: [PATCH 15/15] refactor: replace RgbppLock checking part with ckb lib apis --- packages/btc/src/api/sendRgbppUtxos.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/btc/src/api/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts index 9e651f22..ca64b1ca 100644 --- a/packages/btc/src/api/sendRgbppUtxos.ts +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -1,4 +1,4 @@ -import { Collector, getBtcTimeLockScript, getRgbppLockScript, calculateCommitment } from '@rgbpp-sdk/ckb'; +import { Collector, isRgbppLockCell, isBtcTimeLockCell, calculateCommitment } from '@rgbpp-sdk/ckb'; import { Hash, RawTransaction } from '@ckb-lumos/lumos'; import { bitcoin } from '../bitcoin'; import { Utxo } from '../types'; @@ -33,20 +33,18 @@ export async function sendRgbppUtxos(props: { const ckbVirtualTx = props.ckbVirtualTx; const isCkbMainnet = toIsCkbMainnet(props.source.networkType); - const rgbppLock = getRgbppLockScript(isCkbMainnet); - const rgbppTimeLock = getBtcTimeLockScript(isCkbMainnet); // Handle and check inputs for (let i = 0; i < ckbVirtualTx.inputs.length; i++) { const input = ckbVirtualTx.inputs[i]; - const cell = await props.ckbCollector.getLiveCell(input.previousOutput); - const isRgbppLock = cell.output.lock.codeHash === rgbppLock.codeHash; - const isRgbppTimeLock = cell.output.lock.codeHash === rgbppLock.codeHash; + const liveCell = await props.ckbCollector.getLiveCell(input.previousOutput); + const isRgbppLock = isRgbppLockCell(liveCell.output, isCkbMainnet); + const isRgbppTimeLock = isBtcTimeLockCell(liveCell.output, isCkbMainnet); // If input.type !== null, input.lock must be RgbppLock or RgbppTimeLock - if (cell.output.type) { - if (!isRgbppLock && !isRgbppTimeLock) { + if (liveCell.output.type) { + if (!isRgbppLock) { throw new TxBuildError(ErrorCodes.CKB_INVALID_CELL_LOCK); } @@ -58,8 +56,8 @@ export async function sendRgbppUtxos(props: { // 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) - if (isRgbppLock || isRgbppTimeLock) { - const args = unpackRgbppLockArgs(cell.output.lock.args); + if (isRgbppLock) { + const args = unpackRgbppLockArgs(liveCell.output.lock.args); const utxo = await props.source.getUtxo(args.btcTxid, args.outIndex); if (!utxo) { throw new TxBuildError(ErrorCodes.CANNOT_FIND_UTXO); @@ -83,8 +81,8 @@ export async function sendRgbppUtxos(props: { // Handle and check outputs for (let i = 0; i < ckbVirtualTx.outputs.length; i++) { const output = ckbVirtualTx.outputs[i]; - const isRgbppLock = output.lock.codeHash === rgbppLock.codeHash; - const isRgbppTimeLock = output.lock.codeHash === rgbppTimeLock.codeHash; + const isRgbppLock = isRgbppLockCell(output, isCkbMainnet); + const isRgbppTimeLock = isBtcTimeLockCell(output, isCkbMainnet); // If output.type !== null, then the output.lock must be RgbppLock or RgbppTimeLock if (output.type) {