From 3de6d822921f5431148eac6d74953d7c9ed2dcb9 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 29 Apr 2024 14:41:43 +1000 Subject: [PATCH 01/53] fix: refactor global error handler to fix inaccurate error message --- src/app.ts | 36 ------------------------------------ src/error.ts | 3 --- src/plugins/sentry.ts | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 41 deletions(-) delete mode 100644 src/error.ts diff --git a/src/app.ts b/src/app.ts index 16da977f..862bd553 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,5 @@ import fastify from 'fastify'; import { FastifyInstance } from 'fastify'; -import { AxiosError, HttpStatusCode } from 'axios'; import sensible from '@fastify/sensible'; import compress from '@fastify/compress'; import bitcoinRoutes from './routes/bitcoin'; @@ -18,9 +17,6 @@ import cors from './plugins/cors'; import { NetworkType } from './constants'; import rgbppRoutes from './routes/rgbpp'; import cronRoutes from './routes/cron'; -import { ElectrsAPIError, ElectrsAPINotFoundError } from './services/electrs'; -import { BitcoinRPCError } from './services/bitcoind'; -import { AppErrorCode } from './error'; import { provider } from 'std-env'; import ipBlock from './plugins/ip-block'; import internalRoutes from './routes/internal'; @@ -63,38 +59,6 @@ async function routes(fastify: FastifyInstance) { fastify.cron.startAllJobs(); }); } - - fastify.setErrorHandler((error, _, reply) => { - if ( - error instanceof ElectrsAPIError || - error instanceof ElectrsAPINotFoundError || - error instanceof BitcoinRPCError - ) { - reply - .status(error.statusCode ?? HttpStatusCode.InternalServerError) - .send({ code: error.errorCode, message: error.message }); - return; - } - - if (error instanceof AxiosError) { - const { response } = error; - reply.status(response?.status ?? HttpStatusCode.InternalServerError).send({ - code: AppErrorCode.UnknownResponseError, - message: response?.data ?? error.message, - }); - return; - } - - // captureException only for 5xx errors or unknown errors - if (!error.statusCode || error.statusCode >= HttpStatusCode.InternalServerError) { - fastify.log.error(error); - fastify.Sentry.captureException(error); - } - reply.status(error.statusCode ?? HttpStatusCode.InternalServerError).send({ - code: AppErrorCode.UnknownResponseError, - message: error.message, - }); - }); } export function buildFastify() { diff --git a/src/error.ts b/src/error.ts deleted file mode 100644 index a1870e8a..00000000 --- a/src/error.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum AppErrorCode { - UnknownResponseError = 0x9999, -} diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index fda44fa4..9d442d58 100644 --- a/src/plugins/sentry.ts +++ b/src/plugins/sentry.ts @@ -3,6 +3,9 @@ import fastifySentry from '@immobiliarelabs/fastify-sentry'; import { ProfilingIntegration } from '@sentry/profiling-node'; import pkg from '../../package.json'; import { env } from '../env'; +import { ElectrsAPIError, ElectrsAPINotFoundError } from '../services/electrs'; +import { HttpStatusCode, AxiosError } from 'axios'; +import { BitcoinRPCError } from '../services/bitcoind'; export default fp(async (fastify) => { // @ts-expect-error - fastify-sentry types are not up to date @@ -13,7 +16,34 @@ export default fp(async (fastify) => { integrations: [...(env.SENTRY_PROFILES_SAMPLE_RATE > 0 ? [new ProfilingIntegration()] : [])], environment: env.NODE_ENV, release: pkg.version, - // use custom error handler instead of the default one - setErrorHandler: false, + errorResponse: (error, _, reply) => { + if ( + error instanceof ElectrsAPIError || + error instanceof ElectrsAPINotFoundError || + error instanceof BitcoinRPCError + ) { + reply + .status(error.statusCode ?? HttpStatusCode.InternalServerError) + .send({ code: error.errorCode, message: error.message }); + return; + } + + if (error instanceof AxiosError) { + const { response } = error; + reply.status(response?.status ?? HttpStatusCode.InternalServerError).send({ + message: response?.data ?? error.message, + }); + return; + } + + // captureException only for 5xx errors or unknown errors + if (!error.statusCode || error.statusCode >= HttpStatusCode.InternalServerError) { + fastify.log.error(error); + fastify.Sentry.captureException(error); + } + reply.status(error.statusCode ?? HttpStatusCode.InternalServerError).send({ + message: error.message, + }); + }, }); }); From a9ebdff79a7ace0aad8303b4b2756f33a8054cb2 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 22 Apr 2024 17:51:42 +1000 Subject: [PATCH 02/53] refactor: use mempool.space API instead of bitcoind and electrs --- README.md | 12 +- package.json | 1 + pnpm-lock.yaml | 3 + src/@types/fastify/index.d.ts | 8 +- src/app.ts | 4 +- src/container.ts | 11 +- src/env.ts | 14 +-- src/plugins/healthcheck.ts | 12 -- src/plugins/sentry.ts | 15 +-- src/routes/bitcoin/address.ts | 11 +- src/routes/bitcoin/block.ts | 12 +- src/routes/bitcoin/index.ts | 6 +- src/routes/bitcoin/info.ts | 2 +- src/routes/bitcoin/transaction.ts | 4 +- src/routes/bitcoin/types.ts | 3 +- src/routes/rgbpp/address.ts | 2 +- src/routes/rgbpp/assets.ts | 2 +- src/routes/rgbpp/index.ts | 2 +- src/routes/rgbpp/transaction.ts | 2 +- src/services/bitcoin.ts | 157 +++++++++++++++++++++++++ src/services/bitcoind.ts | 114 ------------------ src/services/ckb.ts | 2 +- src/services/electrs.ts | 170 --------------------------- src/services/spv.ts | 4 +- src/services/transaction.ts | 17 ++- src/services/unlocker.ts | 4 +- test/routes/bitcoind/address.test.ts | 46 ++------ test/services/transaction.test.ts | 46 ++++---- test/services/unlocker.test.ts | 12 +- 29 files changed, 256 insertions(+), 442 deletions(-) create mode 100644 src/services/bitcoin.ts delete mode 100644 src/services/bitcoind.ts delete mode 100644 src/services/electrs.ts diff --git a/README.md b/README.md index 215e49c6..80ddac68 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ A service for Retrieving BTC/RGB++ information/assets and processing transaction #### Requirements -- [bitcoind](https://github.com/bitcoin/bitcoin): Running a Bitcoin full node -- [mempool/electrs](https://github.com/mempool/electrs): Electrum Rust Server (Electrs) indexes Bitcoin chain data +- [mempool.space API](https://mempool.space/docs): mempool.space merely provides data about the Bitcoin network. - [ckb-cell/ckb-bitcoin-spv-service](https://github.com/ckb-cell/ckb-bitcoin-spv-service): CKB Bitcoin SPV Service #### Configuration @@ -58,13 +57,8 @@ JWT_SECRET= # JWT token denylist # JWT_DENYLIST= -# Bitcoin JSON-RPC URL and credentials -BITCOIN_JSON_RPC_URL= -BITCOIN_JSON_RPC_USERNAME= -BITCOIN_JSON_RPC_PASSWORD= - -# Electrs API URL -BITCOIN_ELECTRS_API_URL= +# Bitcoin Mempool.space API URL +BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space, # SPV Service URL BITCOIN_SPV_SERVICE_URL= diff --git a/package.json b/package.json index 617f956d..6a870d89 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", "@immobiliarelabs/fastify-sentry": "^8.0.1", + "@mempool/mempool.js": "^2.3.0", "@nervosnetwork/ckb-sdk-utils": "^0.109.1", "@rgbpp-sdk/btc": "0.0.0-snap-20240423144119", "@rgbpp-sdk/ckb": "0.0.0-snap-20240423144119", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9ff6b78..ec387a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ dependencies: '@immobiliarelabs/fastify-sentry': specifier: ^8.0.1 version: 8.0.1 + '@mempool/mempool.js': + specifier: ^2.3.0 + version: 2.3.0 '@nervosnetwork/ckb-sdk-utils': specifier: ^0.109.1 version: 0.109.1 diff --git a/src/@types/fastify/index.d.ts b/src/@types/fastify/index.d.ts index 848b2c16..a70862ca 100644 --- a/src/@types/fastify/index.d.ts +++ b/src/@types/fastify/index.d.ts @@ -1,10 +1,9 @@ import { AwilixContainer, Cradle } from '../../container'; -import Bitcoind from '../../services/bitcoind'; -import ElectrsAPI from '../../services/electrs'; import TransactionManager from '../../services/transaction'; import Paymaster from '../../services/paymaster'; import BitcoinSPV from '../../services/spv'; -import { CKB } from '../../services/ckb'; +import CKB from '../../services/ckb'; +import Bitcoin from '../../services/bitcoin'; declare module 'fastify' { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -12,8 +11,7 @@ declare module 'fastify' { extends FastifyJwtNamespace<{ namespace: 'security' }> { container: AwilixContainer; ckb: CKB; - electrs: ElectrsAPI; - bitcoind: Bitcoind; + bitcoin: Bitcoin; bitcoinSPV: BitcoinSPV; paymaster: Paymaster; transactionManager: TransactionManager; diff --git a/src/app.ts b/src/app.ts index 862bd553..3eb82234 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,10 +38,8 @@ async function routes(fastify: FastifyInstance) { fastify.register(rateLimit); fastify.register(healthcheck); - // Check if the Electrs API and Bitcoin JSON-RPC server are running on the correct network const env = container.resolve('env'); - await container.resolve('bitcoind').checkNetwork(env.NETWORK as NetworkType); - await container.resolve('electrs').checkNetwork(env.NETWORK as NetworkType); + await container.resolve('bitcoin').checkNetwork(env.NETWORK as NetworkType); fastify.register(internalRoutes, { prefix: '/internal' }); fastify.register(tokenRoutes, { prefix: '/token' }); diff --git a/src/container.ts b/src/container.ts index 3c288119..37647e7c 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,22 +1,20 @@ import { createContainer, InjectionMode, asValue, asClass } from 'awilix'; import { Redis } from 'ioredis'; import pino from 'pino'; -import Bitcoind from './services/bitcoind'; -import ElectrsAPI from './services/electrs'; import { env } from './env'; import TransactionManager from './services/transaction'; import Paymaster from './services/paymaster'; import Unlocker from './services/unlocker'; import BitcoinSPV from './services/spv'; -import { CKB } from './services/ckb'; +import CKB from './services/ckb'; +import Bitcoin from './services/bitcoin'; export interface Cradle { env: typeof env; logger: pino.BaseLogger; redis: Redis; ckb: CKB; - bitcoind: Bitcoind; - electrs: ElectrsAPI; + bitcoin: Bitcoin; bitcoinSPV: BitcoinSPV; paymaster: Paymaster; unlocker: Unlocker; @@ -37,8 +35,7 @@ container.register({ }), ), ckb: asClass(CKB).singleton(), - bitcoind: asClass(Bitcoind).singleton(), - electrs: asClass(ElectrsAPI).singleton(), + bitcoin: asClass(Bitcoin).singleton(), bitcoinSPV: asClass(BitcoinSPV).singleton(), paymaster: asClass(Paymaster).singleton(), transactionManager: asClass(TransactionManager).singleton(), diff --git a/src/env.ts b/src/env.ts index 43825b03..01edd321 100644 --- a/src/env.ts +++ b/src/env.ts @@ -67,19 +67,11 @@ const envSchema = z.object({ .default('') .transform((value) => value.split(',')) .pipe(z.string().array()), + /** - * The URL/USERNAME/PASSWORD of the Bitcoin JSON-RPC server. - * The JSON-RPC server is used to query the Bitcoin blockchain. - */ - BITCOIN_JSON_RPC_URL: z.string(), - BITCOIN_JSON_RPC_USERNAME: z.string(), - BITCOIN_JSON_RPC_PASSWORD: z.string(), - /** - * The URL of the Electrs API. - * Electrs is a Rust implementation of Electrum Server. - * It is used to query the Bitcoin blockchain (balance, transactions, etc). + * Bitcoin Mempool.space API URL */ - BITCOIN_ELECTRS_API_URL: z.string(), + BITCOIN_MEMPOOL_SPACE_API_URL: z.string(), /** * Bitcoin SPV service URL diff --git a/src/plugins/healthcheck.ts b/src/plugins/healthcheck.ts index 8dc77654..9b34f994 100644 --- a/src/plugins/healthcheck.ts +++ b/src/plugins/healthcheck.ts @@ -1,7 +1,5 @@ import healthcheck from 'fastify-custom-healthcheck'; import fp from 'fastify-plugin'; -import Bitcoind from '../services/bitcoind'; -import ElectrsAPI from '../services/electrs'; import TransactionManager from '../services/transaction'; import Paymaster from '../services/paymaster'; @@ -17,16 +15,6 @@ export default fp(async (fastify) => { await redis.ping(); }); - fastify.addHealthCheck('bitcoind', async () => { - const bitcoind: Bitcoind = fastify.container.resolve('bitcoind'); - await bitcoind.getBlockchainInfo(); - }); - - fastify.addHealthCheck('electrs', async () => { - const electrs: ElectrsAPI = fastify.container.resolve('electrs'); - await electrs.getTip(); - }); - fastify.addHealthCheck('queue', async () => { const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); const counts = await transactionManager.getQueueJobCounts(); diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index 9d442d58..55d5b43b 100644 --- a/src/plugins/sentry.ts +++ b/src/plugins/sentry.ts @@ -3,9 +3,8 @@ import fastifySentry from '@immobiliarelabs/fastify-sentry'; import { ProfilingIntegration } from '@sentry/profiling-node'; import pkg from '../../package.json'; import { env } from '../env'; -import { ElectrsAPIError, ElectrsAPINotFoundError } from '../services/electrs'; import { HttpStatusCode, AxiosError } from 'axios'; -import { BitcoinRPCError } from '../services/bitcoind'; +import { BitcoinMempoolAPIError } from '../services/bitcoin'; export default fp(async (fastify) => { // @ts-expect-error - fastify-sentry types are not up to date @@ -16,15 +15,11 @@ export default fp(async (fastify) => { integrations: [...(env.SENTRY_PROFILES_SAMPLE_RATE > 0 ? [new ProfilingIntegration()] : [])], environment: env.NODE_ENV, release: pkg.version, + // handle error in the errorResponse function below + shouldHandleError: false, errorResponse: (error, _, reply) => { - if ( - error instanceof ElectrsAPIError || - error instanceof ElectrsAPINotFoundError || - error instanceof BitcoinRPCError - ) { - reply - .status(error.statusCode ?? HttpStatusCode.InternalServerError) - .send({ code: error.errorCode, message: error.message }); + if (error instanceof BitcoinMempoolAPIError) { + reply.status(error.statusCode ?? HttpStatusCode.InternalServerError).send({ message: error.message }); return; } diff --git a/src/routes/bitcoin/address.ts b/src/routes/bitcoin/address.ts index f7544df7..f6c62d48 100644 --- a/src/routes/bitcoin/address.ts +++ b/src/routes/bitcoin/address.ts @@ -34,7 +34,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async (request) => { const { address } = request.params; const { min_satoshi } = request.query; - const utxos = await fastify.electrs.getUtxoByAddress(address); + const utxos = await fastify.bitcoin.getUtxoByAddress(address); return utxos.reduce( (acc: Balance, utxo: UTXO) => { if (utxo.status.confirmed) { @@ -69,7 +69,10 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType address: z.string().describe('The Bitcoin address'), }), querystring: z.object({ - only_confirmed: z.enum(['true', 'false', 'undefined']).default('true').describe('Only return confirmed UTXOs'), + only_confirmed: z + .enum(['true', 'false', 'undefined']) + .default('true') + .describe('Only return confirmed UTXOs'), min_satoshi: z.coerce.number().optional().describe('The minimum value of the UTXO in satoshi'), }), response: { @@ -80,7 +83,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async function (request) { const { address } = request.params; const { only_confirmed, min_satoshi } = request.query; - let utxos = await fastify.electrs.getUtxoByAddress(address); + let utxos = await fastify.bitcoin.getUtxoByAddress(address); // compatible with the case where only_confirmed is undefined if (only_confirmed === 'true' || only_confirmed === 'undefined') { @@ -109,7 +112,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType }, async (request) => { const { address } = request.params; - const txs = await fastify.electrs.getTransactionsByAddress(address); + const txs = await fastify.bitcoin.getTransactionsByAddress(address); return txs; }, ); diff --git a/src/routes/bitcoin/block.ts b/src/routes/bitcoin/block.ts index bdcc2925..d00c5012 100644 --- a/src/routes/bitcoin/block.ts +++ b/src/routes/bitcoin/block.ts @@ -22,7 +22,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }, async (request, reply) => { const { hash } = request.params; - const block = await fastify.electrs.getBlockByHash(hash); + const block = await fastify.bitcoin.getBlockByHash(hash); reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); return block; }, @@ -39,14 +39,14 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }), response: { 200: z.object({ - txids: z.array(z.string()) + txids: z.array(z.string()), }), }, }, }, async (request, reply) => { const { hash } = request.params; - const txids = await fastify.electrs.getBlockTxIdsByHash(hash); + const txids = await fastify.bitcoin.getBlockTxIdsByHash(hash); reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); return { txids }; }, @@ -70,7 +70,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }, async (request, reply) => { const { hash } = request.params; - const header = await fastify.electrs.getBlockHeaderByHash(hash); + const header = await fastify.bitcoin.getBlockHeaderByHash(hash); reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); return { header, @@ -97,8 +97,8 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr async (request, reply) => { const { height } = request.params; const [hash, chain] = await Promise.all([ - fastify.electrs.getBlockByHeight(height), - fastify.bitcoind.getBlockchainInfo(), + fastify.bitcoin.getBlockHashByHeight(height), + fastify.bitcoin.getBlockchainInfo(), ]); if (height < chain.blocks) { reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); diff --git a/src/routes/bitcoin/index.ts b/src/routes/bitcoin/index.ts index a876cb5c..9f1440f2 100644 --- a/src/routes/bitcoin/index.ts +++ b/src/routes/bitcoin/index.ts @@ -1,17 +1,15 @@ import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; -import ElectrsAPI from '../../services/electrs'; -import Bitcoind from '../../services/bitcoind'; import infoRoute from './info'; import blockRoutes from './block'; import transactionRoutes from './transaction'; import addressRoutes from './address'; import container from '../../container'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import Bitcoin from '../../services/bitcoin'; const bitcoinRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { - fastify.decorate('electrs', container.resolve('electrs')); - fastify.decorate('bitcoind', container.resolve('bitcoind')); + fastify.decorate('bitcoin', container.resolve('bitcoin')); fastify.register(infoRoute); fastify.register(blockRoutes, { prefix: '/block' }); diff --git a/src/routes/bitcoin/info.ts b/src/routes/bitcoin/info.ts index e5d74941..bdde7e6f 100644 --- a/src/routes/bitcoin/info.ts +++ b/src/routes/bitcoin/info.ts @@ -16,7 +16,7 @@ const infoRoute: FastifyPluginCallback, Server, ZodTypeProv }, }, async () => { - const blockchainInfo = await fastify.bitcoind.getBlockchainInfo(); + const blockchainInfo = await fastify.bitcoin.getBlockchainInfo(); return blockchainInfo; }, ); diff --git a/src/routes/bitcoin/transaction.ts b/src/routes/bitcoin/transaction.ts index 5f240787..2fb41b06 100644 --- a/src/routes/bitcoin/transaction.ts +++ b/src/routes/bitcoin/transaction.ts @@ -24,7 +24,7 @@ const transactionRoutes: FastifyPluginCallback, Server, Zod }, async (request) => { const { txhex } = request.body; - const txid = await fastify.bitcoind.sendRawTransaction(txhex); + const txid = await fastify.bitcoin.sendRawTransaction(txhex); return { txid, }; @@ -47,7 +47,7 @@ const transactionRoutes: FastifyPluginCallback, Server, Zod }, async (request, reply) => { const { txid } = request.params; - const transaction = await fastify.electrs.getTransaction(txid); + const transaction = await fastify.bitcoin.getTransaction(txid); if (transaction.status.confirmed) { reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); } diff --git a/src/routes/bitcoin/types.ts b/src/routes/bitcoin/types.ts index fea7c1e0..443bd3df 100644 --- a/src/routes/bitcoin/types.ts +++ b/src/routes/bitcoin/types.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; export const ChainInfo = z.object({ chain: z.string(), blocks: z.number(), - headers: z.number(), bestblockhash: z.string(), difficulty: z.number(), mediantime: z.number(), @@ -63,7 +62,7 @@ const Input = z.object({ scriptsig_asm: z.string(), witness: z.array(z.string()).optional(), is_coinbase: z.boolean(), - sequence: z.number(), + sequence: z.coerce.number(), }); export const Transaction = z.object({ diff --git a/src/routes/rgbpp/address.ts b/src/routes/rgbpp/address.ts index 3767e4a9..e8dad9cf 100644 --- a/src/routes/rgbpp/address.ts +++ b/src/routes/rgbpp/address.ts @@ -47,7 +47,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async (request) => { const { btc_address } = request.params; const { type_script } = request.query; - const utxos = await fastify.electrs.getUtxoByAddress(btc_address); + const utxos = await fastify.bitcoin.getUtxoByAddress(btc_address); const cells = await Promise.all( utxos.map(async (utxo) => { const { txid, vout } = utxo; diff --git a/src/routes/rgbpp/assets.ts b/src/routes/rgbpp/assets.ts index d1e18190..c0fe632a 100644 --- a/src/routes/rgbpp/assets.ts +++ b/src/routes/rgbpp/assets.ts @@ -23,7 +23,7 @@ const assetsRoute: FastifyPluginCallback, Server, ZodTypePr }, async (request) => { const { btc_txid } = request.params; - const transaction = await fastify.electrs.getTransaction(btc_txid); + const transaction = await fastify.bitcoin.getTransaction(btc_txid); const cells: Cell[] = []; for (let index = 0; index < transaction.vout.length; index++) { const args = buildRgbppLockArgs(index, btc_txid); diff --git a/src/routes/rgbpp/index.ts b/src/routes/rgbpp/index.ts index 86b0f23d..89a49a31 100644 --- a/src/routes/rgbpp/index.ts +++ b/src/routes/rgbpp/index.ts @@ -12,7 +12,7 @@ const rgbppRoutes: FastifyPluginCallback, Server, ZodTypePr fastify.decorate('transactionManager', container.resolve('transactionManager')); fastify.decorate('paymaster', container.resolve('paymaster')); fastify.decorate('ckb', container.resolve('ckb')); - fastify.decorate('electrs', container.resolve('electrs')); + fastify.decorate('bitcoin', container.resolve('bitcoin')); fastify.decorate('bitcoinSPV', container.resolve('bitcoinSPV')); fastify.register(transactionRoutes, { prefix: '/transaction' }); diff --git a/src/routes/rgbpp/transaction.ts b/src/routes/rgbpp/transaction.ts index 2bb20889..57867529 100644 --- a/src/routes/rgbpp/transaction.ts +++ b/src/routes/rgbpp/transaction.ts @@ -72,7 +72,7 @@ const transactionRoute: FastifyPluginCallback, Server, ZodT return { txhash: job.returnvalue }; } - const transaction = await fastify.electrs.getTransaction(btc_txid); + const transaction = await fastify.bitcoin.getTransaction(btc_txid); // query CKB transaction hash by RGBPP_LOCK cells for (let index = 0; index < transaction.vout.length; index++) { diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts new file mode 100644 index 00000000..e5129ea5 --- /dev/null +++ b/src/services/bitcoin.ts @@ -0,0 +1,157 @@ +import mempoolJS from '@mempool/mempool.js'; +import { Cradle } from '../container'; +import { Block, ChainInfo, Transaction, UTXO } from '../routes/bitcoin/types'; +import { NetworkType } from '../constants'; +import { AxiosError } from 'axios'; + +export class BitcoinMempoolAPIError extends Error { + public statusCode = 500; + + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const wrapTry = async any>(fn: T): Promise> => { + if (typeof fn !== 'function') { + throw new Error('wrapTry: fn must be a function'); + } + return fn().catch((err: Error) => { + if ((err as AxiosError).isAxiosError) { + const error = new BitcoinMempoolAPIError(err.message); + if ((err as AxiosError).response) { + error.statusCode = (err as AxiosError).response?.status || 500; + } + throw error; + } + throw err; + }); +}; + +export default class Bitcoin { + private mempool: ReturnType; + private cradle: Cradle; + + constructor(cradle: Cradle) { + const url = new URL(cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL); + this.mempool = mempoolJS({ + hostname: url.hostname, + network: cradle.env.NETWORK, + }); + this.cradle = cradle; + } + + public async checkNetwork(network: NetworkType) { + const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height: 0 }); + switch (network) { + case NetworkType.mainnet: + // Bitcoin mainnet genesis block hash + if (hash !== '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f') { + throw new Error('Mempool API is not running on mainnet'); + } + break; + case NetworkType.testnet: + // Bitcoin testnet genesis block hash + if (hash !== '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943') { + throw new Error('Mempool API is not running on testnet'); + } + break; + default: + } + } + + public async getBlockchainInfo(): Promise { + const hash = await this.mempool.bitcoin.blocks.getBlocksTipHash(); + const tip = await this.mempool.bitcoin.blocks.getBlock({ hash }); + + const { difficulty, mediantime } = tip; + return { + chain: this.cradle.env.NETWORK === 'mainnet' ? 'main' : 'test', + blocks: tip.height, + bestblockhash: hash, + difficulty, + mediantime, + }; + } + + public async sendRawTransaction(txhex: string): Promise { + return wrapTry(async () => { + const txid = await this.mempool.bitcoin.transactions.postTx({ txhex }); + return txid as string; + }); + } + + public async getUtxoByAddress(address: string): Promise { + return wrapTry(async () => { + const utxo = await this.mempool.bitcoin.addresses.getAddressTxsUtxo({ address }); + return utxo.map((utxo) => UTXO.parse(utxo)); + }); + } + + public async getTransactionsByAddress(address: string): Promise { + return wrapTry(async () => { + const txs = await this.mempool.bitcoin.addresses.getAddressTxs({ address }); + return txs.map((tx) => Transaction.parse(tx)); + }); + } + + public async getTransaction(txid: string): Promise { + return wrapTry(async () => { + const tx = await this.mempool.bitcoin.transactions.getTx({ txid }); + return Transaction.parse(tx); + }); + } + + public async getTransactionHex(txid: string): Promise { + return wrapTry(() => this.mempool.bitcoin.transactions.getTxHex({ txid })); + } + + public async getBlockByHash(hash: string): Promise { + return wrapTry(async () => { + const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); + return block; + }); + } + + public async getBlockByHeight(height: number): Promise { + return wrapTry(async () => { + const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); + const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); + return block; + }); + } + + public async getBlockHashByHeight(height: number): Promise { + return wrapTry(async () => { + return this.mempool.bitcoin.blocks.getBlockHeight({ height }); + }); + } + + public async getBlockHeaderByHash(hash: string) { + return wrapTry(async () => { + return this.mempool.bitcoin.blocks.getBlockHeader({ hash }); + }); + } + + public async getBlockHeaderByHeight(height: number) { + return wrapTry(async () => { + const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); + return this.getBlockHeaderByHash(hash); + }); + } + + public async getBlockTxIdsByHash(hash: string): Promise { + return wrapTry(async () => { + const txids = await this.mempool.bitcoin.blocks.getBlockTxids({ hash }); + return txids; + }); + } + + public async getTip(): Promise { + return wrapTry(async () => { + return this.mempool.bitcoin.blocks.getBlocksTipHeight(); + }); + } +} diff --git a/src/services/bitcoind.ts b/src/services/bitcoind.ts deleted file mode 100644 index f684f241..00000000 --- a/src/services/bitcoind.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ChainInfo } from '../routes/bitcoin/types'; -import axios, { AxiosError, AxiosInstance } from 'axios'; -import * as Sentry from '@sentry/node'; -import { addLoggerInterceptor } from '../utils/interceptors'; -import { Cradle } from '../container'; -import { NetworkType } from '../constants'; -import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; - -// https://github.com/bitcoin/bitcoin/blob/26.x/src/rpc/protocol.h#L23 -export enum RPCErrorCode { - RPC_MISC_ERROR = -1, - RPC_TYPE_ERROR = -3, - RPC_INVALID_ADDRESS_OR_KEY = -5, - RPC_OUT_OF_MEMORY = -7, - RPC_INVALID_PARAMETER = -8, - RPC_DATABASE_ERROR = -20, - RPC_DESERIALIZATION_ERROR = -22, - RPC_VERIFY_ERROR = -25, - RPC_VERIFY_REJECTED = -26, - RPC_VERIFY_ALREADY_IN_CHAIN = -27, - RPC_IN_WARMUP = -28, - RPC_METHOD_DEPRECATED = -32, -} - -export class BitcoinRPCError extends Error { - public statusCode: number; - public errorCode: RPCErrorCode; - - public static schema = z.object({ - code: z.number(), - message: z.string(), - }); - - constructor(statusCode: number, code: RPCErrorCode, message: string) { - super(message); - this.name = this.constructor.name; - this.statusCode = statusCode; - this.errorCode = code; - } -} - -/** - * Bitcoind, a wrapper for Bitcoin Core JSON-RPC - */ -export default class Bitcoind { - private request: AxiosInstance; - - constructor({ env, logger }: Cradle) { - const { - BITCOIN_JSON_RPC_USERNAME: username, - BITCOIN_JSON_RPC_PASSWORD: password, - BITCOIN_JSON_RPC_URL: baseURL, - } = env; - const credentials = `${username}:${password}`; - const token = Buffer.from(credentials, 'utf-8').toString('base64'); - - this.request = axios.create({ - baseURL, - headers: { - Authorization: `Basic ${token}`, - }, - }); - addLoggerInterceptor(this.request, logger); - } - - private async callMethod(method: string, params: unknown): Promise { - return Sentry.startSpan({ op: this.constructor.name, name: method }, async () => { - try { - const id = randomUUID(); - const response = await this.request.post('', { - jsonrpc: '1.0', - id, - method, - params, - }); - return response.data.result; - } catch (err) { - if (err instanceof AxiosError && err.response?.data.error) { - const { code, message } = BitcoinRPCError.schema.parse(err.response.data.error); - throw new BitcoinRPCError(err.response.status, code, message); - } - throw err; - } - }); - } - - public async checkNetwork(network: NetworkType) { - const chainInfo = await this.getBlockchainInfo(); - switch (network) { - case NetworkType.mainnet: - if (chainInfo.chain !== 'main') { - throw new Error('Bitcoin JSON-RPC is not running on mainnet'); - } - break; - case NetworkType.testnet: - if (chainInfo.chain !== 'test') { - throw new Error('Bitcoin JSON-RPC is not running on testnet'); - } - break; - default: - } - } - - // https://developer.bitcoin.org/reference/rpc/getblockchaininfo.html - public async getBlockchainInfo() { - return this.callMethod('getblockchaininfo', []); - } - - // https://developer.bitcoin.org/reference/rpc/sendrawtransaction.html - public async sendRawTransaction(txHex: string) { - return this.callMethod('sendrawtransaction', [txHex]); - } -} diff --git a/src/services/ckb.ts b/src/services/ckb.ts index bc548f45..3a447030 100644 --- a/src/services/ckb.ts +++ b/src/services/ckb.ts @@ -123,7 +123,7 @@ export class CKBRpcError extends Error { } } -export class CKB { +export default class CKB { public rpc: RPC; public indexer: Indexer; diff --git a/src/services/electrs.ts b/src/services/electrs.ts deleted file mode 100644 index eac7b58b..00000000 --- a/src/services/electrs.ts +++ /dev/null @@ -1,170 +0,0 @@ -import axios, { AxiosError, AxiosInstance, AxiosResponse, HttpStatusCode } from 'axios'; -import { Block, Transaction, UTXO } from '../routes/bitcoin/types'; -import * as Sentry from '@sentry/node'; -import { Cradle } from '../container'; -import { addLoggerInterceptor } from '../utils/interceptors'; -import { NetworkType } from '../constants'; -import { z } from 'zod'; - -// https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 -export enum ElectrsErrorMessage { - Connection = 'Connection error', - Interrupt = 'Interruption by external signal', - TooManyUtxos = 'Too many unspent transaction outputs', - TooManyTxs = 'Too many history transactions', - ElectrumClient = 'Electrum client error', -} - -export enum ElectrsAPIErrorCode { - Connection = 0x1000, - Interrupt = 0x1001, - TooManyUtxos = 0x1002, - TooManyTxs = 0x1003, - ElectrumClient = 0x1004, -} - -const ElectrsAPIErrorMap = { - [ElectrsErrorMessage.Connection]: ElectrsAPIErrorCode.Connection, - [ElectrsErrorMessage.Interrupt]: ElectrsAPIErrorCode.Interrupt, - [ElectrsErrorMessage.TooManyUtxos]: ElectrsAPIErrorCode.TooManyUtxos, - [ElectrsErrorMessage.TooManyTxs]: ElectrsAPIErrorCode.TooManyTxs, - [ElectrsErrorMessage.ElectrumClient]: ElectrsAPIErrorCode.ElectrumClient, -}; - -export class ElectrsAPIError extends Error { - public errorCode: number; - - public static schema = z.object({ - message: z.string(), - }); - - constructor(message: ElectrsErrorMessage) { - super(message); - this.name = this.constructor.name; - this.errorCode = ElectrsAPIErrorMap[message]; - } -} - -export class ElectrsAPINotFoundError extends Error { - public errorCode = HttpStatusCode.NotFound; - public statusCode = HttpStatusCode.NotFound; - - constructor(message: string) { - super(message); - this.name = this.constructor.name; - } -} - -export default class ElectrsAPI { - private request: AxiosInstance; - - constructor({ env, logger }: Cradle) { - this.request = axios.create({ - baseURL: env.BITCOIN_ELECTRS_API_URL, - }); - addLoggerInterceptor(this.request, logger); - } - - private async get(path: string): Promise> { - return Sentry.startSpan({ op: this.constructor.name, name: path }, async () => { - try { - const response = await this.request.get(path); - return response; - } catch (err) { - if (err instanceof AxiosError) { - if (err.status === 404 || err.response?.status === 404) { - throw new ElectrsAPINotFoundError(err.message); - } - - if (err.response?.data && typeof err.response.data === 'string') { - const { data } = err.response; - const message = Object.values(ElectrsErrorMessage).find((message) => data.startsWith(message)); - if (message) { - throw new ElectrsAPIError(message); - } - } - } - throw err; - } - }); - } - - public async checkNetwork(network: NetworkType) { - const hash = await this.getBlockByHeight(0); - switch (network) { - case NetworkType.mainnet: - // Bitcoin mainnet genesis block hash - if (hash !== '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f') { - throw new Error('Electrs API is not running on mainnet'); - } - break; - case NetworkType.testnet: - // Bitcoin testnet genesis block hash - if (hash !== '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943') { - throw new Error('Electrs API is not running on testnet'); - } - break; - default: - } - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-addressaddressutxo - public async getUtxoByAddress(address: string) { - const response = await this.get(`/address/${address}/utxo`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-addressaddresstxs - public async getTransactionsByAddress(address: string) { - const response = await this.get(`/address/${address}/txs`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-txtxid - public async getTransaction(txid: string) { - const response = await this.get(`/tx/${txid}`); - return response.data; - } - - // https://github.com/Blockstream/esplora/blob/master/API.md#get-txtxidhex - public async getTransactionHex(txid: string) { - const response = await this.get(`/tx/${txid}/hex`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-blockhash - public async getBlockByHash(hash: string) { - const response = await this.get(`/block/${hash}`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-block-heightheight - public async getBlockByHeight(height: number) { - const response = await this.get(`/block-height/${height}`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-block-heightheight - public async getBlockHashByHeight(height: number) { - const response = await this.get(`/block-height/${height}`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-blockhashheader - public async getBlockHeaderByHash(hash: string) { - const response = await this.get(`/block/${hash}/header`); - return response.data; - } - - // https://github.com/Blockstream/esplora/blob/master/API.md#get-blockhashtxids - public async getBlockTxIdsByHash(hash: string) { - const response = await this.get(`/block/${hash}/txids`); - return response.data; - } - - // https://github.com/blockstream/esplora/blob/master/API.md#get-blockstipheight - public async getTip() { - const response = await this.get('/blocks/tip/height'); - return response.data; - } -} diff --git a/src/services/spv.ts b/src/services/spv.ts index 8b4dfd99..927328f5 100644 --- a/src/services/spv.ts +++ b/src/services/spv.ts @@ -80,8 +80,8 @@ export default class BitcoinSPV { public async getTxProof(btcTxid: string, confirmations: number = 0) { const txid = remove0x(btcTxid); - const btcTx = await this.cradle.electrs.getTransaction(txid); - const btcTxids = await this.cradle.electrs.getBlockTxIdsByHash(btcTx.status.block_hash!); + const btcTx = await this.cradle.bitcoin.getTransaction(txid); + const btcTxids = await this.cradle.bitcoin.getBlockTxIdsByHash(btcTx.status.block_hash!); const btcIdxInBlock = btcTxids.findIndex((id) => id === txid); return this._getTxProof(txid, btcIdxInBlock, confirmations); } diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 1f9fd569..31646ca7 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -27,13 +27,13 @@ import { Cradle } from '../container'; import { Transaction } from '../routes/bitcoin/types'; import { CKBRawTransaction, CKBVirtualResult } from '../routes/rgbpp/types'; import { BitcoinSPVError } from './spv'; -import { ElectrsAPINotFoundError } from './electrs'; import { BloomFilter } from 'bloom-filters'; import { BI } from '@ckb-lumos/lumos'; import { CKBRpcError, CKBRPCErrorCodes } from './ckb'; import { cloneDeep } from 'lodash'; import { JwtPayload } from '../plugins/jwt'; import { serializeCellDep } from '@nervosnetwork/ckb-sdk-utils'; +import { BitcoinMempoolAPIError } from './bitcoin'; export interface ITransactionRequest { txid: string; @@ -310,10 +310,8 @@ export default class TransactionManager implements ITransactionManager { * @param ckbRawTx - the CKB Raw Transaction */ private async appendTxWitnesses(txid: string, ckbRawTx: CKBRawTransaction) { - // bitcoin JSON-RPC gettransaction is wallet only - // we need to use electrs to get the transaction hex and index in block const [hex, rgbppApiSpvProof] = await Promise.all([ - this.cradle.electrs.getTransactionHex(txid), + this.cradle.bitcoin.getTransactionHex(txid), this.cradle.bitcoinSPV.getTxProof(txid), ]); // using for spv proof, we need to remove the witness data from the transaction @@ -427,7 +425,7 @@ export default class TransactionManager implements ITransactionManager { public async process(job: Job, token?: string) { try { const { ckbVirtualResult, txid } = cloneDeep(job.data); - const btcTx = await this.cradle.electrs.getTransaction(txid); + const btcTx = await this.cradle.bitcoin.getTransaction(txid); const isVerified = await this.verifyTransaction({ ckbVirtualResult, txid }, btcTx); if (!isVerified) { throw new InvalidTransactionError('Invalid transaction', job.data); @@ -471,7 +469,7 @@ export default class TransactionManager implements ITransactionManager { } } catch (err) { this.cradle.logger.debug(err); - if (err instanceof ElectrsAPINotFoundError) { + if (err instanceof BitcoinMempoolAPIError) { // move the job to delayed queue if the transaction is not found yet // only delay the job when the job is created less than 1 hour to make sure the transaction is existed // let the job failed if the transaction is not found after 1 hour @@ -501,9 +499,8 @@ export default class TransactionManager implements ITransactionManager { * retry the mempool missing transactions when the blockchain block is confirmed */ public async retryMissingTransactions() { - const blockchainInfo = await this.cradle.bitcoind.getBlockchainInfo(); + const blockchainInfo = await this.cradle.bitcoin.getBlockchainInfo(); // get the block height that has latest one confirmation - // make sure the electrs and spv service is synced with the bitcoind const targetHeight = blockchainInfo.blocks - 1; const previousHeight = await this.cradle.redis.get('missing-transactions-height'); @@ -515,8 +512,8 @@ export default class TransactionManager implements ITransactionManager { const heights = Array.from({ length: targetHeight - startHeight }, (_, i) => startHeight + i + 1); const txidsGroups = await Promise.all( heights.map(async (height) => { - const blockHash = await this.cradle.electrs.getBlockHashByHeight(height); - return this.cradle.electrs.getBlockTxIdsByHash(blockHash); + const blockHash = await this.cradle.bitcoin.getBlockHashByHeight(height); + return this.cradle.bitcoin.getBlockTxIdsByHash(blockHash); }), ); const txids = txidsGroups.flat(); diff --git a/src/services/unlocker.ts b/src/services/unlocker.ts index d831e7d3..bbadae6f 100644 --- a/src/services/unlocker.ts +++ b/src/services/unlocker.ts @@ -54,7 +54,7 @@ export default class Unlocker implements IUnlocker { const collect = this.collector.collect(); const cells: IndexerCell[] = []; - const { blocks } = await this.cradle.bitcoind.getBlockchainInfo(); + const { blocks } = await this.cradle.bitcoin.getBlockchainInfo(); for await (const cell of collect) { // allow supported asset types only if (!cell.cellOutput.type || !isTypeAssetSupported(cell.cellOutput.type, this.isMainnet)) { @@ -68,7 +68,7 @@ export default class Unlocker implements IUnlocker { const btcTxid = remove0x(btcTxIdFromBtcTimeLockArgs(cell.cellOutput.lock.args)); const { after } = BTCTimeLock.unpack(cell.cellOutput.lock.args); - const btcTx = await this.cradle.electrs.getTransaction(btcTxid); + const btcTx = await this.cradle.bitcoin.getTransaction(btcTxid); const blockHeight = btcTx.status.block_height; // skip if btc tx not confirmed $after blocks yet diff --git a/test/routes/bitcoind/address.test.ts b/test/routes/bitcoind/address.test.ts index 81b36956..645ff971 100644 --- a/test/routes/bitcoind/address.test.ts +++ b/test/routes/bitcoind/address.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test, beforeEach, vi } from 'vitest'; import { buildFastify } from '../../../src/app'; -import { ElectrsAPIError, ElectrsAPIErrorCode, ElectrsErrorMessage } from '../../../src/services/electrs'; import { afterEach } from 'node:test'; let token: string; @@ -92,9 +91,9 @@ describe('/bitcoin/v1/address', () => { const fastify = buildFastify(); await fastify.ready(); - const electrs = fastify.container.resolve('electrs'); - const originalGetUtxoByAddress = electrs.getUtxoByAddress; - vi.spyOn(electrs, 'getUtxoByAddress').mockResolvedValue([ + const bitcoin = fastify.container.resolve('bitcoin'); + const originalGetUtxoByAddress = bitcoin.getUtxoByAddress; + vi.spyOn(bitcoin, 'getUtxoByAddress').mockResolvedValue([ { txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', vout: 0, @@ -122,7 +121,7 @@ describe('/bitcoin/v1/address', () => { }, }); const data = response.json(); - electrs.getUtxoByAddress = originalGetUtxoByAddress; + bitcoin.getUtxoByAddress = originalGetUtxoByAddress; expect(response.statusCode).toBe(200); expect(data.length).toBe(1); @@ -134,9 +133,9 @@ describe('/bitcoin/v1/address', () => { const fastify = buildFastify(); await fastify.ready(); - const electrs = fastify.container.resolve('electrs'); - const originalGetUtxoByAddress = electrs.getUtxoByAddress; - vi.spyOn(electrs, 'getUtxoByAddress').mockResolvedValue([ + const bitcoin = fastify.container.resolve('bitcoin'); + const originalGetUtxoByAddress = bitcoin.getUtxoByAddress; + vi.spyOn(bitcoin, 'getUtxoByAddress').mockResolvedValue([ { txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', vout: 0, @@ -164,7 +163,7 @@ describe('/bitcoin/v1/address', () => { }, }); const data = response.json(); - electrs.getUtxoByAddress = originalGetUtxoByAddress; + bitcoin.getUtxoByAddress = originalGetUtxoByAddress; expect(response.statusCode).toBe(200); expect(data.length).toBe(2); @@ -176,9 +175,9 @@ describe('/bitcoin/v1/address', () => { const fastify = buildFastify(); await fastify.ready(); - const electrs = fastify.container.resolve('electrs'); - const originalGetUtxoByAddress = electrs.getUtxoByAddress; - vi.spyOn(electrs, 'getUtxoByAddress').mockResolvedValue([ + const bitcoin = fastify.container.resolve('bitcoin'); + const originalGetUtxoByAddress = bitcoin.getUtxoByAddress; + vi.spyOn(bitcoin, 'getUtxoByAddress').mockResolvedValue([ { txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', vout: 0, @@ -206,7 +205,7 @@ describe('/bitcoin/v1/address', () => { }, }); const data = response.json(); - electrs.getUtxoByAddress = originalGetUtxoByAddress; + bitcoin.getUtxoByAddress = originalGetUtxoByAddress; expect(response.statusCode).toBe(200); expect(data.length).toBe(1); @@ -214,27 +213,6 @@ describe('/bitcoin/v1/address', () => { await fastify.close(); }); - test('Get address unspent transaction outputs throw too many', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qcq670zweall6zz4f96flfrefhr8myfxz9ll9l2/unspent', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(500); - expect(data.code).toEqual(ElectrsAPIErrorCode.TooManyUtxos); - expect(data.message).toEqual('Too many unspent transaction outputs'); - - await fastify.close(); - }); - test('Get address transactions', async () => { const fastify = buildFastify(); await fastify.ready(); diff --git a/test/services/transaction.test.ts b/test/services/transaction.test.ts index 73f539bc..965b7763 100644 --- a/test/services/transaction.test.ts +++ b/test/services/transaction.test.ts @@ -36,8 +36,7 @@ describe('transactionManager', () => { needPaymasterCell: false, }, }; - // FIXME: mock electrs getTransaction - const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(true); }); @@ -59,8 +58,7 @@ describe('transactionManager', () => { needPaymasterCell: false, }, }; - // FIXME: mock electrs getTransaction - const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(false); }); @@ -83,8 +81,7 @@ describe('transactionManager', () => { needPaymasterCell: false, }, }; - // FIXME: mock electrs getTransaction - const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(false); }); @@ -96,7 +93,7 @@ describe('transactionManager', () => { }, 'getCommitmentFromBtcTx', ).mockResolvedValueOnce(Buffer.from(commitment, 'hex')); - vi.spyOn(transactionManager['cradle']['electrs'], 'getTransaction').mockResolvedValueOnce({ + vi.spyOn(transactionManager['cradle']['bitcoin'], 'getTransaction').mockResolvedValueOnce({ status: { confirmed: false, block_height: 0 }, } as unknown as Transaction); @@ -110,8 +107,7 @@ describe('transactionManager', () => { }, }; - // FIXME: mock electrs getTransaction - const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); await expect( transactionManager.verifyTransaction(transactionRequest, btcTx), ).rejects.toThrowErrorMatchingSnapshot(); @@ -136,20 +132,22 @@ describe('transactionManager', () => { }); test('retryMissingTransactions: should be retry transaction job when missing', async () => { - vi.spyOn(cradle.bitcoind, 'getBlockchainInfo').mockResolvedValue({ + vi.spyOn(cradle.bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 123456, } as unknown as ChainInfo); - vi.spyOn(cradle.electrs, 'getBlockHashByHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); - vi.spyOn(cradle.electrs, 'getBlockTxIdsByHash').mockResolvedValue([ + vi.spyOn(cradle.bitcoin, 'getBlockHashByHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); + vi.spyOn(cradle.bitcoin, 'getBlockTxIdsByHash').mockResolvedValue([ 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', '8ea0fbb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a056', '8eb22b379c0ef491dea2d819e721d5df296bebc67a056a0fbb8c92f11920824d', ]); const retry = vi.fn(); - vi.spyOn(transactionManager['queue'], 'getJobs').mockResolvedValue([{ - id: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', - retry, - } as unknown as Job]) + vi.spyOn(transactionManager['queue'], 'getJobs').mockResolvedValue([ + { + id: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', + retry, + } as unknown as Job, + ]); await transactionManager.retryMissingTransactions(); @@ -157,20 +155,22 @@ describe('transactionManager', () => { }); test('retryMissingTransactions: should not retry transaction job when not match', async () => { - vi.spyOn(cradle.bitcoind, 'getBlockchainInfo').mockResolvedValue({ + vi.spyOn(cradle.bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 123456, } as unknown as ChainInfo); - vi.spyOn(cradle.electrs, 'getBlockHashByHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); - vi.spyOn(cradle.electrs, 'getBlockTxIdsByHash').mockResolvedValue([ + vi.spyOn(cradle.bitcoin, 'getBlockHashByHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); + vi.spyOn(cradle.bitcoin, 'getBlockTxIdsByHash').mockResolvedValue([ 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', '8ea0fbb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a056', '8eb22b379c0ef491dea2d819e721d5df296bebc67a056a0fbb8c92f11920824d', ]); const retry = vi.fn(); - vi.spyOn(transactionManager['queue'], 'getJobs').mockResolvedValue([{ - id: 'bb8c92f119208248ea0fdb22b379c0ef491dea2d819e721d5df296bebc67a056', - retry, - } as unknown as Job]) + vi.spyOn(transactionManager['queue'], 'getJobs').mockResolvedValue([ + { + id: 'bb8c92f119208248ea0fdb22b379c0ef491dea2d819e721d5df296bebc67a056', + retry, + } as unknown as Job, + ]); await transactionManager.retryMissingTransactions(); diff --git a/test/services/unlocker.test.ts b/test/services/unlocker.test.ts index 21a31770..ff08fa3c 100644 --- a/test/services/unlocker.test.ts +++ b/test/services/unlocker.test.ts @@ -75,9 +75,9 @@ describe('Unlocker', () => { test('getNextBatchLockCell: should skip unconfirmed btc tx', async () => { // @ts-expect-error - vi.spyOn(unlocker['cradle'].bitcoind, 'getBlockchainInfo').mockResolvedValue({ blocks: 100 }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 100 }); // @ts-expect-error - vi.spyOn(unlocker['cradle'].electrs, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); mockBtcTimeLockCell(); const cells = await unlocker.getNextBatchLockCell(); @@ -86,9 +86,9 @@ describe('Unlocker', () => { test('getNextBatchLockCell: should return cells when btc tx is confirmed', async () => { // @ts-expect-error - vi.spyOn(unlocker['cradle'].bitcoind, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); // @ts-expect-error - vi.spyOn(unlocker['cradle'].electrs, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); mockBtcTimeLockCell(); const cells = await unlocker.getNextBatchLockCell(); @@ -99,9 +99,9 @@ describe('Unlocker', () => { unlocker['cradle'].env.UNLOCKER_CELL_BATCH_SIZE = 1; // @ts-expect-error - vi.spyOn(unlocker['cradle'].bitcoind, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); // @ts-expect-error - vi.spyOn(unlocker['cradle'].electrs, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); mockBtcTimeLockCell(); const cells = await unlocker.getNextBatchLockCell(); From c09843ae6b363b06d3593efc2bedf6818b68c0f4 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 23 Apr 2024 11:28:23 +1000 Subject: [PATCH 03/53] refactor: update transaction processing mempool error handle --- src/services/transaction.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 31646ca7..4d402109 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -34,6 +34,7 @@ import { cloneDeep } from 'lodash'; import { JwtPayload } from '../plugins/jwt'; import { serializeCellDep } from '@nervosnetwork/ckb-sdk-utils'; import { BitcoinMempoolAPIError } from './bitcoin'; +import { HttpStatusCode } from 'axios'; export interface ITransactionRequest { txid: string; @@ -469,7 +470,7 @@ export default class TransactionManager implements ITransactionManager { } } catch (err) { this.cradle.logger.debug(err); - if (err instanceof BitcoinMempoolAPIError) { + if (err instanceof BitcoinMempoolAPIError && err.statusCode === HttpStatusCode.NotFound) { // move the job to delayed queue if the transaction is not found yet // only delay the job when the job is created less than 1 hour to make sure the transaction is existed // let the job failed if the transaction is not found after 1 hour From 884adeb55db6f302af388056bbb61bd3f6308a34 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 23 Apr 2024 11:32:47 +1000 Subject: [PATCH 04/53] test: add BITCOIN_MEMPOOL_SPACE_API_URL to test env --- .github/workflows/test.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b6459569..6781da17 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,7 +4,7 @@ name: Unit Tests on: workflow_dispatch: push: - branches: + branches: - main - develop pull_request: @@ -48,10 +48,7 @@ jobs: - name: Run Unit Tests env: JWT_SECRET: ${{ secrets.JWT_SECRET }} - BITCOIN_JSON_RPC_URL: ${{ secrets.BITCOIN_JSON_RPC_URL }} - BITCOIN_JSON_RPC_USERNAME: ${{ secrets.BITCOIN_JSON_RPC_USERNAME }} - BITCOIN_JSON_RPC_PASSWORD: ${{ secrets.BITCOIN_JSON_RPC_PASSWORD }} - BITCOIN_ELECTRS_API_URL: ${{ secrets.BITCOIN_ELECTRS_API_URL }} + BITCOIN_MEMPOOL_SPACE_API_URL: ${{ secrets.BITCOIN_MEMPOOL_SPACE_API_URL }} BITCOIN_SPV_SERVICE_URL: ${{ secrets.BITCOIN_SPV_SERVICE_URL }} PAYMASTER_RECEIVE_BTC_ADDRESS: ${{ secrets.PAYMASTER_RECEIVE_BTC_ADDRESS }} CKB_RPC_URL: ${{ secrets.CKB_RPC_URL }} From 962202afc035139627df456c5a2d47418953824d Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 23 Apr 2024 11:40:21 +1000 Subject: [PATCH 05/53] test: update test cases --- test/routes/bitcoind/info.test.ts | 1 - test/routes/bitcoind/transaction.test.ts | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/test/routes/bitcoind/info.test.ts b/test/routes/bitcoind/info.test.ts index 4756ed96..1a87f59e 100644 --- a/test/routes/bitcoind/info.test.ts +++ b/test/routes/bitcoind/info.test.ts @@ -42,7 +42,6 @@ describe('/bitcoin/v1/info', () => { expect(data).toHaveProperty('blocks'); expect(data).toHaveProperty('chain'); expect(data).toHaveProperty('difficulty'); - expect(data).toHaveProperty('headers'); expect(data).toHaveProperty('mediantime'); await fastify.close(); diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts index d9d6af07..68413ed3 100644 --- a/test/routes/bitcoind/transaction.test.ts +++ b/test/routes/bitcoind/transaction.test.ts @@ -59,7 +59,6 @@ describe('/bitcoin/v1/transaction', () => { expect(response.statusCode).toBe(404); expect(data).toEqual({ - code: 404, message: 'Request failed with status code 404', }); @@ -82,12 +81,8 @@ describe('/bitcoin/v1/transaction', () => { '02000000000101fe7b9cd0f75741e2ec1e3a6142eab945e64fab0ef15de4a66c635c0a789e986f0100000000ffffffff02e803000000000000160014dbf4360c0791098b0b14679e5e78015df3f2caad6a88000000000000160014dbf4360c0791098b0b14679e5e78015df3f2caad02473044022065829878f51581488f44c37064b46f552ea7354196fae5536906797b76b370bf02201c459081578dc4e1098fbe3ab68d7d56a99e8e9810bf2806d10053d6b36ffa4d0121037dff8ff2e0bd222690d785f9277e0c4800fc88b0fad522f1442f21a8226253ce00000000', }, }); - const data = response.json(); - - expect(response.statusCode).toBe(500); - expect(data.code).toBe(-25); - expect(data.message).toBe('bad-txns-inputs-missingorspent'); + expect(response.statusCode).toBe(400); await fastify.close(); }); }); From 7dd33cc1278d263217fbe46fdace5b386c0bddee Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 23 Apr 2024 11:58:07 +1000 Subject: [PATCH 06/53] refactor: refactor bitcoin mempool api error handle and test cases --- src/error.ts | 4 +++ src/services/bitcoin.ts | 35 ++++++++++++++++++++++-- src/services/transaction.ts | 4 +-- test/routes/bitcoind/transaction.test.ts | 2 ++ 4 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 src/error.ts diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 00000000..19ec65d4 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,4 @@ +export enum AppErrorCode { + UnknownResponseError = 0x9999, // 39321 + AxiosResponseError = 0x9998, // 39320 +} diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts index e5129ea5..58166ed5 100644 --- a/src/services/bitcoin.ts +++ b/src/services/bitcoin.ts @@ -4,12 +4,43 @@ import { Block, ChainInfo, Transaction, UTXO } from '../routes/bitcoin/types'; import { NetworkType } from '../constants'; import { AxiosError } from 'axios'; -export class BitcoinMempoolAPIError extends Error { +// https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 +export enum MempoolElectrsMessage { + Connection = 'Connection error', + Interrupt = 'Interruption by external signal', + TooManyUtxos = 'Too many unspent transaction outputs', + TooManyTxs = 'Too many history transactions', + ElectrumClient = 'Electrum client error', +} + +export enum MempoolAPIErrorCode { + Connection = 0x1000, // 4096 + Interrupt = 0x1001, // 4097 + TooManyUtxos = 0x1002, // 4098 + TooManyTxs = 0x1003, // 4099 + ElectrumClient = 0x1004, // 4100 + + MempoolUnknown = 0x1111, // 4369 +} + +const MempoolElectrsErrorMap = { + [MempoolElectrsMessage.Connection]: MempoolAPIErrorCode.Connection, + [MempoolElectrsMessage.Interrupt]: MempoolAPIErrorCode.Interrupt, + [MempoolElectrsMessage.TooManyUtxos]: MempoolAPIErrorCode.TooManyUtxos, + [MempoolElectrsMessage.TooManyTxs]: MempoolAPIErrorCode.TooManyTxs, + [MempoolElectrsMessage.ElectrumClient]: MempoolAPIErrorCode.ElectrumClient, +}; + +export class MempoolAPIError extends Error { public statusCode = 500; + public errorCode: MempoolAPIErrorCode; constructor(message: string) { super(message); this.name = this.constructor.name; + + const errorKey = Object.keys(MempoolElectrsErrorMap).find((msg) => message.startsWith(msg)); + this.errorCode = MempoolElectrsErrorMap[errorKey as MempoolElectrsMessage] ?? MempoolAPIErrorCode.MempoolUnknown; } } @@ -20,7 +51,7 @@ const wrapTry = async any>(fn: T): Promise { if ((err as AxiosError).isAxiosError) { - const error = new BitcoinMempoolAPIError(err.message); + const error = new MempoolAPIError(err.message); if ((err as AxiosError).response) { error.statusCode = (err as AxiosError).response?.status || 500; } diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 4d402109..1f83c314 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -33,7 +33,7 @@ import { CKBRpcError, CKBRPCErrorCodes } from './ckb'; import { cloneDeep } from 'lodash'; import { JwtPayload } from '../plugins/jwt'; import { serializeCellDep } from '@nervosnetwork/ckb-sdk-utils'; -import { BitcoinMempoolAPIError } from './bitcoin'; +import { MempoolAPIError } from './bitcoin'; import { HttpStatusCode } from 'axios'; export interface ITransactionRequest { @@ -470,7 +470,7 @@ export default class TransactionManager implements ITransactionManager { } } catch (err) { this.cradle.logger.debug(err); - if (err instanceof BitcoinMempoolAPIError && err.statusCode === HttpStatusCode.NotFound) { + if (err instanceof MempoolAPIError && err.statusCode === HttpStatusCode.NotFound) { // move the job to delayed queue if the transaction is not found yet // only delay the job when the job is created less than 1 hour to make sure the transaction is existed // let the job failed if the transaction is not found after 1 hour diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts index 68413ed3..0562f52c 100644 --- a/test/routes/bitcoind/transaction.test.ts +++ b/test/routes/bitcoind/transaction.test.ts @@ -1,6 +1,7 @@ import { beforeEach, expect, test } from 'vitest'; import { buildFastify } from '../../../src/app'; import { describe } from 'node:test'; +import { MempoolAPIErrorCode } from '../../../src/services/bitcoin'; let token: string; @@ -59,6 +60,7 @@ describe('/bitcoin/v1/transaction', () => { expect(response.statusCode).toBe(404); expect(data).toEqual({ + code: MempoolAPIErrorCode.MempoolUnknown, message: 'Request failed with status code 404', }); From aa95fc8bcce0c64b853e7921a9943213ecc4af01 Mon Sep 17 00:00:00 2001 From: ahonn Date: Wed, 24 Apr 2024 15:55:18 +1000 Subject: [PATCH 07/53] refactor: useing electrs as a fallback for the mempool.space API --- src/@types/fastify/index.d.ts | 4 +- src/container.ts | 6 +- src/env.ts | 6 ++ src/routes/rgbpp/index.ts | 2 +- src/routes/rgbpp/spv.ts | 2 +- src/services/bitcoin.ts | 124 +++++++++++++++++++++++++++------- src/services/spv.ts | 2 +- src/services/transaction.ts | 2 +- src/services/unlocker.ts | 2 +- src/utils/electrs.ts | 71 +++++++++++++++++++ test/services/spv.test.ts | 22 +++--- 11 files changed, 196 insertions(+), 47 deletions(-) create mode 100644 src/utils/electrs.ts diff --git a/src/@types/fastify/index.d.ts b/src/@types/fastify/index.d.ts index a70862ca..193f2698 100644 --- a/src/@types/fastify/index.d.ts +++ b/src/@types/fastify/index.d.ts @@ -1,7 +1,7 @@ import { AwilixContainer, Cradle } from '../../container'; import TransactionManager from '../../services/transaction'; import Paymaster from '../../services/paymaster'; -import BitcoinSPV from '../../services/spv'; +import SPV from '../../services/spv'; import CKB from '../../services/ckb'; import Bitcoin from '../../services/bitcoin'; @@ -12,7 +12,7 @@ declare module 'fastify' { container: AwilixContainer; ckb: CKB; bitcoin: Bitcoin; - bitcoinSPV: BitcoinSPV; + spv: SPV; paymaster: Paymaster; transactionManager: TransactionManager; } diff --git a/src/container.ts b/src/container.ts index 37647e7c..3de14a22 100644 --- a/src/container.ts +++ b/src/container.ts @@ -5,7 +5,7 @@ import { env } from './env'; import TransactionManager from './services/transaction'; import Paymaster from './services/paymaster'; import Unlocker from './services/unlocker'; -import BitcoinSPV from './services/spv'; +import SPV from './services/spv'; import CKB from './services/ckb'; import Bitcoin from './services/bitcoin'; @@ -15,7 +15,7 @@ export interface Cradle { redis: Redis; ckb: CKB; bitcoin: Bitcoin; - bitcoinSPV: BitcoinSPV; + spv: SPV; paymaster: Paymaster; unlocker: Unlocker; transactionManager: TransactionManager; @@ -36,7 +36,7 @@ container.register({ ), ckb: asClass(CKB).singleton(), bitcoin: asClass(Bitcoin).singleton(), - bitcoinSPV: asClass(BitcoinSPV).singleton(), + spv: asClass(SPV).singleton(), paymaster: asClass(Paymaster).singleton(), transactionManager: asClass(TransactionManager).singleton(), unlocker: asClass(Unlocker).singleton(), diff --git a/src/env.ts b/src/env.ts index 01edd321..7e4be431 100644 --- a/src/env.ts +++ b/src/env.ts @@ -72,6 +72,12 @@ const envSchema = z.object({ * Bitcoin Mempool.space API URL */ BITCOIN_MEMPOOL_SPACE_API_URL: z.string(), + /** + * The URL of the Electrs API. + * Electrs is a Rust implementation of Electrum Server. + * used for fallback when the mempool.space API is down. + */ + BITCOIN_ELECTRS_API_URL: z.string().optional(), /** * Bitcoin SPV service URL diff --git a/src/routes/rgbpp/index.ts b/src/routes/rgbpp/index.ts index 89a49a31..2e8e3aaf 100644 --- a/src/routes/rgbpp/index.ts +++ b/src/routes/rgbpp/index.ts @@ -13,7 +13,7 @@ const rgbppRoutes: FastifyPluginCallback, Server, ZodTypePr fastify.decorate('paymaster', container.resolve('paymaster')); fastify.decorate('ckb', container.resolve('ckb')); fastify.decorate('bitcoin', container.resolve('bitcoin')); - fastify.decorate('bitcoinSPV', container.resolve('bitcoinSPV')); + fastify.decorate('spv', container.resolve('spv')); fastify.register(transactionRoutes, { prefix: '/transaction' }); fastify.register(assetsRoute, { prefix: '/assets' }); diff --git a/src/routes/rgbpp/spv.ts b/src/routes/rgbpp/spv.ts index b28c5a56..9db89eff 100644 --- a/src/routes/rgbpp/spv.ts +++ b/src/routes/rgbpp/spv.ts @@ -28,7 +28,7 @@ const spvRoute: FastifyPluginCallback, Server, ZodTypeProvi async (request, reply) => { try { const { btc_txid, confirmations } = request.query; - const proof = await fastify.bitcoinSPV.getTxProof(btc_txid, confirmations); + const proof = await fastify.spv.getTxProof(btc_txid, confirmations); if (proof) { reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); reply.header(CUSTOM_HEADERS.ResponseCacheMaxAge, SPV_PROOF_CACHE_MAX_AGE); diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts index 58166ed5..bcca5ac7 100644 --- a/src/services/bitcoin.ts +++ b/src/services/bitcoin.ts @@ -3,6 +3,8 @@ import { Cradle } from '../container'; import { Block, ChainInfo, Transaction, UTXO } from '../routes/bitcoin/types'; import { NetworkType } from '../constants'; import { AxiosError } from 'axios'; +import Electrs from '../utils/electrs'; +import * as Sentry from '@sentry/node'; // https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 export enum MempoolElectrsMessage { @@ -45,37 +47,47 @@ export class MempoolAPIError extends Error { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -const wrapTry = async any>(fn: T): Promise> => { +const wrapTry = async Promise>(fn: T): Promise> => { if (typeof fn !== 'function') { throw new Error('wrapTry: fn must be a function'); } - return fn().catch((err: Error) => { + + try { + const ret = await fn(); + return ret; + } catch (err) { if ((err as AxiosError).isAxiosError) { - const error = new MempoolAPIError(err.message); + const error = new MempoolAPIError((err as AxiosError).message); if ((err as AxiosError).response) { error.statusCode = (err as AxiosError).response?.status || 500; } throw error; } throw err; - }); + } }; export default class Bitcoin { - private mempool: ReturnType; private cradle: Cradle; + private mempool: ReturnType; + private electrs?: Electrs; constructor(cradle: Cradle) { + this.cradle = cradle; + const url = new URL(cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL); this.mempool = mempoolJS({ hostname: url.hostname, network: cradle.env.NETWORK, }); - this.cradle = cradle; + + if (cradle.env.BITCOIN_ELECTRS_API_URL) { + this.electrs = new Electrs(cradle); + } } public async checkNetwork(network: NetworkType) { - const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height: 0 }); + const hash = await this.getBlockHashByHeight(0); switch (network) { case NetworkType.mainnet: // Bitcoin mainnet genesis block hash @@ -109,15 +121,33 @@ export default class Bitcoin { public async sendRawTransaction(txhex: string): Promise { return wrapTry(async () => { - const txid = await this.mempool.bitcoin.transactions.postTx({ txhex }); - return txid as string; + try { + const txid = await this.mempool.bitcoin.transactions.postTx({ txhex }); + return txid as string; + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + return this.electrs.sendRawTransaction(txhex); + } + throw err; + } }); } public async getUtxoByAddress(address: string): Promise { return wrapTry(async () => { - const utxo = await this.mempool.bitcoin.addresses.getAddressTxsUtxo({ address }); - return utxo.map((utxo) => UTXO.parse(utxo)); + try { + const utxo = await this.mempool.bitcoin.addresses.getAddressTxsUtxo({ address }); + return utxo.map((utxo) => UTXO.parse(utxo)); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + return this.electrs.getUtxoByAddress(address); + } + throw err; + } }); } @@ -148,41 +178,83 @@ export default class Bitcoin { public async getBlockByHeight(height: number): Promise { return wrapTry(async () => { - const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); - const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); - return block; + try { + const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); + const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); + return Block.parse(block); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + const hash = await this.electrs.getBlockHashByHeight(height); + const block = await this.electrs.getBlockByHash(hash); + return Block.parse(block); + } + throw err; + } }); } public async getBlockHashByHeight(height: number): Promise { return wrapTry(async () => { - return this.mempool.bitcoin.blocks.getBlockHeight({ height }); + try { + const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); + return hash; + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + const hash = await this.electrs.getBlockHashByHeight(height); + return hash; + } + throw err; + } }); } public async getBlockHeaderByHash(hash: string) { return wrapTry(async () => { - return this.mempool.bitcoin.blocks.getBlockHeader({ hash }); - }); - } - - public async getBlockHeaderByHeight(height: number) { - return wrapTry(async () => { - const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); - return this.getBlockHeaderByHash(hash); + try { + return this.mempool.bitcoin.blocks.getBlockHeader({ hash }); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + return this.electrs.getBlockHeaderByHash(hash); + } + throw err; + } }); } public async getBlockTxIdsByHash(hash: string): Promise { return wrapTry(async () => { - const txids = await this.mempool.bitcoin.blocks.getBlockTxids({ hash }); - return txids; + try { + const txids = await this.mempool.bitcoin.blocks.getBlockTxids({ hash }); + return txids; + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + return this.electrs.getBlockTxIdsByHash(hash); + } + throw err; + } }); } public async getTip(): Promise { return wrapTry(async () => { - return this.mempool.bitcoin.blocks.getBlocksTipHeight(); + try { + return this.mempool.bitcoin.blocks.getBlocksTipHeight(); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + return this.electrs.getTip(); + } + throw err; + } }); } } diff --git a/src/services/spv.ts b/src/services/spv.ts index 927328f5..defec8fb 100644 --- a/src/services/spv.ts +++ b/src/services/spv.ts @@ -43,7 +43,7 @@ export class BitcoinSPVError extends Error { /** * Bitcoin SPV service client */ -export default class BitcoinSPV { +export default class SPV { private request: AxiosInstance; private cradle: Cradle; diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 1f83c314..9d29c89b 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -313,7 +313,7 @@ export default class TransactionManager implements ITransactionManager { private async appendTxWitnesses(txid: string, ckbRawTx: CKBRawTransaction) { const [hex, rgbppApiSpvProof] = await Promise.all([ this.cradle.bitcoin.getTransactionHex(txid), - this.cradle.bitcoinSPV.getTxProof(txid), + this.cradle.spv.getTxProof(txid), ]); // using for spv proof, we need to remove the witness data from the transaction const hexWithoutWitness = transactionToHex(BitcoinTransaction.fromHex(hex), false); diff --git a/src/services/unlocker.ts b/src/services/unlocker.ts index bbadae6f..51301dad 100644 --- a/src/services/unlocker.ts +++ b/src/services/unlocker.ts @@ -117,7 +117,7 @@ export default class Unlocker implements IUnlocker { }); const btcAssetsApi = { - getRgbppSpvProof: this.cradle.bitcoinSPV.getTxProof.bind(this.cradle.bitcoinSPV), + getRgbppSpvProof: this.cradle.spv.getTxProof.bind(this.cradle.spv), } as unknown as BtcAssetsApi; const ckbRawTx = await buildBtcTimeCellsSpentTx({ btcTimeCells: cells, diff --git a/src/utils/electrs.ts b/src/utils/electrs.ts new file mode 100644 index 00000000..0a78d7c7 --- /dev/null +++ b/src/utils/electrs.ts @@ -0,0 +1,71 @@ +import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import { Transaction, Block } from 'bitcoinjs-lib'; +import { Cradle } from '../container'; +import { UTXO } from '../routes/bitcoin/types'; +import { addLoggerInterceptor } from './interceptors'; + +export default class Electrs { + private request: AxiosInstance; + + constructor({ env, logger }: Cradle) { + this.request = axios.create({ + baseURL: env.BITCOIN_ELECTRS_API_URL, + }); + addLoggerInterceptor(this.request, logger); + } + + private async get(path: string): Promise> { + const response = await this.request.get(path); + return response; + } + + public async sendRawTransaction(hex: string) { + const response = await this.request.post('/tx', hex); + return response.data; + } + + public async getUtxoByAddress(address: string) { + const response = await this.get(`/address/${address}/utxo`); + return response.data; + } + + public async getTransactionsByAddress(address: string) { + const response = await this.get(`/address/${address}/txs`); + return response.data; + } + + public async getTransaction(txid: string) { + const response = await this.get(`/tx/${txid}`); + return response.data; + } + + public async getTransactionHex(txid: string) { + const response = await this.get(`/tx/${txid}/hex`); + return response.data; + } + + public async getBlockByHash(hash: string) { + const response = await this.get(`/block/${hash}`); + return response.data; + } + + public async getBlockHashByHeight(height: number) { + const response = await this.get(`/block-height/${height}`); + return response.data; + } + + public async getBlockHeaderByHash(hash: string) { + const response = await this.get(`/block/${hash}/header`); + return response.data; + } + + public async getBlockTxIdsByHash(hash: string) { + const response = await this.get(`/block/${hash}/txids`); + return response.data; + } + + public async getTip() { + const response = await this.get('/blocks/tip/height'); + return response.data; + } +} diff --git a/test/services/spv.test.ts b/test/services/spv.test.ts index 96e0ae80..1a96714f 100644 --- a/test/services/spv.test.ts +++ b/test/services/spv.test.ts @@ -1,13 +1,13 @@ import container from '../../src/container'; import { describe, test, beforeEach, afterEach, vi, expect } from 'vitest'; -import BitcoinSPV from '../../src/services/spv'; +import SPV from '../../src/services/spv'; describe('BitcoinSPV', () => { - let bitcoinSPV: BitcoinSPV; + let spv: SPV; beforeEach(async () => { const cradle = container.cradle; - bitcoinSPV = new BitcoinSPV(cradle); + spv = new SPV(cradle); }); afterEach(() => { @@ -15,7 +15,7 @@ describe('BitcoinSPV', () => { }); test('getTxProof: throw BitcoinSPVError', async () => { - vi.spyOn(bitcoinSPV['request'], 'post').mockResolvedValue({ + vi.spyOn(spv['request'], 'post').mockResolvedValue({ data: { jsonrpc: '2.0', error: { @@ -27,25 +27,25 @@ describe('BitcoinSPV', () => { }, }); await expect( - bitcoinSPV.getTxProof('ede749ecee5e607e761e4fffb6d754799498056872456a7d33abe426d7b9951c', 100), + spv.getTxProof('ede749ecee5e607e761e4fffb6d754799498056872456a7d33abe426d7b9951c', 100), ).rejects.toThrowErrorMatchingSnapshot(); }); test('getTxProof: get proof successfuly', async () => { - vi.spyOn(bitcoinSPV['request'], 'post').mockResolvedValue({ + vi.spyOn(spv['request'], 'post').mockResolvedValue({ data: { jsonrpc: '2.0', result: { - "spv_client": { - "tx_hash": "0x5e570545c3ffd656199d3babd85f05377ac91b396126b166cf370e2f0edddae5", - "index": "0x1" + spv_client: { + tx_hash: '0x5e570545c3ffd656199d3babd85f05377ac91b396126b166cf370e2f0edddae5', + index: '0x1', }, - "proof": "00000000000000" + proof: '00000000000000', }, id: 'aa1a7882-9c0a-4eaa-87e8-1ed906b957f8', }, }); - const proof = await bitcoinSPV.getTxProof('ede749ecee5e607e761e4fffb6d754799498056872456a7d33abe426d7b9951c', 100); + const proof = await spv.getTxProof('ede749ecee5e607e761e4fffb6d754799498056872456a7d33abe426d7b9951c', 100); expect(proof).toHaveProperty('spv_client'); expect(proof).toHaveProperty('proof'); }); From 6ca04a7570f18d2f58b0b0e89d9768d2dda3b52b Mon Sep 17 00:00:00 2001 From: ahonn Date: Wed, 24 Apr 2024 22:30:23 +1000 Subject: [PATCH 08/53] refactor: rename ckb/bitcoin/spv/transaction services --- src/@types/fastify/index.d.ts | 16 +++---- src/container.ts | 24 +++++----- src/plugins/cron.ts | 10 ++-- src/plugins/healthcheck.ts | 8 ++-- src/routes/bitcoin/index.ts | 4 +- src/routes/cron/process-transactions.ts | 10 ++-- src/routes/internal/index.ts | 2 +- src/routes/internal/job.ts | 2 +- src/routes/rgbpp/index.ts | 2 +- src/routes/rgbpp/transaction.ts | 8 ++-- src/services/bitcoin.ts | 34 +++++++------- src/services/ckb.ts | 2 +- src/services/spv.ts | 2 +- src/services/transaction.ts | 46 +++++++++---------- test/routes/bitcoind/transaction.test.ts | 4 +- test/routes/rgbpp/transaction.test.ts | 15 +++--- .../__snapshots__/transaction.test.ts.snap | 2 +- test/services/spv.test.ts | 6 +-- test/services/transaction.test.ts | 40 ++++++++-------- 19 files changed, 119 insertions(+), 118 deletions(-) diff --git a/src/@types/fastify/index.d.ts b/src/@types/fastify/index.d.ts index 193f2698..ab4ab505 100644 --- a/src/@types/fastify/index.d.ts +++ b/src/@types/fastify/index.d.ts @@ -1,19 +1,19 @@ import { AwilixContainer, Cradle } from '../../container'; -import TransactionManager from '../../services/transaction'; +import TransactionProcessor from '../../services/transaction'; import Paymaster from '../../services/paymaster'; -import SPV from '../../services/spv'; -import CKB from '../../services/ckb'; -import Bitcoin from '../../services/bitcoin'; +import SPVClient from '../../services/spv'; +import CKBClient from '../../services/ckb'; +import BitcoinClient from '../../services/bitcoin'; declare module 'fastify' { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface FastifyInstance extends FastifyJwtNamespace<{ namespace: 'security' }> { container: AwilixContainer; - ckb: CKB; - bitcoin: Bitcoin; - spv: SPV; + ckb: CKBClient; + bitcoin: BitcoinClient; + spv: SPVClient; paymaster: Paymaster; - transactionManager: TransactionManager; + transactionProcessor: TransactionProcessor; } } diff --git a/src/container.ts b/src/container.ts index 3de14a22..6c60eae6 100644 --- a/src/container.ts +++ b/src/container.ts @@ -2,23 +2,23 @@ import { createContainer, InjectionMode, asValue, asClass } from 'awilix'; import { Redis } from 'ioredis'; import pino from 'pino'; import { env } from './env'; -import TransactionManager from './services/transaction'; +import TransactionProcessor from './services/transaction'; import Paymaster from './services/paymaster'; import Unlocker from './services/unlocker'; -import SPV from './services/spv'; -import CKB from './services/ckb'; -import Bitcoin from './services/bitcoin'; +import SPVClient from './services/spv'; +import CKBClient from './services/ckb'; +import BitcoinClient from './services/bitcoin'; export interface Cradle { env: typeof env; logger: pino.BaseLogger; redis: Redis; - ckb: CKB; - bitcoin: Bitcoin; - spv: SPV; + ckb: CKBClient; + bitcoin: BitcoinClient; + spv: SPVClient; paymaster: Paymaster; unlocker: Unlocker; - transactionManager: TransactionManager; + transactionProcessor: TransactionProcessor; } const container = createContainer({ @@ -34,11 +34,11 @@ container.register({ maxRetriesPerRequest: null, }), ), - ckb: asClass(CKB).singleton(), - bitcoin: asClass(Bitcoin).singleton(), - spv: asClass(SPV).singleton(), + ckb: asClass(CKBClient).singleton(), + bitcoin: asClass(BitcoinClient).singleton(), + spv: asClass(SPVClient).singleton(), paymaster: asClass(Paymaster).singleton(), - transactionManager: asClass(TransactionManager).singleton(), + transactionProcessor: asClass(TransactionProcessor).singleton(), unlocker: asClass(Unlocker).singleton(), }); diff --git a/src/plugins/cron.ts b/src/plugins/cron.ts index c6698abb..6f476a41 100644 --- a/src/plugins/cron.ts +++ b/src/plugins/cron.ts @@ -1,5 +1,5 @@ import fp from 'fastify-plugin'; -import TransactionManager from '../services/transaction'; +import TransactionProcessor from '../services/transaction'; import cron from 'fastify-cron'; import { Env } from '../env'; import Unlocker from '../services/unlocker'; @@ -44,9 +44,9 @@ export default fp(async (fastify) => { }; // processing rgb++ ckb transaction - const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); + const transactionProcessor: TransactionProcessor = fastify.container.resolve('transactionProcessor'); fastify.addHook('onReady', async () => { - transactionManager.startProcess({ + transactionProcessor.startProcess({ onActive: (job) => { fastify.log.info(`Job active: ${job.id}`); }, @@ -56,7 +56,7 @@ export default fp(async (fastify) => { }); }); fastify.addHook('onClose', async () => { - transactionManager.closeProcess(); + transactionProcessor.closeProcess(); }); const retryMissingTransactionsJob = { @@ -67,7 +67,7 @@ export default fp(async (fastify) => { const { name, cronTime } = retryMissingTransactionsJob; const checkIn = getSentryCheckIn(name, cronTime); try { - await transactionManager.retryMissingTransactions(); + await transactionProcessor.retryMissingTransactions(); checkIn.ok(); } catch (err) { checkIn.error(); diff --git a/src/plugins/healthcheck.ts b/src/plugins/healthcheck.ts index 9b34f994..b6d59151 100644 --- a/src/plugins/healthcheck.ts +++ b/src/plugins/healthcheck.ts @@ -1,6 +1,6 @@ import healthcheck from 'fastify-custom-healthcheck'; import fp from 'fastify-plugin'; -import TransactionManager from '../services/transaction'; +import TransactionProcessor from '../services/transaction'; import Paymaster from '../services/paymaster'; export default fp(async (fastify) => { @@ -16,12 +16,12 @@ export default fp(async (fastify) => { }); fastify.addHealthCheck('queue', async () => { - const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); - const counts = await transactionManager.getQueueJobCounts(); + const transactionProcessor: TransactionProcessor = fastify.container.resolve('transactionProcessor'); + const counts = await transactionProcessor.getQueueJobCounts(); if (!counts) { throw new Error('Transaction queue is not available'); } - const isRunning = await transactionManager.isWorkerRunning(); + const isRunning = await transactionProcessor.isWorkerRunning(); if (!isRunning) { throw new Error('Transaction worker is not running'); } diff --git a/src/routes/bitcoin/index.ts b/src/routes/bitcoin/index.ts index 9f1440f2..92870f86 100644 --- a/src/routes/bitcoin/index.ts +++ b/src/routes/bitcoin/index.ts @@ -6,10 +6,10 @@ import transactionRoutes from './transaction'; import addressRoutes from './address'; import container from '../../container'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; -import Bitcoin from '../../services/bitcoin'; +import BitcoinClient from '../../services/bitcoin'; const bitcoinRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { - fastify.decorate('bitcoin', container.resolve('bitcoin')); + fastify.decorate('bitcoin', container.resolve('bitcoin')); fastify.register(infoRoute); fastify.register(blockRoutes, { prefix: '/block' }); diff --git a/src/routes/cron/process-transactions.ts b/src/routes/cron/process-transactions.ts index b20b5d5e..1c273c2d 100644 --- a/src/routes/cron/process-transactions.ts +++ b/src/routes/cron/process-transactions.ts @@ -3,7 +3,7 @@ import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; import container from '../../container'; -import TransactionManager from '../../services/transaction'; +import TransactionProcessor from '../../services/transaction'; import { VERCEL_MAX_DURATION } from '../../constants'; const processTransactionsCronRoute: FastifyPluginCallback, Server, ZodTypeProvider> = ( @@ -21,11 +21,11 @@ const processTransactionsCronRoute: FastifyPluginCallback, }, async () => { const logger = container.resolve('logger'); - const transactionManager: TransactionManager = container.resolve('transactionManager'); + const transactionProcessor: TransactionProcessor = container.resolve('transactionProcessor'); try { await new Promise((resolve) => { setTimeout(resolve, (VERCEL_MAX_DURATION - 10) * 1000); - transactionManager.startProcess({ + transactionProcessor.startProcess({ onActive: (job) => { logger.info(`Job active: ${job.id}`); }, @@ -34,8 +34,8 @@ const processTransactionsCronRoute: FastifyPluginCallback, }, }); }); - await transactionManager.pauseProcess(); - await transactionManager.closeProcess(); + await transactionProcessor.pauseProcess(); + await transactionProcessor.closeProcess(); } catch (err) { logger.error(err); fastify.Sentry.captureException(err); diff --git a/src/routes/internal/index.ts b/src/routes/internal/index.ts index f8ea4545..d3ad1082 100644 --- a/src/routes/internal/index.ts +++ b/src/routes/internal/index.ts @@ -11,7 +11,7 @@ const internalRoutes: FastifyPluginCallback, Server, ZodTyp fastify.addHook('onRequest', adminAuthorize); } - fastify.decorate('transactionManager', container.resolve('transactionManager')); + fastify.decorate('transactionProcessor', container.resolve('transactionProcessor')); fastify.register(jobRoutes, { prefix: '/job' }); done(); diff --git a/src/routes/internal/job.ts b/src/routes/internal/job.ts index fe7e9f86..c4621f7d 100644 --- a/src/routes/internal/job.ts +++ b/src/routes/internal/job.ts @@ -25,7 +25,7 @@ const jobRoutes: FastifyPluginCallback, Server, ZodTypeProv }, async (request) => { const { max_attempts } = request.body; - const results = await fastify.transactionManager.retryAllFailedJobs(max_attempts); + const results = await fastify.transactionProcessor.retryAllFailedJobs(max_attempts); return results; }, ); diff --git a/src/routes/rgbpp/index.ts b/src/routes/rgbpp/index.ts index 2e8e3aaf..15c22921 100644 --- a/src/routes/rgbpp/index.ts +++ b/src/routes/rgbpp/index.ts @@ -9,7 +9,7 @@ import spvRoute from './spv'; import paymasterRoutes from './paymaster'; const rgbppRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { - fastify.decorate('transactionManager', container.resolve('transactionManager')); + fastify.decorate('transactionProcessor', container.resolve('transactionProcessor')); fastify.decorate('paymaster', container.resolve('paymaster')); fastify.decorate('ckb', container.resolve('ckb')); fastify.decorate('bitcoin', container.resolve('bitcoin')); diff --git a/src/routes/rgbpp/transaction.ts b/src/routes/rgbpp/transaction.ts index 57867529..d8588cd6 100644 --- a/src/routes/rgbpp/transaction.ts +++ b/src/routes/rgbpp/transaction.ts @@ -36,7 +36,7 @@ const transactionRoute: FastifyPluginCallback, Server, ZodT async (request, reply) => { const { btc_txid, ckb_virtual_result } = request.body; const jwt = (await request.jwtDecode()) as JwtPayload; - const job: Job = await fastify.transactionManager.enqueueTransaction({ + const job: Job = await fastify.transactionProcessor.enqueueTransaction({ txid: btc_txid, ckbVirtualResult: ckb_virtual_result, context: { jwt }, @@ -67,7 +67,7 @@ const transactionRoute: FastifyPluginCallback, Server, ZodT const isMainnet = env.NETWORK === 'mainnet'; // get the transaction hash from the job if it exists - const job = await fastify.transactionManager.getTransactionRequest(btc_txid); + const job = await fastify.transactionProcessor.getTransactionRequest(btc_txid); if (job?.returnvalue) { return { txhash: job.returnvalue }; } @@ -168,7 +168,7 @@ const transactionRoute: FastifyPluginCallback, Server, ZodT async (request, reply) => { const { btc_txid } = request.params; const { with_data } = request.query; - const job = await fastify.transactionManager.getTransactionRequest(btc_txid); + const job = await fastify.transactionProcessor.getTransactionRequest(btc_txid); if (!job) { reply.status(404); return; @@ -215,7 +215,7 @@ const transactionRoute: FastifyPluginCallback, Server, ZodT }, async (request, reply) => { const { btc_txid } = request.body; - const job = await fastify.transactionManager.getTransactionRequest(btc_txid); + const job = await fastify.transactionProcessor.getTransactionRequest(btc_txid); if (!job) { reply.status(404); return; diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts index bcca5ac7..765a043e 100644 --- a/src/services/bitcoin.ts +++ b/src/services/bitcoin.ts @@ -7,7 +7,7 @@ import Electrs from '../utils/electrs'; import * as Sentry from '@sentry/node'; // https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 -export enum MempoolElectrsMessage { +export enum MempoolErrorMessage { Connection = 'Connection error', Interrupt = 'Interruption by external signal', TooManyUtxos = 'Too many unspent transaction outputs', @@ -15,7 +15,7 @@ export enum MempoolElectrsMessage { ElectrumClient = 'Electrum client error', } -export enum MempoolAPIErrorCode { +export enum BitcoinClientErrorCode { Connection = 0x1000, // 4096 Interrupt = 0x1001, // 4097 TooManyUtxos = 0x1002, // 4098 @@ -25,24 +25,24 @@ export enum MempoolAPIErrorCode { MempoolUnknown = 0x1111, // 4369 } -const MempoolElectrsErrorMap = { - [MempoolElectrsMessage.Connection]: MempoolAPIErrorCode.Connection, - [MempoolElectrsMessage.Interrupt]: MempoolAPIErrorCode.Interrupt, - [MempoolElectrsMessage.TooManyUtxos]: MempoolAPIErrorCode.TooManyUtxos, - [MempoolElectrsMessage.TooManyTxs]: MempoolAPIErrorCode.TooManyTxs, - [MempoolElectrsMessage.ElectrumClient]: MempoolAPIErrorCode.ElectrumClient, +const BitcoinClientErrorMap = { + [MempoolErrorMessage.Connection]: BitcoinClientErrorCode.Connection, + [MempoolErrorMessage.Interrupt]: BitcoinClientErrorCode.Interrupt, + [MempoolErrorMessage.TooManyUtxos]: BitcoinClientErrorCode.TooManyUtxos, + [MempoolErrorMessage.TooManyTxs]: BitcoinClientErrorCode.TooManyTxs, + [MempoolErrorMessage.ElectrumClient]: BitcoinClientErrorCode.ElectrumClient, }; -export class MempoolAPIError extends Error { +export class BitcoinClientAPIError extends Error { public statusCode = 500; - public errorCode: MempoolAPIErrorCode; + public errorCode: BitcoinClientErrorCode; constructor(message: string) { super(message); this.name = this.constructor.name; - const errorKey = Object.keys(MempoolElectrsErrorMap).find((msg) => message.startsWith(msg)); - this.errorCode = MempoolElectrsErrorMap[errorKey as MempoolElectrsMessage] ?? MempoolAPIErrorCode.MempoolUnknown; + const errorKey = Object.keys(BitcoinClientErrorMap).find((msg) => message.startsWith(msg)); + this.errorCode = BitcoinClientErrorMap[errorKey as MempoolErrorMessage] ?? BitcoinClientErrorCode.MempoolUnknown; } } @@ -57,7 +57,7 @@ const wrapTry = async Promise>(fn: T): Promise return ret; } catch (err) { if ((err as AxiosError).isAxiosError) { - const error = new MempoolAPIError((err as AxiosError).message); + const error = new BitcoinClientAPIError((err as AxiosError).message); if ((err as AxiosError).response) { error.statusCode = (err as AxiosError).response?.status || 500; } @@ -67,7 +67,7 @@ const wrapTry = async Promise>(fn: T): Promise } }; -export default class Bitcoin { +export default class BitcoinClient { private cradle: Cradle; private mempool: ReturnType; private electrs?: Electrs; @@ -151,9 +151,11 @@ export default class Bitcoin { }); } - public async getTransactionsByAddress(address: string): Promise { + public async getTransactionsByAddress(address: string, after_txid?: string): Promise { return wrapTry(async () => { - const txs = await this.mempool.bitcoin.addresses.getAddressTxs({ address }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error after_txid is not defined in the type definition + const txs = await this.mempool.bitcoin.addresses.getAddressTxs({ address, after_txid }); return txs.map((tx) => Transaction.parse(tx)); }); } diff --git a/src/services/ckb.ts b/src/services/ckb.ts index 3a447030..8b0cfd5b 100644 --- a/src/services/ckb.ts +++ b/src/services/ckb.ts @@ -123,7 +123,7 @@ export class CKBRpcError extends Error { } } -export default class CKB { +export default class CKBClient { public rpc: RPC; public indexer: Indexer; diff --git a/src/services/spv.ts b/src/services/spv.ts index defec8fb..0a154605 100644 --- a/src/services/spv.ts +++ b/src/services/spv.ts @@ -43,7 +43,7 @@ export class BitcoinSPVError extends Error { /** * Bitcoin SPV service client */ -export default class SPV { +export default class SPVClient { private request: AxiosInstance; private cradle: Cradle; diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 9d29c89b..033d88e2 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -33,7 +33,7 @@ import { CKBRpcError, CKBRPCErrorCodes } from './ckb'; import { cloneDeep } from 'lodash'; import { JwtPayload } from '../plugins/jwt'; import { serializeCellDep } from '@nervosnetwork/ckb-sdk-utils'; -import { MempoolAPIError } from './bitcoin'; +import { BitcoinClientAPIError } from './bitcoin'; import { HttpStatusCode } from 'axios'; export interface ITransactionRequest { @@ -50,7 +50,7 @@ export interface IProcessCallbacks { onFailed?: (job: Job | undefined, err: Error) => void; } -interface ITransactionManager { +interface ITransactionProcessor { enqueueTransaction(request: ITransactionRequest): Promise>; getTransactionRequest(txid: string): Promise | undefined>; retryAllFailedJobs(): Promise<{ txid: string; state: string }[]>; @@ -86,7 +86,7 @@ class OpReturnNotFoundError extends Error { } /** - * TransactionManager + * TransactionProcessor * responsible for processing RGB++ CKB transactions, including: * - enqueueing transaction requests to the queue * - verifying transaction requests, including checking the commitment @@ -95,7 +95,7 @@ class OpReturnNotFoundError extends Error { * - add paymaster cell and sign the CKB transaction if needed * - sending CKB transaction to the network and waiting for confirmation */ -export default class TransactionManager implements ITransactionManager { +export default class TransactionProcessor implements ITransactionProcessor { private cradle: Cradle; private queue: Queue; private worker: Worker; @@ -211,25 +211,25 @@ export default class TransactionManager implements ITransactionManager { // make sure the commitment matches the Bitcoin transaction const btcTxCommitment = await this.getCommitmentFromBtcTx(btcTx); if (commitment !== btcTxCommitment.toString('hex')) { - this.cradle.logger.info(`[TransactionManager] Bitcoin Transaction Commitment Mismatch: ${txid}`); + this.cradle.logger.info(`[TransactionProcessor] Bitcoin Transaction Commitment Mismatch: ${txid}`); return false; } // make sure the CKB Virtual Transaction is valid const ckbRawTxWithoutBtcTxId = await this.resetOutputLockScript(ckbRawTx, txid); if (commitment !== calculateCommitment(ckbRawTxWithoutBtcTxId)) { - this.cradle.logger.info(`[TransactionManager] Invalid CKB Virtual Transaction: ${txid}`); + this.cradle.logger.info(`[TransactionProcessor] Invalid CKB Virtual Transaction: ${txid}`); return false; } // make sure the Bitcoin transaction is confirmed if (!btcTx.status.confirmed) { // https://docs.bullmq.io/patterns/process-step-jobs#delaying - this.cradle.logger.info(`[TransactionManager] Bitcoin Transaction Not Confirmed: ${txid}`); + this.cradle.logger.info(`[TransactionProcessor] Bitcoin Transaction Not Confirmed: ${txid}`); throw new TransactionNotConfirmedError(txid); } - this.cradle.logger.info(`[TransactionManager] Transaction Verified: ${txid}`); + this.cradle.logger.info(`[TransactionProcessor] Transaction Verified: ${txid}`); return true; } @@ -239,7 +239,7 @@ export default class TransactionManager implements ITransactionManager { * @param token - the token to move the job */ private async moveJobToDelayed(job: Job, token?: string) { - this.cradle.logger.info(`[TransactionManager] Moving job ${job.id} to delayed queue`); + this.cradle.logger.info(`[TransactionProcessor] Moving job ${job.id} to delayed queue`); const timestamp = Date.now() + this.cradle.env.TRANSACTION_QUEUE_JOB_DELAY; await job.moveToDelayed(timestamp, token); // https://docs.bullmq.io/patterns/process-step-jobs#delaying @@ -257,7 +257,7 @@ export default class TransactionManager implements ITransactionManager { if (this.isRgbppLock(output.lock)) { const { btcTxid } = RGBPPLock.unpack(output.lock.args); const txid = remove0x(btcTxid); - this.cradle.logger.debug(`[TransactionManager] RGBPP_LOCK args txid: ${btcTxid}`); + this.cradle.logger.debug(`[TransactionProcessor] RGBPP_LOCK args txid: ${btcTxid}`); return ( output.lock.codeHash === this.rgbppLockScript.codeHash && output.lock.hashType === this.rgbppLockScript.hashType && @@ -267,7 +267,7 @@ export default class TransactionManager implements ITransactionManager { if (this.isBtcTimeLock(output.lock)) { const btcTxid = btcTxIdFromBtcTimeLockArgs(output.lock.args); const txid = remove0x(btcTxid); - this.cradle.logger.debug(`[TransactionManager] BTC_TIME_LOCK args txid: ${txid}`); + this.cradle.logger.debug(`[TransactionProcessor] BTC_TIME_LOCK args txid: ${txid}`); return ( output.lock.codeHash === this.btcTimeLockScript.codeHash && output.lock.hashType === this.btcTimeLockScript.hashType && @@ -277,7 +277,7 @@ export default class TransactionManager implements ITransactionManager { return false; }); if (needUpdateCkbTx) { - this.cradle.logger.info(`[TransactionManager] Update CKB Raw Transaction with real BTC txid: ${txid}`); + this.cradle.logger.info(`[TransactionProcessor] Update CKB Raw Transaction with real BTC txid: ${txid}`); ckbRawTx = updateCkbTxWithRealBtcTxId({ ckbRawTx, btcTxId: txid, isMainnet: this.isMainnet }); } return ckbRawTx; @@ -371,14 +371,14 @@ export default class TransactionManager implements ITransactionManager { // make sure the paymaster received a UTXO as container fee const hasPaymasterUTXO = this.cradle.paymaster.hasPaymasterReceivedBtcUTXO(btcTx); if (!hasPaymasterUTXO) { - this.cradle.logger.info(`[TransactionManager] Paymaster receives UTXO not found: ${btcTx.txid}`); + this.cradle.logger.info(`[TransactionProcessor] Paymaster receives UTXO not found: ${btcTx.txid}`); throw new InvalidTransactionError('Paymaster receives UTXO not found', { txid: btcTx.txid, ckbVirtualResult, }); } } else { - this.cradle.logger.warn(`[TransactionManager] Paymaster receives UTXO check disabled`); + this.cradle.logger.warn(`[TransactionProcessor] Paymaster receives UTXO check disabled`); } const tx = await this.cradle.paymaster.appendCellAndSignTx(btcTx.txid, { @@ -394,7 +394,7 @@ export default class TransactionManager implements ITransactionManager { */ private async fixPoolRejectedTransactionByMinFeeRate(job: Job) { this.cradle.logger.debug( - `[TransactionManager] Fix pool rejected transaction by increasing the fee rate: ${job.data.txid}`, + `[TransactionProcessor] Fix pool rejected transaction by increasing the fee rate: ${job.data.txid}`, ); const { txid, ckbVirtualResult } = job.data; const { ckbRawTx } = ckbVirtualResult; @@ -439,18 +439,18 @@ export default class TransactionManager implements ITransactionManager { if (ckbVirtualResult.needPaymasterCell) { signedTx = await this.appendPaymasterCellAndSignTx(btcTx, ckbVirtualResult, signedTx); } - this.cradle.logger.debug(`[TransactionManager] Transaction signed: ${JSON.stringify(signedTx)}`); + this.cradle.logger.debug(`[TransactionProcessor] Transaction signed: ${JSON.stringify(signedTx)}`); try { const txHash = await this.cradle.ckb.sendTransaction(signedTx); job.returnvalue = txHash; - this.cradle.logger.info(`[TransactionManager] Transaction sent: ${txHash}`); + this.cradle.logger.info(`[TransactionProcessor] Transaction sent: ${txHash}`); await this.cradle.ckb.waitForTranscationConfirmed(txHash); - this.cradle.logger.info(`[TransactionManager] Transaction confirmed: ${txHash}`); + this.cradle.logger.info(`[TransactionProcessor] Transaction confirmed: ${txHash}`); // mark the paymaster cell as spent to avoid double spending if (ckbVirtualResult.needPaymasterCell) { - this.cradle.logger.info(`[TransactionManager] Mark paymaster cell as spent: ${txHash}`); + this.cradle.logger.info(`[TransactionProcessor] Mark paymaster cell as spent: ${txHash}`); await this.cradle.paymaster.markPaymasterCellAsSpent(txid, signedTx!); } return txHash; @@ -470,7 +470,7 @@ export default class TransactionManager implements ITransactionManager { } } catch (err) { this.cradle.logger.debug(err); - if (err instanceof MempoolAPIError && err.statusCode === HttpStatusCode.NotFound) { + if (err instanceof BitcoinClientAPIError && err.statusCode === HttpStatusCode.NotFound) { // move the job to delayed queue if the transaction is not found yet // only delay the job when the job is created less than 1 hour to make sure the transaction is existed // let the job failed if the transaction is not found after 1 hour @@ -508,7 +508,7 @@ export default class TransactionManager implements ITransactionManager { const startHeight = BI.from(previousHeight ?? targetHeight - 1).toNumber(); if (targetHeight > startHeight) { - this.cradle.logger.info(`[TransactionManager] Missing transactions handling started`); + this.cradle.logger.info(`[TransactionProcessor] Missing transactions handling started`); // get all the txids from previousHeight to currentHeight const heights = Array.from({ length: targetHeight - startHeight }, (_, i) => startHeight + i + 1); const txidsGroups = await Promise.all( @@ -527,7 +527,7 @@ export default class TransactionManager implements ITransactionManager { jobs.map(async (job) => { const txid = job.id as string; if (filter.has(txid)) { - this.cradle.logger.info(`[TransactionManager] Retry missing transaction: ${txid}`); + this.cradle.logger.info(`[TransactionProcessor] Retry missing transaction: ${txid}`); await job.retry(); } }), @@ -583,7 +583,7 @@ export default class TransactionManager implements ITransactionManager { } const results = await Promise.all( jobs.map(async (job) => { - this.cradle.logger.info(`[TransactionManager] Retry failed job: ${job.id}`); + this.cradle.logger.info(`[TransactionProcessor] Retry failed job: ${job.id}`); await job.retry(); const state = await job.getState(); return { diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts index 0562f52c..5d588f4f 100644 --- a/test/routes/bitcoind/transaction.test.ts +++ b/test/routes/bitcoind/transaction.test.ts @@ -1,7 +1,7 @@ import { beforeEach, expect, test } from 'vitest'; import { buildFastify } from '../../../src/app'; import { describe } from 'node:test'; -import { MempoolAPIErrorCode } from '../../../src/services/bitcoin'; +import { BitcoinClientErrorCode } from '../../../src/services/bitcoin'; let token: string; @@ -60,7 +60,7 @@ describe('/bitcoin/v1/transaction', () => { expect(response.statusCode).toBe(404); expect(data).toEqual({ - code: MempoolAPIErrorCode.MempoolUnknown, + code: BitcoinClientErrorCode.MempoolUnknown, message: 'Request failed with status code 404', }); diff --git a/test/routes/rgbpp/transaction.test.ts b/test/routes/rgbpp/transaction.test.ts index 3c8112d2..d414fc3e 100644 --- a/test/routes/rgbpp/transaction.test.ts +++ b/test/routes/rgbpp/transaction.test.ts @@ -1,8 +1,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { buildFastify } from '../../../src/app'; import { describe } from 'node:test'; -import { Env } from '../../../src/env'; -import TransactionManager, { ITransactionRequest } from '../../../src/services/transaction'; +import TransactionProcessor, { ITransactionRequest } from '../../../src/services/transaction'; import { CKBVirtualResult } from '../../../src/routes/rgbpp/types'; import { Job } from 'bullmq'; @@ -90,9 +89,9 @@ describe('/bitcoin/v1/transaction', () => { const fastify = buildFastify(); await fastify.ready(); - const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); + const transactionProcessor: TransactionProcessor = fastify.container.resolve('transactionProcessor'); - vi.spyOn(transactionManager, 'getTransactionRequest').mockResolvedValue({ + vi.spyOn(transactionProcessor, 'getTransactionRequest').mockResolvedValue({ getState: vi.fn().mockResolvedValue('completed'), attemptsMade: 1, data: { @@ -124,9 +123,9 @@ describe('/bitcoin/v1/transaction', () => { const fastify = buildFastify(); await fastify.ready(); - const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); + const transactionProcessor: TransactionProcessor = fastify.container.resolve('transactionProcessor'); - vi.spyOn(transactionManager, 'getTransactionRequest').mockResolvedValue({ + vi.spyOn(transactionProcessor, 'getTransactionRequest').mockResolvedValue({ getState: vi.fn().mockResolvedValue('failed'), attemptsMade: 1, failedReason: 'Failed to send transaction', @@ -160,9 +159,9 @@ describe('/bitcoin/v1/transaction', () => { const fastify = buildFastify(); await fastify.ready(); - const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); + const transactionProcessor: TransactionProcessor = fastify.container.resolve('transactionProcessor'); - vi.spyOn(transactionManager, 'getTransactionRequest').mockResolvedValue({ + vi.spyOn(transactionProcessor, 'getTransactionRequest').mockResolvedValue({ getState: vi.fn().mockResolvedValue('completed'), attemptsMade: 1, data: { diff --git a/test/services/__snapshots__/transaction.test.ts.snap b/test/services/__snapshots__/transaction.test.ts.snap index 1d364010..e7c5f7d1 100644 --- a/test/services/__snapshots__/transaction.test.ts.snap +++ b/test/services/__snapshots__/transaction.test.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`transactionManager > verifyTransaction: should throw TransactionNotConfirmedError for unconfirmed transaction 1`] = `[TransactionNotConfirmedError: Transaction not confirmed: bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f]`; +exports[`transactionProcessor > verifyTransaction: should throw TransactionNotConfirmedError for unconfirmed transaction 1`] = `[TransactionNotConfirmedError: Transaction not confirmed: bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f]`; diff --git a/test/services/spv.test.ts b/test/services/spv.test.ts index 1a96714f..3de92cb7 100644 --- a/test/services/spv.test.ts +++ b/test/services/spv.test.ts @@ -1,13 +1,13 @@ import container from '../../src/container'; import { describe, test, beforeEach, afterEach, vi, expect } from 'vitest'; -import SPV from '../../src/services/spv'; +import SPVClient from '../../src/services/spv'; describe('BitcoinSPV', () => { - let spv: SPV; + let spv: SPVClient; beforeEach(async () => { const cradle = container.cradle; - spv = new SPV(cradle); + spv = new SPVClient(cradle); }); afterEach(() => { diff --git a/test/services/transaction.test.ts b/test/services/transaction.test.ts index 965b7763..577457d2 100644 --- a/test/services/transaction.test.ts +++ b/test/services/transaction.test.ts @@ -1,5 +1,5 @@ import { describe, beforeEach, expect, test, vi } from 'vitest'; -import TransactionManager, { ITransactionRequest } from '../../src/services/transaction'; +import TransactionProcessor, { ITransactionRequest } from '../../src/services/transaction'; import container from '../../src/container'; import { CKBVirtualResult, InputCell, OutputCell } from '../../src/routes/rgbpp/types'; import { ChainInfo, Transaction } from '../../src/routes/bitcoin/types'; @@ -11,17 +11,17 @@ const commitment = calculateCommitment({ outputs: [] as OutputCell[], } as CKBVirtualResult['ckbRawTx']); -describe('transactionManager', () => { - let transactionManager: TransactionManager; +describe('transactionProcessor', () => { + let transactionProcessor: TransactionProcessor; const cradle = container.cradle; beforeEach(async () => { - transactionManager = new TransactionManager(cradle); + transactionProcessor = new TransactionProcessor(cradle); }); test('verifyTransaction: should return true for valid transaction', async () => { vi.spyOn( - transactionManager as unknown as { + transactionProcessor as unknown as { getCommitmentFromBtcTx: (txid: string) => Promise; }, 'getCommitmentFromBtcTx', @@ -37,13 +37,13 @@ describe('transactionManager', () => { }, }; const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); - const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); + const isValid = await transactionProcessor.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(true); }); test('verifyTransaction: should return false for mismatch commitment', async () => { vi.spyOn( - transactionManager as unknown as { + transactionProcessor as unknown as { getCommitmentFromBtcTx: (txid: string) => Promise; }, 'getCommitmentFromBtcTx', @@ -59,14 +59,14 @@ describe('transactionManager', () => { }, }; const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); - const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); + const isValid = await transactionProcessor.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(false); }); test('verifyTransaction: should return false for mismatch ckb tx', async () => { const commitment = 'mismatchcommitment'; vi.spyOn( - transactionManager as unknown as { + transactionProcessor as unknown as { getCommitmentFromBtcTx: (txid: string) => Promise; }, 'getCommitmentFromBtcTx', @@ -82,18 +82,18 @@ describe('transactionManager', () => { }, }; const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); - const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); + const isValid = await transactionProcessor.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(false); }); test('verifyTransaction: should throw TransactionNotConfirmedError for unconfirmed transaction', async () => { vi.spyOn( - transactionManager as unknown as { + transactionProcessor as unknown as { getCommitmentFromBtcTx: (txid: string) => Promise; }, 'getCommitmentFromBtcTx', ).mockResolvedValueOnce(Buffer.from(commitment, 'hex')); - vi.spyOn(transactionManager['cradle']['bitcoin'], 'getTransaction').mockResolvedValueOnce({ + vi.spyOn(transactionProcessor['cradle']['bitcoin'], 'getTransaction').mockResolvedValueOnce({ status: { confirmed: false, block_height: 0 }, } as unknown as Transaction); @@ -109,7 +109,7 @@ describe('transactionManager', () => { const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); await expect( - transactionManager.verifyTransaction(transactionRequest, btcTx), + transactionProcessor.verifyTransaction(transactionRequest, btcTx), ).rejects.toThrowErrorMatchingSnapshot(); }); @@ -124,9 +124,9 @@ describe('transactionManager', () => { }, }; - transactionManager.enqueueTransaction(transactionRequest); - const count = await transactionManager['queue'].getJobCounts(); - const job = await transactionManager['queue'].getJob(transactionRequest.txid); + transactionProcessor.enqueueTransaction(transactionRequest); + const count = await transactionProcessor['queue'].getJobCounts(); + const job = await transactionProcessor['queue'].getJob(transactionRequest.txid); expect(count.delayed).toBe(1); expect(job?.delay).toBe(cradle.env.TRANSACTION_QUEUE_JOB_DELAY); }); @@ -142,14 +142,14 @@ describe('transactionManager', () => { '8eb22b379c0ef491dea2d819e721d5df296bebc67a056a0fbb8c92f11920824d', ]); const retry = vi.fn(); - vi.spyOn(transactionManager['queue'], 'getJobs').mockResolvedValue([ + vi.spyOn(transactionProcessor['queue'], 'getJobs').mockResolvedValue([ { id: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', retry, } as unknown as Job, ]); - await transactionManager.retryMissingTransactions(); + await transactionProcessor.retryMissingTransactions(); expect(retry).toHaveBeenCalled(); }); @@ -165,14 +165,14 @@ describe('transactionManager', () => { '8eb22b379c0ef491dea2d819e721d5df296bebc67a056a0fbb8c92f11920824d', ]); const retry = vi.fn(); - vi.spyOn(transactionManager['queue'], 'getJobs').mockResolvedValue([ + vi.spyOn(transactionProcessor['queue'], 'getJobs').mockResolvedValue([ { id: 'bb8c92f119208248ea0fdb22b379c0ef491dea2d819e721d5df296bebc67a056', retry, } as unknown as Job, ]); - await transactionManager.retryMissingTransactions(); + await transactionProcessor.retryMissingTransactions(); expect(retry).not.toHaveBeenCalled(); }); From fc4d0300480f0a1dc96c95831c9c3a6c4ec01f03 Mon Sep 17 00:00:00 2001 From: ahonn Date: Wed, 24 Apr 2024 22:32:30 +1000 Subject: [PATCH 09/53] refactor: rename Electrs to ElectrsClient --- src/services/bitcoin.ts | 6 +++--- src/utils/electrs.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts index 765a043e..c212431e 100644 --- a/src/services/bitcoin.ts +++ b/src/services/bitcoin.ts @@ -3,7 +3,7 @@ import { Cradle } from '../container'; import { Block, ChainInfo, Transaction, UTXO } from '../routes/bitcoin/types'; import { NetworkType } from '../constants'; import { AxiosError } from 'axios'; -import Electrs from '../utils/electrs'; +import ElectrsClient from '../utils/electrs'; import * as Sentry from '@sentry/node'; // https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 @@ -70,7 +70,7 @@ const wrapTry = async Promise>(fn: T): Promise export default class BitcoinClient { private cradle: Cradle; private mempool: ReturnType; - private electrs?: Electrs; + private electrs?: ElectrsClient; constructor(cradle: Cradle) { this.cradle = cradle; @@ -82,7 +82,7 @@ export default class BitcoinClient { }); if (cradle.env.BITCOIN_ELECTRS_API_URL) { - this.electrs = new Electrs(cradle); + this.electrs = new ElectrsClient(cradle); } } diff --git a/src/utils/electrs.ts b/src/utils/electrs.ts index 0a78d7c7..61791aef 100644 --- a/src/utils/electrs.ts +++ b/src/utils/electrs.ts @@ -4,7 +4,7 @@ import { Cradle } from '../container'; import { UTXO } from '../routes/bitcoin/types'; import { addLoggerInterceptor } from './interceptors'; -export default class Electrs { +export default class ElectrsClient { private request: AxiosInstance; constructor({ env, logger }: Cradle) { From cb7dd0c4ce16b803341b19d85bb9cafe0095fc02 Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 25 Apr 2024 14:46:34 +1000 Subject: [PATCH 10/53] feat: add after_txid query params to /bitcoin/v1/address/:address/txs --- src/routes/bitcoin/address.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/bitcoin/address.ts b/src/routes/bitcoin/address.ts index f6c62d48..8cc80e5e 100644 --- a/src/routes/bitcoin/address.ts +++ b/src/routes/bitcoin/address.ts @@ -105,6 +105,9 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType params: z.object({ address: z.string().describe('The Bitcoin address'), }), + querystring: z.object({ + after_txid: z.string().optional().describe('The txid of the transaction to start after'), + }), response: { 200: z.array(Transaction), }, @@ -112,7 +115,8 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType }, async (request) => { const { address } = request.params; - const txs = await fastify.bitcoin.getTransactionsByAddress(address); + const { after_txid } = request.query; + const txs = await fastify.bitcoin.getTransactionsByAddress(address, after_txid); return txs; }, ); From b59a2c91f3a8a0e7b03fdec9f42c096f48dad352 Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 25 Apr 2024 15:10:09 +1000 Subject: [PATCH 11/53] feat: add new bitcoin route for get recommanded fees --- src/routes/bitcoin/fees.ts | 26 ++++++++++++++++++++++++++ src/routes/bitcoin/index.ts | 2 ++ src/routes/bitcoin/types.ts | 9 +++++++++ src/services/bitcoin.ts | 19 +++++++++++++------ src/utils/electrs.ts | 4 ++-- 5 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 src/routes/bitcoin/fees.ts diff --git a/src/routes/bitcoin/fees.ts b/src/routes/bitcoin/fees.ts new file mode 100644 index 00000000..769a6d09 --- /dev/null +++ b/src/routes/bitcoin/fees.ts @@ -0,0 +1,26 @@ +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { RecommendedFees } from './types'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; + +const feesRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.get( + '/recommended', + { + schema: { + description: 'Get recommended fees for Bitcoin transactions', + tags: ['Bitcoin'], + response: { + 200: RecommendedFees, + }, + }, + }, + async () => { + const fees = await fastify.bitcoin.getRecommendedFees(); + return fees; + }, + ); + done(); +}; + +export default feesRoutes; diff --git a/src/routes/bitcoin/index.ts b/src/routes/bitcoin/index.ts index 92870f86..1fc8e084 100644 --- a/src/routes/bitcoin/index.ts +++ b/src/routes/bitcoin/index.ts @@ -7,6 +7,7 @@ import addressRoutes from './address'; import container from '../../container'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; import BitcoinClient from '../../services/bitcoin'; +import feesRoutes from './fees'; const bitcoinRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { fastify.decorate('bitcoin', container.resolve('bitcoin')); @@ -15,6 +16,7 @@ const bitcoinRoutes: FastifyPluginCallback, Server, ZodType fastify.register(blockRoutes, { prefix: '/block' }); fastify.register(transactionRoutes, { prefix: '/transaction' }); fastify.register(addressRoutes, { prefix: '/address' }); + fastify.register(feesRoutes, { prefix: '/fees' }); done(); }; diff --git a/src/routes/bitcoin/types.ts b/src/routes/bitcoin/types.ts index 443bd3df..c705d83c 100644 --- a/src/routes/bitcoin/types.ts +++ b/src/routes/bitcoin/types.ts @@ -77,8 +77,17 @@ export const Transaction = z.object({ status: Status, }); +export const RecommendedFees = z.object({ + fastestFee: z.number(), + halfHourFee: z.number(), + hourFee: z.number(), + economyFee: z.number(), + minimumFee: z.number(), +}); + export type ChainInfo = z.infer; export type Block = z.infer; export type Balance = z.infer; export type UTXO = z.infer; export type Transaction = z.infer; +export type RecommendedFees = z.infer; diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts index c212431e..694a9713 100644 --- a/src/services/bitcoin.ts +++ b/src/services/bitcoin.ts @@ -1,6 +1,6 @@ import mempoolJS from '@mempool/mempool.js'; import { Cradle } from '../container'; -import { Block, ChainInfo, Transaction, UTXO } from '../routes/bitcoin/types'; +import { Block, ChainInfo, RecommendedFees, Transaction, UTXO } from '../routes/bitcoin/types'; import { NetworkType } from '../constants'; import { AxiosError } from 'axios'; import ElectrsClient from '../utils/electrs'; @@ -106,8 +106,8 @@ export default class BitcoinClient { } public async getBlockchainInfo(): Promise { - const hash = await this.mempool.bitcoin.blocks.getBlocksTipHash(); - const tip = await this.mempool.bitcoin.blocks.getBlock({ hash }); + const hash = await this.getBlocksTipHash(); + const tip = await this.getBlockByHash(hash); const { difficulty, mediantime } = tip; return { @@ -119,6 +119,13 @@ export default class BitcoinClient { }; } + public async getRecommendedFees(): Promise { + return wrapTry(async () => { + const fees = await this.mempool.bitcoin.fees.getFeesRecommended(); + return RecommendedFees.parse(fees); + }); + } + public async sendRawTransaction(txhex: string): Promise { return wrapTry(async () => { try { @@ -245,15 +252,15 @@ export default class BitcoinClient { }); } - public async getTip(): Promise { + public async getBlocksTipHash(): Promise { return wrapTry(async () => { try { - return this.mempool.bitcoin.blocks.getBlocksTipHeight(); + return this.mempool.bitcoin.blocks.getBlocksTipHash(); } catch (err) { this.cradle.logger.error(err); Sentry.captureException(err); if (this.electrs) { - return this.electrs.getTip(); + return this.electrs.getBlocksTipHash(); } throw err; } diff --git a/src/utils/electrs.ts b/src/utils/electrs.ts index 61791aef..dae6c3c2 100644 --- a/src/utils/electrs.ts +++ b/src/utils/electrs.ts @@ -64,8 +64,8 @@ export default class ElectrsClient { return response.data; } - public async getTip() { - const response = await this.get('/blocks/tip/height'); + public async getBlocksTipHash() { + const response = await this.get('/blocks/tip/hash'); return response.data; } } From ddd1fbc099b0c6308f1ba7e17e2d136a1925167c Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 25 Apr 2024 15:24:28 +1000 Subject: [PATCH 12/53] test: update app test --- test/app.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/app.test.ts b/test/app.test.ts index f385a20f..76ce45a4 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -25,6 +25,7 @@ test('`/docs/json` - 200', async () => { '/bitcoin/v1/address/{address}/balance', '/bitcoin/v1/address/{address}/unspent', '/bitcoin/v1/address/{address}/txs', + '/bitcoin/v1/fees/recommended', '/rgbpp/v1/transaction/ckb-tx', '/rgbpp/v1/transaction/{btc_txid}', '/rgbpp/v1/transaction/{btc_txid}/job', From 0bb9eebfaef95d9d0fa6b4cbe9f07d178093c07c Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 25 Apr 2024 15:40:05 +1000 Subject: [PATCH 13/53] test: add BITCOIN_ELECTRS_API_URL back --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6781da17..ae88fe78 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -49,6 +49,7 @@ jobs: env: JWT_SECRET: ${{ secrets.JWT_SECRET }} BITCOIN_MEMPOOL_SPACE_API_URL: ${{ secrets.BITCOIN_MEMPOOL_SPACE_API_URL }} + BITCOIN_ELECTRS_API_URL: ${{ secrets.BITCOIN_ELECTRS_API_URL }} BITCOIN_SPV_SERVICE_URL: ${{ secrets.BITCOIN_SPV_SERVICE_URL }} PAYMASTER_RECEIVE_BTC_ADDRESS: ${{ secrets.PAYMASTER_RECEIVE_BTC_ADDRESS }} CKB_RPC_URL: ${{ secrets.CKB_RPC_URL }} From 6a994514062bc7860acb0a76c88db5588f80799a Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 25 Apr 2024 16:59:50 +1000 Subject: [PATCH 14/53] refactor: update healthcheck --- src/plugins/healthcheck.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/plugins/healthcheck.ts b/src/plugins/healthcheck.ts index b6d59151..c3780068 100644 --- a/src/plugins/healthcheck.ts +++ b/src/plugins/healthcheck.ts @@ -2,8 +2,11 @@ import healthcheck from 'fastify-custom-healthcheck'; import fp from 'fastify-plugin'; import TransactionProcessor from '../services/transaction'; import Paymaster from '../services/paymaster'; +import axios from 'axios'; +import { Env } from '../env'; export default fp(async (fastify) => { + const env: Env = fastify.container.resolve('env'); await fastify.register(healthcheck, { path: '/healthcheck', exposeFailure: true, @@ -15,6 +18,14 @@ export default fp(async (fastify) => { await redis.ping(); }); + fastify.addHealthCheck('mempool', async () => { + await axios.get(`${env.BITCOIN_MEMPOOL_SPACE_API_URL}/api/blocks/tip/height`); + }); + + fastify.addHealthCheck('electrs', async () => { + await axios.get(`${env.BITCOIN_ELECTRS_API_URL}/blocks/tip/height`); + }); + fastify.addHealthCheck('queue', async () => { const transactionProcessor: TransactionProcessor = fastify.container.resolve('transactionProcessor'); const counts = await transactionProcessor.getQueueJobCounts(); From 3b6168e2cd84aa99d3aab3dc2c20984fbf9dc0ce Mon Sep 17 00:00:00 2001 From: ahonn Date: Fri, 26 Apr 2024 11:13:50 +1000 Subject: [PATCH 15/53] chore: update env --- src/env.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/env.ts b/src/env.ts index 7e4be431..347ebd7d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -70,6 +70,7 @@ const envSchema = z.object({ /** * Bitcoin Mempool.space API URL + * used to get bitcoin data and broadcast transaction */ BITCOIN_MEMPOOL_SPACE_API_URL: z.string(), /** From 3a07f4d4577e639df856c6eb5a07411717e3a44c Mon Sep 17 00:00:00 2001 From: ahonn Date: Fri, 26 Apr 2024 11:39:12 +1000 Subject: [PATCH 16/53] refactor: add bitcoin client electrs fallback --- src/services/bitcoin.ts | 61 +++++++++++++++++++++++++++++++++++------ src/utils/electrs.ts | 8 ++++-- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts index 694a9713..e795b5a3 100644 --- a/src/services/bitcoin.ts +++ b/src/services/bitcoin.ts @@ -160,28 +160,71 @@ export default class BitcoinClient { public async getTransactionsByAddress(address: string, after_txid?: string): Promise { return wrapTry(async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error after_txid is not defined in the type definition - const txs = await this.mempool.bitcoin.addresses.getAddressTxs({ address, after_txid }); - return txs.map((tx) => Transaction.parse(tx)); + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error after_txid is not defined in the type definition + const txs = await this.mempool.bitcoin.addresses.getAddressTxs({ address, after_txid }); + return txs.map((tx) => Transaction.parse(tx)); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + const txs = await this.electrs.getTransactionsByAddress(address, after_txid); + return txs.map((tx) => Transaction.parse(tx)); + } + throw err; + } }); } public async getTransaction(txid: string): Promise { return wrapTry(async () => { - const tx = await this.mempool.bitcoin.transactions.getTx({ txid }); - return Transaction.parse(tx); + try { + const tx = await this.mempool.bitcoin.transactions.getTx({ txid }); + return Transaction.parse(tx); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + const tx = await this.electrs.getTransaction(txid); + return Transaction.parse(tx); + } + throw err; + } }); } public async getTransactionHex(txid: string): Promise { - return wrapTry(() => this.mempool.bitcoin.transactions.getTxHex({ txid })); + return wrapTry(async () => { + try { + const hex = await this.mempool.bitcoin.transactions.getTxHex({ txid }); + return hex; + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + const hex = await this.electrs.getTransactionHex(txid); + return hex; + } + throw err; + } + }); } public async getBlockByHash(hash: string): Promise { return wrapTry(async () => { - const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); - return block; + try { + const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); + return Block.parse(block); + } catch (err) { + this.cradle.logger.error(err); + Sentry.captureException(err); + if (this.electrs) { + const block = await this.electrs.getBlockByHash(hash); + return Block.parse(block); + } + throw err; + } }); } diff --git a/src/utils/electrs.ts b/src/utils/electrs.ts index dae6c3c2..0aabb9e4 100644 --- a/src/utils/electrs.ts +++ b/src/utils/electrs.ts @@ -29,8 +29,12 @@ export default class ElectrsClient { return response.data; } - public async getTransactionsByAddress(address: string) { - const response = await this.get(`/address/${address}/txs`); + public async getTransactionsByAddress(address: string, after_txid?: string) { + let url = `/address/${address}/txs`; + if (after_txid) { + url += `?after_txid=${after_txid}`; + } + const response = await this.get(url); return response.data; } From a49c8940e7ac52b8f73169525ede7fc13c0c57c1 Mon Sep 17 00:00:00 2001 From: ahonn Date: Fri, 26 Apr 2024 11:42:38 +1000 Subject: [PATCH 17/53] chore: update .example.env --- .env.example | 12 ++++++------ src/env.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 09f6c3b5..11f1580a 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,13 @@ JWT_SECRET= # JWT token denylist # JWT_DENYLIST= -# Bitcoin JSON-RPC URL and credentials -BITCOIN_JSON_RPC_URL= -BITCOIN_JSON_RPC_USERNAME= -BITCOIN_JSON_RPC_PASSWORD= +# Bitcoin Mempool.space API URL +# used to get bitcoin data and broadcast transaction +BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space -# Electrs API URL -BITCOIN_ELECTRS_API_URL= +# Electrs API URL (optional) +# used for fallback when the mempool.space API is not available +# BITCOIN_ELECTRS_API_URL= # SPV Service URL BITCOIN_SPV_SERVICE_URL= diff --git a/src/env.ts b/src/env.ts index 347ebd7d..742da9cd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -70,13 +70,13 @@ const envSchema = z.object({ /** * Bitcoin Mempool.space API URL - * used to get bitcoin data and broadcast transaction + * used to get bitcoin data and broadcast transaction. */ BITCOIN_MEMPOOL_SPACE_API_URL: z.string(), /** * The URL of the Electrs API. * Electrs is a Rust implementation of Electrum Server. - * used for fallback when the mempool.space API is down. + * used for fallback when the mempool.space API is not available. */ BITCOIN_ELECTRS_API_URL: z.string().optional(), From 1f7c52d7bcd965e628f18a7121fe5003f3c12138 Mon Sep 17 00:00:00 2001 From: ahonn Date: Fri, 26 Apr 2024 11:43:38 +1000 Subject: [PATCH 18/53] docs: update README.md --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 80ddac68..59149371 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,12 @@ JWT_SECRET= # JWT_DENYLIST= # Bitcoin Mempool.space API URL -BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space, +# used to get bitcoin data and broadcast transaction +BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space + +# Electrs API URL (optional) +# used for fallback when the mempool.space API is not available +# BITCOIN_ELECTRS_API_URL= # SPV Service URL BITCOIN_SPV_SERVICE_URL= From 7347a35ba308c684b054213477296a3216c8e722 Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 00:42:07 +1000 Subject: [PATCH 19/53] refactor: support electrs as main data provider and mempool.space as fallback --- src/env.ts | 331 ++++++++++++++++-------------- src/routes/bitcoin/address.ts | 6 +- src/routes/bitcoin/block.ts | 10 +- src/routes/bitcoin/fees.ts | 2 +- src/routes/bitcoin/transaction.ts | 4 +- src/routes/rgbpp/address.ts | 2 +- src/routes/rgbpp/assets.ts | 2 +- src/routes/rgbpp/transaction.ts | 2 +- src/services/bitcoin.ts | 312 ---------------------------- src/services/bitcoin/electrs.ts | 72 +++++++ src/services/bitcoin/index.ts | 194 +++++++++++++++++ src/services/bitcoin/interface.ts | 15 ++ src/services/bitcoin/mempool.ts | 73 +++++++ src/services/bitcoin/schema.ts | 93 +++++++++ src/services/spv.ts | 4 +- src/services/transaction.ts | 8 +- src/services/unlocker.ts | 2 +- src/utils/electrs.ts | 75 ------- 18 files changed, 644 insertions(+), 563 deletions(-) delete mode 100644 src/services/bitcoin.ts create mode 100644 src/services/bitcoin/electrs.ts create mode 100644 src/services/bitcoin/index.ts create mode 100644 src/services/bitcoin/interface.ts create mode 100644 src/services/bitcoin/mempool.ts create mode 100644 src/services/bitcoin/schema.ts delete mode 100644 src/utils/electrs.ts diff --git a/src/env.ts b/src/env.ts index 742da9cd..373b1e63 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,161 +3,182 @@ import z from 'zod'; import process from 'node:process'; import { omit } from 'lodash'; -const envSchema = z.object({ - NODE_ENV: z.string().default('development'), - PORT: z.string().optional(), - ADDRESS: z.string().optional(), - NETWORK: z.enum(['mainnet', 'testnet']).default('testnet'), - LOGGER_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), - - /** - * Set /token/generate default domain param - */ - DOMAIN: z.string().optional(), - - /** - * Fastify `trustProxy` option - * - only supports true/false: Trust all proxies (true) or do not trust any proxies (false). - * - * https://fastify.dev/docs/latest/Reference/Server/#trustproxy - */ - TRUST_PROXY: z - .enum(['true', 'false']) - .default('false') - .transform((value) => value === 'true'), - - /** - * Redis URL, used for caching and rate limiting. - */ - REDIS_URL: z.string(), - - /** - * Sentry Configuration - */ - SENTRY_DSN_URL: z.string().optional(), - SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().default(0.5), - SENTRY_PROFILES_SAMPLE_RATE: z.coerce.number().default(0.5), - - /** - * The rate limit per minute for each IP address. - */ - RATE_LIMIT_PER_MINUTE: z.coerce.number().default(100), - /** - * The blocklist of IP addresses that are denied access to the API. - */ - IP_BLOCKLIST: z - .string() - .default('') - .transform((value) => value.split(',')) - .pipe(z.string().array()), - - ADMIN_USERNAME: z.string().optional(), - ADMIN_PASSWORD: z.string().optional(), - - /** - * JWT_SECRET is used to sign the JWT token for authentication. - */ - JWT_SECRET: z.string(), - /** - * JWT_DENYLIST is used to store the denylisted JWT tokens. - * support multiple tokens separated by comma, use token or jti to denylist. - */ - JWT_DENYLIST: z - .string() - .default('') - .transform((value) => value.split(',')) - .pipe(z.string().array()), - - /** - * Bitcoin Mempool.space API URL - * used to get bitcoin data and broadcast transaction. - */ - BITCOIN_MEMPOOL_SPACE_API_URL: z.string(), - /** - * The URL of the Electrs API. - * Electrs is a Rust implementation of Electrum Server. - * used for fallback when the mempool.space API is not available. - */ - BITCOIN_ELECTRS_API_URL: z.string().optional(), - - /** - * Bitcoin SPV service URL - * https://github.com/ckb-cell/ckb-bitcoin-spv-service - */ - BITCOIN_SPV_SERVICE_URL: z.string(), - /** - * The URL of the CKB JSON-RPC server. - */ - CKB_RPC_URL: z.string(), - /** - * Paymaster private key, used to sign the transaction with paymaster cell. - */ - PAYMASTER_PRIVATE_KEY: z.string(), - /** - * Paymaster cell capacity in shannons - * (254 CKB for RGB++ capacity + 61 CKB for change cell capacity + 1 CKB for fee cell) - */ - PAYMASTER_CELL_CAPACITY: z.coerce.number().default(316 * 10 ** 8), - /** - * Paymaster cell queue preset count, used to refill paymaster cell. - */ - PAYMASTER_CELL_PRESET_COUNT: z.coerce.number().default(500), - /** - * Paymaster cell refill threshold, refill paymaster cell when the balance is less than this threshold. - */ - PAYMASTER_CELL_REFILL_THRESHOLD: z.coerce.number().default(0.3), - - /** - * Paymaster receive UTXO check flag, used to check the paymaster BTC UTXO when processing rgb++ ckb transaction. - */ - PAYMASTER_RECEIVE_UTXO_CHECK: z - .enum(['true', 'false']) - .default('false') - .transform((value) => value === 'true'), - /** - * Paymaster bitcoin address, used to receive BTC from users. - * enable paymaster BTC UTXO check if set. - */ - PAYMASTER_RECEIVE_BTC_ADDRESS: z.string().optional(), - /** - * Paymaster receives BTC UTXO size in sats - */ - PAYMASTER_BTC_CONTAINER_FEE_SATS: z.coerce.number().default(7000), - - /** - * BTCTimeLock cell unlock batch size - */ - UNLOCKER_CRON_SCHEDULE: z.string().default('*/5 * * * *'), - /** - * BTCTimeLock cell unlock cron job schedule, default is every 5 minutes - */ - UNLOCKER_CELL_BATCH_SIZE: z.coerce.number().default(100), - /** - * BTCTimeLock cell unlocker monitor slug, used for monitoring unlocker status on sentry - */ - UNLOCKER_MONITOR_SLUG: z.string().default('btctimelock-cells-unlock'), - - /** - * RGB++ CKB transaction Queue cron job delay in milliseconds - * the /rgbpp/v1/transaction/ckb-tx endpoint is called, the transaction will be added to the queue - */ - TRANSACTION_QUEUE_JOB_DELAY: z.coerce.number().default(120 * 1000), - /** - * RGB++ CKB transaction Queue cron job attempts - * used to retry the transaction queue job when failed - */ - TRANSACTION_QUEUE_JOB_ATTEMPTS: z.coerce.number().default(6), - /** - * Pay fee for transaction with pool reject by min fee rate, false by default - * (If set to true, the transaction will be paid for the minimum fee rate and resent - * when the transaction throw PoolRejectedTransactionByMinFeeRate error) - * - */ - TRANSACTION_PAY_FOR_MIN_FEE_RATE_REJECT: z - .enum(['true', 'false']) - .default('false') - .transform((value) => value === 'true'), -}); +const envSchema = z + .object({ + NODE_ENV: z.string().default('development'), + PORT: z.string().optional(), + ADDRESS: z.string().optional(), + NETWORK: z.enum(['mainnet', 'testnet']).default('testnet'), + LOGGER_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), + + /** + * Set /token/generate default domain param + */ + DOMAIN: z.string().optional(), + + /** + * Fastify `trustProxy` option + * - only supports true/false: Trust all proxies (true) or do not trust any proxies (false). + * + * https://fastify.dev/docs/latest/Reference/Server/#trustproxy + */ + TRUST_PROXY: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + + /** + * Redis URL, used for caching and rate limiting. + */ + REDIS_URL: z.string(), + + /** + * Sentry Configuration + */ + SENTRY_DSN_URL: z.string().optional(), + SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().default(0.5), + SENTRY_PROFILES_SAMPLE_RATE: z.coerce.number().default(0.5), + + /** + * The rate limit per minute for each IP address. + */ + RATE_LIMIT_PER_MINUTE: z.coerce.number().default(100), + /** + * The blocklist of IP addresses that are denied access to the API. + */ + IP_BLOCKLIST: z + .string() + .default('') + .transform((value) => value.split(',')) + .pipe(z.string().array()), + + ADMIN_USERNAME: z.string().optional(), + ADMIN_PASSWORD: z.string().optional(), + + /** + * JWT_SECRET is used to sign the JWT token for authentication. + */ + JWT_SECRET: z.string(), + /** + * JWT_DENYLIST is used to store the denylisted JWT tokens. + * support multiple tokens separated by comma, use token or jti to denylist. + */ + JWT_DENYLIST: z + .string() + .default('') + .transform((value) => value.split(',')) + .pipe(z.string().array()), + + /** + * Bitcoin SPV service URL + * https://github.com/ckb-cell/ckb-bitcoin-spv-service + */ + BITCOIN_SPV_SERVICE_URL: z.string(), + /** + * The URL of the CKB JSON-RPC server. + */ + CKB_RPC_URL: z.string(), + /** + * Paymaster private key, used to sign the transaction with paymaster cell. + */ + PAYMASTER_PRIVATE_KEY: z.string(), + /** + * Paymaster cell capacity in shannons + * (254 CKB for RGB++ capacity + 61 CKB for change cell capacity + 1 CKB for fee cell) + */ + PAYMASTER_CELL_CAPACITY: z.coerce.number().default(316 * 10 ** 8), + /** + * Paymaster cell queue preset count, used to refill paymaster cell. + */ + PAYMASTER_CELL_PRESET_COUNT: z.coerce.number().default(500), + /** + * Paymaster cell refill threshold, refill paymaster cell when the balance is less than this threshold. + */ + PAYMASTER_CELL_REFILL_THRESHOLD: z.coerce.number().default(0.3), + + /** + * Paymaster receive UTXO check flag, used to check the paymaster BTC UTXO when processing rgb++ ckb transaction. + */ + PAYMASTER_RECEIVE_UTXO_CHECK: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + /** + * Paymaster bitcoin address, used to receive BTC from users. + * enable paymaster BTC UTXO check if set. + */ + PAYMASTER_RECEIVE_BTC_ADDRESS: z.string().optional(), + /** + * Paymaster receives BTC UTXO size in sats + */ + PAYMASTER_BTC_CONTAINER_FEE_SATS: z.coerce.number().default(7000), + + /** + * BTCTimeLock cell unlock batch size + */ + UNLOCKER_CRON_SCHEDULE: z.string().default('*/5 * * * *'), + /** + * BTCTimeLock cell unlock cron job schedule, default is every 5 minutes + */ + UNLOCKER_CELL_BATCH_SIZE: z.coerce.number().default(100), + /** + * BTCTimeLock cell unlocker monitor slug, used for monitoring unlocker status on sentry + */ + UNLOCKER_MONITOR_SLUG: z.string().default('btctimelock-cells-unlock'), + + /** + * RGB++ CKB transaction Queue cron job delay in milliseconds + * the /rgbpp/v1/transaction/ckb-tx endpoint is called, the transaction will be added to the queue + */ + TRANSACTION_QUEUE_JOB_DELAY: z.coerce.number().default(120 * 1000), + /** + * RGB++ CKB transaction Queue cron job attempts + * used to retry the transaction queue job when failed + */ + TRANSACTION_QUEUE_JOB_ATTEMPTS: z.coerce.number().default(6), + /** + * Pay fee for transaction with pool reject by min fee rate, false by default + * (If set to true, the transaction will be paid for the minimum fee rate and resent + * when the transaction throw PoolRejectedTransactionByMinFeeRate error) + * + */ + TRANSACTION_PAY_FOR_MIN_FEE_RATE_REJECT: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + }) + .and( + z.union([ + z.object({ + /** + * Bitcoin Mempool.space API URL + * used to get bitcoin data and broadcast transaction. + */ + BITCOIN_MEMPOOL_SPACE_API_URL: z.string(), + /** + * The URL of the Electrs API. + * Electrs is a Rust implementation of Electrum Server. + * used for fallback when the mempool.space API is not available. + */ + BITCOIN_ELECTRS_API_URL: z.string().optional(), + BITCOIN_DATA_PROVIDER: z.literal('mempool').default('mempool'), + }), + z.object({ + /** + * The URL of the Electrs API. + * Electrs is a Rust implementation of Electrum Server. + * used for fallback when the mempool.space API is not available. + */ + BITCOIN_ELECTRS_API_URL: z.string(), + /** + * Bitcoin Mempool.space API URL + * used to get bitcoin data and broadcast transaction. + */ + BITCOIN_MEMPOOL_SPACE_API_URL: z.string().optional(), + BITCOIN_DATA_PROVIDER: z.literal('electrs'), + }), + ]), + ); export type Env = z.infer; export const env = envSchema.parse(process.env); diff --git a/src/routes/bitcoin/address.ts b/src/routes/bitcoin/address.ts index 8cc80e5e..515c6183 100644 --- a/src/routes/bitcoin/address.ts +++ b/src/routes/bitcoin/address.ts @@ -34,7 +34,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async (request) => { const { address } = request.params; const { min_satoshi } = request.query; - const utxos = await fastify.bitcoin.getUtxoByAddress(address); + const utxos = await fastify.bitcoin.getAddressTxsUtxo({ address }); return utxos.reduce( (acc: Balance, utxo: UTXO) => { if (utxo.status.confirmed) { @@ -83,7 +83,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async function (request) { const { address } = request.params; const { only_confirmed, min_satoshi } = request.query; - let utxos = await fastify.bitcoin.getUtxoByAddress(address); + let utxos = await fastify.bitcoin.getAddressTxsUtxo({ address }); // compatible with the case where only_confirmed is undefined if (only_confirmed === 'true' || only_confirmed === 'undefined') { @@ -116,7 +116,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async (request) => { const { address } = request.params; const { after_txid } = request.query; - const txs = await fastify.bitcoin.getTransactionsByAddress(address, after_txid); + const txs = await fastify.bitcoin.getAddressTxs({ address, after_txid }); return txs; }, ); diff --git a/src/routes/bitcoin/block.ts b/src/routes/bitcoin/block.ts index d00c5012..fd02c844 100644 --- a/src/routes/bitcoin/block.ts +++ b/src/routes/bitcoin/block.ts @@ -22,7 +22,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }, async (request, reply) => { const { hash } = request.params; - const block = await fastify.bitcoin.getBlockByHash(hash); + const block = await fastify.bitcoin.getBlock({ hash }); reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); return block; }, @@ -46,7 +46,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }, async (request, reply) => { const { hash } = request.params; - const txids = await fastify.bitcoin.getBlockTxIdsByHash(hash); + const txids = await fastify.bitcoin.getBlockTxids({ hash }); reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); return { txids }; }, @@ -70,7 +70,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }, async (request, reply) => { const { hash } = request.params; - const header = await fastify.bitcoin.getBlockHeaderByHash(hash); + const header = await fastify.bitcoin.getBlockHeader({ hash }); reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); return { header, @@ -82,7 +82,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr '/height/:height', { schema: { - description: 'Get a block by its height', + description: 'Get a block hash by its height', tags: ['Bitcoin'], params: z.object({ height: z.coerce.number().describe('The Bitcoin block height'), @@ -97,7 +97,7 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr async (request, reply) => { const { height } = request.params; const [hash, chain] = await Promise.all([ - fastify.bitcoin.getBlockHashByHeight(height), + fastify.bitcoin.getBlockHeight({ height }), fastify.bitcoin.getBlockchainInfo(), ]); if (height < chain.blocks) { diff --git a/src/routes/bitcoin/fees.ts b/src/routes/bitcoin/fees.ts index 769a6d09..5f0e5f19 100644 --- a/src/routes/bitcoin/fees.ts +++ b/src/routes/bitcoin/fees.ts @@ -16,7 +16,7 @@ const feesRoutes: FastifyPluginCallback, Server, ZodTypePro }, }, async () => { - const fees = await fastify.bitcoin.getRecommendedFees(); + const fees = await fastify.bitcoin.getFeesRecommended(); return fees; }, ); diff --git a/src/routes/bitcoin/transaction.ts b/src/routes/bitcoin/transaction.ts index 2fb41b06..e30f6206 100644 --- a/src/routes/bitcoin/transaction.ts +++ b/src/routes/bitcoin/transaction.ts @@ -24,7 +24,7 @@ const transactionRoutes: FastifyPluginCallback, Server, Zod }, async (request) => { const { txhex } = request.body; - const txid = await fastify.bitcoin.sendRawTransaction(txhex); + const txid = await fastify.bitcoin.postTx({ txhex }); return { txid, }; @@ -47,7 +47,7 @@ const transactionRoutes: FastifyPluginCallback, Server, Zod }, async (request, reply) => { const { txid } = request.params; - const transaction = await fastify.bitcoin.getTransaction(txid); + const transaction = await fastify.bitcoin.getTx({ txid }); if (transaction.status.confirmed) { reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); } diff --git a/src/routes/rgbpp/address.ts b/src/routes/rgbpp/address.ts index e8dad9cf..5c90782f 100644 --- a/src/routes/rgbpp/address.ts +++ b/src/routes/rgbpp/address.ts @@ -47,7 +47,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType async (request) => { const { btc_address } = request.params; const { type_script } = request.query; - const utxos = await fastify.bitcoin.getUtxoByAddress(btc_address); + const utxos = await fastify.bitcoin.getAddressTxsUtxo({ address: btc_address }); const cells = await Promise.all( utxos.map(async (utxo) => { const { txid, vout } = utxo; diff --git a/src/routes/rgbpp/assets.ts b/src/routes/rgbpp/assets.ts index c0fe632a..ff9a2273 100644 --- a/src/routes/rgbpp/assets.ts +++ b/src/routes/rgbpp/assets.ts @@ -23,7 +23,7 @@ const assetsRoute: FastifyPluginCallback, Server, ZodTypePr }, async (request) => { const { btc_txid } = request.params; - const transaction = await fastify.bitcoin.getTransaction(btc_txid); + const transaction = await fastify.bitcoin.getTx({ txid: btc_txid }); const cells: Cell[] = []; for (let index = 0; index < transaction.vout.length; index++) { const args = buildRgbppLockArgs(index, btc_txid); diff --git a/src/routes/rgbpp/transaction.ts b/src/routes/rgbpp/transaction.ts index d8588cd6..a64edc7b 100644 --- a/src/routes/rgbpp/transaction.ts +++ b/src/routes/rgbpp/transaction.ts @@ -72,7 +72,7 @@ const transactionRoute: FastifyPluginCallback, Server, ZodT return { txhash: job.returnvalue }; } - const transaction = await fastify.bitcoin.getTransaction(btc_txid); + const transaction = await fastify.bitcoin.getTx({ txid: btc_txid }); // query CKB transaction hash by RGBPP_LOCK cells for (let index = 0; index < transaction.vout.length; index++) { diff --git a/src/services/bitcoin.ts b/src/services/bitcoin.ts deleted file mode 100644 index e795b5a3..00000000 --- a/src/services/bitcoin.ts +++ /dev/null @@ -1,312 +0,0 @@ -import mempoolJS from '@mempool/mempool.js'; -import { Cradle } from '../container'; -import { Block, ChainInfo, RecommendedFees, Transaction, UTXO } from '../routes/bitcoin/types'; -import { NetworkType } from '../constants'; -import { AxiosError } from 'axios'; -import ElectrsClient from '../utils/electrs'; -import * as Sentry from '@sentry/node'; - -// https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 -export enum MempoolErrorMessage { - Connection = 'Connection error', - Interrupt = 'Interruption by external signal', - TooManyUtxos = 'Too many unspent transaction outputs', - TooManyTxs = 'Too many history transactions', - ElectrumClient = 'Electrum client error', -} - -export enum BitcoinClientErrorCode { - Connection = 0x1000, // 4096 - Interrupt = 0x1001, // 4097 - TooManyUtxos = 0x1002, // 4098 - TooManyTxs = 0x1003, // 4099 - ElectrumClient = 0x1004, // 4100 - - MempoolUnknown = 0x1111, // 4369 -} - -const BitcoinClientErrorMap = { - [MempoolErrorMessage.Connection]: BitcoinClientErrorCode.Connection, - [MempoolErrorMessage.Interrupt]: BitcoinClientErrorCode.Interrupt, - [MempoolErrorMessage.TooManyUtxos]: BitcoinClientErrorCode.TooManyUtxos, - [MempoolErrorMessage.TooManyTxs]: BitcoinClientErrorCode.TooManyTxs, - [MempoolErrorMessage.ElectrumClient]: BitcoinClientErrorCode.ElectrumClient, -}; - -export class BitcoinClientAPIError extends Error { - public statusCode = 500; - public errorCode: BitcoinClientErrorCode; - - constructor(message: string) { - super(message); - this.name = this.constructor.name; - - const errorKey = Object.keys(BitcoinClientErrorMap).find((msg) => message.startsWith(msg)); - this.errorCode = BitcoinClientErrorMap[errorKey as MempoolErrorMessage] ?? BitcoinClientErrorCode.MempoolUnknown; - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const wrapTry = async Promise>(fn: T): Promise> => { - if (typeof fn !== 'function') { - throw new Error('wrapTry: fn must be a function'); - } - - try { - const ret = await fn(); - return ret; - } catch (err) { - if ((err as AxiosError).isAxiosError) { - const error = new BitcoinClientAPIError((err as AxiosError).message); - if ((err as AxiosError).response) { - error.statusCode = (err as AxiosError).response?.status || 500; - } - throw error; - } - throw err; - } -}; - -export default class BitcoinClient { - private cradle: Cradle; - private mempool: ReturnType; - private electrs?: ElectrsClient; - - constructor(cradle: Cradle) { - this.cradle = cradle; - - const url = new URL(cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL); - this.mempool = mempoolJS({ - hostname: url.hostname, - network: cradle.env.NETWORK, - }); - - if (cradle.env.BITCOIN_ELECTRS_API_URL) { - this.electrs = new ElectrsClient(cradle); - } - } - - public async checkNetwork(network: NetworkType) { - const hash = await this.getBlockHashByHeight(0); - switch (network) { - case NetworkType.mainnet: - // Bitcoin mainnet genesis block hash - if (hash !== '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f') { - throw new Error('Mempool API is not running on mainnet'); - } - break; - case NetworkType.testnet: - // Bitcoin testnet genesis block hash - if (hash !== '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943') { - throw new Error('Mempool API is not running on testnet'); - } - break; - default: - } - } - - public async getBlockchainInfo(): Promise { - const hash = await this.getBlocksTipHash(); - const tip = await this.getBlockByHash(hash); - - const { difficulty, mediantime } = tip; - return { - chain: this.cradle.env.NETWORK === 'mainnet' ? 'main' : 'test', - blocks: tip.height, - bestblockhash: hash, - difficulty, - mediantime, - }; - } - - public async getRecommendedFees(): Promise { - return wrapTry(async () => { - const fees = await this.mempool.bitcoin.fees.getFeesRecommended(); - return RecommendedFees.parse(fees); - }); - } - - public async sendRawTransaction(txhex: string): Promise { - return wrapTry(async () => { - try { - const txid = await this.mempool.bitcoin.transactions.postTx({ txhex }); - return txid as string; - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - return this.electrs.sendRawTransaction(txhex); - } - throw err; - } - }); - } - - public async getUtxoByAddress(address: string): Promise { - return wrapTry(async () => { - try { - const utxo = await this.mempool.bitcoin.addresses.getAddressTxsUtxo({ address }); - return utxo.map((utxo) => UTXO.parse(utxo)); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - return this.electrs.getUtxoByAddress(address); - } - throw err; - } - }); - } - - public async getTransactionsByAddress(address: string, after_txid?: string): Promise { - return wrapTry(async () => { - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error after_txid is not defined in the type definition - const txs = await this.mempool.bitcoin.addresses.getAddressTxs({ address, after_txid }); - return txs.map((tx) => Transaction.parse(tx)); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - const txs = await this.electrs.getTransactionsByAddress(address, after_txid); - return txs.map((tx) => Transaction.parse(tx)); - } - throw err; - } - }); - } - - public async getTransaction(txid: string): Promise { - return wrapTry(async () => { - try { - const tx = await this.mempool.bitcoin.transactions.getTx({ txid }); - return Transaction.parse(tx); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - const tx = await this.electrs.getTransaction(txid); - return Transaction.parse(tx); - } - throw err; - } - }); - } - - public async getTransactionHex(txid: string): Promise { - return wrapTry(async () => { - try { - const hex = await this.mempool.bitcoin.transactions.getTxHex({ txid }); - return hex; - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - const hex = await this.electrs.getTransactionHex(txid); - return hex; - } - throw err; - } - }); - } - - public async getBlockByHash(hash: string): Promise { - return wrapTry(async () => { - try { - const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); - return Block.parse(block); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - const block = await this.electrs.getBlockByHash(hash); - return Block.parse(block); - } - throw err; - } - }); - } - - public async getBlockByHeight(height: number): Promise { - return wrapTry(async () => { - try { - const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); - const block = await this.mempool.bitcoin.blocks.getBlock({ hash }); - return Block.parse(block); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - const hash = await this.electrs.getBlockHashByHeight(height); - const block = await this.electrs.getBlockByHash(hash); - return Block.parse(block); - } - throw err; - } - }); - } - - public async getBlockHashByHeight(height: number): Promise { - return wrapTry(async () => { - try { - const hash = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); - return hash; - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - const hash = await this.electrs.getBlockHashByHeight(height); - return hash; - } - throw err; - } - }); - } - - public async getBlockHeaderByHash(hash: string) { - return wrapTry(async () => { - try { - return this.mempool.bitcoin.blocks.getBlockHeader({ hash }); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - return this.electrs.getBlockHeaderByHash(hash); - } - throw err; - } - }); - } - - public async getBlockTxIdsByHash(hash: string): Promise { - return wrapTry(async () => { - try { - const txids = await this.mempool.bitcoin.blocks.getBlockTxids({ hash }); - return txids; - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - return this.electrs.getBlockTxIdsByHash(hash); - } - throw err; - } - }); - } - - public async getBlocksTipHash(): Promise { - return wrapTry(async () => { - try { - return this.mempool.bitcoin.blocks.getBlocksTipHash(); - } catch (err) { - this.cradle.logger.error(err); - Sentry.captureException(err); - if (this.electrs) { - return this.electrs.getBlocksTipHash(); - } - throw err; - } - }); - } -} diff --git a/src/services/bitcoin/electrs.ts b/src/services/bitcoin/electrs.ts new file mode 100644 index 00000000..56d1d680 --- /dev/null +++ b/src/services/bitcoin/electrs.ts @@ -0,0 +1,72 @@ +import axios, { AxiosInstance } from 'axios'; +import { Cradle } from '../../container'; +import { IBitcoinDataProvider } from './interface'; +import { Block, RecommendedFees, Transaction, UTXO } from './schema'; + +export class ElectrsClient implements IBitcoinDataProvider { + private request: AxiosInstance; + + constructor(cradle: Cradle) { + this.request = axios.create({ + baseURL: cradle.env.BITCOIN_ELECTRS_API_URL, + }); + } + + public async getFeesRecommended(): Promise { + throw new Error('ElectrsClient does not support getFeesRecommended'); + } + + public async postTx({ txhex }: { txhex: string }) { + const response = await this.request.post('/tx', txhex); + return response.data; + } + + public async getAddressTxsUtxo({ address }: { address: string }) { + const response = await this.request.get(`/address/${address}/utxo`); + return response.data; + } + + public async getAddressTxs({ address, after_txid }: { address: string; after_txid?: string }) { + let url = `/address/${address}/txs`; + if (after_txid) { + url += `?after_txid=${after_txid}`; + } + const response = await this.request.get(url); + return response.data.map((tx) => Transaction.parse(tx)); + } + + public async getTx({ txid }: { txid: string }) { + const response = await this.request.get(`/tx/${txid}`); + return Transaction.parse(response.data); + } + + public async getTxHex({ txid }: { txid: string }) { + const response = await this.request.get(`/tx/${txid}/hex`); + return response.data; + } + + public async getBlock({ hash }: { hash: string }) { + const response = await this.request.get(`/block/${hash}`); + return Block.parse(response.data); + } + + public async getBlockHeight({ height }: { height: number }) { + const response = await this.request.get(`/block-height/${height}`); + return response.data; + } + + public async getBlockHeader({ hash }: { hash: string }) { + const response = await this.request.get(`/block/${hash}/header`); + return response.data; + } + + public async getBlockTxids({ hash }: { hash: string }) { + const response = await this.request.get(`/block/${hash}/txids`); + return response.data; + } + + public async getBlocksTipHash() { + const response = await this.request.get('/blocks/tip/hash'); + return response.data; + } +} diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts new file mode 100644 index 00000000..4c8deffd --- /dev/null +++ b/src/services/bitcoin/index.ts @@ -0,0 +1,194 @@ +import { AxiosError } from 'axios'; +import * as Sentry from '@sentry/node'; +import { Cradle } from '../../container'; +import { IBitcoinDataProvider } from './interface'; +import { MempoolClient } from './mempool'; +import { ElectrsClient } from './electrs'; +import { NetworkType } from '../../constants'; +import { ChainInfo } from './schema'; + +// https://github.com/mempool/electrs/blob/d4f788fc3d7a2b4eca4c5629270e46baba7d0f19/src/errors.rs#L6 +export enum BitcoinClientErrorMessage { + Connection = 'Connection error', + Interrupt = 'Interruption by external signal', + TooManyUtxos = 'Too many unspent transaction outputs', + TooManyTxs = 'Too many history transactions', + ElectrumClient = 'Electrum client error', +} + +export enum BitcoinClientErrorCode { + Connection = 0x1000, // 4096 + Interrupt = 0x1001, // 4097 + TooManyUtxos = 0x1002, // 4098 + TooManyTxs = 0x1003, // 4099 + ElectrumClient = 0x1004, // 4100 + + MempoolUnknown = 0x1111, // 4369 +} + +const BitcoinClientErrorMap = { + [BitcoinClientErrorMessage.Connection]: BitcoinClientErrorCode.Connection, + [BitcoinClientErrorMessage.Interrupt]: BitcoinClientErrorCode.Interrupt, + [BitcoinClientErrorMessage.TooManyUtxos]: BitcoinClientErrorCode.TooManyUtxos, + [BitcoinClientErrorMessage.TooManyTxs]: BitcoinClientErrorCode.TooManyTxs, + [BitcoinClientErrorMessage.ElectrumClient]: BitcoinClientErrorCode.ElectrumClient, +}; + +export class BitcoinClientAPIError extends Error { + public statusCode = 500; + public errorCode: BitcoinClientErrorCode; + + constructor(message: string) { + super(message); + this.name = this.constructor.name; + + const errorKey = Object.keys(BitcoinClientErrorMap).find((msg) => message.startsWith(msg)); + this.errorCode = + BitcoinClientErrorMap[errorKey as BitcoinClientErrorMessage] ?? BitcoinClientErrorCode.MempoolUnknown; + } +} + +interface IBitcoinClient extends IBitcoinDataProvider { + checkNetwork(network: NetworkType): Promise; + getBlockchainInfo(): Promise; +} + +export default class BitcoinClient implements IBitcoinClient { + private cradle: Cradle; + private source: IBitcoinDataProvider; + private fallback?: IBitcoinDataProvider; + + constructor(cradle: Cradle) { + this.cradle = cradle; + + const { env } = cradle; + switch (env.BITCOIN_DATA_PROVIDER) { + case 'mempool': + this.cradle.logger.info('Using Mempool.space API as the bitcoin data provider'); + this.source = new MempoolClient(cradle); + if (env.BITCOIN_ELECTRS_API_URL) { + this.cradle.logger.info('Using Electrs API as the fallback bitcoin data provider'); + this.fallback = new ElectrsClient(cradle); + } + break; + case 'electrs': + this.cradle.logger.info('Using Electrs API as the bitcoin data provider'); + this.source = new ElectrsClient(cradle); + if (env.BITCOIN_MEMPOOL_SPACE_API_URL) { + this.cradle.logger.info('Using Mempool.space API as the fallback bitcoin data provider'); + this.fallback = new MempoolClient(cradle); + } + break; + default: + throw new Error('Invalid bitcoin data provider'); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async call( + method: K, + args: Parameters, + ): Promise> { + try { + this.cradle.logger.debug(`Calling ${method} with args: ${JSON.stringify(args)}`); + // @ts-expect-error args: A spread argument must either have a tuple type or be passed to a rest parameter + const result = await this.source[method].call(this.source, ...args).catch((err) => { + Sentry.captureException(err); + if (this.fallback) { + this.cradle.logger.warn(`Fallback to ${this.fallback.constructor.name} due to error: ${err.message}`); + // @ts-expect-error same as above + return this.fallback[method].call(this.fallback, ...args); + } + throw err; + }); + // @ts-expect-error return type is correct + return result; + } catch (err) { + if ((err as AxiosError).isAxiosError) { + const error = new BitcoinClientAPIError((err as AxiosError).message); + if ((err as AxiosError).response) { + error.statusCode = (err as AxiosError).response?.status || 500; + } + throw error; + } + throw err; + } + } + + public async checkNetwork(network: NetworkType) { + const hash = await this.getBlockHeight({ height: 0 }); + switch (network) { + case NetworkType.mainnet: + // Bitcoin mainnet genesis block hash + if (hash !== '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f') { + throw new Error('Mempool API is not running on mainnet'); + } + break; + case NetworkType.testnet: + // Bitcoin testnet genesis block hash + if (hash !== '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943') { + throw new Error('Mempool API is not running on testnet'); + } + break; + default: + } + } + + public async getBlockchainInfo(): Promise { + const hash = await this.getBlocksTipHash(); + const tip = await this.getBlock({ hash }); + + const { difficulty, mediantime } = tip; + return { + chain: this.cradle.env.NETWORK === 'mainnet' ? 'main' : 'test', + blocks: tip.height, + bestblockhash: hash, + difficulty, + mediantime, + }; + } + + public async getFeesRecommended() { + return this.call('getFeesRecommended', []); + } + + public async postTx({ txhex }: { txhex: string }) { + return this.call('postTx', [{ txhex }]); + } + + public async getAddressTxsUtxo({ address }: { address: string }) { + return this.call('getAddressTxsUtxo', [{ address }]); + } + + public async getAddressTxs({ address, after_txid }: { address: string; after_txid?: string }) { + return this.call('getAddressTxs', [{ address, after_txid }]); + } + + public async getTx({ txid }: { txid: string }) { + return this.call('getTx', [{ txid }]); + } + + public async getTxHex({ txid }: { txid: string }) { + return this.call('getTxHex', [{ txid }]); + } + + public async getBlock({ hash }: { hash: string }) { + return this.call('getBlock', [{ hash }]); + } + + public async getBlockHeight({ height }: { height: number }) { + return this.call('getBlockHeight', [{ height }]); + } + + public async getBlockHeader({ hash }: { hash: string }) { + return this.call('getBlockHeader', [{ hash }]); + } + + public async getBlockTxids({ hash }: { hash: string }) { + return this.call('getBlockTxids', [{ hash }]); + } + + public async getBlocksTipHash() { + return this.call('getBlocksTipHash', []); + } +} diff --git a/src/services/bitcoin/interface.ts b/src/services/bitcoin/interface.ts new file mode 100644 index 00000000..afda0d0a --- /dev/null +++ b/src/services/bitcoin/interface.ts @@ -0,0 +1,15 @@ +import { Block, RecommendedFees, Transaction, UTXO } from './schema'; + +export interface IBitcoinDataProvider { + getFeesRecommended(): Promise; + postTx({ txhex }: { txhex: string }): Promise; + getAddressTxsUtxo({ address }: { address: string }): Promise; + getAddressTxs({ address, after_txid }: { address: string; after_txid?: string }): Promise; + getTx({ txid }: { txid: string }): Promise; + getTxHex({ txid }: { txid: string }): Promise; + getBlock({ hash }: { hash: string }): Promise; + getBlockHeight({ height }: { height: number }): Promise; + getBlockHeader({ hash }: { hash: string }): Promise; + getBlockTxids({ hash }: { hash: string }): Promise; + getBlocksTipHash(): Promise; +} diff --git a/src/services/bitcoin/mempool.ts b/src/services/bitcoin/mempool.ts new file mode 100644 index 00000000..12051f18 --- /dev/null +++ b/src/services/bitcoin/mempool.ts @@ -0,0 +1,73 @@ +import { Cradle } from '../../container'; +import { IBitcoinDataProvider } from './interface'; +import mempoolJS from '@mempool/mempool.js'; +import { Block, RecommendedFees, Transaction, UTXO } from './schema'; + +export class MempoolClient implements IBitcoinDataProvider { + private mempool: ReturnType; + + constructor(cradle: Cradle) { + const url = new URL(cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL); + this.mempool = mempoolJS({ + hostname: url.hostname, + network: cradle.env.NETWORK, + }); + } + + public async getFeesRecommended() { + const response = await this.mempool.bitcoin.fees.getFeesRecommended(); + return RecommendedFees.parse(response); + } + + public async postTx({ txhex }: { txhex: string }) { + const response = await this.mempool.bitcoin.transactions.postTx({ txhex }); + return response as string; + } + + public async getAddressTxsUtxo({ address }: { address: string }) { + const response = await this.mempool.bitcoin.addresses.getAddressTxsUtxo({ address }); + return response.map((utxo) => UTXO.parse(utxo)); + } + + public async getAddressTxs({ address, after_txid }: { address: string; after_txid?: string }) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const response = await this.mempool.bitcoin.addresses.getAddressTxs({ address, after_txid }); + return response.map((tx) => Transaction.parse(tx)); + } + + public async getTx({ txid }: { txid: string }) { + const response = await this.mempool.bitcoin.transactions.getTx({ txid }); + return Transaction.parse(response); + } + + public async getTxHex({ txid }: { txid: string }) { + const response = await this.mempool.bitcoin.transactions.getTxHex({ txid }); + return response; + } + + public async getBlock({ hash }: { hash: string }) { + const response = await this.mempool.bitcoin.blocks.getBlock({ hash }); + return Block.parse(response); + } + + public async getBlockHeight({ height }: { height: number }) { + const response = await this.mempool.bitcoin.blocks.getBlockHeight({ height }); + return response; + } + + public async getBlockHeader({ hash }: { hash: string }) { + const response = await this.mempool.bitcoin.blocks.getBlockHeader({ hash }); + return response; + } + + public async getBlockTxids({ hash }: { hash: string }) { + const response = await this.mempool.bitcoin.blocks.getBlockTxids({ hash }); + return response; + } + + public async getBlocksTipHash() { + const response = await this.mempool.bitcoin.blocks.getBlocksTipHash(); + return response; + } +} diff --git a/src/services/bitcoin/schema.ts b/src/services/bitcoin/schema.ts new file mode 100644 index 00000000..c705d83c --- /dev/null +++ b/src/services/bitcoin/schema.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +export const ChainInfo = z.object({ + chain: z.string(), + blocks: z.number(), + bestblockhash: z.string(), + difficulty: z.number(), + mediantime: z.number(), +}); + +export const Block = z.object({ + id: z.string(), + height: z.number(), + version: z.number(), + timestamp: z.number(), + tx_count: z.number(), + size: z.number(), + weight: z.number(), + merkle_root: z.string(), + previousblockhash: z.string(), + mediantime: z.number(), + nonce: z.number(), + bits: z.number(), + difficulty: z.number(), +}); + +export const Status = z.object({ + confirmed: z.boolean(), + block_height: z.number().optional(), + block_hash: z.string().optional(), + block_time: z.number().optional(), +}); + +export const Balance = z.object({ + address: z.string(), + satoshi: z.number(), + pending_satoshi: z.number(), + dust_satoshi: z.number(), + utxo_count: z.number(), +}); + +export const UTXO = z.object({ + txid: z.string(), + vout: z.number(), + value: z.number(), + status: Status, +}); + +const Output = z.object({ + scriptpubkey: z.string(), + scriptpubkey_asm: z.string(), + scriptpubkey_type: z.string(), + scriptpubkey_address: z.string().optional(), + value: z.number(), +}); + +const Input = z.object({ + txid: z.string(), + vout: z.number(), + prevout: Output.or(z.null()), + scriptsig: z.string(), + scriptsig_asm: z.string(), + witness: z.array(z.string()).optional(), + is_coinbase: z.boolean(), + sequence: z.coerce.number(), +}); + +export const Transaction = z.object({ + txid: z.string(), + version: z.number(), + locktime: z.number(), + vin: z.array(Input), + vout: z.array(Output), + size: z.number(), + weight: z.number(), + fee: z.number(), + status: Status, +}); + +export const RecommendedFees = z.object({ + fastestFee: z.number(), + halfHourFee: z.number(), + hourFee: z.number(), + economyFee: z.number(), + minimumFee: z.number(), +}); + +export type ChainInfo = z.infer; +export type Block = z.infer; +export type Balance = z.infer; +export type UTXO = z.infer; +export type Transaction = z.infer; +export type RecommendedFees = z.infer; diff --git a/src/services/spv.ts b/src/services/spv.ts index 0a154605..64c5dd68 100644 --- a/src/services/spv.ts +++ b/src/services/spv.ts @@ -80,8 +80,8 @@ export default class SPVClient { public async getTxProof(btcTxid: string, confirmations: number = 0) { const txid = remove0x(btcTxid); - const btcTx = await this.cradle.bitcoin.getTransaction(txid); - const btcTxids = await this.cradle.bitcoin.getBlockTxIdsByHash(btcTx.status.block_hash!); + const btcTx = await this.cradle.bitcoin.getTx({ txid }); + const btcTxids = await this.cradle.bitcoin.getBlockTxids({ hash: btcTx.status.block_hash! }); const btcIdxInBlock = btcTxids.findIndex((id) => id === txid); return this._getTxProof(txid, btcIdxInBlock, confirmations); } diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 033d88e2..1347179e 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -312,7 +312,7 @@ export default class TransactionProcessor implements ITransactionProcessor { */ private async appendTxWitnesses(txid: string, ckbRawTx: CKBRawTransaction) { const [hex, rgbppApiSpvProof] = await Promise.all([ - this.cradle.bitcoin.getTransactionHex(txid), + this.cradle.bitcoin.getTxHex({ txid }), this.cradle.spv.getTxProof(txid), ]); // using for spv proof, we need to remove the witness data from the transaction @@ -426,7 +426,7 @@ export default class TransactionProcessor implements ITransactionProcessor { public async process(job: Job, token?: string) { try { const { ckbVirtualResult, txid } = cloneDeep(job.data); - const btcTx = await this.cradle.bitcoin.getTransaction(txid); + const btcTx = await this.cradle.bitcoin.getTx({ txid }); const isVerified = await this.verifyTransaction({ ckbVirtualResult, txid }, btcTx); if (!isVerified) { throw new InvalidTransactionError('Invalid transaction', job.data); @@ -513,8 +513,8 @@ export default class TransactionProcessor implements ITransactionProcessor { const heights = Array.from({ length: targetHeight - startHeight }, (_, i) => startHeight + i + 1); const txidsGroups = await Promise.all( heights.map(async (height) => { - const blockHash = await this.cradle.bitcoin.getBlockHashByHeight(height); - return this.cradle.bitcoin.getBlockTxIdsByHash(blockHash); + const blockHash = await this.cradle.bitcoin.getBlockHeight({ height }); + return this.cradle.bitcoin.getBlockTxids({ hash: blockHash }); }), ); const txids = txidsGroups.flat(); diff --git a/src/services/unlocker.ts b/src/services/unlocker.ts index 51301dad..68df0d77 100644 --- a/src/services/unlocker.ts +++ b/src/services/unlocker.ts @@ -68,7 +68,7 @@ export default class Unlocker implements IUnlocker { const btcTxid = remove0x(btcTxIdFromBtcTimeLockArgs(cell.cellOutput.lock.args)); const { after } = BTCTimeLock.unpack(cell.cellOutput.lock.args); - const btcTx = await this.cradle.bitcoin.getTransaction(btcTxid); + const btcTx = await this.cradle.bitcoin.getTx({ txid: btcTxid }); const blockHeight = btcTx.status.block_height; // skip if btc tx not confirmed $after blocks yet diff --git a/src/utils/electrs.ts b/src/utils/electrs.ts deleted file mode 100644 index 0aabb9e4..00000000 --- a/src/utils/electrs.ts +++ /dev/null @@ -1,75 +0,0 @@ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import { Transaction, Block } from 'bitcoinjs-lib'; -import { Cradle } from '../container'; -import { UTXO } from '../routes/bitcoin/types'; -import { addLoggerInterceptor } from './interceptors'; - -export default class ElectrsClient { - private request: AxiosInstance; - - constructor({ env, logger }: Cradle) { - this.request = axios.create({ - baseURL: env.BITCOIN_ELECTRS_API_URL, - }); - addLoggerInterceptor(this.request, logger); - } - - private async get(path: string): Promise> { - const response = await this.request.get(path); - return response; - } - - public async sendRawTransaction(hex: string) { - const response = await this.request.post('/tx', hex); - return response.data; - } - - public async getUtxoByAddress(address: string) { - const response = await this.get(`/address/${address}/utxo`); - return response.data; - } - - public async getTransactionsByAddress(address: string, after_txid?: string) { - let url = `/address/${address}/txs`; - if (after_txid) { - url += `?after_txid=${after_txid}`; - } - const response = await this.get(url); - return response.data; - } - - public async getTransaction(txid: string) { - const response = await this.get(`/tx/${txid}`); - return response.data; - } - - public async getTransactionHex(txid: string) { - const response = await this.get(`/tx/${txid}/hex`); - return response.data; - } - - public async getBlockByHash(hash: string) { - const response = await this.get(`/block/${hash}`); - return response.data; - } - - public async getBlockHashByHeight(height: number) { - const response = await this.get(`/block-height/${height}`); - return response.data; - } - - public async getBlockHeaderByHash(hash: string) { - const response = await this.get(`/block/${hash}/header`); - return response.data; - } - - public async getBlockTxIdsByHash(hash: string) { - const response = await this.get(`/block/${hash}/txids`); - return response.data; - } - - public async getBlocksTipHash() { - const response = await this.get('/blocks/tip/hash'); - return response.data; - } -} From 15bcd97fb93f18bd68ec4e0ee3c52322a2652ff6 Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 00:55:21 +1000 Subject: [PATCH 20/53] fix: fix elelctrs & mempool client type issues --- src/env.ts | 5 +++++ src/services/bitcoin/electrs.ts | 3 +++ src/services/bitcoin/mempool.ts | 3 +++ 3 files changed, 11 insertions(+) diff --git a/src/env.ts b/src/env.ts index 373b1e63..a860deb5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -161,6 +161,11 @@ const envSchema = z * used for fallback when the mempool.space API is not available. */ BITCOIN_ELECTRS_API_URL: z.string().optional(), + /** + * Bitcoin data provider, support mempool and electrs + * use mempool.space as default, electrs as fallback + * change to electrs if you want to use electrs as default and mempool.space as fallback + */ BITCOIN_DATA_PROVIDER: z.literal('mempool').default('mempool'), }), z.object({ diff --git a/src/services/bitcoin/electrs.ts b/src/services/bitcoin/electrs.ts index 56d1d680..98052546 100644 --- a/src/services/bitcoin/electrs.ts +++ b/src/services/bitcoin/electrs.ts @@ -7,6 +7,9 @@ export class ElectrsClient implements IBitcoinDataProvider { private request: AxiosInstance; constructor(cradle: Cradle) { + if (!cradle.env.BITCOIN_ELECTRS_API_URL) { + throw new Error('BITCOIN_ELECTRS_API_URL is required'); + } this.request = axios.create({ baseURL: cradle.env.BITCOIN_ELECTRS_API_URL, }); diff --git a/src/services/bitcoin/mempool.ts b/src/services/bitcoin/mempool.ts index 12051f18..ac0d4ad1 100644 --- a/src/services/bitcoin/mempool.ts +++ b/src/services/bitcoin/mempool.ts @@ -7,6 +7,9 @@ export class MempoolClient implements IBitcoinDataProvider { private mempool: ReturnType; constructor(cradle: Cradle) { + if (!cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL) { + throw new Error('BITCOIN_MEMPOOL_SPACE_API_URL is required'); + } const url = new URL(cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL); this.mempool = mempoolJS({ hostname: url.hostname, From 519ca0f2f01617c9dd71e801d41195c55f704dbe Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 01:00:18 +1000 Subject: [PATCH 21/53] test: fix transaction processor test case --- test/services/transaction.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/services/transaction.test.ts b/test/services/transaction.test.ts index 577457d2..1b8a8f8e 100644 --- a/test/services/transaction.test.ts +++ b/test/services/transaction.test.ts @@ -36,7 +36,7 @@ describe('transactionProcessor', () => { needPaymasterCell: false, }, }; - const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTx({ txid: transactionRequest.txid }); const isValid = await transactionProcessor.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(true); }); @@ -58,7 +58,7 @@ describe('transactionProcessor', () => { needPaymasterCell: false, }, }; - const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTx({ txid: transactionRequest.txid }); const isValid = await transactionProcessor.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(false); }); @@ -81,7 +81,7 @@ describe('transactionProcessor', () => { needPaymasterCell: false, }, }; - const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTx({ txid: transactionRequest.txid }); const isValid = await transactionProcessor.verifyTransaction(transactionRequest, btcTx); expect(isValid).toBe(false); }); @@ -93,7 +93,7 @@ describe('transactionProcessor', () => { }, 'getCommitmentFromBtcTx', ).mockResolvedValueOnce(Buffer.from(commitment, 'hex')); - vi.spyOn(transactionProcessor['cradle']['bitcoin'], 'getTransaction').mockResolvedValueOnce({ + vi.spyOn(transactionProcessor['cradle']['bitcoin'], 'getTx').mockResolvedValueOnce({ status: { confirmed: false, block_height: 0 }, } as unknown as Transaction); @@ -107,7 +107,7 @@ describe('transactionProcessor', () => { }, }; - const btcTx = await cradle.bitcoin.getTransaction(transactionRequest.txid); + const btcTx = await cradle.bitcoin.getTx({ txid: transactionRequest.txid }); await expect( transactionProcessor.verifyTransaction(transactionRequest, btcTx), ).rejects.toThrowErrorMatchingSnapshot(); @@ -135,8 +135,8 @@ describe('transactionProcessor', () => { vi.spyOn(cradle.bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 123456, } as unknown as ChainInfo); - vi.spyOn(cradle.bitcoin, 'getBlockHashByHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); - vi.spyOn(cradle.bitcoin, 'getBlockTxIdsByHash').mockResolvedValue([ + vi.spyOn(cradle.bitcoin, 'getBlockHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); + vi.spyOn(cradle.bitcoin, 'getBlockTxids').mockResolvedValue([ 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', '8ea0fbb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a056', '8eb22b379c0ef491dea2d819e721d5df296bebc67a056a0fbb8c92f11920824d', @@ -158,8 +158,8 @@ describe('transactionProcessor', () => { vi.spyOn(cradle.bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 123456, } as unknown as ChainInfo); - vi.spyOn(cradle.bitcoin, 'getBlockHashByHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); - vi.spyOn(cradle.bitcoin, 'getBlockTxIdsByHash').mockResolvedValue([ + vi.spyOn(cradle.bitcoin, 'getBlockHeight').mockResolvedValue('00000000abcdefghijklmnopqrstuvwxyz'); + vi.spyOn(cradle.bitcoin, 'getBlockTxids').mockResolvedValue([ 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', '8ea0fbb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a056', '8eb22b379c0ef491dea2d819e721d5df296bebc67a056a0fbb8c92f11920824d', From 649df0fd2780d35a9d7bebbd4a64f163a9644a8e Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 14:44:25 +1000 Subject: [PATCH 22/53] feat: server cache bitcoin recommended fees 10 seconds --- src/plugins/cache.ts | 3 --- src/routes/bitcoin/fees.ts | 5 ++++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/cache.ts b/src/plugins/cache.ts index a1a0683b..91a37a41 100644 --- a/src/plugins/cache.ts +++ b/src/plugins/cache.ts @@ -94,9 +94,6 @@ export default fp(async (fastify) => { return; } - // if the response is not cacheable, set default cache control headers - reply.cacheControl('public'); - reply.cacheControl('max-age', 10); next(); }); } catch (err) { diff --git a/src/routes/bitcoin/fees.ts b/src/routes/bitcoin/fees.ts index 5f0e5f19..80072aad 100644 --- a/src/routes/bitcoin/fees.ts +++ b/src/routes/bitcoin/fees.ts @@ -2,6 +2,7 @@ import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; import { RecommendedFees } from './types'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { CUSTOM_HEADERS } from '../../constants'; const feesRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { fastify.get( @@ -15,8 +16,10 @@ const feesRoutes: FastifyPluginCallback, Server, ZodTypePro }, }, }, - async () => { + async (_, reply) => { const fees = await fastify.bitcoin.getFeesRecommended(); + reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); + reply.header(CUSTOM_HEADERS.ResponseCacheMaxAge, 10); return fees; }, ); From b34166737dbe23ff9bb880ae0ec79c8615d84246 Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 14:51:17 +1000 Subject: [PATCH 23/53] test: add matrix data providers and fix test cases --- .github/workflows/test.yaml | 5 +++++ test/routes/bitcoind/address.test.ts | 25 +++++++++++++------------ test/services/unlocker.test.ts | 6 +++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ae88fe78..8e2f2f31 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,6 +15,10 @@ on: jobs: test: runs-on: ubuntu-latest + concurrency: test-group + strategy: + matrix: + BITCOIN_DATA_PROVIDER: [mempool, electrs] services: redis: @@ -48,6 +52,7 @@ jobs: - name: Run Unit Tests env: JWT_SECRET: ${{ secrets.JWT_SECRET }} + BITCOIN_DATA_PROVIDER: ${{ matrix.BITCOIN_DATA_PROVIDER }} BITCOIN_MEMPOOL_SPACE_API_URL: ${{ secrets.BITCOIN_MEMPOOL_SPACE_API_URL }} BITCOIN_ELECTRS_API_URL: ${{ secrets.BITCOIN_ELECTRS_API_URL }} BITCOIN_SPV_SERVICE_URL: ${{ secrets.BITCOIN_SPV_SERVICE_URL }} diff --git a/test/routes/bitcoind/address.test.ts b/test/routes/bitcoind/address.test.ts index 645ff971..3cdf3eea 100644 --- a/test/routes/bitcoind/address.test.ts +++ b/test/routes/bitcoind/address.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, beforeEach, vi } from 'vitest'; import { buildFastify } from '../../../src/app'; import { afterEach } from 'node:test'; +import BitcoinClient from '../../../src/services/bitcoin'; let token: string; @@ -91,9 +92,9 @@ describe('/bitcoin/v1/address', () => { const fastify = buildFastify(); await fastify.ready(); - const bitcoin = fastify.container.resolve('bitcoin'); - const originalGetUtxoByAddress = bitcoin.getUtxoByAddress; - vi.spyOn(bitcoin, 'getUtxoByAddress').mockResolvedValue([ + const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); + const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; + vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ { txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', vout: 0, @@ -121,7 +122,7 @@ describe('/bitcoin/v1/address', () => { }, }); const data = response.json(); - bitcoin.getUtxoByAddress = originalGetUtxoByAddress; + bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; expect(response.statusCode).toBe(200); expect(data.length).toBe(1); @@ -133,9 +134,9 @@ describe('/bitcoin/v1/address', () => { const fastify = buildFastify(); await fastify.ready(); - const bitcoin = fastify.container.resolve('bitcoin'); - const originalGetUtxoByAddress = bitcoin.getUtxoByAddress; - vi.spyOn(bitcoin, 'getUtxoByAddress').mockResolvedValue([ + const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); + const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; + vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ { txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', vout: 0, @@ -163,7 +164,7 @@ describe('/bitcoin/v1/address', () => { }, }); const data = response.json(); - bitcoin.getUtxoByAddress = originalGetUtxoByAddress; + bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; expect(response.statusCode).toBe(200); expect(data.length).toBe(2); @@ -175,9 +176,9 @@ describe('/bitcoin/v1/address', () => { const fastify = buildFastify(); await fastify.ready(); - const bitcoin = fastify.container.resolve('bitcoin'); - const originalGetUtxoByAddress = bitcoin.getUtxoByAddress; - vi.spyOn(bitcoin, 'getUtxoByAddress').mockResolvedValue([ + const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); + const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; + vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ { txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', vout: 0, @@ -205,7 +206,7 @@ describe('/bitcoin/v1/address', () => { }, }); const data = response.json(); - bitcoin.getUtxoByAddress = originalGetUtxoByAddress; + bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; expect(response.statusCode).toBe(200); expect(data.length).toBe(1); diff --git a/test/services/unlocker.test.ts b/test/services/unlocker.test.ts index ff08fa3c..7512ca29 100644 --- a/test/services/unlocker.test.ts +++ b/test/services/unlocker.test.ts @@ -77,7 +77,7 @@ describe('Unlocker', () => { // @ts-expect-error vi.spyOn(unlocker['cradle'].bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 100 }); // @ts-expect-error - vi.spyOn(unlocker['cradle'].bitcoin, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getTx').mockResolvedValue({ status: { block_height: 95 } }); mockBtcTimeLockCell(); const cells = await unlocker.getNextBatchLockCell(); @@ -88,7 +88,7 @@ describe('Unlocker', () => { // @ts-expect-error vi.spyOn(unlocker['cradle'].bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); // @ts-expect-error - vi.spyOn(unlocker['cradle'].bitcoin, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getTx').mockResolvedValue({ status: { block_height: 95 } }); mockBtcTimeLockCell(); const cells = await unlocker.getNextBatchLockCell(); @@ -101,7 +101,7 @@ describe('Unlocker', () => { // @ts-expect-error vi.spyOn(unlocker['cradle'].bitcoin, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); // @ts-expect-error - vi.spyOn(unlocker['cradle'].bitcoin, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + vi.spyOn(unlocker['cradle'].bitcoin, 'getTx').mockResolvedValue({ status: { block_height: 95 } }); mockBtcTimeLockCell(); const cells = await unlocker.getNextBatchLockCell(); From abb5e91d84edaaa1befac16e4c2c5ca65fcafde3 Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 15:01:45 +1000 Subject: [PATCH 24/53] test: add bitcoin client data providers test --- test/services/bitcoin.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/services/bitcoin.test.ts diff --git a/test/services/bitcoin.test.ts b/test/services/bitcoin.test.ts new file mode 100644 index 00000000..d33b59aa --- /dev/null +++ b/test/services/bitcoin.test.ts @@ -0,0 +1,24 @@ +import container from '../../src/container'; +import { describe, test, beforeEach, expect } from 'vitest'; +import BitcoinClient from '../../src/services/bitcoin'; +import { ElectrsClient } from '../../src/services/bitcoin/electrs'; +import { MempoolClient } from '../../src/services/bitcoin/mempool'; + +describe('BitcoinClient', () => { + let bitcoin: BitcoinClient; + + beforeEach(async () => { + const cradle = container.cradle; + bitcoin = new BitcoinClient(cradle); + }); + + test('BitcoinClient: Should be use data providers', async () => { + if (container.cradle.env.BITCOIN_DATA_PROVIDER === 'mempool') { + expect(bitcoin['source'].constructor).toBe(MempoolClient); + expect(bitcoin['fallback']?.constructor).toBe(ElectrsClient); + } else { + expect(bitcoin['source'].constructor).toBe(ElectrsClient); + expect(bitcoin['fallback']?.constructor).toBe(MempoolClient); + } + }); +}); From 0acfc7f40758931d404115b18da302aa0fc8f9a2 Mon Sep 17 00:00:00 2001 From: ahonn Date: Sun, 28 Apr 2024 15:08:09 +1000 Subject: [PATCH 25/53] docs: update README.md and .env.example --- .env.example | 13 ++++++++----- README.md | 20 +++++++++++++------- src/env.ts | 2 +- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 11f1580a..2e8bb2a8 100644 --- a/.env.example +++ b/.env.example @@ -30,13 +30,16 @@ JWT_SECRET= # JWT token denylist # JWT_DENYLIST= +# Bitcoin data provider, support mempool and electrs +# use mempool.space as default, electrs as fallback +# change to electrs if you want to use electrs as default and mempool.space as fallback +BITCOIN_DATA_PROVIDER=mempool # Bitcoin Mempool.space API URL -# used to get bitcoin data and broadcast transaction +# optinal when BITCOIN_DATA_PROVIDER=electrs BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space - -# Electrs API URL (optional) -# used for fallback when the mempool.space API is not available -# BITCOIN_ELECTRS_API_URL= +# Electrs API URL +# optinal when BITCOIN_DATA_PROVIDER=mempool +BITCOIN_ELECTRS_API_URL= # SPV Service URL BITCOIN_SPV_SERVICE_URL= diff --git a/README.md b/README.md index 59149371..9d65ad79 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ A service for Retrieving BTC/RGB++ information/assets and processing transactions with these assets ### Features + - Retrieving Blockchain Information such as Bitcoin chain info, blocks, headers, transactions, addresses and RGB++ assets - Transaction Handling by posting transactions to the /bitcoin/v1/transaction or /rgbpp/v1/transaction/ckb-tx endpoint - RGB++ CKB transaction Queue simplifies the RGB++ assets workflows by some cron jobs @@ -11,12 +12,14 @@ A service for Retrieving BTC/RGB++ information/assets and processing transaction #### Requirements -- [mempool.space API](https://mempool.space/docs): mempool.space merely provides data about the Bitcoin network. +- [mempool.space](https://mempool.space/docs) or [mempool/electrs](https://github.com/mempool/electrs): provides data about the Bitcoin network. + - We can use either of them as data provider + - Or use both, designating one as the primary provider and the other as the fallback - [ckb-cell/ckb-bitcoin-spv-service](https://github.com/ckb-cell/ckb-bitcoin-spv-service): CKB Bitcoin SPV Service #### Configuration -Copy the `.env.example` file to `.env`: +Copy the `.env.example` file to `.env`: ```bash cp .env.example .env @@ -57,13 +60,16 @@ JWT_SECRET= # JWT token denylist # JWT_DENYLIST= +# Bitcoin data provider, support mempool and electrs +# use mempool.space as default, electrs as fallback +# change to electrs if you want to use electrs as default and mempool.space as fallback +BITCOIN_DATA_PROVIDER=mempool # Bitcoin Mempool.space API URL -# used to get bitcoin data and broadcast transaction +# optinal when BITCOIN_DATA_PROVIDER=electrs BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space - -# Electrs API URL (optional) -# used for fallback when the mempool.space API is not available -# BITCOIN_ELECTRS_API_URL= +# Electrs API URL +# optinal when BITCOIN_DATA_PROVIDER=mempool +BITCOIN_ELECTRS_API_URL= # SPV Service URL BITCOIN_SPV_SERVICE_URL= diff --git a/src/env.ts b/src/env.ts index a860deb5..1f83e434 100644 --- a/src/env.ts +++ b/src/env.ts @@ -172,12 +172,12 @@ const envSchema = z /** * The URL of the Electrs API. * Electrs is a Rust implementation of Electrum Server. - * used for fallback when the mempool.space API is not available. */ BITCOIN_ELECTRS_API_URL: z.string(), /** * Bitcoin Mempool.space API URL * used to get bitcoin data and broadcast transaction. + * used for fallback when the electrs API is not available. */ BITCOIN_MEMPOOL_SPACE_API_URL: z.string().optional(), BITCOIN_DATA_PROVIDER: z.literal('electrs'), From 29075ed81fb7bc91486b9a3dc97c910de1786b56 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 29 Apr 2024 15:05:01 +1000 Subject: [PATCH 26/53] fix: fix error handler --- src/plugins/sentry.ts | 6 +++--- src/services/bitcoin/index.ts | 11 +++++------ test/routes/bitcoind/transaction.test.ts | 2 -- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index 55d5b43b..b0c9a043 100644 --- a/src/plugins/sentry.ts +++ b/src/plugins/sentry.ts @@ -4,7 +4,7 @@ import { ProfilingIntegration } from '@sentry/profiling-node'; import pkg from '../../package.json'; import { env } from '../env'; import { HttpStatusCode, AxiosError } from 'axios'; -import { BitcoinMempoolAPIError } from '../services/bitcoin'; +import { BitcoinClientAPIError } from '../services/bitcoin'; export default fp(async (fastify) => { // @ts-expect-error - fastify-sentry types are not up to date @@ -16,9 +16,9 @@ export default fp(async (fastify) => { environment: env.NODE_ENV, release: pkg.version, // handle error in the errorResponse function below - shouldHandleError: false, + shouldHandleError: () => false, errorResponse: (error, _, reply) => { - if (error instanceof BitcoinMempoolAPIError) { + if (error instanceof BitcoinClientAPIError) { reply.status(error.statusCode ?? HttpStatusCode.InternalServerError).send({ message: error.message }); return; } diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index 4c8deffd..3e3ebfa8 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -1,4 +1,4 @@ -import { AxiosError } from 'axios'; +import { AxiosError, HttpStatusCode } from 'axios'; import * as Sentry from '@sentry/node'; import { Cradle } from '../../container'; import { IBitcoinDataProvider } from './interface'; @@ -22,8 +22,6 @@ export enum BitcoinClientErrorCode { TooManyUtxos = 0x1002, // 4098 TooManyTxs = 0x1003, // 4099 ElectrumClient = 0x1004, // 4100 - - MempoolUnknown = 0x1111, // 4369 } const BitcoinClientErrorMap = { @@ -35,7 +33,7 @@ const BitcoinClientErrorMap = { }; export class BitcoinClientAPIError extends Error { - public statusCode = 500; + public statusCode = HttpStatusCode.ServiceUnavailable; public errorCode: BitcoinClientErrorCode; constructor(message: string) { @@ -43,8 +41,7 @@ export class BitcoinClientAPIError extends Error { this.name = this.constructor.name; const errorKey = Object.keys(BitcoinClientErrorMap).find((msg) => message.startsWith(msg)); - this.errorCode = - BitcoinClientErrorMap[errorKey as BitcoinClientErrorMessage] ?? BitcoinClientErrorCode.MempoolUnknown; + this.errorCode = BitcoinClientErrorMap[errorKey as BitcoinClientErrorMessage]; } } @@ -93,6 +90,7 @@ export default class BitcoinClient implements IBitcoinClient { this.cradle.logger.debug(`Calling ${method} with args: ${JSON.stringify(args)}`); // @ts-expect-error args: A spread argument must either have a tuple type or be passed to a rest parameter const result = await this.source[method].call(this.source, ...args).catch((err) => { + this.cradle.logger.error(err); Sentry.captureException(err); if (this.fallback) { this.cradle.logger.warn(`Fallback to ${this.fallback.constructor.name} due to error: ${err.message}`); @@ -104,6 +102,7 @@ export default class BitcoinClient implements IBitcoinClient { // @ts-expect-error return type is correct return result; } catch (err) { + this.cradle.logger.error(err); if ((err as AxiosError).isAxiosError) { const error = new BitcoinClientAPIError((err as AxiosError).message); if ((err as AxiosError).response) { diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts index 5d588f4f..68413ed3 100644 --- a/test/routes/bitcoind/transaction.test.ts +++ b/test/routes/bitcoind/transaction.test.ts @@ -1,7 +1,6 @@ import { beforeEach, expect, test } from 'vitest'; import { buildFastify } from '../../../src/app'; import { describe } from 'node:test'; -import { BitcoinClientErrorCode } from '../../../src/services/bitcoin'; let token: string; @@ -60,7 +59,6 @@ describe('/bitcoin/v1/transaction', () => { expect(response.statusCode).toBe(404); expect(data).toEqual({ - code: BitcoinClientErrorCode.MempoolUnknown, message: 'Request failed with status code 404', }); From 80ecb8b93dab944f5a81ea243f0b9180042b274c Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 29 Apr 2024 16:34:09 +1000 Subject: [PATCH 27/53] feat: use @cell-studio/mempool.js instead of @mempool/mempool.js --- package.json | 2 +- pnpm-lock.yaml | 17 ++++++++++++++--- src/services/bitcoin/mempool.ts | 4 +--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 6a870d89..f86b2f45 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ ] }, "dependencies": { + "@cell-studio/mempool.js": "^2.4.0", "@ckb-lumos/base": "^0.22.2 ", "@ckb-lumos/ckb-indexer": "^0.22.2", "@ckb-lumos/codec": "^0.22.2", @@ -39,7 +40,6 @@ "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", "@immobiliarelabs/fastify-sentry": "^8.0.1", - "@mempool/mempool.js": "^2.3.0", "@nervosnetwork/ckb-sdk-utils": "^0.109.1", "@rgbpp-sdk/btc": "0.0.0-snap-20240423144119", "@rgbpp-sdk/ckb": "0.0.0-snap-20240423144119", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec387a23..e7262e54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@cell-studio/mempool.js': + specifier: ^2.4.0 + version: 2.4.0 '@ckb-lumos/base': specifier: '^0.22.2 ' version: 0.22.2 @@ -47,9 +50,6 @@ dependencies: '@immobiliarelabs/fastify-sentry': specifier: ^8.0.1 version: 8.0.1 - '@mempool/mempool.js': - specifier: ^2.3.0 - version: 2.3.0 '@nervosnetwork/ckb-sdk-utils': specifier: ^0.109.1 version: 0.109.1 @@ -398,6 +398,17 @@ packages: '@noble/secp256k1': 1.7.1 dev: false + /@cell-studio/mempool.js@2.4.0: + resolution: {integrity: sha512-g9cvOGrBILES4nfryfFDfNZvIq2WxlYk3IcBrH4otAv/j8DGCQ3IyxjZq81qv6bz7TeFxVp1YUEr2T7FluJZNQ==} + dependencies: + axios: 1.6.7 + ws: 8.3.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + dev: false + /@ckb-lumos/base@0.22.2: resolution: {integrity: sha512-nosUCSa5rTV2IzxbEpqzrvUeQNXB66mgA0h40+QEdnE/gV/s4ke83AScrTAxWkErJy1G/sToIHCc2kWwO95DfQ==} engines: {node: '>=12.0.0'} diff --git a/src/services/bitcoin/mempool.ts b/src/services/bitcoin/mempool.ts index ac0d4ad1..52a8a336 100644 --- a/src/services/bitcoin/mempool.ts +++ b/src/services/bitcoin/mempool.ts @@ -1,6 +1,6 @@ import { Cradle } from '../../container'; import { IBitcoinDataProvider } from './interface'; -import mempoolJS from '@mempool/mempool.js'; +import mempoolJS from '@cell-studio/mempool.js'; import { Block, RecommendedFees, Transaction, UTXO } from './schema'; export class MempoolClient implements IBitcoinDataProvider { @@ -33,8 +33,6 @@ export class MempoolClient implements IBitcoinDataProvider { } public async getAddressTxs({ address, after_txid }: { address: string; after_txid?: string }) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error const response = await this.mempool.bitcoin.addresses.getAddressTxs({ address, after_txid }); return response.map((tx) => Transaction.parse(tx)); } From d2607ff0e92a23992b611d5167f8b19ea08c0661 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 29 Apr 2024 16:54:42 +1000 Subject: [PATCH 28/53] test: update test case snapshots --- .../bitcoind/__snapshots__/block.test.ts.snap | 26 +++++++++---------- test/routes/bitcoind/block.test.ts | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/test/routes/bitcoind/__snapshots__/block.test.ts.snap b/test/routes/bitcoind/__snapshots__/block.test.ts.snap index 0cf064df..4e4d1c70 100644 --- a/test/routes/bitcoind/__snapshots__/block.test.ts.snap +++ b/test/routes/bitcoind/__snapshots__/block.test.ts.snap @@ -2,19 +2,19 @@ exports[`/bitcoin/v1/block > Get block by hash 1`] = ` { - "bits": 422051352, - "difficulty": 107392535.89663602, - "height": 2579636, - "id": "0000000000000005ae0b929ee3afbf2956aaa0059f9d7608dc396cf5f8f4dda6", - "mediantime": 1709002141, - "merkle_root": "5b4c5be9e900d3702d78fe91fb3395d74f158107671734aaf86b071d11ad2c65", - "nonce": 1330201347, - "previousblockhash": "00000000385247e00cbbd81c48a9358d65d6aa56dee36e0c39b6794f5962af00", - "size": 624046, - "timestamp": 1709006957, - "tx_count": 1206, - "version": 538968064, - "weight": 1050313, + "bits": 436273151, + "difficulty": 16777216, + "height": 2091140, + "id": "000000000000009c08dc77c3f224d9f5bbe335a78b996ec1e0701e065537ca81", + "mediantime": 1630621997, + "merkle_root": "5d10d8d158bb8eb217d01fecc435bd10eda028043a913dc2bfe0ccf536a51cc9", + "nonce": 1600805744, + "previousblockhash": "0000000000000073f95d1fc0a93d449f82a754410c635e46264ec6c7c4d5741e", + "size": 575, + "timestamp": 1630625150, + "tx_count": 2, + "version": 543162372, + "weight": 1865, } `; diff --git a/test/routes/bitcoind/block.test.ts b/test/routes/bitcoind/block.test.ts index ef796989..e7759be0 100644 --- a/test/routes/bitcoind/block.test.ts +++ b/test/routes/bitcoind/block.test.ts @@ -28,7 +28,7 @@ describe('/bitcoin/v1/block', () => { const response = await fastify.inject({ method: 'GET', - url: '/bitcoin/v1/block/0000000000000005ae0b929ee3afbf2956aaa0059f9d7608dc396cf5f8f4dda6', + url: '/bitcoin/v1/block/000000000000009c08dc77c3f224d9f5bbe335a78b996ec1e0701e065537ca81', headers: { Authorization: `Bearer ${token}`, Origin: 'https://test.com', From ce0f1f905f257d97d1ab4780469dba72691c2929 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 29 Apr 2024 18:09:56 +1000 Subject: [PATCH 29/53] chore: add filled count to sentry context when throw PaymasterCellNotEnoughError --- src/services/paymaster.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/paymaster.ts b/src/services/paymaster.ts index f8773bb6..36538823 100644 --- a/src/services/paymaster.ts +++ b/src/services/paymaster.ts @@ -89,7 +89,7 @@ export default class Paymaster implements IPaymaster { return null; } - private async captureExceptionToSentryScope(err: Error) { + private async captureExceptionToSentryScope(err: Error, attrs?: Record) { const remaining = await this.queue.getWaitingCount(); Sentry.withScope((scope) => { scope.setContext('paymaster', { @@ -97,6 +97,7 @@ export default class Paymaster implements IPaymaster { remaining: remaining, preset: this.presetCount, threshold: this.refillThreshold, + ...attrs, }); scope.captureException(err); }); @@ -186,7 +187,9 @@ export default class Paymaster implements IPaymaster { // XXX: consider to send an alert email or other notifications this.cradle.logger.warn('Filled paymaster cells less than the preset count'); const error = new PaymasterCellNotEnoughError('Filled paymaster cells less than the preset count'); - this.captureExceptionToSentryScope(error); + this.captureExceptionToSentryScope(error, { + filled, + }); } this.refilling = false; } From 58d6230efef2e7d0f936476eb32ee937abef1c56 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 29 Apr 2024 18:48:01 +1000 Subject: [PATCH 30/53] feat: calculate fees when mempool.space recommend fees api failed --- src/services/bitcoin/electrs.ts | 2 +- src/services/bitcoin/mempool.ts | 79 ++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/services/bitcoin/electrs.ts b/src/services/bitcoin/electrs.ts index 98052546..0f5b17cc 100644 --- a/src/services/bitcoin/electrs.ts +++ b/src/services/bitcoin/electrs.ts @@ -16,7 +16,7 @@ export class ElectrsClient implements IBitcoinDataProvider { } public async getFeesRecommended(): Promise { - throw new Error('ElectrsClient does not support getFeesRecommended'); + throw new Error('Electrs: Recommended fees not available'); } public async postTx({ txhex }: { txhex: string }) { diff --git a/src/services/bitcoin/mempool.ts b/src/services/bitcoin/mempool.ts index 52a8a336..d6b5a5f9 100644 --- a/src/services/bitcoin/mempool.ts +++ b/src/services/bitcoin/mempool.ts @@ -2,9 +2,12 @@ import { Cradle } from '../../container'; import { IBitcoinDataProvider } from './interface'; import mempoolJS from '@cell-studio/mempool.js'; import { Block, RecommendedFees, Transaction, UTXO } from './schema'; +import * as Sentry from '@sentry/node'; +import { FeesMempoolBlocks } from '@cell-studio/mempool.js/lib/interfaces/bitcoin/fees'; export class MempoolClient implements IBitcoinDataProvider { private mempool: ReturnType; + private defaultFee = 1; constructor(cradle: Cradle) { if (!cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL) { @@ -17,9 +20,81 @@ export class MempoolClient implements IBitcoinDataProvider { }); } + // https://github.com/mempool/mempool/blob/dbd4d152ce831859375753fb4ca32ac0e5b1aff8/backend/src/api/fee-api.ts#L77 + private roundUpToNearest(value: number, nearest: number): number { + return Math.ceil(value / nearest) * nearest; + } + + // https://github.com/mempool/mempool/blob/dbd4d152ce831859375753fb4ca32ac0e5b1aff8/backend/src/api/fee-api.ts#L65 + private optimizeMedianFee( + pBlock: FeesMempoolBlocks, + nextBlock: FeesMempoolBlocks | undefined, + previousFee?: number, + ): number { + const useFee = previousFee ? (pBlock.medianFee + previousFee) / 2 : pBlock.medianFee; + if (pBlock.blockVSize <= 500000) { + return this.defaultFee; + } + if (pBlock.blockVSize <= 950000 && !nextBlock) { + const multiplier = (pBlock.blockVSize - 500000) / 500000; + return Math.max(Math.round(useFee * multiplier), this.defaultFee); + } + return this.roundUpToNearest(useFee, this.defaultFee); + } + + // https://github.com/mempool/mempool/blob/dbd4d152ce831859375753fb4ca32ac0e5b1aff8/backend/src/api/fee-api.ts#L22 + private async calculateRecommendedFee(): Promise { + const pBlocks = await this.mempool.bitcoin.fees.getFeesMempoolBlocks(); + const minimumFee = this.defaultFee; + const defaultMinFee = this.defaultFee; + + if (!pBlocks.length) { + return { + fastestFee: defaultMinFee, + halfHourFee: defaultMinFee, + hourFee: defaultMinFee, + economyFee: minimumFee, + minimumFee: minimumFee, + }; + } + + const firstMedianFee = this.optimizeMedianFee(pBlocks[0], pBlocks[1]); + const secondMedianFee = pBlocks[1] + ? this.optimizeMedianFee(pBlocks[1], pBlocks[2], firstMedianFee) + : this.defaultFee; + const thirdMedianFee = pBlocks[2] + ? this.optimizeMedianFee(pBlocks[2], pBlocks[3], secondMedianFee) + : this.defaultFee; + + let fastestFee = Math.max(minimumFee, firstMedianFee); + let halfHourFee = Math.max(minimumFee, secondMedianFee); + let hourFee = Math.max(minimumFee, thirdMedianFee); + const economyFee = Math.max(minimumFee, Math.min(2 * minimumFee, thirdMedianFee)); + + fastestFee = Math.max(fastestFee, halfHourFee, hourFee, economyFee); + halfHourFee = Math.max(halfHourFee, hourFee, economyFee); + hourFee = Math.max(hourFee, economyFee); + + return { + fastestFee: fastestFee, + halfHourFee: halfHourFee, + hourFee: hourFee, + economyFee: economyFee, + minimumFee: minimumFee, + }; + } + public async getFeesRecommended() { - const response = await this.mempool.bitcoin.fees.getFeesRecommended(); - return RecommendedFees.parse(response); + try { + const response = await this.mempool.bitcoin.fees.getFeesRecommended(); + return RecommendedFees.parse(response); + } catch (e) { + Sentry.withScope((scope) => { + scope.captureException(e); + }); + const fees = await this.calculateRecommendedFee(); + return RecommendedFees.parse(fees); + } } public async postTx({ txhex }: { txhex: string }) { From 3e6c11cf89e20e4cbd4ea608adf03b1681121fd3 Mon Sep 17 00:00:00 2001 From: Yuexun Date: Tue, 30 Apr 2024 11:46:37 +1000 Subject: [PATCH 31/53] fix: fix the state issue of inactive paymaster cell job (#118) --- src/services/paymaster.ts | 13 ++++++++++++- src/services/transaction.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/services/paymaster.ts b/src/services/paymaster.ts index 36538823..a6cf9a73 100644 --- a/src/services/paymaster.ts +++ b/src/services/paymaster.ts @@ -248,7 +248,16 @@ export default class Paymaster implements IPaymaster { const job = await this.queue.getJob(jobId); if (job) { this.cradle.logger.info(`[Paymaster] Paymaster cell already in the queue: ${jobId}`); - continue; + // cause the issue that the job is not moved to delayed when appendCellAndSignTx throw error + // try to remove the inactive job and add the cell back to the queue + // (inactive job means the job is processed on 1 minute ago but not completed) + const active = await job.isActive(); + if (active && job.processedOn && job.processedOn < Date.now() - 60_000) { + this.cradle.logger.warn(`[Paymaster] Remove the inactive paymaster cell: ${jobId}`); + await job.remove(); + } else { + continue; + } } // add the cell to the queue await this.queue.add(PAYMASTER_CELL_QUEUE_NAME, cell, { jobId }); @@ -309,6 +318,7 @@ export default class Paymaster implements IPaymaster { try { const job = await this.getPaymasterCellJobByRawTx(signedTx); if (job) { + this.cradle.logger.info(`[Paymaster] Mark paymaster cell as spent: ${token}`); await job.moveToCompleted(null, token, false); } } catch (err) { @@ -327,6 +337,7 @@ export default class Paymaster implements IPaymaster { try { const job = await this.getPaymasterCellJobByRawTx(signedTx); if (job) { + this.cradle.logger.info(`[Paymaster] Mark paymaster cell as unspent: ${token}`); await job.moveToDelayed(Date.now(), token); } } catch (err) { diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 1347179e..9cf2e6ea 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -435,13 +435,13 @@ export default class TransactionProcessor implements ITransactionProcessor { const ckbRawTx = this.getCkbRawTxWithRealBtcTxid(ckbVirtualResult, txid); let signedTx = await this.appendTxWitnesses(txid, ckbRawTx); - // append paymaster cell and sign the transaction if needed - if (ckbVirtualResult.needPaymasterCell) { - signedTx = await this.appendPaymasterCellAndSignTx(btcTx, ckbVirtualResult, signedTx); - } - this.cradle.logger.debug(`[TransactionProcessor] Transaction signed: ${JSON.stringify(signedTx)}`); - try { + // append paymaster cell and sign the transaction if needed + if (ckbVirtualResult.needPaymasterCell) { + signedTx = await this.appendPaymasterCellAndSignTx(btcTx, ckbVirtualResult, signedTx); + } + this.cradle.logger.debug(`[TransactionProcessor] Transaction signed: ${JSON.stringify(signedTx)}`); + const txHash = await this.cradle.ckb.sendTransaction(signedTx); job.returnvalue = txHash; this.cradle.logger.info(`[TransactionProcessor] Transaction sent: ${txHash}`); From 060229b50d540481c43aeb0f3772f8dcbe5034af Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 30 Apr 2024 12:17:03 +1000 Subject: [PATCH 32/53] docs: update .env.example and README.md --- .env.example | 10 ++++++++-- README.md | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 2e8bb2a8..e3134c5c 100644 --- a/.env.example +++ b/.env.example @@ -55,18 +55,24 @@ PAYMASTER_CELL_CAPACITY=31600000000 PAYMASTER_CELL_PRESET_COUNT=500 # Paymaster cell refill threshold, refill paymaster cell when the balance is less than this threshold. PAYMASTER_CELL_REFILL_THRESHOLD=0.3 +# Check the paymaster BTC UTXO when processing rgb++ ckb transaction +PAYMASTER_RECEIVE_UTXO_CHECK=false # Paymaster bitcoin address, used to receive BTC from users PAYMASTER_RECEIVE_BTC_ADDRESS= # Paymaster receives BTC UTXO size in sats PAYMASTER_BTC_CONTAINER_FEE_SATS=7000 -# BTCTimeLock cell unlock batch size -UNLOCKER_CELL_BATCH_SIZE=100 # BTCTimeLock cell unlock cron job schedule, default is every 5 minutes UNLOCKER_CRON_SCHEDULE='*/5 * * * *' +# BTCTimeLock cell unlock batch size +UNLOCKER_CELL_BATCH_SIZE=100 # BTCTimeLock cell unlocker monitor slug, used for monitoring unlocker status on sentry UNLOCKER_MONITOR_SLUG=btctimelock-cells-unlocker # RGB++ CKB transaction Queue cron job delay in milliseconds # the /rgbpp/v1/transaction/ckb-tx endpoint is called, the transaction will be added to the queue TRANSACTION_QUEUE_JOB_DELAY=12000 +# RGB++ CKB transaction Queue cron job attempts +TRANSACTION_QUEUE_JOB_ATTEMPTS=6 +# Pay fee for transaction with pool reject by min fee rate, false by default +TRANSACTION_PAY_FOR_MIN_FEE_RATE_REJECT=false diff --git a/README.md b/README.md index 9d65ad79..879234c5 100644 --- a/README.md +++ b/README.md @@ -85,21 +85,27 @@ PAYMASTER_CELL_CAPACITY=31600000000 PAYMASTER_CELL_PRESET_COUNT=500 # Paymaster cell refill threshold, refill paymaster cell when the balance is less than this threshold. PAYMASTER_CELL_REFILL_THRESHOLD=0.3 +# Check the paymaster BTC UTXO when processing rgb++ ckb transaction +PAYMASTER_RECEIVE_UTXO_CHECK=false # Paymaster bitcoin address, used to receive BTC from users PAYMASTER_RECEIVE_BTC_ADDRESS= # Paymaster receives BTC UTXO size in sats PAYMASTER_BTC_CONTAINER_FEE_SATS=7000 -# BTCTimeLock cell unlock batch size -UNLOCKER_CELL_BATCH_SIZE=100 # BTCTimeLock cell unlock cron job schedule, default is every 5 minutes UNLOCKER_CRON_SCHEDULE='*/5 * * * *' +# BTCTimeLock cell unlock batch size +UNLOCKER_CELL_BATCH_SIZE=100 # BTCTimeLock cell unlocker monitor slug, used for monitoring unlocker status on sentry UNLOCKER_MONITOR_SLUG=btctimelock-cells-unlocker # RGB++ CKB transaction Queue cron job delay in milliseconds # the /rgbpp/v1/transaction/ckb-tx endpoint is called, the transaction will be added to the queue TRANSACTION_QUEUE_JOB_DELAY=12000 +# RGB++ CKB transaction Queue cron job attempts +TRANSACTION_QUEUE_JOB_ATTEMPTS=6 +# Pay fee for transaction with pool reject by min fee rate, false by default +TRANSACTION_PAY_FOR_MIN_FEE_RATE_REJECT=false ``` More configuration options can be found in the `src/env.ts` file. From df1694a976f721d440eedaf86a8692bb28fe22e4 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 30 Apr 2024 12:19:14 +1000 Subject: [PATCH 33/53] docs: update TRANSACTION_QUEUE_JOB_DELAY --- .env.example | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e3134c5c..167bcda2 100644 --- a/.env.example +++ b/.env.example @@ -71,7 +71,7 @@ UNLOCKER_MONITOR_SLUG=btctimelock-cells-unlocker # RGB++ CKB transaction Queue cron job delay in milliseconds # the /rgbpp/v1/transaction/ckb-tx endpoint is called, the transaction will be added to the queue -TRANSACTION_QUEUE_JOB_DELAY=12000 +TRANSACTION_QUEUE_JOB_DELAY=120000 # RGB++ CKB transaction Queue cron job attempts TRANSACTION_QUEUE_JOB_ATTEMPTS=6 # Pay fee for transaction with pool reject by min fee rate, false by default diff --git a/README.md b/README.md index 879234c5..df128b63 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ UNLOCKER_MONITOR_SLUG=btctimelock-cells-unlocker # RGB++ CKB transaction Queue cron job delay in milliseconds # the /rgbpp/v1/transaction/ckb-tx endpoint is called, the transaction will be added to the queue -TRANSACTION_QUEUE_JOB_DELAY=12000 +TRANSACTION_QUEUE_JOB_DELAY=120000 # RGB++ CKB transaction Queue cron job attempts TRANSACTION_QUEUE_JOB_ATTEMPTS=6 # Pay fee for transaction with pool reject by min fee rate, false by default From d5fb5f204e5adfba49585665e089f176a127bd26 Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 2 May 2024 11:11:00 +1000 Subject: [PATCH 34/53] feat(unlock): add spore BTCTimeLock cells unlock support --- package.json | 6 ++-- pnpm-lock.yaml | 52 ++++++++-------------------- src/services/unlocker.ts | 75 +++++++++++++++++++++++++++++----------- 3 files changed, 72 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index f86b2f45..ff2ac518 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ "@fastify/swagger-ui": "^3.0.0", "@immobiliarelabs/fastify-sentry": "^8.0.1", "@nervosnetwork/ckb-sdk-utils": "^0.109.1", - "@rgbpp-sdk/btc": "0.0.0-snap-20240423144119", - "@rgbpp-sdk/ckb": "0.0.0-snap-20240423144119", - "@rgbpp-sdk/service": "0.0.0-snap-20240423144119", + "@rgbpp-sdk/btc": "0.0.0-snap-20240430102443", + "@rgbpp-sdk/ckb": "0.0.0-snap-20240430102443", + "@rgbpp-sdk/service": "0.0.0-snap-20240430102443", "@sentry/node": "^7.102.1", "@sentry/profiling-node": "^7.102.1", "awilix": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7262e54..35cba1f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,14 +54,14 @@ dependencies: specifier: ^0.109.1 version: 0.109.1 '@rgbpp-sdk/btc': - specifier: 0.0.0-snap-20240423144119 - version: 0.0.0-snap-20240423144119(@ckb-lumos/lumos@0.22.2) + specifier: 0.0.0-snap-20240430102443 + version: 0.0.0-snap-20240430102443(@ckb-lumos/lumos@0.22.2) '@rgbpp-sdk/ckb': - specifier: 0.0.0-snap-20240423144119 - version: 0.0.0-snap-20240423144119(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21) + specifier: 0.0.0-snap-20240430102443 + version: 0.0.0-snap-20240430102443(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21) '@rgbpp-sdk/service': - specifier: 0.0.0-snap-20240423144119 - version: 0.0.0-snap-20240423144119 + specifier: 0.0.0-snap-20240430102443 + version: 0.0.0-snap-20240430102443 '@sentry/node': specifier: ^7.102.1 version: 7.102.1 @@ -1140,17 +1140,6 @@ packages: - supports-color dev: true - /@mempool/mempool.js@2.3.0: - resolution: {integrity: sha512-FrN9WjZCEyyLodrTPQxmlWDh8B/UGK0jlKfVNzJxqzQ1IMPo/Hpdws8xwYEcsks5JqsaxbjLwaC3GAtJ6Brd0A==} - dependencies: - axios: 0.24.0 - ws: 8.3.0 - transitivePeerDependencies: - - bufferutil - - debug - - utf-8-validate - dev: false - /@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2: resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==} cpu: [arm64] @@ -1271,28 +1260,25 @@ packages: resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==} dev: true - /@rgbpp-sdk/btc@0.0.0-snap-20240423144119(@ckb-lumos/lumos@0.22.2): - resolution: {integrity: sha512-8m8LMg0zROX06gqLBP4Q3UGKkWB1zAd7jvvtbw+DOne3xZSWpswC94f4lK5/NGkVb7tjtDRa6y0/6LABkNgX6A==} + /@rgbpp-sdk/btc@0.0.0-snap-20240430102443(@ckb-lumos/lumos@0.22.2): + resolution: {integrity: sha512-gUscYKTdTc/MonrLL6YlOlabZUgiQ29f6jIAh6LYY52qkAH48PKpJ+HwYr8yLvBdU/JMhijeRaj6PwXG/19AEQ==} dependencies: '@bitcoinerlab/secp256k1': 1.1.1 '@ckb-lumos/codec': 0.22.2 - '@mempool/mempool.js': 2.3.0 '@nervosnetwork/ckb-types': 0.109.1 - '@rgbpp-sdk/ckb': 0.0.0-snap-20240423144119(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21) - '@rgbpp-sdk/service': 0.0.0-snap-20240423144119 + '@rgbpp-sdk/ckb': 0.0.0-snap-20240430102443(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21) + '@rgbpp-sdk/service': 0.0.0-snap-20240430102443 bip32: 4.0.0 bitcoinjs-lib: 6.1.5 ecpair: 2.1.0 lodash: 4.17.21 transitivePeerDependencies: - '@ckb-lumos/lumos' - - bufferutil - debug - - utf-8-validate dev: false - /@rgbpp-sdk/ckb@0.0.0-snap-20240423144119(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21): - resolution: {integrity: sha512-x5lJPQIa93/+eQPjRL8Yca654pThTrKrmWnUisqVLYOHytOxW8o++TDe985ihkChfzEZcCarZ/Cinc53zK0nkg==} + /@rgbpp-sdk/ckb@0.0.0-snap-20240430102443(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21): + resolution: {integrity: sha512-bAeOk1NlZmbuKiT+3DKwrMbtawtB8FiHnFGsIps4/DAWd0CFi5MThQ19bJXa/CL6RKYsPoyI3RWfm3BtHdQ9FQ==} dependencies: '@ckb-lumos/base': 0.22.2 '@ckb-lumos/codec': 0.22.2 @@ -1300,7 +1286,7 @@ packages: '@nervosnetwork/ckb-sdk-core': 0.109.1 '@nervosnetwork/ckb-sdk-utils': 0.109.1 '@nervosnetwork/ckb-types': 0.109.1 - '@rgbpp-sdk/service': 0.0.0-snap-20240423144119 + '@rgbpp-sdk/service': 0.0.0-snap-20240430102443 '@spore-sdk/core': 0.2.0-beta.8(@ckb-lumos/lumos@0.22.2)(lodash@4.17.21) axios: 1.6.8 camelcase-keys: 7.0.2 @@ -1311,8 +1297,8 @@ packages: - lodash dev: false - /@rgbpp-sdk/service@0.0.0-snap-20240423144119: - resolution: {integrity: sha512-UNwABcpRvFk+2bWdyhVKrAatgqVTRCcekEQzV8kRWPZhR7Yi9oeR+aJ4iP9/vC3ibPIlxLHxcO7k85HNi7JTcA==} + /@rgbpp-sdk/service@0.0.0-snap-20240430102443: + resolution: {integrity: sha512-9lWF246i7LVYHjVhbeyWLldzKTN6e7kgsIMBBCOVcMsZ8NvDFoosRabtdKLdD6Vh3SS+As2aC0oyL22uqG3iAA==} dependencies: '@ckb-lumos/base': 0.22.2 '@ckb-lumos/codec': 0.22.2 @@ -2255,14 +2241,6 @@ packages: fast-glob: 3.3.2 dev: false - /axios@0.24.0: - resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} - dependencies: - follow-redirects: 1.15.6 - transitivePeerDependencies: - - debug - dev: false - /axios@1.6.7: resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==} dependencies: diff --git a/src/services/unlocker.ts b/src/services/unlocker.ts index 68df0d77..493868fb 100644 --- a/src/services/unlocker.ts +++ b/src/services/unlocker.ts @@ -5,9 +5,11 @@ import { Collector, IndexerCell, buildBtcTimeCellsSpentTx, + buildSporeBtcTimeCellsSpentTx, getBtcTimeLockScript, isClusterSporeTypeSupported, isTypeAssetSupported, + isUDTTypeSupported, remove0x, sendCkbTx, signBtcTimeCellSpentTx, @@ -18,7 +20,7 @@ import { Cradle } from '../container'; interface IUnlocker { getNextBatchLockCell(): Promise; - unlockCells(): Promise; + unlockCells(): Promise; } /** @@ -61,11 +63,6 @@ export default class Unlocker implements IUnlocker { continue; } - // temporary skip cluster spore type - if (isClusterSporeTypeSupported(cell.cellOutput.type, this.isMainnet)) { - continue; - } - const btcTxid = remove0x(btcTxIdFromBtcTimeLockArgs(cell.cellOutput.lock.args)); const { after } = BTCTimeLock.unpack(cell.cellOutput.lock.args); const btcTx = await this.cradle.bitcoin.getTx({ txid: btcTxid }); @@ -102,29 +99,50 @@ export default class Unlocker implements IUnlocker { } /** - * Unlock the BTC time lock cells and send the CKB transaction + * Build CKB transaction to spend the BTC time lock cells + * @param cells - BTC time lock cells */ - public async unlockCells() { - const cells = await this.getNextBatchLockCell(); - if (cells.length === 0) { - return; + private async buildSpentTxs(cells: IndexerCell[]): Promise { + const btcAssetsApi = { + getRgbppSpvProof: this.cradle.spv.getTxProof.bind(this.cradle.spv), + } as unknown as BtcAssetsApi; + + const ckbRawTxs = []; + + // udt type cells unlock + const udtTypeCells = cells.filter((cell) => isUDTTypeSupported(cell.output.type!, this.isMainnet)); + if (udtTypeCells.length > 0) { + const ckbRawTx = await buildBtcTimeCellsSpentTx({ + btcTimeCells: udtTypeCells, + btcAssetsApi, + isMainnet: this.isMainnet, + }); + ckbRawTxs.push(ckbRawTx); } - this.cradle.logger.info(`[Unlocker] Unlock ${cells.length} BTC time lock cells`); + // spore type cells unlock + const sporeTypeCells = cells.filter((cell) => isClusterSporeTypeSupported(cell.output.type!, this.isMainnet)); + if (sporeTypeCells.length > 0) { + const ckbRawTx = await buildSporeBtcTimeCellsSpentTx({ + btcTimeCells: sporeTypeCells, + btcAssetsApi, + isMainnet: this.isMainnet, + }); + ckbRawTxs.push(ckbRawTx); + } + return ckbRawTxs; + } + + /** + * Sign and send the CKB transaction to unlock the BTC time lock cells + * @param ckbRawTx - CKB raw transaction + */ + private async sendUnlockTx(ckbRawTx: CKBComponents.RawTransaction) { const collector = new Collector({ ckbNodeUrl: this.cradle.env.CKB_RPC_URL, ckbIndexerUrl: this.cradle.env.CKB_RPC_URL, }); - const btcAssetsApi = { - getRgbppSpvProof: this.cradle.spv.getTxProof.bind(this.cradle.spv), - } as unknown as BtcAssetsApi; - const ckbRawTx = await buildBtcTimeCellsSpentTx({ - btcTimeCells: cells, - btcAssetsApi, - isMainnet: this.isMainnet, - }); - const outputCapacityRange = [ BI.from(1).toHexString(), BI.from(this.cradle.env.PAYMASTER_CELL_CAPACITY).toHexString(), @@ -146,4 +164,19 @@ export default class Unlocker implements IUnlocker { this.cradle.logger.info(`[Unlocker] Transaction sent: ${txHash}`); return txHash; } + + /** + * Unlock the BTC time lock cells and send the CKB transaction + */ + public async unlockCells() { + const cells = await this.getNextBatchLockCell(); + if (cells.length === 0) { + return []; + } + this.cradle.logger.info(`[Unlocker] Unlock ${cells.length} BTC time lock cells`); + + const ckbRawTxs = await this.buildSpentTxs(cells); + const txhashs = await Promise.all(ckbRawTxs.map(async (ckbRawTx) => this.sendUnlockTx(ckbRawTx))); + return txhashs; + } } From 1cf58ea3ac6e381d3f67c3b3be448d8c818a4c74 Mon Sep 17 00:00:00 2001 From: ahonn Date: Thu, 2 May 2024 11:13:45 +1000 Subject: [PATCH 35/53] fix: upgrade generateSporeTransferCoBuild --- src/services/transaction.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 9cf2e6ea..c5bbf20d 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -350,8 +350,10 @@ export default class TransactionProcessor implements ITransactionProcessor { return cell?.output.type && isClusterSporeTypeSupported(cell?.output.type, this.isMainnet); }); if (sporeLiveCell?.cell) { - const [output] = signedTx.outputs; - signedTx.witnesses[signedTx.witnesses.length - 1] = generateSporeTransferCoBuild(sporeLiveCell.cell, output); + signedTx.witnesses[signedTx.witnesses.length - 1] = generateSporeTransferCoBuild( + [sporeLiveCell.cell], + signedTx.outputs, + ); } return signedTx; } From 54908d4e05bdcc35743c2a742c9de7339e0b0972 Mon Sep 17 00:00:00 2001 From: ahonn Date: Fri, 3 May 2024 19:26:25 +1000 Subject: [PATCH 36/53] feat(bitcoin): add /bitcoin/v1/transactiona/:btc_txid/hex route --- src/routes/bitcoin/transaction.ts | 24 +++++++++ test/app.test.ts | 1 + .../__snapshots__/address.test.ts.snap | 10 ++++ .../__snapshots__/transaction.test.ts.snap | 54 +++++++++++++++++++ test/routes/bitcoind/transaction.test.ts | 22 ++++++++ 5 files changed, 111 insertions(+) diff --git a/src/routes/bitcoin/transaction.ts b/src/routes/bitcoin/transaction.ts index e30f6206..3e514238 100644 --- a/src/routes/bitcoin/transaction.ts +++ b/src/routes/bitcoin/transaction.ts @@ -54,6 +54,30 @@ const transactionRoutes: FastifyPluginCallback, Server, Zod return transaction; }, ); + + fastify.get( + '/:txid/hex', + { + schema: { + description: 'Get a transaction hex by its txid', + tags: ['Bitcoin'], + params: z.object({ + txid: z.string().describe('The Bitcoin transaction id'), + }), + response: { + 200: z.object({ + hex: z.string(), + }), + }, + }, + }, + async (request, reply) => { + const { txid } = request.params; + const hex = await fastify.bitcoin.getTxHex({ txid }); + reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); + return { hex }; + }, + ); done(); }; diff --git a/test/app.test.ts b/test/app.test.ts index 76ce45a4..a27bfa22 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -22,6 +22,7 @@ test('`/docs/json` - 200', async () => { '/bitcoin/v1/block/height/{height}', '/bitcoin/v1/transaction', '/bitcoin/v1/transaction/{txid}', + '/bitcoin/v1/transaction/{txid}/hex', '/bitcoin/v1/address/{address}/balance', '/bitcoin/v1/address/{address}/unspent', '/bitcoin/v1/address/{address}/txs', diff --git a/test/routes/bitcoind/__snapshots__/address.test.ts.snap b/test/routes/bitcoind/__snapshots__/address.test.ts.snap index d9ad6951..b0056eb5 100644 --- a/test/routes/bitcoind/__snapshots__/address.test.ts.snap +++ b/test/routes/bitcoind/__snapshots__/address.test.ts.snap @@ -182,3 +182,13 @@ exports[`/bitcoin/v1/address > Get address transactions 1`] = ` }, ] `; + +exports[`/bitcoin/v1/address > Get address unspent transaction outputs with only_confirmed = undefined 1`] = ` +{ + "address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "dust_satoshi": 1000, + "pending_satoshi": 0, + "satoshi": 0, + "utxo_count": 1, +} +`; diff --git a/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap b/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap index 79021174..9604b77f 100644 --- a/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap +++ b/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap @@ -1,5 +1,59 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Get not exists transaction 1`] = ` +{ + "fee": 141, + "locktime": 0, + "size": 223, + "status": { + "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", + "block_height": 2579043, + "block_time": 1708580004, + "confirmed": true, + }, + "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", + "version": 2, + "vin": [ + { + "is_coinbase": false, + "prevout": { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 181793, + }, + "scriptsig": "", + "scriptsig_asm": "", + "sequence": 4294967295, + "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", + "vout": 0, + "witness": [ + "3045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d111101", + "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", + ], + }, + ], + "vout": [ + { + "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_type": "v0_p2wpkh", + "value": 1000, + }, + { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 180652, + }, + ], + "weight": 562, +} +`; + exports[`Get transaction by txid 1`] = ` { "fee": 141, diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts index 68413ed3..04812be9 100644 --- a/test/routes/bitcoind/transaction.test.ts +++ b/test/routes/bitcoind/transaction.test.ts @@ -65,6 +65,28 @@ describe('/bitcoin/v1/transaction', () => { await fastify.close(); }); + test('Get transaction hex', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc/hex', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = await response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toEqual({ + hex: '020000000001013115e849359a46737e7bf3e121a5a5de666a1ad01933e3df7dc48f97199f18d00000000000ffffffff02e803000000000000160014dd72437bde53e22df65feb845a1dd35784f3e66cacc1020000000000160014f8d0addc86183d385061ee80c1b16b1975eacf4202483045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d1111012102d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b0831300000000', + }); + + await fastify.close(); + }); + test('Send exists raw transaction', async () => { const fastify = buildFastify(); await fastify.ready(); From f1e116787686774d278cd37f20c829723b72e50b Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 13:04:50 +1000 Subject: [PATCH 37/53] fix: pass all spore cells to generateSporeTransferCoBuild when transfer spores --- src/services/transaction.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/services/transaction.ts b/src/services/transaction.ts index c5bbf20d..7a47733a 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -346,12 +346,18 @@ export default class TransactionProcessor implements ITransactionProcessor { return this.cradle.ckb.rpc.getLiveCell(input.previousOutput!, false); }), ); - const sporeLiveCell = inputs.find(({ cell }) => { - return cell?.output.type && isClusterSporeTypeSupported(cell?.output.type, this.isMainnet); - }); - if (sporeLiveCell?.cell) { + const sporeLiveCells = inputs + .filter(({ status, cell }) => { + return ( + status === CKBComponents.CellStatus.Live && + cell?.output.type && + isClusterSporeTypeSupported(cell?.output.type, this.isMainnet) + ); + }) + .map((liveCell) => liveCell.cell!); + if (sporeLiveCells.length > 0) { signedTx.witnesses[signedTx.witnesses.length - 1] = generateSporeTransferCoBuild( - [sporeLiveCell.cell], + sporeLiveCells, signedTx.outputs, ); } From 0151627c3cb2c85863117702dacc1dda08c059a7 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 13:26:59 +1000 Subject: [PATCH 38/53] fix: rename test/route/bitcoind to test/route/bitcoin --- .../__snapshots__/address.test.ts.snap | 184 ++++++++++++++ .../bitcoin/__snapshots__/block.test.ts.snap | 31 +++ .../__snapshots__/transaction.test.ts.snap | 55 ++++ test/routes/bitcoin/address.test.ts | 236 ++++++++++++++++++ test/routes/bitcoin/block.test.ts | 84 +++++++ test/routes/bitcoin/info.test.ts | 49 ++++ test/routes/bitcoin/transaction.test.ts | 110 ++++++++ vitest.config.ts | 1 + 8 files changed, 750 insertions(+) create mode 100644 test/routes/bitcoin/__snapshots__/address.test.ts.snap create mode 100644 test/routes/bitcoin/__snapshots__/block.test.ts.snap create mode 100644 test/routes/bitcoin/__snapshots__/transaction.test.ts.snap create mode 100644 test/routes/bitcoin/address.test.ts create mode 100644 test/routes/bitcoin/block.test.ts create mode 100644 test/routes/bitcoin/info.test.ts create mode 100644 test/routes/bitcoin/transaction.test.ts diff --git a/test/routes/bitcoin/__snapshots__/address.test.ts.snap b/test/routes/bitcoin/__snapshots__/address.test.ts.snap new file mode 100644 index 00000000..d9ad6951 --- /dev/null +++ b/test/routes/bitcoin/__snapshots__/address.test.ts.snap @@ -0,0 +1,184 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`/bitcoin/v1/address > Get address balance 1`] = ` +{ + "address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "dust_satoshi": 0, + "pending_satoshi": 0, + "satoshi": 1000, + "utxo_count": 1, +} +`; + +exports[`/bitcoin/v1/address > Get address balance with min_satoshi param 1`] = ` +{ + "address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "dust_satoshi": 1000, + "pending_satoshi": 0, + "satoshi": 0, + "utxo_count": 1, +} +`; + +exports[`/bitcoin/v1/address > Get address transactions 1`] = ` +[ + { + "fee": 679, + "locktime": 0, + "size": 340, + "status": { + "block_hash": "0000000000960b9bc86db1c241e3b22690ae9e0360a960141f1923e87eb53ea8", + "block_height": 2585607, + "block_time": 1712556136, + "confirmed": true, + }, + "txid": "b7a7863485661d8e03fedcc39bbf4de25d703e34e0c065f2eec8e85706d93eb7", + "version": 2, + "vin": [ + { + "is_coinbase": false, + "prevout": { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 890, + }, + "scriptsig": "", + "scriptsig_asm": "", + "sequence": 4294967295, + "txid": "b91c5bfddea79135de6a23de329b31c2696c673661ef90970e5c247ccf8bb7b1", + "vout": 0, + "witness": [ + "304402201c38168040ecb9fd312a18a2770ec303195c2d56c735fbfce5d7081fc33e7d6f02201f8bdaa14c424900f8d52872b10f77628fb30b104c04aa89b466610c9253a3f201", + "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", + ], + }, + { + "is_coinbase": false, + "prevout": { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 789, + }, + "scriptsig": "", + "scriptsig_asm": "", + "sequence": 4294967295, + "txid": "03c4e6b25192823fec378a553b063e07ff20d526eb223352279a4be004f95cca", + "vout": 1, + "witness": [ + "3045022100cef667e1370b7e73a01f3b59c7f7f402932da59ba173f1ecb705ad4e40f986fb0220572e96ba212077e1076a253514e9308db11164fc1b16d0ca162209442f25aef101", + "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", + ], + }, + ], + "vout": [ + { + "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_type": "v0_p2wpkh", + "value": 1000, + }, + ], + "weight": 709, + }, + { + "fee": 110, + "locktime": 0, + "size": 192, + "status": { + "block_hash": "00000000000000037864097e5c7beff520ee329289b1623f7c521132e5268a66", + "block_height": 2585601, + "block_time": 1712551024, + "confirmed": true, + }, + "txid": "b91c5bfddea79135de6a23de329b31c2696c673661ef90970e5c247ccf8bb7b1", + "version": 2, + "vin": [ + { + "is_coinbase": false, + "prevout": { + "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_type": "v0_p2wpkh", + "value": 1000, + }, + "scriptsig": "", + "scriptsig_asm": "", + "sequence": 4294967295, + "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", + "vout": 0, + "witness": [ + "3045022100f6373e6ce8e3206b9f1254e8322dbbd0c5f4990f214997fef1e4bb6cb535a00602205eb0665a34fb7e9b3789cb55e45d05b1f0daca9e53f5e3a1393cab44725caa0801", + "020b4e7dba2f094598f41e40f2eedbe274293f4248e6cac4672322dbb0b510d38f", + ], + }, + ], + "vout": [ + { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 890, + }, + ], + "weight": 438, + }, + { + "fee": 141, + "locktime": 0, + "size": 223, + "status": { + "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", + "block_height": 2579043, + "block_time": 1708580004, + "confirmed": true, + }, + "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", + "version": 2, + "vin": [ + { + "is_coinbase": false, + "prevout": { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 181793, + }, + "scriptsig": "", + "scriptsig_asm": "", + "sequence": 4294967295, + "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", + "vout": 0, + "witness": [ + "3045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d111101", + "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", + ], + }, + ], + "vout": [ + { + "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_type": "v0_p2wpkh", + "value": 1000, + }, + { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 180652, + }, + ], + "weight": 562, + }, +] +`; diff --git a/test/routes/bitcoin/__snapshots__/block.test.ts.snap b/test/routes/bitcoin/__snapshots__/block.test.ts.snap new file mode 100644 index 00000000..4e4d1c70 --- /dev/null +++ b/test/routes/bitcoin/__snapshots__/block.test.ts.snap @@ -0,0 +1,31 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`/bitcoin/v1/block > Get block by hash 1`] = ` +{ + "bits": 436273151, + "difficulty": 16777216, + "height": 2091140, + "id": "000000000000009c08dc77c3f224d9f5bbe335a78b996ec1e0701e065537ca81", + "mediantime": 1630621997, + "merkle_root": "5d10d8d158bb8eb217d01fecc435bd10eda028043a913dc2bfe0ccf536a51cc9", + "nonce": 1600805744, + "previousblockhash": "0000000000000073f95d1fc0a93d449f82a754410c635e46264ec6c7c4d5741e", + "size": 575, + "timestamp": 1630625150, + "tx_count": 2, + "version": 543162372, + "weight": 1865, +} +`; + +exports[`/bitcoin/v1/block > Get block hash by height 1`] = ` +{ + "hash": "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", +} +`; + +exports[`/bitcoin/v1/block > Get block header by hash 1`] = ` +{ + "header": "0000202000af62594f79b6390c6ee3de56aad6658d35a9481cd8bb0ce047523800000000652cad111d076bf8aa3417670781154fd79533fb91fe782d70d300e9e95b4c5b6d60dd6518fe27190343494f", +} +`; diff --git a/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap b/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap new file mode 100644 index 00000000..79021174 --- /dev/null +++ b/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Get transaction by txid 1`] = ` +{ + "fee": 141, + "locktime": 0, + "size": 223, + "status": { + "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", + "block_height": 2579043, + "block_time": 1708580004, + "confirmed": true, + }, + "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", + "version": 2, + "vin": [ + { + "is_coinbase": false, + "prevout": { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 181793, + }, + "scriptsig": "", + "scriptsig_asm": "", + "sequence": 4294967295, + "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", + "vout": 0, + "witness": [ + "3045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d111101", + "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", + ], + }, + ], + "vout": [ + { + "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", + "scriptpubkey_type": "v0_p2wpkh", + "value": 1000, + }, + { + "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", + "scriptpubkey_type": "v0_p2wpkh", + "value": 180652, + }, + ], + "weight": 562, +} +`; diff --git a/test/routes/bitcoin/address.test.ts b/test/routes/bitcoin/address.test.ts new file mode 100644 index 00000000..3cdf3eea --- /dev/null +++ b/test/routes/bitcoin/address.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test, beforeEach, vi } from 'vitest'; +import { buildFastify } from '../../../src/app'; +import { afterEach } from 'node:test'; +import BitcoinClient from '../../../src/services/bitcoin'; + +let token: string; + +describe('/bitcoin/v1/address', () => { + beforeEach(async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'POST', + url: '/token/generate', + payload: { + app: 'test', + domain: 'test.com', + }, + }); + const data = response.json(); + token = data.token; + + await fastify.close(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test('Get address balance', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/balance', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); + + test('Get address balance with min_satoshi param', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/balance?min_satoshi=10000', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); + + test('Get address balance with invalid address', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0try/balance', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(400); + expect(data.message).toBe('Invalid bitcoin address'); + + await fastify.close(); + }); + + test('Get address unspent transaction outputs', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); + const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; + vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ + { + txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + vout: 0, + status: { + confirmed: true, + }, + value: 100000, + }, + { + txid: '1706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + vout: 0, + status: { + confirmed: false, + }, + value: 100000, + }, + ]); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; + + expect(response.statusCode).toBe(200); + expect(data.length).toBe(1); + + await fastify.close(); + }); + + test('Get address unspent transaction outputs with unconfirmed', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); + const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; + vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ + { + txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + vout: 0, + status: { + confirmed: true, + }, + value: 100000, + }, + { + txid: '1706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + vout: 0, + status: { + confirmed: false, + }, + value: 100000, + }, + ]); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent?only_confirmed=false', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; + + expect(response.statusCode).toBe(200); + expect(data.length).toBe(2); + + await fastify.close(); + }); + + test('Get address unspent transaction outputs with only_confirmed = undefined', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); + const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; + vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ + { + txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + vout: 0, + status: { + confirmed: true, + }, + value: 100000, + }, + { + txid: '1706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + vout: 0, + status: { + confirmed: false, + }, + value: 100000, + }, + ]); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent?only_confirmed=undefined', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; + + expect(response.statusCode).toBe(200); + expect(data.length).toBe(1); + + await fastify.close(); + }); + + test('Get address transactions', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/txs', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); +}); diff --git a/test/routes/bitcoin/block.test.ts b/test/routes/bitcoin/block.test.ts new file mode 100644 index 00000000..e7759be0 --- /dev/null +++ b/test/routes/bitcoin/block.test.ts @@ -0,0 +1,84 @@ +import { describe, beforeEach, expect, test } from 'vitest'; +import { buildFastify } from '../../../src/app'; + +describe('/bitcoin/v1/block', () => { + let token: string; + + beforeEach(async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'POST', + url: '/token/generate', + payload: { + app: 'test', + domain: 'test.com', + }, + }); + const data = response.json(); + token = data.token; + + await fastify.close(); + }); + + test('Get block by hash', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/block/000000000000009c08dc77c3f224d9f5bbe335a78b996ec1e0701e065537ca81', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); + + test('Get block header by hash', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/block/0000000000000005ae0b929ee3afbf2956aaa0059f9d7608dc396cf5f8f4dda6/header', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); + + test('Get block hash by height', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/block/height/0', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); +}); diff --git a/test/routes/bitcoin/info.test.ts b/test/routes/bitcoin/info.test.ts new file mode 100644 index 00000000..1a87f59e --- /dev/null +++ b/test/routes/bitcoin/info.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, expect, test } from 'vitest'; +import { buildFastify } from '../../../src/app'; +import { describe } from 'node:test'; + +let token: string; + +describe('/bitcoin/v1/info', () => { + beforeEach(async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'POST', + url: '/token/generate', + payload: { + app: 'test', + domain: 'test.com', + }, + }); + const data = response.json(); + token = data.token; + + await fastify.close(); + }); + + test('Get blockchain info', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/info', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toHaveProperty('bestblockhash'); + expect(data).toHaveProperty('blocks'); + expect(data).toHaveProperty('chain'); + expect(data).toHaveProperty('difficulty'); + expect(data).toHaveProperty('mediantime'); + + await fastify.close(); + }); +}); diff --git a/test/routes/bitcoin/transaction.test.ts b/test/routes/bitcoin/transaction.test.ts new file mode 100644 index 00000000..04812be9 --- /dev/null +++ b/test/routes/bitcoin/transaction.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, expect, test } from 'vitest'; +import { buildFastify } from '../../../src/app'; +import { describe } from 'node:test'; + +let token: string; + +describe('/bitcoin/v1/transaction', () => { + beforeEach(async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'POST', + url: '/token/generate', + payload: { + app: 'test', + domain: 'test.com', + }, + }); + const data = response.json(); + token = data.token; + + await fastify.close(); + }); + + test('Get transaction by txid', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toMatchSnapshot(); + + await fastify.close(); + }); + + test('Get not exists transaction', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babf1', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = await response.json(); + + expect(response.statusCode).toBe(404); + expect(data).toEqual({ + message: 'Request failed with status code 404', + }); + + await fastify.close(); + }); + + test('Get transaction hex', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'GET', + url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc/hex', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + }); + const data = await response.json(); + + expect(response.statusCode).toBe(200); + expect(data).toEqual({ + hex: '020000000001013115e849359a46737e7bf3e121a5a5de666a1ad01933e3df7dc48f97199f18d00000000000ffffffff02e803000000000000160014dd72437bde53e22df65feb845a1dd35784f3e66cacc1020000000000160014f8d0addc86183d385061ee80c1b16b1975eacf4202483045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d1111012102d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b0831300000000', + }); + + await fastify.close(); + }); + + test('Send exists raw transaction', async () => { + const fastify = buildFastify(); + await fastify.ready(); + + const response = await fastify.inject({ + method: 'POST', + url: '/bitcoin/v1/transaction', + headers: { + Authorization: `Bearer ${token}`, + Origin: 'https://test.com', + }, + body: { + txhex: + '02000000000101fe7b9cd0f75741e2ec1e3a6142eab945e64fab0ef15de4a66c635c0a789e986f0100000000ffffffff02e803000000000000160014dbf4360c0791098b0b14679e5e78015df3f2caad6a88000000000000160014dbf4360c0791098b0b14679e5e78015df3f2caad02473044022065829878f51581488f44c37064b46f552ea7354196fae5536906797b76b370bf02201c459081578dc4e1098fbe3ab68d7d56a99e8e9810bf2806d10053d6b36ffa4d0121037dff8ff2e0bd222690d785f9277e0c4800fc88b0fad522f1442f21a8226253ce00000000', + }, + }); + + expect(response.statusCode).toBe(400); + await fastify.close(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 819bd3e2..92f62083 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ coverage: { provider: 'istanbul', }, + testTimeout: 30000, }, }); From d4a64935039b00e5ae60d2f26dfd4c331caddf14 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 13:27:31 +1000 Subject: [PATCH 39/53] test: add testTimeout --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 819bd3e2..92f62083 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ coverage: { provider: 'istanbul', }, + testTimeout: 30000, }, }); From f1d444a6e808d30fa7a6823fce6077235c43b90c Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 13:31:43 +1000 Subject: [PATCH 40/53] ci: remove concurrency to avoid action cancel --- .github/workflows/test.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8e2f2f31..bc7eb114 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,7 +15,6 @@ on: jobs: test: runs-on: ubuntu-latest - concurrency: test-group strategy: matrix: BITCOIN_DATA_PROVIDER: [mempool, electrs] From 7ce2febc617ce5bd0c50dfc6cbeb3ab1613b74ee Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 13:53:09 +1000 Subject: [PATCH 41/53] fix(env): set BITCOIN_DATA_PROVIDER=electrs by default --- src/env.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/env.ts b/src/env.ts index 1f83e434..733d108a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -166,7 +166,7 @@ const envSchema = z * use mempool.space as default, electrs as fallback * change to electrs if you want to use electrs as default and mempool.space as fallback */ - BITCOIN_DATA_PROVIDER: z.literal('mempool').default('mempool'), + BITCOIN_DATA_PROVIDER: z.literal('mempool'), }), z.object({ /** @@ -180,7 +180,7 @@ const envSchema = z * used for fallback when the electrs API is not available. */ BITCOIN_MEMPOOL_SPACE_API_URL: z.string().optional(), - BITCOIN_DATA_PROVIDER: z.literal('electrs'), + BITCOIN_DATA_PROVIDER: z.literal('electrs').default('electrs'), }), ]), ); From af55c72f4ccc456e4567748c56a9dc04c96c2a21 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 14:33:21 +1000 Subject: [PATCH 42/53] docs: add upgrading to v2 docs --- docs/upgrading-to-v2.md | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/upgrading-to-v2.md diff --git a/docs/upgrading-to-v2.md b/docs/upgrading-to-v2.md new file mode 100644 index 00000000..8b10ca94 --- /dev/null +++ b/docs/upgrading-to-v2.md @@ -0,0 +1,45 @@ +## Upgrading to V2 + +We have released v2.0.0 for support mempool.space API as the primary Bitcoin data provider, and it was used to broadcast transactions. But it is still compatible with the electrs used previously. + +There are two ways to upgrade: + +### Upgrading from V1.x.x and still use electrs (**compatible**) +Suppose you do not want to use the mempool.space API as the main data provider, **you do not need to make any changes**. + +But we recommend you remove the following env vars for safety: + +```env +BITCOIN_JSON_RPC_URL= +BITCOIN_JSON_RPC_USERNAME= +BITCOIN_JSON_RPC_PASSWORD= +``` + +and add the following env vars to make sure to use electrs as the primary data provider, and add mempool.space API as a fallback: + +```env +BITCOIN_DATA_PROVIDER=electrs # recommend, electrs by default +BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space # optional, mempool.space as the fallback +``` + +### Upgrading from V1.x.x and using mempool.space API by default +The new feature in v2.0.0, we can use mempool.space API as the primary data provider, and use electrs as a fallback. + +Add the following env vars: + +```env +BITCOIN_DATA_PROVIDER=mempool +BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space +``` + +If you want to use the previous electrs as a fallback, keep the original `BITCOIN_ELECTRS_API_URL` env var. Otherwise, remove this var to avoid using electrs. + +```env +BITCOIN_ELECTRS_API_URL= # optional, electrs as fallback +``` + +#### Recommended Fees API +If use mempool.space API as the primary data provider, then we can use `/bitcoin/v1/fees/recommended` to get the bitcoin fees. and we will calculate fees when mempool.space recommend fees API unavailable (see https://github.com/ckb-cell/btc-assets-api/pull/114). + +**use electrs as the primary data provider and didn't set `BITCOIN_MEMPOOL_SPACE_API_URL` as a fallback, then recommended fees API will be unavailable** + From 057f6871af5ed765eed5cab314e8aa6714379756 Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 14:33:46 +1000 Subject: [PATCH 43/53] bump: v2.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ff2ac518..cad40c7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "btc-assets-api", - "version": "1.5.0", + "version": "2.0.0", "title": "Bitcoin/RGB++ Assets API", "description": "", "main": "index.js", From fbeea7b6bbbbeec7a142323d67718e4231c9050b Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 15:07:00 +1000 Subject: [PATCH 44/53] feat: post bitcoin transaction to multi electrs nodes --- src/env.ts | 7 +++++++ src/services/bitcoin/electrs.ts | 7 +++---- src/services/bitcoin/index.ts | 20 +++++++++++++++----- src/services/bitcoin/mempool.ts | 6 +++--- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/env.ts b/src/env.ts index 1f83e434..a78028bc 100644 --- a/src/env.ts +++ b/src/env.ts @@ -74,6 +74,13 @@ const envSchema = z * https://github.com/ckb-cell/ckb-bitcoin-spv-service */ BITCOIN_SPV_SERVICE_URL: z.string(), + + /** + * Bitcoin additional broadcast electrs URL list + * broadcast transaction to multiple electrs API when receive bitcoin transaction from users + */ + BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST: z.string().transform((value) => value.split(',')), + /** * The URL of the CKB JSON-RPC server. */ diff --git a/src/services/bitcoin/electrs.ts b/src/services/bitcoin/electrs.ts index 0f5b17cc..7cc59c02 100644 --- a/src/services/bitcoin/electrs.ts +++ b/src/services/bitcoin/electrs.ts @@ -1,17 +1,16 @@ import axios, { AxiosInstance } from 'axios'; -import { Cradle } from '../../container'; import { IBitcoinDataProvider } from './interface'; import { Block, RecommendedFees, Transaction, UTXO } from './schema'; export class ElectrsClient implements IBitcoinDataProvider { private request: AxiosInstance; - constructor(cradle: Cradle) { - if (!cradle.env.BITCOIN_ELECTRS_API_URL) { + constructor(baseURL: string) { + if (!baseURL) { throw new Error('BITCOIN_ELECTRS_API_URL is required'); } this.request = axios.create({ - baseURL: cradle.env.BITCOIN_ELECTRS_API_URL, + baseURL, }); } diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index 3e3ebfa8..1d627c72 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -54,6 +54,7 @@ export default class BitcoinClient implements IBitcoinClient { private cradle: Cradle; private source: IBitcoinDataProvider; private fallback?: IBitcoinDataProvider; + private backups: IBitcoinDataProvider[] = []; constructor(cradle: Cradle) { this.cradle = cradle; @@ -62,23 +63,30 @@ export default class BitcoinClient implements IBitcoinClient { switch (env.BITCOIN_DATA_PROVIDER) { case 'mempool': this.cradle.logger.info('Using Mempool.space API as the bitcoin data provider'); - this.source = new MempoolClient(cradle); + this.source = new MempoolClient(env.BITCOIN_MEMPOOL_SPACE_API_URL, cradle); if (env.BITCOIN_ELECTRS_API_URL) { this.cradle.logger.info('Using Electrs API as the fallback bitcoin data provider'); - this.fallback = new ElectrsClient(cradle); + this.fallback = new ElectrsClient(env.BITCOIN_ELECTRS_API_URL); } break; case 'electrs': this.cradle.logger.info('Using Electrs API as the bitcoin data provider'); - this.source = new ElectrsClient(cradle); + this.source = new ElectrsClient(env.BITCOIN_ELECTRS_API_URL); if (env.BITCOIN_MEMPOOL_SPACE_API_URL) { this.cradle.logger.info('Using Mempool.space API as the fallback bitcoin data provider'); - this.fallback = new MempoolClient(cradle); + this.fallback = new MempoolClient(env.BITCOIN_MEMPOOL_SPACE_API_URL, cradle); } break; default: throw new Error('Invalid bitcoin data provider'); } + + if (this.fallback) { + this.backups.push(this.fallback); + } + if (env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST) { + this.backups = env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.map((url) => new ElectrsClient(url)); + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -152,7 +160,9 @@ export default class BitcoinClient implements IBitcoinClient { } public async postTx({ txhex }: { txhex: string }) { - return this.call('postTx', [{ txhex }]); + const txid = await this.call('postTx', [{ txhex }]); + Promise.all(this.backups.map((backup) => backup.postTx({ txhex }))); + return txid; } public async getAddressTxsUtxo({ address }: { address: string }) { diff --git a/src/services/bitcoin/mempool.ts b/src/services/bitcoin/mempool.ts index d6b5a5f9..2bb2b0c3 100644 --- a/src/services/bitcoin/mempool.ts +++ b/src/services/bitcoin/mempool.ts @@ -9,11 +9,11 @@ export class MempoolClient implements IBitcoinDataProvider { private mempool: ReturnType; private defaultFee = 1; - constructor(cradle: Cradle) { - if (!cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL) { + constructor(baseURL: string, cradle: Cradle) { + if (!baseURL) { throw new Error('BITCOIN_MEMPOOL_SPACE_API_URL is required'); } - const url = new URL(cradle.env.BITCOIN_MEMPOOL_SPACE_API_URL); + const url = new URL(baseURL); this.mempool = mempoolJS({ hostname: url.hostname, network: cradle.env.NETWORK, From d780c08cc242b1b9e1534013a517c48aeeabd65a Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 15:09:55 +1000 Subject: [PATCH 45/53] refactor: add IBitcoinBroadcastBackuper interface --- src/services/bitcoin/index.ts | 10 +++++----- src/services/bitcoin/interface.ts | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index 1d627c72..d5334e7f 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -1,7 +1,7 @@ import { AxiosError, HttpStatusCode } from 'axios'; import * as Sentry from '@sentry/node'; import { Cradle } from '../../container'; -import { IBitcoinDataProvider } from './interface'; +import { IBitcoinBroadcastBackuper, IBitcoinDataProvider } from './interface'; import { MempoolClient } from './mempool'; import { ElectrsClient } from './electrs'; import { NetworkType } from '../../constants'; @@ -54,7 +54,7 @@ export default class BitcoinClient implements IBitcoinClient { private cradle: Cradle; private source: IBitcoinDataProvider; private fallback?: IBitcoinDataProvider; - private backups: IBitcoinDataProvider[] = []; + private backupers: IBitcoinBroadcastBackuper[] = []; constructor(cradle: Cradle) { this.cradle = cradle; @@ -82,10 +82,10 @@ export default class BitcoinClient implements IBitcoinClient { } if (this.fallback) { - this.backups.push(this.fallback); + this.backupers.push(this.fallback); } if (env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST) { - this.backups = env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.map((url) => new ElectrsClient(url)); + this.backupers = env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.map((url) => new ElectrsClient(url)); } } @@ -161,7 +161,7 @@ export default class BitcoinClient implements IBitcoinClient { public async postTx({ txhex }: { txhex: string }) { const txid = await this.call('postTx', [{ txhex }]); - Promise.all(this.backups.map((backup) => backup.postTx({ txhex }))); + Promise.all(this.backupers.map((backuper) => backuper.postTx({ txhex }))); return txid; } diff --git a/src/services/bitcoin/interface.ts b/src/services/bitcoin/interface.ts index afda0d0a..199db697 100644 --- a/src/services/bitcoin/interface.ts +++ b/src/services/bitcoin/interface.ts @@ -13,3 +13,5 @@ export interface IBitcoinDataProvider { getBlockTxids({ hash }: { hash: string }): Promise; getBlocksTipHash(): Promise; } + +export type IBitcoinBroadcastBackuper = Pick; From 7e153643ccf6dcee91ba40a059c11d44206888bd Mon Sep 17 00:00:00 2001 From: ahonn Date: Mon, 6 May 2024 15:18:49 +1000 Subject: [PATCH 46/53] fix: bITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST optional, check url list length --- src/env.ts | 5 ++++- src/services/bitcoin/index.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/env.ts b/src/env.ts index a78028bc..0f378470 100644 --- a/src/env.ts +++ b/src/env.ts @@ -79,7 +79,10 @@ const envSchema = z * Bitcoin additional broadcast electrs URL list * broadcast transaction to multiple electrs API when receive bitcoin transaction from users */ - BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST: z.string().transform((value) => value.split(',')), + BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST: z + .string() + .transform((value) => value.split(',')) + .optional(), /** * The URL of the CKB JSON-RPC server. diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index d5334e7f..b7dcbc32 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -84,7 +84,10 @@ export default class BitcoinClient implements IBitcoinClient { if (this.fallback) { this.backupers.push(this.fallback); } - if (env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST) { + if ( + env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST && + env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.length > 0 + ) { this.backupers = env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.map((url) => new ElectrsClient(url)); } } From 84fe0c67931bb98fbbf7f207bee0a479d56bf5ae Mon Sep 17 00:00:00 2001 From: Yuexun Date: Mon, 6 May 2024 15:33:51 +1000 Subject: [PATCH 47/53] docs: update docs/upgrading-to-v2.md Co-authored-by: Dylan Max --- docs/upgrading-to-v2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upgrading-to-v2.md b/docs/upgrading-to-v2.md index 8b10ca94..4d7660bf 100644 --- a/docs/upgrading-to-v2.md +++ b/docs/upgrading-to-v2.md @@ -1,6 +1,6 @@ ## Upgrading to V2 -We have released v2.0.0 for support mempool.space API as the primary Bitcoin data provider, and it was used to broadcast transactions. But it is still compatible with the electrs used previously. +We have released v2.0.0 for support mempool.space API as the primary Bitcoin data provider, and it is used to broadcast transactions. But it is still compatible with the electrs used previously. There are two ways to upgrade: From a3b7fdac378cc324356506dc7fda2dcab93a7d43 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 7 May 2024 14:25:26 +1000 Subject: [PATCH 48/53] fix: fix bitcoin client backupers initial and remove unnecessary error throw --- src/services/bitcoin/electrs.ts | 3 --- src/services/bitcoin/index.ts | 3 ++- src/services/bitcoin/mempool.ts | 3 --- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/services/bitcoin/electrs.ts b/src/services/bitcoin/electrs.ts index 7cc59c02..ecf2001e 100644 --- a/src/services/bitcoin/electrs.ts +++ b/src/services/bitcoin/electrs.ts @@ -6,9 +6,6 @@ export class ElectrsClient implements IBitcoinDataProvider { private request: AxiosInstance; constructor(baseURL: string) { - if (!baseURL) { - throw new Error('BITCOIN_ELECTRS_API_URL is required'); - } this.request = axios.create({ baseURL, }); diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index b7dcbc32..1f66d46b 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -88,7 +88,8 @@ export default class BitcoinClient implements IBitcoinClient { env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST && env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.length > 0 ) { - this.backupers = env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.map((url) => new ElectrsClient(url)); + const additionalElectrs = env.BITCOIN_ADDITIONAL_BROADCAST_ELECTRS_URL_LIST.map((url) => new ElectrsClient(url)); + this.backupers.push(...additionalElectrs); } } diff --git a/src/services/bitcoin/mempool.ts b/src/services/bitcoin/mempool.ts index 2bb2b0c3..c3f1b2c8 100644 --- a/src/services/bitcoin/mempool.ts +++ b/src/services/bitcoin/mempool.ts @@ -10,9 +10,6 @@ export class MempoolClient implements IBitcoinDataProvider { private defaultFee = 1; constructor(baseURL: string, cradle: Cradle) { - if (!baseURL) { - throw new Error('BITCOIN_MEMPOOL_SPACE_API_URL is required'); - } const url = new URL(baseURL); this.mempool = mempoolJS({ hostname: url.hostname, From b1840adf8607693d2cc4a46649248a0c9abf1472 Mon Sep 17 00:00:00 2001 From: Yuexun Date: Mon, 6 May 2024 17:03:08 +1000 Subject: [PATCH 49/53] docs: update docs/upgrading-to-v2.md Co-authored-by: Dylan Max Co-authored-by: Flouse <1297478+Flouse@users.noreply.github.com> --- docs/upgrading-to-v2.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/upgrading-to-v2.md b/docs/upgrading-to-v2.md index 4d7660bf..869b97ae 100644 --- a/docs/upgrading-to-v2.md +++ b/docs/upgrading-to-v2.md @@ -1,10 +1,11 @@ ## Upgrading to V2 -We have released v2.0.0 for support mempool.space API as the primary Bitcoin data provider, and it is used to broadcast transactions. But it is still compatible with the electrs used previously. +We have released v2.0.0 for support `mempool.space` API as a Bitcoin data provider, and it +provides a set of [IBitcoinDataProvider](https://github.com/ckb-cell/btc-assets-api/blob/8fb495576c957e9006ef648d6c24312a3f10e34f/src/services/bitcoin/interface.ts#L3) interfaces. Note that it is still compatible with the `electrs` used previously. There are two ways to upgrade: -### Upgrading from V1.x.x and still use electrs (**compatible**) +### Upgrading from v1.x.x and use electrs (**compatible, by default**) Suppose you do not want to use the mempool.space API as the main data provider, **you do not need to make any changes**. But we recommend you remove the following env vars for safety: @@ -22,7 +23,7 @@ BITCOIN_DATA_PROVIDER=electrs # recommend, electrs by default BITCOIN_MEMPOOL_SPACE_API_URL=https://mempool.space # optional, mempool.space as the fallback ``` -### Upgrading from V1.x.x and using mempool.space API by default +### Upgrading from v1.x.x and using mempool.space API (**new feature**) The new feature in v2.0.0, we can use mempool.space API as the primary data provider, and use electrs as a fallback. Add the following env vars: @@ -41,5 +42,8 @@ BITCOIN_ELECTRS_API_URL= # optional, electrs as fallback #### Recommended Fees API If use mempool.space API as the primary data provider, then we can use `/bitcoin/v1/fees/recommended` to get the bitcoin fees. and we will calculate fees when mempool.space recommend fees API unavailable (see https://github.com/ckb-cell/btc-assets-api/pull/114). -**use electrs as the primary data provider and didn't set `BITCOIN_MEMPOOL_SPACE_API_URL` as a fallback, then recommended fees API will be unavailable** +**use electrs as the primary data provider and dosen't set `BITCOIN_MEMPOOL_SPACE_API_URL` as a fallback, then recommended fees API will be unavailable** + + + From 063dd4ba4e3e0e5e30e2eeb3bdfe0c8287424306 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 7 May 2024 15:32:05 +1000 Subject: [PATCH 50/53] fix: fix CKBComponents is not defined --- src/services/transaction.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/services/transaction.ts b/src/services/transaction.ts index 7a47733a..b311197f 100644 --- a/src/services/transaction.ts +++ b/src/services/transaction.ts @@ -348,11 +348,7 @@ export default class TransactionProcessor implements ITransactionProcessor { ); const sporeLiveCells = inputs .filter(({ status, cell }) => { - return ( - status === CKBComponents.CellStatus.Live && - cell?.output.type && - isClusterSporeTypeSupported(cell?.output.type, this.isMainnet) - ); + return status === 'live' && cell?.output.type && isClusterSporeTypeSupported(cell?.output.type, this.isMainnet); }) .map((liveCell) => liveCell.cell!); if (sporeLiveCells.length > 0) { From bb5be9fc2299432c5f1390f3d70a3e7c94f9a8f7 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 7 May 2024 19:23:00 +1000 Subject: [PATCH 51/53] fix: fix bitcoin client error message handle --- src/services/bitcoin/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index 1f66d46b..fcd1c4e4 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -115,11 +115,9 @@ export default class BitcoinClient implements IBitcoinClient { return result; } catch (err) { this.cradle.logger.error(err); - if ((err as AxiosError).isAxiosError) { - const error = new BitcoinClientAPIError((err as AxiosError).message); - if ((err as AxiosError).response) { - error.statusCode = (err as AxiosError).response?.status || 500; - } + if (err instanceof AxiosError) { + const error = new BitcoinClientAPIError(err.response?.data ?? err.message); + error.statusCode = err.response?.status || 500; throw error; } throw err; From 7d1e3a4f6770836b0b06d6f18d0090a1f674aebf Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 7 May 2024 19:36:05 +1000 Subject: [PATCH 52/53] test: update test cases & snapshot --- src/services/bitcoin/index.ts | 4 +- .../__snapshots__/transaction.test.ts.snap | 6 + test/routes/bitcoin/transaction.test.ts | 4 +- .../__snapshots__/address.test.ts.snap | 194 -------------- .../bitcoind/__snapshots__/block.test.ts.snap | 31 --- .../__snapshots__/transaction.test.ts.snap | 109 -------- test/routes/bitcoind/address.test.ts | 236 ------------------ test/routes/bitcoind/block.test.ts | 84 ------- test/routes/bitcoind/info.test.ts | 49 ---- test/routes/bitcoind/transaction.test.ts | 110 -------- 10 files changed, 10 insertions(+), 817 deletions(-) delete mode 100644 test/routes/bitcoind/__snapshots__/address.test.ts.snap delete mode 100644 test/routes/bitcoind/__snapshots__/block.test.ts.snap delete mode 100644 test/routes/bitcoind/__snapshots__/transaction.test.ts.snap delete mode 100644 test/routes/bitcoind/address.test.ts delete mode 100644 test/routes/bitcoind/block.test.ts delete mode 100644 test/routes/bitcoind/info.test.ts delete mode 100644 test/routes/bitcoind/transaction.test.ts diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index fcd1c4e4..69d24a40 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -117,7 +117,9 @@ export default class BitcoinClient implements IBitcoinClient { this.cradle.logger.error(err); if (err instanceof AxiosError) { const error = new BitcoinClientAPIError(err.response?.data ?? err.message); - error.statusCode = err.response?.status || 500; + if (err.response?.status) { + error.statusCode = err.response.status; + } throw error; } throw err; diff --git a/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap b/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap index 79021174..49b396f0 100644 --- a/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap +++ b/test/routes/bitcoin/__snapshots__/transaction.test.ts.snap @@ -1,5 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Get not exists transaction 1`] = ` +{ + "message": "Transaction not found", +} +`; + exports[`Get transaction by txid 1`] = ` { "fee": 141, diff --git a/test/routes/bitcoin/transaction.test.ts b/test/routes/bitcoin/transaction.test.ts index 04812be9..f0946872 100644 --- a/test/routes/bitcoin/transaction.test.ts +++ b/test/routes/bitcoin/transaction.test.ts @@ -58,9 +58,7 @@ describe('/bitcoin/v1/transaction', () => { const data = await response.json(); expect(response.statusCode).toBe(404); - expect(data).toEqual({ - message: 'Request failed with status code 404', - }); + expect(data).toMatchSnapshot(); await fastify.close(); }); diff --git a/test/routes/bitcoind/__snapshots__/address.test.ts.snap b/test/routes/bitcoind/__snapshots__/address.test.ts.snap deleted file mode 100644 index b0056eb5..00000000 --- a/test/routes/bitcoind/__snapshots__/address.test.ts.snap +++ /dev/null @@ -1,194 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`/bitcoin/v1/address > Get address balance 1`] = ` -{ - "address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "dust_satoshi": 0, - "pending_satoshi": 0, - "satoshi": 1000, - "utxo_count": 1, -} -`; - -exports[`/bitcoin/v1/address > Get address balance with min_satoshi param 1`] = ` -{ - "address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "dust_satoshi": 1000, - "pending_satoshi": 0, - "satoshi": 0, - "utxo_count": 1, -} -`; - -exports[`/bitcoin/v1/address > Get address transactions 1`] = ` -[ - { - "fee": 679, - "locktime": 0, - "size": 340, - "status": { - "block_hash": "0000000000960b9bc86db1c241e3b22690ae9e0360a960141f1923e87eb53ea8", - "block_height": 2585607, - "block_time": 1712556136, - "confirmed": true, - }, - "txid": "b7a7863485661d8e03fedcc39bbf4de25d703e34e0c065f2eec8e85706d93eb7", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 890, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967295, - "txid": "b91c5bfddea79135de6a23de329b31c2696c673661ef90970e5c247ccf8bb7b1", - "vout": 0, - "witness": [ - "304402201c38168040ecb9fd312a18a2770ec303195c2d56c735fbfce5d7081fc33e7d6f02201f8bdaa14c424900f8d52872b10f77628fb30b104c04aa89b466610c9253a3f201", - "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", - ], - }, - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 789, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967295, - "txid": "03c4e6b25192823fec378a553b063e07ff20d526eb223352279a4be004f95cca", - "vout": 1, - "witness": [ - "3045022100cef667e1370b7e73a01f3b59c7f7f402932da59ba173f1ecb705ad4e40f986fb0220572e96ba212077e1076a253514e9308db11164fc1b16d0ca162209442f25aef101", - "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - ], - "weight": 709, - }, - { - "fee": 110, - "locktime": 0, - "size": 192, - "status": { - "block_hash": "00000000000000037864097e5c7beff520ee329289b1623f7c521132e5268a66", - "block_height": 2585601, - "block_time": 1712551024, - "confirmed": true, - }, - "txid": "b91c5bfddea79135de6a23de329b31c2696c673661ef90970e5c247ccf8bb7b1", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967295, - "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", - "vout": 0, - "witness": [ - "3045022100f6373e6ce8e3206b9f1254e8322dbbd0c5f4990f214997fef1e4bb6cb535a00602205eb0665a34fb7e9b3789cb55e45d05b1f0daca9e53f5e3a1393cab44725caa0801", - "020b4e7dba2f094598f41e40f2eedbe274293f4248e6cac4672322dbb0b510d38f", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 890, - }, - ], - "weight": 438, - }, - { - "fee": 141, - "locktime": 0, - "size": 223, - "status": { - "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", - "block_height": 2579043, - "block_time": 1708580004, - "confirmed": true, - }, - "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 181793, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967295, - "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", - "vout": 0, - "witness": [ - "3045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d111101", - "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 180652, - }, - ], - "weight": 562, - }, -] -`; - -exports[`/bitcoin/v1/address > Get address unspent transaction outputs with only_confirmed = undefined 1`] = ` -{ - "address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "dust_satoshi": 1000, - "pending_satoshi": 0, - "satoshi": 0, - "utxo_count": 1, -} -`; diff --git a/test/routes/bitcoind/__snapshots__/block.test.ts.snap b/test/routes/bitcoind/__snapshots__/block.test.ts.snap deleted file mode 100644 index 4e4d1c70..00000000 --- a/test/routes/bitcoind/__snapshots__/block.test.ts.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`/bitcoin/v1/block > Get block by hash 1`] = ` -{ - "bits": 436273151, - "difficulty": 16777216, - "height": 2091140, - "id": "000000000000009c08dc77c3f224d9f5bbe335a78b996ec1e0701e065537ca81", - "mediantime": 1630621997, - "merkle_root": "5d10d8d158bb8eb217d01fecc435bd10eda028043a913dc2bfe0ccf536a51cc9", - "nonce": 1600805744, - "previousblockhash": "0000000000000073f95d1fc0a93d449f82a754410c635e46264ec6c7c4d5741e", - "size": 575, - "timestamp": 1630625150, - "tx_count": 2, - "version": 543162372, - "weight": 1865, -} -`; - -exports[`/bitcoin/v1/block > Get block hash by height 1`] = ` -{ - "hash": "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", -} -`; - -exports[`/bitcoin/v1/block > Get block header by hash 1`] = ` -{ - "header": "0000202000af62594f79b6390c6ee3de56aad6658d35a9481cd8bb0ce047523800000000652cad111d076bf8aa3417670781154fd79533fb91fe782d70d300e9e95b4c5b6d60dd6518fe27190343494f", -} -`; diff --git a/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap b/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap deleted file mode 100644 index 9604b77f..00000000 --- a/test/routes/bitcoind/__snapshots__/transaction.test.ts.snap +++ /dev/null @@ -1,109 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Get not exists transaction 1`] = ` -{ - "fee": 141, - "locktime": 0, - "size": 223, - "status": { - "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", - "block_height": 2579043, - "block_time": 1708580004, - "confirmed": true, - }, - "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 181793, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967295, - "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", - "vout": 0, - "witness": [ - "3045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d111101", - "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 180652, - }, - ], - "weight": 562, -} -`; - -exports[`Get transaction by txid 1`] = ` -{ - "fee": 141, - "locktime": 0, - "size": 223, - "status": { - "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", - "block_height": 2579043, - "block_time": 1708580004, - "confirmed": true, - }, - "txid": "9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 181793, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967295, - "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", - "vout": 0, - "witness": [ - "3045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d111101", - "02d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b08313", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "0014dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_address": "tb1qm4eyx777203zmajlawz958wn27z08envm2jelm", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 dd72437bde53e22df65feb845a1dd35784f3e66c", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 180652, - }, - ], - "weight": 562, -} -`; diff --git a/test/routes/bitcoind/address.test.ts b/test/routes/bitcoind/address.test.ts deleted file mode 100644 index 3cdf3eea..00000000 --- a/test/routes/bitcoind/address.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, expect, test, beforeEach, vi } from 'vitest'; -import { buildFastify } from '../../../src/app'; -import { afterEach } from 'node:test'; -import BitcoinClient from '../../../src/services/bitcoin'; - -let token: string; - -describe('/bitcoin/v1/address', () => { - beforeEach(async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'POST', - url: '/token/generate', - payload: { - app: 'test', - domain: 'test.com', - }, - }); - const data = response.json(); - token = data.token; - - await fastify.close(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - test('Get address balance', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/balance', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); - - test('Get address balance with min_satoshi param', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/balance?min_satoshi=10000', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); - - test('Get address balance with invalid address', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0try/balance', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(400); - expect(data.message).toBe('Invalid bitcoin address'); - - await fastify.close(); - }); - - test('Get address unspent transaction outputs', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); - const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; - vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ - { - txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - vout: 0, - status: { - confirmed: true, - }, - value: 100000, - }, - { - txid: '1706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - vout: 0, - status: { - confirmed: false, - }, - value: 100000, - }, - ]); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; - - expect(response.statusCode).toBe(200); - expect(data.length).toBe(1); - - await fastify.close(); - }); - - test('Get address unspent transaction outputs with unconfirmed', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); - const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; - vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ - { - txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - vout: 0, - status: { - confirmed: true, - }, - value: 100000, - }, - { - txid: '1706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - vout: 0, - status: { - confirmed: false, - }, - value: 100000, - }, - ]); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent?only_confirmed=false', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; - - expect(response.statusCode).toBe(200); - expect(data.length).toBe(2); - - await fastify.close(); - }); - - test('Get address unspent transaction outputs with only_confirmed = undefined', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const bitcoin: BitcoinClient = fastify.container.resolve('bitcoin'); - const originalGetAddressTxsUtxo = bitcoin.getAddressTxsUtxo; - vi.spyOn(bitcoin, 'getAddressTxsUtxo').mockResolvedValue([ - { - txid: '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - vout: 0, - status: { - confirmed: true, - }, - value: 100000, - }, - { - txid: '1706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - vout: 0, - status: { - confirmed: false, - }, - value: 100000, - }, - ]); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent?only_confirmed=undefined', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - bitcoin.getAddressTxsUtxo = originalGetAddressTxsUtxo; - - expect(response.statusCode).toBe(200); - expect(data.length).toBe(1); - - await fastify.close(); - }); - - test('Get address transactions', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/txs', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); -}); diff --git a/test/routes/bitcoind/block.test.ts b/test/routes/bitcoind/block.test.ts deleted file mode 100644 index e7759be0..00000000 --- a/test/routes/bitcoind/block.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, beforeEach, expect, test } from 'vitest'; -import { buildFastify } from '../../../src/app'; - -describe('/bitcoin/v1/block', () => { - let token: string; - - beforeEach(async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'POST', - url: '/token/generate', - payload: { - app: 'test', - domain: 'test.com', - }, - }); - const data = response.json(); - token = data.token; - - await fastify.close(); - }); - - test('Get block by hash', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/block/000000000000009c08dc77c3f224d9f5bbe335a78b996ec1e0701e065537ca81', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); - - test('Get block header by hash', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/block/0000000000000005ae0b929ee3afbf2956aaa0059f9d7608dc396cf5f8f4dda6/header', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); - - test('Get block hash by height', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/block/height/0', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); -}); diff --git a/test/routes/bitcoind/info.test.ts b/test/routes/bitcoind/info.test.ts deleted file mode 100644 index 1a87f59e..00000000 --- a/test/routes/bitcoind/info.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { beforeEach, expect, test } from 'vitest'; -import { buildFastify } from '../../../src/app'; -import { describe } from 'node:test'; - -let token: string; - -describe('/bitcoin/v1/info', () => { - beforeEach(async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'POST', - url: '/token/generate', - payload: { - app: 'test', - domain: 'test.com', - }, - }); - const data = response.json(); - token = data.token; - - await fastify.close(); - }); - - test('Get blockchain info', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/info', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toHaveProperty('bestblockhash'); - expect(data).toHaveProperty('blocks'); - expect(data).toHaveProperty('chain'); - expect(data).toHaveProperty('difficulty'); - expect(data).toHaveProperty('mediantime'); - - await fastify.close(); - }); -}); diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts deleted file mode 100644 index 04812be9..00000000 --- a/test/routes/bitcoind/transaction.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeEach, expect, test } from 'vitest'; -import { buildFastify } from '../../../src/app'; -import { describe } from 'node:test'; - -let token: string; - -describe('/bitcoin/v1/transaction', () => { - beforeEach(async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'POST', - url: '/token/generate', - payload: { - app: 'test', - domain: 'test.com', - }, - }); - const data = response.json(); - token = data.token; - - await fastify.close(); - }); - - test('Get transaction by txid', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toMatchSnapshot(); - - await fastify.close(); - }); - - test('Get not exists transaction', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babf1', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = await response.json(); - - expect(response.statusCode).toBe(404); - expect(data).toEqual({ - message: 'Request failed with status code 404', - }); - - await fastify.close(); - }); - - test('Get transaction hex', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'GET', - url: '/bitcoin/v1/transaction/9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc/hex', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - }); - const data = await response.json(); - - expect(response.statusCode).toBe(200); - expect(data).toEqual({ - hex: '020000000001013115e849359a46737e7bf3e121a5a5de666a1ad01933e3df7dc48f97199f18d00000000000ffffffff02e803000000000000160014dd72437bde53e22df65feb845a1dd35784f3e66cacc1020000000000160014f8d0addc86183d385061ee80c1b16b1975eacf4202483045022100f50e2e6ee5ea04e4f7029d29a28e80266969f91357055e1d53b0715b768c448102204ccd9dbb38d0cc007e07b9c8b4088d4fac4ab65fef49774bafe27acbfc3d1111012102d05848540f152d730e272bc9628dd4e4a5f4126fdf118ad6a4fd6afd26b0831300000000', - }); - - await fastify.close(); - }); - - test('Send exists raw transaction', async () => { - const fastify = buildFastify(); - await fastify.ready(); - - const response = await fastify.inject({ - method: 'POST', - url: '/bitcoin/v1/transaction', - headers: { - Authorization: `Bearer ${token}`, - Origin: 'https://test.com', - }, - body: { - txhex: - '02000000000101fe7b9cd0f75741e2ec1e3a6142eab945e64fab0ef15de4a66c635c0a789e986f0100000000ffffffff02e803000000000000160014dbf4360c0791098b0b14679e5e78015df3f2caad6a88000000000000160014dbf4360c0791098b0b14679e5e78015df3f2caad02473044022065829878f51581488f44c37064b46f552ea7354196fae5536906797b76b370bf02201c459081578dc4e1098fbe3ab68d7d56a99e8e9810bf2806d10053d6b36ffa4d0121037dff8ff2e0bd222690d785f9277e0c4800fc88b0fad522f1442f21a8226253ce00000000', - }, - }); - - expect(response.statusCode).toBe(400); - await fastify.close(); - }); -}); From 7356146a786fdeebab3e2a0f564c93cb5acfa651 Mon Sep 17 00:00:00 2001 From: ahonn Date: Tue, 7 May 2024 19:47:38 +1000 Subject: [PATCH 53/53] fix: use isAxiosError to get axios error type --- src/plugins/sentry.ts | 4 ++-- src/services/bitcoin/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/sentry.ts b/src/plugins/sentry.ts index b0c9a043..6c7a733a 100644 --- a/src/plugins/sentry.ts +++ b/src/plugins/sentry.ts @@ -3,7 +3,7 @@ import fastifySentry from '@immobiliarelabs/fastify-sentry'; import { ProfilingIntegration } from '@sentry/profiling-node'; import pkg from '../../package.json'; import { env } from '../env'; -import { HttpStatusCode, AxiosError } from 'axios'; +import { HttpStatusCode, isAxiosError } from 'axios'; import { BitcoinClientAPIError } from '../services/bitcoin'; export default fp(async (fastify) => { @@ -23,7 +23,7 @@ export default fp(async (fastify) => { return; } - if (error instanceof AxiosError) { + if (isAxiosError(error)) { const { response } = error; reply.status(response?.status ?? HttpStatusCode.InternalServerError).send({ message: response?.data ?? error.message, diff --git a/src/services/bitcoin/index.ts b/src/services/bitcoin/index.ts index 69d24a40..191ecc81 100644 --- a/src/services/bitcoin/index.ts +++ b/src/services/bitcoin/index.ts @@ -1,4 +1,4 @@ -import { AxiosError, HttpStatusCode } from 'axios'; +import { HttpStatusCode, isAxiosError } from 'axios'; import * as Sentry from '@sentry/node'; import { Cradle } from '../../container'; import { IBitcoinBroadcastBackuper, IBitcoinDataProvider } from './interface'; @@ -115,7 +115,7 @@ export default class BitcoinClient implements IBitcoinClient { return result; } catch (err) { this.cradle.logger.error(err); - if (err instanceof AxiosError) { + if (isAxiosError(err)) { const error = new BitcoinClientAPIError(err.response?.data ?? err.message); if (err.response?.status) { error.statusCode = err.response.status;