diff --git a/.changeset/nervous-doors-peel.md b/.changeset/nervous-doors-peel.md new file mode 100644 index 00000000..79bc56d7 --- /dev/null +++ b/.changeset/nervous-doors-peel.md @@ -0,0 +1,7 @@ +--- +'@rgbpp-sdk/service': minor +--- + +Implement `getRgbppSpvProof` for `OfflineBtcAssetsDataSource`, enabling support for offline unlocking of BTC time lock cells. + + - Add an example demonstrating how to unlock BTC time lock cells offline. \ No newline at end of file diff --git a/examples/rgbpp/env.ts b/examples/rgbpp/env.ts index a50f89f6..82c88889 100644 --- a/examples/rgbpp/env.ts +++ b/examples/rgbpp/env.ts @@ -7,7 +7,7 @@ import { systemScripts, } from '@nervosnetwork/ckb-sdk-utils'; import { NetworkType, AddressType, DataSource } from 'rgbpp/btc'; -import { BtcAssetsApi, OfflineBtcAssetsDataSource, OfflineBtcUtxo, BtcApiUtxo } from 'rgbpp/service'; +import { BtcAssetsApi, OfflineBtcAssetsDataSource, OfflineBtcUtxo, BtcApiUtxo, SpvProofEntry } from 'rgbpp/service'; import { BTCTestnetType, Collector, @@ -96,7 +96,11 @@ export const initOfflineCkbCollector = async ( }; }; -export const initOfflineBtcDataSource = async (rgbppLockArgsList: string[], address: string) => { +export const initOfflineBtcDataSource = async ( + rgbppLockArgsList: string[], + address: string, + spvProofs: SpvProofEntry[] = [], +) => { const btcTxIds = rgbppLockArgsList.map((rgbppLockArgs) => remove0x(unpackRgbppLockArgs(rgbppLockArgs).btcTxId)); const btcTxs = await Promise.all( btcTxIds.map(async (btcTxId) => { @@ -130,7 +134,7 @@ export const initOfflineBtcDataSource = async (rgbppLockArgsList: string[], addr }); return new DataSource( - new OfflineBtcAssetsDataSource({ txs: btcTxs, utxos: Array.from(utxoMap.values()) }), + new OfflineBtcAssetsDataSource({ txs: btcTxs, utxos: Array.from(utxoMap.values()), rgbppSpvProofs: spvProofs }), networkType, ); }; diff --git a/examples/rgbpp/xudt/offline/5-unlock-btc-time-cell.ts b/examples/rgbpp/xudt/offline/5-unlock-btc-time-cell.ts new file mode 100644 index 00000000..176738bc --- /dev/null +++ b/examples/rgbpp/xudt/offline/5-unlock-btc-time-cell.ts @@ -0,0 +1,57 @@ +import { buildBtcTimeCellsSpentTx, signBtcTimeCellSpentTx } from 'rgbpp'; +import { sendCkbTx, getBtcTimeLockScript, btcTxIdAndAfterFromBtcTimeLockArgs } from 'rgbpp/ckb'; +import { BTC_TESTNET_TYPE, CKB_PRIVATE_KEY, btcService, ckbAddress, collector, isMainnet } from '../../env'; +import { OfflineBtcAssetsDataSource, SpvProofEntry } from 'rgbpp/service'; + +const unlockBtcTimeCell = async ({ btcTimeCellArgs }: { btcTimeCellArgs: string }) => { + const btcTimeCells = await collector.getCells({ + lock: { + ...getBtcTimeLockScript(isMainnet, BTC_TESTNET_TYPE), + args: btcTimeCellArgs, + }, + isDataMustBeEmpty: false, + }); + if (!btcTimeCells || btcTimeCells.length === 0) { + throw new Error('No btc time cell found'); + } + + const spvProofs: SpvProofEntry[] = []; + for (const btcTimeCell of btcTimeCells) { + const { btcTxId, after } = btcTxIdAndAfterFromBtcTimeLockArgs(btcTimeCell.output.lock.args); + spvProofs.push({ + txid: btcTxId, + confirmations: after, + proof: await btcService.getRgbppSpvProof(btcTxId, after), + }); + } + + const offlineBtcAssetsDataSource = new OfflineBtcAssetsDataSource({ + txs: [], + utxos: [], + rgbppSpvProofs: spvProofs, + }); + + const ckbRawTx: CKBComponents.RawTransaction = await buildBtcTimeCellsSpentTx({ + btcTimeCells, + btcAssetsApi: offlineBtcAssetsDataSource, + isMainnet, + btcTestnetType: BTC_TESTNET_TYPE, + }); + + const signedTx = await signBtcTimeCellSpentTx({ + secp256k1PrivateKey: CKB_PRIVATE_KEY, + collector, + masterCkbAddress: ckbAddress, + ckbRawTx, + isMainnet, + }); + + const txHash = await sendCkbTx({ collector, signedTx }); + console.info(`BTC time cell has been spent and CKB tx hash is ${txHash}`); +}; + +// The btcTimeCellArgs is from the outputs[0].lock.args(BTC Time lock args) of the 3-btc-leap-ckb.ts CKB transaction +unlockBtcTimeCell({ + btcTimeCellArgs: + '0x7d00000010000000590000005d000000490000001000000030000000310000009bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8011400000021e782eeb1c9893b341ed71c2dfe6fa496a6435c0600000045241651e435d786d0ba8a1280d4bb3283eca10db728e2ba0a3978c136f5bb19', +}); diff --git a/examples/rgbpp/xudt/offline/5-ckb-leap-btc.ts b/examples/rgbpp/xudt/offline/6-ckb-leap-btc.ts similarity index 100% rename from examples/rgbpp/xudt/offline/5-ckb-leap-btc.ts rename to examples/rgbpp/xudt/offline/6-ckb-leap-btc.ts diff --git a/packages/ckb/src/utils/cell-dep.ts b/packages/ckb/src/utils/cell-dep.ts index 5bfd1786..484da82a 100644 --- a/packages/ckb/src/utils/cell-dep.ts +++ b/packages/ckb/src/utils/cell-dep.ts @@ -62,7 +62,7 @@ const fetchCellDepsJsonFromStaticSource = async () => { } }; -const fetchCellDepsJson = async () => { +export const fetchCellDepsJson = async () => { try { const response = await request(VERCEL_SERVER_CELL_DEPS_JSON_URL); if (response && response.data) { diff --git a/packages/service/src/error.ts b/packages/service/src/error.ts index d7fb7ede..1b542fed 100644 --- a/packages/service/src/error.ts +++ b/packages/service/src/error.ts @@ -10,6 +10,7 @@ export enum ErrorCodes { ASSETS_API_RESPONSE_DECODE_ERROR, OFFLINE_DATA_SOURCE_METHOD_NOT_AVAILABLE, + OFFLINE_DATA_SOURCE_SPV_PROOF_NOT_FOUND, } export const ErrorMessages = { @@ -22,6 +23,7 @@ export const ErrorMessages = { [ErrorCodes.ASSETS_API_RESPONSE_DECODE_ERROR]: 'Failed to decode the response of BtcAssetsAPI', [ErrorCodes.OFFLINE_DATA_SOURCE_METHOD_NOT_AVAILABLE]: 'Method not available for offline data source', + [ErrorCodes.OFFLINE_DATA_SOURCE_SPV_PROOF_NOT_FOUND]: 'SPV proof not found for the given txid and confirmations', }; export class BtcAssetsApiError extends Error { diff --git a/packages/service/src/service/offline-service.ts b/packages/service/src/service/offline-service.ts index 78de90cf..73e405ae 100644 --- a/packages/service/src/service/offline-service.ts +++ b/packages/service/src/service/offline-service.ts @@ -16,11 +16,19 @@ import { RgbppApiRetryCkbTransactionPayload, OfflineBtcUtxo, BtcApiRecommendedFeeRates, + RgbppApiSpvProof, } from '../types'; export interface OfflineBtcData { txs: BtcApiTransaction[]; utxos: OfflineBtcUtxo[]; + rgbppSpvProofs: SpvProofEntry[]; +} + +export interface SpvProofEntry { + txid: string; + confirmations: number; + proof: RgbppApiSpvProof; } /* @@ -35,6 +43,8 @@ export class OfflineBtcAssetsDataSource extends BtcAssetsApi { private txs: Record; // address -> utxos private utxos: Record; + // txid:confirmations -> spv proof + private rgbppSpvProofs: Record; private defaultFee = 1; @@ -56,6 +66,18 @@ export class OfflineBtcAssetsDataSource extends BtcAssetsApi { }, {} as Record, ); + + this.rgbppSpvProofs = offlineData.rgbppSpvProofs.reduce( + (acc, proof) => { + acc[this.spvKey(proof.txid, proof.confirmations)] = proof.proof; + return acc; + }, + {} as Record, + ); + } + + private spvKey(txid: string, confirmations: number) { + return `${txid}:${confirmations}`; } getBtcTransaction(txId: string): Promise { @@ -86,6 +108,17 @@ export class OfflineBtcAssetsDataSource extends BtcAssetsApi { }); } + getRgbppSpvProof(btcTxId: string, confirmations: number) { + const proof = this.rgbppSpvProofs[this.spvKey(btcTxId, confirmations)]; + if (!proof) { + throw new OfflineBtcAssetsDataSourceError( + ErrorCodes.OFFLINE_DATA_SOURCE_SPV_PROOF_NOT_FOUND, + `SPV proof not found for txid ${btcTxId} with ${confirmations} confirmations`, + ); + } + return Promise.resolve(proof); + } + /* * The following methods are not available in offline mode. */ @@ -160,10 +193,6 @@ export class OfflineBtcAssetsDataSource extends BtcAssetsApi { return Promise.reject(new OfflineBtcAssetsDataSourceError(ErrorCodes.OFFLINE_DATA_SOURCE_METHOD_NOT_AVAILABLE)); } - getRgbppSpvProof(btcTxId: string, confirmations: number) { - return Promise.reject(new OfflineBtcAssetsDataSourceError(ErrorCodes.OFFLINE_DATA_SOURCE_METHOD_NOT_AVAILABLE)); - } - sendRgbppCkbTransaction(payload: RgbppApiSendCkbTransactionPayload) { return Promise.reject(new OfflineBtcAssetsDataSourceError(ErrorCodes.OFFLINE_DATA_SOURCE_METHOD_NOT_AVAILABLE)); }