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/.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/.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/.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 diff --git a/packages/btc/README.md b/packages/btc/README.md index 58b5d0fb..cc371610 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 @@ -75,12 +75,9 @@ 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); -// Create a PSBT const psbt = await sendBtc({ from: account.address, // your P2WPKH address tos: [ @@ -89,8 +86,7 @@ const psbt = await sendBtc({ value: 1000, // transfer satoshi amount }, ], - feeRate: 1, // optional - networkType, + feeRate: 1, // optional, default to 1 sat/vbyte source, }); @@ -104,15 +100,49 @@ const res = await service.sendTransaction(tx.toHex()); console.log('txid:', res.txid); ``` -Create an `OP_RETURN` output: +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.TESTNET); + +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 + 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 +import { sendBtc, BtcAssetsApi, DataSource, NetworkType } from '@rgbpp-sdk/btc'; 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({ @@ -123,8 +153,57 @@ const psbt = await sendBtc({ value: 0, // normally the value is 0 }, ], - feeRate: 1, // optional - networkType, + changeAddress: account.address, // optional, where to return the change + feeRate: 1, // optional, default to 1 sat/vbyte + 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 service = BtcAssetsApi.fromToken('btc_assets_api_url', 'your_token'); +const source = new DataSource(service, NetworkType.TESTNET); + +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 + 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 source, }); @@ -145,18 +224,118 @@ 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; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; + }): Promise; +} +``` + +#### sendUtxos + +```typescript +interface sendUtxos { + (props: { + inputs: Utxo[]; + outputs: InitOutput[]; + source: DataSource; + from: string; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; + }): Promise; +} +``` + +#### 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 +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 +346,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 +362,14 @@ interface BtcAssetsApiToken { } ``` +#### BtcAssetsApiBalanceParams + +```typescript +interface BtcAssetsApiBalanceParams { + min_satoshi?: number; +} +``` + #### BtcAssetsApiBalance ```typescript @@ -194,6 +381,14 @@ interface BtcAssetsApiBalance { } ``` +#### BtcAssetsApiUtxoParams + +```typescript +interface BtcAssetsApiUtxoParams { + min_satoshi?: number; +} +``` + #### BtcAssetsApiUtxo ```typescript @@ -262,10 +457,10 @@ interface BtcAssetsApiTransaction { ### Basic -#### UnspentOutput +#### Utxo ```typescript -interface UnspentOutput { +interface Utxo { txid: string; vout: number; value: number; @@ -284,8 +479,6 @@ enum AddressType { P2WPKH, P2TR, P2SH_P2WPKH, - M44_P2WPKH, // deprecated - M44_P2TR, // deprecated P2WSH, P2SH, UNKNOWN, diff --git a/packages/btc/package.json b/packages/btc/package.json index a30a9b37..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}'", @@ -17,6 +17,8 @@ ], "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", diff --git a/packages/btc/src/address.ts b/packages/btc/src/address.ts index 21a2b7ad..826be50f 100644 --- a/packages/btc/src/address.ts +++ b/packages/btc/src/address.ts @@ -1,8 +1,17 @@ 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'; + +export enum AddressType { + P2PKH, + P2WPKH, + P2TR, + P2SH_P2WPKH, + P2WSH, + P2SH, + UNKNOWN, +} /** * Check weather the address is supported as a from address. @@ -22,7 +31,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..79b303c8 100644 --- a/packages/btc/src/api/sendBtc.ts +++ b/packages/btc/src/api/sendBtc.ts @@ -1,39 +1,33 @@ 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; changeAddress?: string; 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/sendRgbppUtxos.ts b/packages/btc/src/api/sendRgbppUtxos.ts new file mode 100644 index 00000000..ca64b1ca --- /dev/null +++ b/packages/btc/src/api/sendRgbppUtxos.ts @@ -0,0 +1,159 @@ +import { Collector, isRgbppLockCell, isBtcTimeLockCell, calculateCommitment } from '@rgbpp-sdk/ckb'; +import { Hash, RawTransaction } from '@ckb-lumos/lumos'; +import { bitcoin } from '../bitcoin'; +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 { toIsCkbMainnet } from '../network'; +import { sendUtxos } from './sendUtxos'; +import { RGBPP_UTXO_DUST_LIMIT } from '../constants'; + +export async function sendRgbppUtxos(props: { + ckbVirtualTx: RawTransaction; + paymaster: TxAddressOutput; + commitment: Hash; + tos?: string[]; + + ckbCollector: Collector; + rgbppMinUtxoSatoshi?: number; + + from: string; + source: DataSource; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; +}): Promise { + const inputs: Utxo[] = []; + const outputs: InitOutput[] = []; + let lastTypeInputIndex = -1; + let lastTypeOutputIndex = -1; + + const ckbVirtualTx = props.ckbVirtualTx; + const isCkbMainnet = toIsCkbMainnet(props.source.networkType); + + // Handle and check inputs + for (let i = 0; i < ckbVirtualTx.inputs.length; i++) { + const input = ckbVirtualTx.inputs[i]; + + 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 (liveCell.output.type) { + if (!isRgbppLock) { + 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) { + 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); + } + 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]; + 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) { + 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 (isRgbppLock) { + 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, + 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 new file mode 100644 index 00000000..cd9cdc31 --- /dev/null +++ b/packages/btc/src/api/sendUtxos.ts @@ -0,0 +1,32 @@ +import { bitcoin } from '../bitcoin'; +import { Utxo } from '../types'; +import { DataSource } from '../query/source'; +import { TxBuilder, InitOutput } from '../transaction/build'; + +export async function sendUtxos(props: { + inputs: Utxo[]; + outputs: InitOutput[]; + source: DataSource; + from: string; + fromPubkey?: string; + changeAddress?: string; + minUtxoSatoshi?: number; + feeRate?: number; +}): Promise { + const tx = new TxBuilder({ + source: props.source, + feeRate: props.feeRate, + minUtxoSatoshi: props.minUtxoSatoshi, + }); + + 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/ckb/molecule.ts b/packages/btc/src/ckb/molecule.ts new file mode 100644 index 00000000..5cd00627 --- /dev/null +++ b/packages/btc/src/ckb/molecule.ts @@ -0,0 +1,14 @@ +import { BytesLike, UnpackResult } from '@ckb-lumos/lumos/codec'; +import { RGBPPLock } from '@rgbpp-sdk/ckb'; +import { ErrorCodes, TxBuildError } from '../error'; + +/** + * Unpack RgbppLockArgs from a BytesLike (Buffer, Uint8Array, HexString, etc) value. + */ +export function unpackRgbppLockArgs(source: BytesLike): UnpackResult { + try { + return RGBPPLock.unpack(source); + } catch { + throw new TxBuildError(ErrorCodes.CKB_RGBPP_LOCK_UNPACK_ERROR); + } +} diff --git a/packages/btc/src/constants.ts b/packages/btc/src/constants.ts index e3b1534e..47bf4989 100644 --- a/packages/btc/src/constants.ts +++ b/packages/btc/src/constants.ts @@ -1 +1,23 @@ -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; + +/** + * 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/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 bb0a8a88..922d9886 100644 --- a/packages/btc/src/index.ts +++ b/packages/btc/src/index.ts @@ -15,3 +15,5 @@ export * from './transaction/embed'; export * from './transaction/fee'; export * from './api/sendBtc'; +export * from './api/sendUtxos'; +export * from './api/sendRgbppUtxos'; 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/packages/btc/src/query/source.ts b/packages/btc/src/query/source.ts index d8edb663..4dc31fe1 100644 --- a/packages/btc/src/query/source.ts +++ b/packages/btc/src/query/source.ts @@ -1,29 +1,52 @@ +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 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); 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): UnspentOutput => { + .map((row): Utxo => { return { address, scriptPk, @@ -35,17 +58,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 +82,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..8a783140 100644 --- a/packages/btc/src/transaction/build.ts +++ b/packages/btc/src/transaction/build.ts @@ -1,13 +1,14 @@ -import clone from 'lodash/clone'; +import clone from 'lodash/cloneDeep'; import { bitcoin } from '../bitcoin'; import { DataSource } from '../query/source'; +import { Utxo } from '../types'; +import { AddressType } from '../address'; 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, FEE_RATE } from '../constants'; +import { remove0x, toXOnly } from '../utils'; import { FeeEstimator } from './fee'; interface TxInput { @@ -17,23 +18,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,118 +46,262 @@ 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; minUtxoSatoshi?: number; feeRate?: number }) { this.source = props.source; + this.networkType = this.source.networkType; - this.feeRate = props.feeRate ?? 1; - this.networkType = props.networkType; - this.changedAddress = props.changeAddress; - this.minUtxoSatoshi = props.minUtxoSatoshi ?? MIN_COLLECTABLE_SATOSHI; + this.feeRate = props.feeRate ?? FEE_RATE; + 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; - return this.addOutput({ - script: scriptPubkey, - value: to.value, - }); + if ('data' in output) { + result = { + script: dataToOpReturnScriptPubkey(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 ('address' in to) { - return this.addOutput(to); + if (!result) { + throw new TxBuildError(ErrorCodes.UNSUPPORTED_OUTPUT); } - throw new TxBuildError(ErrorCodes.UNSUPPORTED_OUTPUT); + this.outputs.push(result); + } + + addOutputs(outputs: InitOutput[]) { + outputs.forEach((output) => { + this.addOutput(output); + }); + } + + 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 ([-1, 0, 1].includes(fee - previousFee)) { + break; + } + + previousFee = fee; + this.inputs = clone(originalInputs); + this.outputs = clone(originalOutputs); + } } - async collectInputsAndPayFee(props: { + async injectSatoshi(props: { address: string; - pubkey?: string; - fee?: number; - extraChange?: number; - }): 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 { utxos, satoshi, exceedSatoshi } = await this.source.collectSatoshi( - address, - targetAmount, - this.minUtxoSatoshi, - ); - if (satoshi < targetAmount) { - throw new TxBuildError(ErrorCodes.INSUFFICIENT_UTXO); + publicKey?: string; + targetAmount: number; + changeAddress?: string; + injectCollected?: boolean; + deductFromOutputs?: boolean; + }) { + if (!isSupportedFromAddress(props.address)) { + throw new TxBuildError(ErrorCodes.UNSUPPORTED_ADDRESS_TYPE); } - const originalInputs = clone(this.inputs); - utxos.forEach((utxo) => { - this.addInput({ - ...utxo, - pubkey, + 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, + }); }); - }); - const originalOutputs = clone(this.outputs); - const changeSatoshi = exceedSatoshi + extraChange; - const requireChangeUtxo = changeSatoshi > 0; - if (requireChangeUtxo) { + 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); + } + + // 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: this.changedAddress, - value: changeSatoshi, + address: props.changeAddress ?? props.address, + value: changeAmount, }); } - const addressType = getAddressType(address); - const estimatedFee = await this.calculateFee(addressType); - if (estimatedFee > fee || changeSatoshi < this.minUtxoSatoshi) { - this.inputs = originalInputs; - this.outputs = originalOutputs; + return { + collected, + changeIndex, + changeAmount, + }; + } + + async injectChange(props: { amount: number; address: string; publicKey?: string }) { + const { address, publicKey, amount } = props; - const nextExtraChange = (() => { - if (requireChangeUtxo) { - if (changeSatoshi < this.minUtxoSatoshi) { - return this.minUtxoSatoshi; - } - return extraChange; - } - return 0; - })(); + 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; + } - return await this.collectInputsAndPayFee({ + output.value += amount; + return; + } + + if (amount < this.minUtxoSatoshi) { + const { collected } = await this.injectSatoshi({ address, - pubkey, - fee: estimatedFee, - extraChange: nextExtraChange, + publicKey, + targetAmount: amount, + injectCollected: true, + deductFromOutputs: false, + }); + if (collected < amount) { + throw new TxBuildError(ErrorCodes.INSUFFICIENT_UTXO); + } + } else { + this.addOutput({ + address: address, + value: amount, }); } } 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 { @@ -173,15 +321,30 @@ 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, + minUtxoSatoshi: this.minUtxoSatoshi, }); + tx.inputs = clone(this.inputs); tx.outputs = clone(this.outputs); + return tx; } @@ -198,14 +361,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 +386,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..e3e0d08c 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!; } @@ -25,7 +30,7 @@ export function dataToOpReturnScriptPubkey(data: Buffer): 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/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 a3751d5b..ec31442b 100644 --- a/packages/btc/src/types.ts +++ b/packages/btc/src/types.ts @@ -1,4 +1,6 @@ -export interface UnspentOutput { +import { AddressType } from './address'; + +export interface Utxo { txid: string; vout: number; value: number; @@ -7,13 +9,3 @@ export interface UnspentOutput { scriptPk: string; pubkey?: string; } - -export enum AddressType { - P2PKH, - P2WPKH, - P2TR, - P2SH_P2WPKH, - P2WSH, - P2SH, - UNKNOWN, -} diff --git a/packages/btc/src/utils.ts b/packages/btc/src/utils.ts index 21622131..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 removeHexPrefix(hex: string): string { +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/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 a94bf39d..f4261fca 100644 --- a/packages/btc/tests/Transaction.test.ts +++ b/packages/btc/tests/Transaction.test.ts @@ -1,134 +1,591 @@ 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'; +import { accounts, network, service, source } from './shared/env'; +import { expectPsbtFeeInRange } from './shared/utils'; 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('sendBtc()', () => { + 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, + }, + ], + source, + }); + + // Sign & finalize inputs + 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()); + // 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, + }, + ], + source, + }); + + // Create a tweaked signer + const tweakedSigner = tweakSigner(accounts.charlie.keyPair, { + network, + }); + + // Sign & finalize inputs + psbt.signAllInputs(tweakedSigner); + psbt.finalizeAllInputs(); + + 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()); + // 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, }, ], - networkType, + minUtxoSatoshi: balance.satoshi + 1, 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + 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(); - // 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 scripts = bitcoin.script.decompile(opReturnOutput.script); + expect(scripts).toBeDefined(); + + 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)); + + 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 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('sendUtxos()', () => { + 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); + expect(psbt.txOutputs).toHaveLength(2); + + 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 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); + expect(psbt.txOutputs).toHaveLength(2); + + 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 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(2); + expect(psbt.txOutputs).toHaveLength(2); + + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); - // Create a tweaked signer - const tweakedSigner = tweakSigner(accounts.charlie.keyPair, { - network, + // 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, + }, + ], + 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(1); + expect(psbt.txOutputs).toHaveLength(2); + + 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}`); }); - await expect(() => - sendBtc({ + 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'), + }, + { + txid: '4e1e9f8ff4bf245793c05bf2da58bff812c332a296d93c6935fbc980d906e567', + vout: 2, + 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + console.log(psbt.data.inputs.map((row) => row.finalScriptWitness!.byteLength)); + + expect(psbt.txInputs).toHaveLength(2); + expect(psbt.txOutputs).toHaveLength(1); + + 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 () => { + 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.length).toBeGreaterThanOrEqual(2); + expect(psbt.txOutputs).toHaveLength(2); + + 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), change to outs[0]', 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, + }, + ], + 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(); + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(1); - const scripts = bitcoin.script.decompile(opReturnOutput.script); - expect(scripts).toBeDefined(); + console.log('tx paid fee:', psbt.getFee()); + expectPsbtFeeInRange(psbt); - 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}`); + }); - const data = scripts![1]; - expect(data).toBeInstanceOf(Buffer); - expect((data as Buffer).toString('hex')).toEqual('00'.repeat(32)); + it('Transfer protected RGBPP_UTXOs, sum(ins) = sum(outs)', 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + expect(psbt.txInputs.length).toBeGreaterThanOrEqual(3); + expect(psbt.txOutputs).toHaveLength(3); - // Sign & finalize inputs - 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()); + // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + }); + it('Transfer protected RGBPP_UTXOs, each has 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + console.log(psbt.txOutputs); + expect(psbt.txInputs).toHaveLength(1); + expect(psbt.txOutputs).toHaveLength(3); + expect(psbt.txOutputs[0].value).toBeLessThan(psbt.txOutputs[1].value); + expect(psbt.txOutputs[1].value).toBeLessThan(psbt.txOutputs[2].value); + + 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, 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, + }, + ], + source, + }); + + // Sign & finalize inputs + psbt.signAllInputs(accounts.charlie.keyPair); + psbt.finalizeAllInputs(); + + console.log(psbt.txOutputs); + 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); + + 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}`); + }); + }); - // Broadcast transaction - // const tx = psbt.extractTransaction(); - // const res = await service.sendTransaction(tx.toHex()); - // console.log(`explorer: https://mempool.space/testnet/tx/${res.txid}`); + describe.todo('sendRgbppUtxos()', () => { + // TODO: fill tests }); }); 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!, 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`, + ); +} 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"] } 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"] } 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/pnpm-lock.yaml b/pnpm-lock.yaml index 6424560d..e7837c9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,12 @@ 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 + '@rgbpp-sdk/ckb': + specifier: workspace:^ + version: link:../ckb bip32: specifier: ^4.0.0 version: 4.0.0 @@ -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: 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