diff --git a/.env.example b/.env.example index 8b6e97e1..bcc310a5 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,16 @@ BITCOIN_JSON_RPC_USERNAME= BITCOIN_JSON_RPC_PASSWORD= BITCOIN_ELECTRS_API_URL= + +BITCOIN_SPV_SERVICE_URL= + +CKB_RPC_URL=https://testnet.ckb.dev/rpc +CKB_INDEXER_URL=https://testnet.ckb.dev/indexer + +PAYMASTER_PRIVATE_KEY= +PAYMASTER_CELL_CAPACITY=31600000000 +PAYMASTER_CELL_PRESET_COUNT=500 + +UNLOCKER_CELL_BATCH_SIZE=100 + +TRANSACTION_QUEUE_JOB_DELAY=12000 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 62e21cfe..43a3cbef 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,6 +11,17 @@ jobs: test: runs-on: ubuntu-latest + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: - name: Checkout Code uses: actions/checkout@v3 @@ -36,4 +47,10 @@ jobs: 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_SPV_SERVICE_URL: ${{ secrets.BITCOIN_SPV_SERVICE_URL }} + CKB_RPC_URL: ${{ secrets.CKB_RPC_URL }} + CKB_INDEXER_URL: ${{ secrets.CKB_INDEXER_URL }} + PAYMASTER_PRIVATE_KEY: ${{ secrets.PAYMASTER_PRIVATE_KEY }} + REDIS_URL: redis://localhost:6379 + CI_REDIS_URL: redis://localhost:6379 run: pnpm test diff --git a/.gitignore b/.gitignore index 3acee13d..c5077bd0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ yarn.lock coverage data redis.conf +dump.rdb +.envrc diff --git a/api/serverless.ts b/api/serverless.ts index 0ac6ec99..4046410a 100644 --- a/api/serverless.ts +++ b/api/serverless.ts @@ -1,5 +1,9 @@ import { buildFastify } from '../src/app'; +export const config = { + maxDuration: 300, +}; + const app = buildFastify(); export default async (req: Request, res: Response) => { diff --git a/devbox.json b/devbox.json new file mode 100644 index 00000000..dfbeeb85 --- /dev/null +++ b/devbox.json @@ -0,0 +1,7 @@ +{ + "packages": [ + "nodejs@latest", + "nodePackages.pnpm@latest", + "redis@latest" + ] +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 00000000..18fac5d7 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,66 @@ +{ + "lockfile_version": "1", + "packages": { + "nodePackages.pnpm@latest": { + "last_modified": "2024-03-08T13:51:52Z", + "resolved": "github:NixOS/nixpkgs/a343533bccc62400e8a9560423486a3b6c11a23b#nodePackages.pnpm", + "source": "devbox-search", + "version": "8.15.3", + "systems": { + "aarch64-darwin": { + "store_path": "/nix/store/m9p6q42njy1k33fdzf2axqyhy8vh4vn5-pnpm-8.15.3" + }, + "aarch64-linux": { + "store_path": "/nix/store/war7jm6ka7afc2v8a78hlq8v9m4pg38m-pnpm-8.15.3" + }, + "x86_64-darwin": { + "store_path": "/nix/store/3w3bs2cdqx7sivcldcf6drwrhilv511m-pnpm-8.15.3" + }, + "x86_64-linux": { + "store_path": "/nix/store/hcq09j80njlfghy65qmwhn5nq20nk8kl-pnpm-8.15.3" + } + } + }, + "nodejs@latest": { + "last_modified": "2024-03-09T07:11:56Z", + "resolved": "github:NixOS/nixpkgs/0e7f98a5f30166cbed344569426850b21e4091d4#nodejs_21", + "source": "devbox-search", + "version": "21.7.1", + "systems": { + "aarch64-darwin": { + "store_path": "/nix/store/mh1db6rni4mlcr473sh3fy6jg2m38jvg-nodejs-21.7.1" + }, + "aarch64-linux": { + "store_path": "/nix/store/ziyb3ny5f9hcwp0lsb9gwdv1pqsn03x1-nodejs-21.7.1" + }, + "x86_64-darwin": { + "store_path": "/nix/store/0zaa3aarbsj38g62ihv94gz2hgvgh6bc-nodejs-21.7.1" + }, + "x86_64-linux": { + "store_path": "/nix/store/4arhczh30ychpx6h2wpj5nhx39wm0nla-nodejs-21.7.1" + } + } + }, + "redis@latest": { + "last_modified": "2024-03-08T13:51:52Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/a343533bccc62400e8a9560423486a3b6c11a23b#redis", + "source": "devbox-search", + "version": "7.2.4", + "systems": { + "aarch64-darwin": { + "store_path": "/nix/store/700kyznxcmlkqqabhwa64vmyg4aj6igj-redis-7.2.4" + }, + "aarch64-linux": { + "store_path": "/nix/store/xlc976dh4nb2aa0gzs0jfgld4cc3x8si-redis-7.2.4" + }, + "x86_64-darwin": { + "store_path": "/nix/store/5v58fadclljqa2fmwq281bx5wma9cslb-redis-7.2.4" + }, + "x86_64-linux": { + "store_path": "/nix/store/zp1lapdicjxnjhiz8l6j2j4nn6sa9fg5-redis-7.2.4" + } + } + } + } +} diff --git a/package.json b/package.json index 274e39e4..8f9053b4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ ] }, "dependencies": { + "@ckb-lumos/base": "^0.21.1", + "@ckb-lumos/ckb-indexer": "^0.21.1", + "@ckb-lumos/codec": "^0.21.1", + "@ckb-lumos/lumos": "^0.21.1", "@fastify/compress": "^7.0.0", "@fastify/cors": "^9.0.1", "@fastify/http-proxy": "^9.4.0", @@ -32,12 +36,17 @@ "@fastify/sensible": "^5.5.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^3.0.0", + "@nervosnetwork/ckb-sdk-utils": "^0.109.1", + "@rgbpp-sdk/btc": "0.0.0-snap-20240326062949", + "@rgbpp-sdk/ckb": "0.0.0-snap-20240326062949", "@sentry/node": "^7.102.1", "@sentry/profiling-node": "^7.102.1", "awilix": "^10.0.1", "axios": "^1.6.7", + "bullmq": "^5.4.2", "dotenv": "^16.4.2", "fastify": "^4.26.0", + "fastify-cron": "^1.3.1", "fastify-plugin": "^4.5.1", "fastify-type-provider-zod": "^1.1.9", "ioredis": "^5.3.2", @@ -45,23 +54,27 @@ "multicoin-address-validator": "^0.5.16", "pino": "^8.19.0", "std-env": "^3.7.0", + "uuid": "^9.0.1", "zod": "^3.22.4" }, "devDependencies": { - "@types/ioredis-mock": "^8.2.5", "@types/lodash": "^4.17.0", "@types/multicoin-address-validator": "^0.5.2", "@types/node": "^20.11.17", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitest/coverage-istanbul": "^1.3.1", "@vitest/ui": "^1.3.1", + "bitcoinjs-lib": "^6.1.5", + "cross-env": "^7.0.3", + "ecpair": "^2.1.0", "eslint": "^8.56.0", - "ioredis-mock": "^8.9.0", "lint-staged": "^15.2.2", "pino-pretty": "^10.3.1", "prettier": "^3.2.5", "simple-git-hooks": "^2.10.0", + "tiny-secp256k1": "^2.2.3", "tsx": "^4.7.1", "typescript": "^5.3.3", "vercel": "^33.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f2f811e..19983a38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,18 @@ settings: excludeLinksFromLockfile: false dependencies: + '@ckb-lumos/base': + specifier: ^0.21.1 + version: 0.21.1 + '@ckb-lumos/ckb-indexer': + specifier: ^0.21.1 + version: 0.21.1 + '@ckb-lumos/codec': + specifier: ^0.21.1 + version: 0.21.1 + '@ckb-lumos/lumos': + specifier: ^0.21.1 + version: 0.21.1 '@fastify/compress': specifier: ^7.0.0 version: 7.0.0 @@ -32,6 +44,15 @@ dependencies: '@fastify/swagger-ui': specifier: ^3.0.0 version: 3.0.0 + '@nervosnetwork/ckb-sdk-utils': + specifier: ^0.109.1 + version: 0.109.1 + '@rgbpp-sdk/btc': + specifier: 0.0.0-snap-20240326062949 + version: 0.0.0-snap-20240326062949 + '@rgbpp-sdk/ckb': + specifier: 0.0.0-snap-20240326062949 + version: 0.0.0-snap-20240326062949 '@sentry/node': specifier: ^7.102.1 version: 7.102.1 @@ -44,12 +65,18 @@ dependencies: axios: specifier: ^1.6.7 version: 1.6.7 + bullmq: + specifier: ^5.4.2 + version: 5.4.2 dotenv: specifier: ^16.4.2 version: 16.4.2 fastify: specifier: ^4.26.0 version: 4.26.0 + fastify-cron: + specifier: ^1.3.1 + version: 1.3.1(fastify@4.26.0) fastify-plugin: specifier: ^4.5.1 version: 4.5.1 @@ -71,14 +98,14 @@ dependencies: std-env: specifier: ^3.7.0 version: 3.7.0 + uuid: + specifier: ^9.0.1 + version: 9.0.1 zod: specifier: ^3.22.4 version: 3.22.4 devDependencies: - '@types/ioredis-mock': - specifier: ^8.2.5 - version: 8.2.5 '@types/lodash': specifier: ^4.17.0 version: 4.17.0 @@ -88,6 +115,9 @@ devDependencies: '@types/node': specifier: ^20.11.17 version: 20.11.17 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 '@typescript-eslint/eslint-plugin': specifier: ^6.21.0 version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) @@ -100,12 +130,18 @@ devDependencies: '@vitest/ui': specifier: ^1.3.1 version: 1.3.1(vitest@1.3.1) + bitcoinjs-lib: + specifier: ^6.1.5 + version: 6.1.5 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + ecpair: + specifier: ^2.1.0 + version: 2.1.0 eslint: specifier: ^8.56.0 version: 8.56.0 - ioredis-mock: - specifier: ^8.9.0 - version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.3.2) lint-staged: specifier: ^15.2.2 version: 15.2.2 @@ -118,6 +154,9 @@ devDependencies: simple-git-hooks: specifier: ^2.10.0 version: 2.10.0 + tiny-secp256k1: + specifier: ^2.2.3 + version: 2.2.3 tsx: specifier: ^4.7.1 version: 4.7.1 @@ -337,6 +376,314 @@ packages: to-fast-properties: 2.0.0 dev: true + /@bitcoinerlab/secp256k1@1.1.1: + resolution: {integrity: sha512-uhjW51WfVLpnHN7+G0saDcM/k9IqcyTbZ+bDgLF3AX8V/a3KXSE9vn7UPBrcdU72tp0J4YPR7BHp2m7MLAZ/1Q==} + dependencies: + '@noble/hashes': 1.4.0 + '@noble/secp256k1': 1.7.1 + dev: false + + /@ckb-lumos/base@0.21.1: + resolution: {integrity: sha512-7O+jBl7pqMsRbYTMNnbpamgaQzvaLZq+ftMtnKZ3A+Zbs6hZcGbz/6nfbRZnyJitPXQHRPT5KAQz6g+TiYqJGg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.21.1 + '@ckb-lumos/codec': 0.21.1 + '@ckb-lumos/toolkit': 0.21.1 + '@types/blake2b': 2.1.3 + '@types/lodash.isequal': 4.5.8 + blake2b: 2.1.4 + js-xxhash: 1.0.4 + lodash.isequal: 4.5.0 + dev: false + + /@ckb-lumos/base@0.22.0-next.5: + resolution: {integrity: sha512-2I4f/zJm3Bdz/5soXo35vDh98Vffp2clO8n1ZeZRsUtBZUtvAGNbggC4J8/0a/yFw3cStaOJ1Tztla5E0hP+xA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + '@types/blake2b': 2.1.3 + '@types/lodash.isequal': 4.5.8 + blake2b: 2.1.4 + js-xxhash: 1.0.4 + lodash.isequal: 4.5.0 + dev: false + + /@ckb-lumos/bi@0.21.1: + resolution: {integrity: sha512-6q8uesvu3DAM7GReei9H5seino4tnakTeg8uXtZBPDC6rboMohLCPQvEwhl1iHmsybXvBYVQt4Te1BPPZtuaRw==} + engines: {node: '>=12.0.0'} + dependencies: + jsbi: 4.3.0 + dev: false + + /@ckb-lumos/bi@0.22.0-next.5: + resolution: {integrity: sha512-hfh9PQDLJeZedlJ6qBVxx2MR9ndU0XyIl53vh1I+S4SGmvl/z2PGEY06vN34nkjmZ/1c87/KW+sL2Cpii0IxGg==} + engines: {node: '>=12.0.0'} + dependencies: + jsbi: 4.3.0 + dev: false + + /@ckb-lumos/ckb-indexer@0.21.1: + resolution: {integrity: sha512-592pMVP3lwTXF7TmlOcayvGYKOhkYpjbLHUDo7By4yWbm7ZpdexaN5hn0X1sjJgMuee5prxGr9/fY684VTpJQw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + '@ckb-lumos/codec': 0.21.1 + '@ckb-lumos/rpc': 0.21.1 + '@ckb-lumos/toolkit': 0.21.1 + cross-fetch: 3.1.8 + events: 3.3.0 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/ckb-indexer@0.22.0-next.5: + resolution: {integrity: sha512-1UZ0whrn9k18wrvIIvsdAYRCGInNA4qcrMcxsqSV7ADQnPsw8c+ARkIyzQrqaM2bzvPa3O+aVXnZ7g2oTrhtFA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + cross-fetch: 3.1.8 + events: 3.3.0 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/codec@0.21.1: + resolution: {integrity: sha512-z6IUUxVZrx663iC7VM9CmaQZL8jsdM3ybgz0UCS24JgBXTNec+Uz0/Zrl7yeH6fBpVls44C2wObcHKigKaNVAA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.21.1 + dev: false + + /@ckb-lumos/codec@0.22.0-next.5: + resolution: {integrity: sha512-AVvREoEc0zSbGSdFZOph1WL9QtUKUjQ2PABpApROMTqDCryG9/FpBrInjm2y50USZlwcEE3+ocEqFqJjfuTXKQ==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.22.0-next.5 + dev: false + + /@ckb-lumos/common-scripts@0.21.1: + resolution: {integrity: sha512-EfZQ9wdxPmEsxVVwtBjhpZVKbYCm1FJkMt59ABsIO1Ub7yi0qap7AQl0MMbuLwWIGKwS2w0U3wx/oJPm7z1RXg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + '@ckb-lumos/codec': 0.21.1 + '@ckb-lumos/config-manager': 0.21.1 + '@ckb-lumos/helpers': 0.21.1 + '@ckb-lumos/rpc': 0.21.1 + '@ckb-lumos/toolkit': 0.21.1 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/common-scripts@0.22.0-next.5: + resolution: {integrity: sha512-DXGwyc0pmrq0fj+NflDBeBgJSL2bNAVHpULpbUncDUPIBJf9IFyMDSZUgzX6hYjTX0WA07e5CEFo5wwHb24Dmw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/config-manager': 0.22.0-next.5 + '@ckb-lumos/helpers': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + bech32: 2.0.0 + bs58: 5.0.0 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/config-manager@0.21.1: + resolution: {integrity: sha512-BmrNqYyaksdCKHWagyC8+R8GUxhIO+sOM5S925jlkpjju2sUbH0Id2/zmdb7I9KxdKnbx3WsR+hqy7/bYqw1lA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + '@ckb-lumos/codec': 0.21.1 + '@types/deep-freeze-strict': 1.1.2 + deep-freeze-strict: 1.1.1 + dev: false + + /@ckb-lumos/config-manager@0.22.0-next.5: + resolution: {integrity: sha512-kicoRgPP9DPxA90que/+9R0iQgiIJQNreZv1/EJ+4GDOj/pFb0iBbTEQxF3zXtLKjnZoMTqTQH7e4CAV0ZftSA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@types/deep-freeze-strict': 1.1.2 + deep-freeze-strict: 1.1.1 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/hd@0.21.1: + resolution: {integrity: sha512-BnfpJf/sx/dJzL5BrOxPeKbKgv2x74KWd0xwjw1/gBQ2IMhu0S1mLwFsOT3Zu2nuhpQYvZGvr0cd3vD/SoMDow==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + bn.js: 5.2.1 + elliptic: 6.5.5 + scrypt-js: 3.0.1 + sha3: 2.1.4 + uuid: 8.3.2 + dev: false + + /@ckb-lumos/hd@0.22.0-next.5: + resolution: {integrity: sha512-d+Ws/fknuh+P9+aEBhLWiCOxMVE6i78T+2bEj8aWDLoVfrLvuCqFEyVRCspOG22pgEj0h853nMsetrb4pI+fdw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + bn.js: 5.2.1 + elliptic: 6.5.5 + scrypt-js: 3.0.1 + sha3: 2.1.4 + uuid: 8.3.2 + dev: false + + /@ckb-lumos/helpers@0.21.1: + resolution: {integrity: sha512-jFN6DtWzwVNEY4kmnzczRaQqtyRJQwzAEuHRUQ0LqTiIGM+SlfgjH/l/InAG4cIhDOurMudnUJ4ex68wmbkhVw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + '@ckb-lumos/codec': 0.21.1 + '@ckb-lumos/config-manager': 0.21.1 + '@ckb-lumos/toolkit': 0.21.1 + bech32: 2.0.0 + immutable: 4.3.5 + dev: false + + /@ckb-lumos/helpers@0.22.0-next.5: + resolution: {integrity: sha512-fsPr7oTQuKdfEvqdakgfDv9daU4PLPc9g8l13klMhEayaTamBndG3GdhTTASFMBxQaz58E6thTObsig220yVIg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/config-manager': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + bech32: 2.0.0 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/light-client@0.22.0-next.5: + resolution: {integrity: sha512-PbrGQUQAILO7QI93qm8Cke7J8CduCppKNTJd9yP7Y6ldtFKfwev8uYVB+xhUaciQclLZ26kbe1peaxd9xP8pXQ==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/ckb-indexer': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + cross-fetch: 3.1.8 + events: 3.3.0 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/lumos@0.21.1: + resolution: {integrity: sha512-a5n8xaIUvmaPsw2fBki8Jamy6/6uQnLWDZM9SQX29cZ1YVoAk/slnBmEbCIGXmxDhcuAlLkTeJaiLDKEGrZ6pg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + '@ckb-lumos/ckb-indexer': 0.21.1 + '@ckb-lumos/common-scripts': 0.21.1 + '@ckb-lumos/config-manager': 0.21.1 + '@ckb-lumos/hd': 0.21.1 + '@ckb-lumos/helpers': 0.21.1 + '@ckb-lumos/rpc': 0.21.1 + '@ckb-lumos/toolkit': 0.21.1 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/lumos@0.22.0-next.5: + resolution: {integrity: sha512-Dv+MKa664zsD9Nxrnor04Hb52MNcwgtEutgzdlE+qtAB/skp6P0NVnU9uQF7RiYHNpdmUe8PROlkzB4jaKz9NA==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + '@ckb-lumos/ckb-indexer': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/common-scripts': 0.22.0-next.5 + '@ckb-lumos/config-manager': 0.22.0-next.5 + '@ckb-lumos/hd': 0.22.0-next.5 + '@ckb-lumos/helpers': 0.22.0-next.5 + '@ckb-lumos/light-client': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + '@ckb-lumos/transaction-manager': 0.22.0-next.5 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/rpc@0.21.1: + resolution: {integrity: sha512-gZWXYCyQ98s84Pb+buOiYL3HOIxQPLHQdCyo96GFerNw9lB1XsbaGWzfHPYpZvOQqYtnJ1GUfTkQkADrQ7hmew==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/bi': 0.21.1 + abort-controller: 3.0.0 + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/rpc@0.22.0-next.5: + resolution: {integrity: sha512-ohH5kj/iiyKaTksgWCc4STwnEON9cCC2tPf3eP7XUaJ9ZO01ahOZ9C85NccBoK2lOqcLrZag3Y0LDQWw+eFbBg==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/bi': 0.22.0-next.5 + abort-controller: 3.0.0 + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + dev: false + + /@ckb-lumos/toolkit@0.21.1: + resolution: {integrity: sha512-awrFos7uQXEVGbqKSv/8Fc8B8XAfxdYoyYak4zFyAAmxxA0NiTTvk9V8TsOA7zVXpxct4Jal22+qUe+4Jg8T/g==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.21.1 + dev: false + + /@ckb-lumos/toolkit@0.22.0-next.5: + resolution: {integrity: sha512-D41GgB6D8WDh94gBNGXQJTxbI2NFFSH/71jE4kdRI6+qqbl/zYlI3nsLdQmY1RajIYyLgYLNAVHiDIWK/KGO/w==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/bi': 0.22.0-next.5 + dev: false + + /@ckb-lumos/transaction-manager@0.22.0-next.5: + resolution: {integrity: sha512-Xs10tTPgxqrB1cUMUqTdAZDDpTs3ESxPLv9HmyR2jLFI6HVJjSbGJipXODYJaJ+iUNPG/qZaehHmuQoGgbBwiw==} + engines: {node: '>=12.0.0'} + dependencies: + '@ckb-lumos/base': 0.22.0-next.5 + '@ckb-lumos/ckb-indexer': 0.22.0-next.5 + '@ckb-lumos/codec': 0.22.0-next.5 + '@ckb-lumos/rpc': 0.22.0-next.5 + '@ckb-lumos/toolkit': 0.22.0-next.5 + immutable: 4.3.5 + transitivePeerDependencies: + - encoding + dev: false + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -795,12 +1142,9 @@ packages: resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} dev: true - /@ioredis/as-callback@3.0.0: - resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} - dev: true - /@ioredis/commands@1.2.0: resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -886,6 +1230,97 @@ packages: - supports-color dev: true + /@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2: + resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.2: + resolution: {integrity: sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.2: + resolution: {integrity: sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@msgpackr-extract/msgpackr-extract-linux-arm@3.0.2: + resolution: {integrity: sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@msgpackr-extract/msgpackr-extract-linux-x64@3.0.2: + resolution: {integrity: sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@msgpackr-extract/msgpackr-extract-win32-x64@3.0.2: + resolution: {integrity: sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nervosnetwork/ckb-sdk-core@0.109.1: + resolution: {integrity: sha512-YU3yJZvz69WU6Bw//vRxzxLGcIj74CwGffdp6GYZbQpZwFaACT6FCojvOMjHIyJ8Rw32r114LhMKHvyBwagWjw==} + dependencies: + '@nervosnetwork/ckb-sdk-rpc': 0.109.1 + '@nervosnetwork/ckb-sdk-utils': 0.109.1 + '@nervosnetwork/ckb-types': 0.109.1 + tslib: 2.3.1 + transitivePeerDependencies: + - debug + dev: false + + /@nervosnetwork/ckb-sdk-rpc@0.109.1: + resolution: {integrity: sha512-RoUhXmaOm1g7ga1G3guNTzSDit79k7nqj3rYbk2jSlULRo34g9L06juU24hmwwuFZfiCaARI9mgACu0ScW9itw==} + dependencies: + '@nervosnetwork/ckb-sdk-utils': 0.109.1 + axios: 1.6.7 + tslib: 2.3.1 + transitivePeerDependencies: + - debug + dev: false + + /@nervosnetwork/ckb-sdk-utils@0.109.1: + resolution: {integrity: sha512-KK8w+JZGPt/Gq/Y0b87AuQp8mGR46fBSkqnjwASdBAi2rts9tJ6srEaZ3FVVa9LtjTlThQ120hex+mcyastrkQ==} + dependencies: + '@nervosnetwork/ckb-types': 0.109.1 + bech32: 2.0.0 + elliptic: 6.5.4 + jsbi: 3.1.3 + tslib: 2.3.1 + dev: false + + /@nervosnetwork/ckb-types@0.109.1: + resolution: {integrity: sha512-mD5mOCGa1JertKZekHSUVYwFPW27VJ0/MdwblWvEEK7pNIU6az+dLiIxgvl4TxR+j+7/GqmXNH1U59CM92y/wg==} + dev: false + + /@noble/hashes@1.4.0: + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + /@noble/secp256k1@1.7.1: + resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -915,6 +1350,38 @@ packages: resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==} dev: true + /@rgbpp-sdk/btc@0.0.0-snap-20240326062949: + resolution: {integrity: sha512-eTcXGs6xrReDOPCsVbLihyoULRpXcR6p5qN6D9BeXzrIrwLNI5YnvtIL67BgatYLC0NI5HpQhNU+oQmSb3nc4g==} + dependencies: + '@bitcoinerlab/secp256k1': 1.1.1 + '@ckb-lumos/lumos': 0.22.0-next.5 + '@nervosnetwork/ckb-types': 0.109.1 + '@rgbpp-sdk/ckb': 0.0.0-snap-20240326062949 + bip32: 4.0.0 + bitcoinjs-lib: 6.1.5 + ecpair: 2.1.0 + lodash: 4.17.21 + transitivePeerDependencies: + - debug + - encoding + dev: false + + /@rgbpp-sdk/ckb@0.0.0-snap-20240326062949: + resolution: {integrity: sha512-MpilSYXDsShHSBWfNsF1J8LaS1AmDwfEzHnqlFiU55RdiG64M+EgTIQ32ObCMApXttq7BVdazYAfjFkotSPOeg==} + dependencies: + '@ckb-lumos/base': 0.21.1 + '@ckb-lumos/codec': 0.22.0-next.5 + '@nervosnetwork/ckb-sdk-core': 0.109.1 + '@nervosnetwork/ckb-sdk-utils': 0.109.1 + '@nervosnetwork/ckb-types': 0.109.1 + axios: 1.6.8 + bignumber.js: 9.1.2 + camelcase-keys: 7.0.2 + js-sha256: 0.11.0 + transitivePeerDependencies: + - debug + dev: false + /@rollup/pluginutils@4.2.1: resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} @@ -1027,6 +1494,10 @@ packages: dev: true optional: true + /@scure/base@1.1.6: + resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} + dev: false + /@sentry-internal/tracing@7.102.1: resolution: {integrity: sha512-RkFlFyAC0fQOvBbBqnq0CLmFW5m3JJz9pKbZd5vXPraWAlniKSb1bC/4DF9SlNx0FN1LWG+IU3ISdpzwwTeAGg==} engines: {node: '>=8'} @@ -1114,26 +1585,41 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true - /@types/estree@1.0.5: - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - dev: true + /@types/blake2b@2.1.3: + resolution: {integrity: sha512-MFCdX0MNxFBP/xEILO5Td0kv6nI7+Q2iRWZbTL/yzH2/eDVZS5Wd1LHdsmXClvsCyzqaZfHFzZaN6BUeUCfSDA==} + dev: false - /@types/ioredis-mock@8.2.5: - resolution: {integrity: sha512-cZyuwC9LGtg7s5G9/w6rpy3IOZ6F/hFR0pQlWYZESMo1xQUYbDpa6haqB4grTePjsGzcB/YLBFCjqRunK5wieg==} + /@types/cron@2.4.0: + resolution: {integrity: sha512-5bBaAkqvSFBX8JMi/xCofNzG5E594TNsApMz68dLd/sQYz/HGQqgcxGHTRjOvD4G3Y+YF1Oo3S7QdCvKt1KAJQ==} + deprecated: This is a stub types definition. cron provides its own type definitions, so you do not need this installed. dependencies: - '@types/node': 20.11.17 - ioredis: 5.3.2 - transitivePeerDependencies: - - supports-color + cron: 2.4.4 + dev: false + + /@types/deep-freeze-strict@1.1.2: + resolution: {integrity: sha512-VvMETBojHvhX4f+ocYTySQlXMZfxKV3Jyb7iCWlWaC+exbedkv6Iv2bZZqI736qXjVguH6IH7bzwMBMfTT+zuQ==} + dev: false + + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true + /@types/lodash.isequal@4.5.8: + resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} + dependencies: + '@types/lodash': 4.17.0 + dev: false + /@types/lodash@4.17.0: resolution: {integrity: sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==} - dev: true + + /@types/luxon@3.3.8: + resolution: {integrity: sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==} + dev: false /@types/multicoin-address-validator@0.5.2: resolution: {integrity: sha512-NKySMMcSWl6lr7w6jPcSpbMqAazry+S+6wp0k4Iyf0hucp8xRNGAHz4jG5sohdSolBINaKTNcBp+VtH0eBFU0Q==} @@ -1153,6 +1639,10 @@ packages: resolution: {integrity: sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==} dev: true + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + /@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1781,16 +2271,41 @@ packages: - debug dev: false + /axios@1.6.8: + resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==} + dependencies: + follow-redirects: 1.15.6 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + + /b4a@1.6.6: + resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} + dev: false + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base-x@3.0.9: + resolution: {integrity: sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==} + dependencies: + safe-buffer: 5.2.1 + /base-x@4.0.0: resolution: {integrity: sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==} - dev: false /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /bech32@2.0.0: + resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==} + + /bignumber.js@9.1.2: + resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} + dev: false + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -1802,10 +2317,53 @@ packages: file-uri-to-path: 1.0.0 dev: true + /bip174@2.1.1: + resolution: {integrity: sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==} + engines: {node: '>=8.0.0'} + + /bip32@4.0.0: + resolution: {integrity: sha512-aOGy88DDlVUhspIXJN+dVEtclhIsfAUppD43V0j40cPTld3pv/0X/MlrZSZ6jowIaQQzFwP8M6rFU2z2mVYjDQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@noble/hashes': 1.4.0 + '@scure/base': 1.1.6 + typeforce: 1.18.0 + wif: 2.0.6 + dev: false + + /bitcoinjs-lib@6.1.5: + resolution: {integrity: sha512-yuf6xs9QX/E8LWE2aMJPNd0IxGofwfuVOiYdNUESkc+2bHHVKjhJd8qewqapeoolh9fihzHGoDCB5Vkr57RZCQ==} + engines: {node: '>=8.0.0'} + dependencies: + '@noble/hashes': 1.4.0 + bech32: 2.0.0 + bip174: 2.1.1 + bs58check: 3.0.1 + typeforce: 1.18.0 + varuint-bitcoin: 1.1.2 + + /blake2b-wasm@2.4.0: + resolution: {integrity: sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==} + dependencies: + b4a: 1.6.6 + nanoassert: 2.0.0 + dev: false + + /blake2b@2.1.4: + resolution: {integrity: sha512-AyBuuJNI64gIvwx13qiICz6H6hpmjvYS5DGkG6jbXMOT8Z3WUJ3V1X0FlhIoT1b/5JtHE3ki+xjtMvu1nn+t9A==} + dependencies: + blake2b-wasm: 2.4.0 + nanoassert: 2.0.0 + dev: false + /bn.js@4.12.0: resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} dev: false + /bn.js@5.2.1: + resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} + dev: false + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1824,6 +2382,10 @@ packages: dependencies: fill-range: 7.0.1 + /brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + dev: false + /browserify-bignum@1.3.0-2: resolution: {integrity: sha512-PwVvKC3WIV7ENfsG6VAIDq4R5st6kQt+Fod3WL5l7+MRONClo3J6xGQvRJHHM/ScwcNCH3GfYX5UOCuoNN/rLw==} dev: false @@ -1839,6 +2401,29 @@ packages: update-browserslist-db: 1.0.13(browserslist@4.23.0) dev: true + /bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + dependencies: + base-x: 3.0.9 + + /bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + dependencies: + base-x: 4.0.0 + + /bs58check@2.1.2: + resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} + dependencies: + bs58: 4.0.1 + create-hash: 1.2.0 + safe-buffer: 5.2.1 + + /bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + dependencies: + '@noble/hashes': 1.4.0 + bs58: 5.0.0 + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: true @@ -1853,6 +2438,21 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /bullmq@5.4.2: + resolution: {integrity: sha512-dkR/KGUw18miLe3QWtvSlmGvEe08aZF+w1jZyqEHMWFW3RP4162qp6OGud0/QCAOjusiRI8UOxUhbnortPY+rA==} + dependencies: + cron-parser: 4.9.0 + ioredis: 5.3.2 + lodash: 4.17.21 + msgpackr: 1.10.1 + node-abort-controller: 3.1.1 + semver: 7.6.0 + tslib: 2.6.2 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + dev: false + /bundle@2.1.0: resolution: {integrity: sha512-d7TeT8m2HuymDjSEmMppWe/h5SSPPUZkaWKrAofx6gNXDdZ3FL/81oOTGPG+LIaZbNr9m4rtUi98Yw0Q1vHIIw==} dev: false @@ -1879,6 +2479,21 @@ packages: tslib: 2.6.2 dev: false + /camelcase-keys@7.0.2: + resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} + engines: {node: '>=12'} + dependencies: + camelcase: 6.3.0 + map-obj: 4.3.0 + quick-lru: 5.1.1 + type-fest: 1.4.0 + dev: false + + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + dev: false + /caniuse-lite@1.0.30001591: resolution: {integrity: sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==} dev: true @@ -1952,6 +2567,12 @@ packages: engines: {node: '>=10'} dev: true + /cipher-base@1.0.4: + resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==} + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + /cjs-module-lexer@1.2.3: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} dev: true @@ -1974,6 +2595,7 @@ packages: /cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + dev: false /code-block-writer@10.1.1: resolution: {integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==} @@ -2069,10 +2691,49 @@ packages: buffer: 6.0.3 dev: false + /create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + dependencies: + cipher-base: 1.0.4 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.2 + sha.js: 2.4.11 + /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true + /cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + dependencies: + luxon: 3.4.4 + dev: false + + /cron@2.4.4: + resolution: {integrity: sha512-MHlPImXJj3K7x7lyUHjtKEOl69CSlTOWxS89jiFgNkzXfvhVjhMz/nc7/EIfN9vgooZp8XTtXJ1FREdmbyXOiQ==} + dependencies: + '@types/luxon': 3.3.8 + luxon: 3.3.0 + dev: false + + /cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + dependencies: + cross-spawn: 7.0.3 + dev: true + + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2115,6 +2776,10 @@ packages: type-detect: 4.0.8 dev: true + /deep-freeze-strict@1.1.1: + resolution: {integrity: sha512-QemROZMM2IvhAcCFvahdX2Vbm4S/txeq5rFYU9fh4mQP79WTMW5c/HkQ2ICl1zuzcDZdPZ6zarDxQeQMsVYoNA==} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -2131,6 +2796,7 @@ packages: /denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + dev: false /depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} @@ -2203,6 +2869,14 @@ packages: safe-buffer: 5.2.1 dev: false + /ecpair@2.1.0: + resolution: {integrity: sha512-cL/mh3MtJutFOvFc27GPZE2pWL3a3k4YvzUWEOvilnfZVlH3Jwgx/7d6tlD7/75tNk8TG2m+7Kgtz0SI1tWcqw==} + engines: {node: '>=8.0.0'} + dependencies: + randombytes: 2.1.0 + typeforce: 1.18.0 + wif: 2.0.6 + /edge-runtime@2.5.9: resolution: {integrity: sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==} engines: {node: '>=16'} @@ -2223,6 +2897,30 @@ packages: resolution: {integrity: sha512-GatzRKnGPS1go29ep25reM94xxd1Wj8ritU0yRhCJ/tr1Bg8gKnm6R9O/yPOhGQBoLMZ9ezfrpghNaTw97C/PQ==} dev: true + /elliptic@6.5.4: + resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} + dependencies: + bn.js: 4.12.0 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + + /elliptic@6.5.5: + resolution: {integrity: sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==} + dependencies: + bn.js: 4.12.0 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + /emoji-regex@10.3.0: resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} dev: true @@ -2743,6 +3441,21 @@ packages: reusify: 1.0.4 dev: false + /fastify-cron@1.3.1(fastify@4.26.0): + resolution: {integrity: sha512-BgOUeyu2HAJcNnL+8XnAcVLt1AMeN/SnCGTG4wDlHNFfUo/5RRnFYW8uRy3ZFpa0g43Q6ko5zdP+q1SWq8ui2Q==} + peerDependencies: + fastify: ^4.1.0 + dependencies: + '@types/cron': 2.4.0 + cron: 2.4.4 + fastify: 4.26.0 + fastify-plugin: 3.0.1 + dev: false + + /fastify-plugin@3.0.1: + resolution: {integrity: sha512-qKcDXmuZadJqdTm6vlCqioEbyewF60b/0LOFCcYN1B6BIZGlYJumWWOYs70SFYLDAH4YqdE1cxH/RKMG7rFxgA==} + dev: false + /fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} dev: false @@ -2806,22 +3519,6 @@ packages: pend: 1.2.0 dev: true - /fengari-interop@0.1.3(fengari@0.1.4): - resolution: {integrity: sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==} - peerDependencies: - fengari: ^0.1.0 - dependencies: - fengari: 0.1.4 - dev: true - - /fengari@0.1.4: - resolution: {integrity: sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==} - dependencies: - readline-sync: 1.4.10 - sprintf-js: 1.1.3 - tmp: 0.0.33 - dev: true - /fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} dev: true @@ -2883,6 +3580,16 @@ packages: optional: true dev: false + /follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} @@ -3098,10 +3805,33 @@ packages: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} dev: true + /hash-base@3.1.0: + resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==} + engines: {node: '>=4'} + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + safe-buffer: 5.2.1 + + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + /help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} dev: true + /hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + /html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true @@ -3171,6 +3901,10 @@ packages: engines: {node: '>= 4'} dev: true + /immutable@4.3.5: + resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -3206,22 +3940,6 @@ packages: p-is-promise: 3.0.0 dev: false - /ioredis-mock@8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.3.2): - resolution: {integrity: sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==} - engines: {node: '>=12.22'} - peerDependencies: - '@types/ioredis-mock': ^8 - ioredis: ^5 - dependencies: - '@ioredis/as-callback': 3.0.0 - '@ioredis/commands': 1.2.0 - '@types/ioredis-mock': 8.2.5 - fengari: 0.1.4 - fengari-interop: 0.1.3(fengari@0.1.4) - ioredis: 5.3.2 - semver: 7.6.0 - dev: true - /ioredis@5.3.2: resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} engines: {node: '>=12.22.0'} @@ -3237,6 +3955,7 @@ packages: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color + dev: false /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} @@ -3366,6 +4085,10 @@ packages: engines: {node: '>=10'} dev: true + /js-sha256@0.11.0: + resolution: {integrity: sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==} + dev: false + /js-sha512@0.9.0: resolution: {integrity: sha512-mirki9WS/SUahm+1TbAPkqvbCiCfOAAsyXeHxK1UkullnJVVqoJG2pL9ObvT05CN+tM7fxhfYm0NbXn+1hWoZg==} dev: false @@ -3378,6 +4101,11 @@ packages: resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==} dev: true + /js-xxhash@1.0.4: + resolution: {integrity: sha512-S/6Oo7ruxx5k8m4qlMnbpwQdJjRsvvfcIhIk1dA9c5y5GNhYHKYKu9krEK3QgBax6CxJuf4gRL2opgLkdzWIKg==} + engines: {node: '>=8.0.0'} + dev: false + /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3385,6 +4113,14 @@ packages: argparse: 2.0.1 dev: true + /jsbi@3.1.3: + resolution: {integrity: sha512-nBJqA0C6Qns+ZxurbEoIR56wyjiUszpNy70FHvxO5ervMoCbZVE3z3kxr5nKGhlxr/9MhKTSUBs7cAwwuf3g9w==} + dev: false + + /jsbi@4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} @@ -3533,9 +4269,11 @@ packages: /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false /lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} @@ -3589,6 +4327,16 @@ packages: dependencies: yallist: 4.0.0 + /luxon@3.3.0: + resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} + engines: {node: '>=12'} + dev: false + + /luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + dev: false + /magic-string@0.30.7: resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} engines: {node: '>=12'} @@ -3622,6 +4370,18 @@ packages: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true + /map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + dev: false + + /md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + dependencies: + hash-base: 3.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -3684,6 +4444,10 @@ packages: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} dev: false + /minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -3788,6 +4552,28 @@ packages: /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + /msgpackr-extract@3.0.2: + resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==} + hasBin: true + requiresBuild: true + dependencies: + node-gyp-build-optional-packages: 5.0.7 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.2 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.2 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.2 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.2 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.2 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2 + dev: false + optional: true + + /msgpackr@1.10.1: + resolution: {integrity: sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==} + optionalDependencies: + msgpackr-extract: 3.0.2 + dev: false + /multicoin-address-validator@0.5.16: resolution: {integrity: sha512-qI47N+F+hUewANkSi/7IONIDiGR6TV3bOCq07g/klPYF60lqo5qobGJlijgMTNdgqCkny73rANRXh2kuCQw6iA==} engines: {node: '>=12.0.0'} @@ -3803,6 +4589,10 @@ packages: lodash.isequal: 4.5.0 dev: false + /nanoassert@2.0.0: + resolution: {integrity: sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==} + dev: false + /nanoid@3.3.7: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3827,6 +4617,10 @@ packages: semver: 7.6.0 dev: false + /node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + dev: false + /node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} @@ -3861,7 +4655,13 @@ packages: optional: true dependencies: whatwg-url: 5.0.0 - dev: true + + /node-gyp-build-optional-packages@5.0.7: + resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} + hasBin: true + requiresBuild: true + dev: false + optional: true /node-gyp-build@4.8.0: resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} @@ -3967,11 +4767,6 @@ packages: engines: {node: '>= 6.0'} dev: true - /os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - dev: true - /p-finally@2.0.1: resolution: {integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==} engines: {node: '>=8'} @@ -4261,6 +5056,16 @@ packages: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} dev: false + /quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + dev: false + + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + /raw-body@2.4.1: resolution: {integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==} engines: {node: '>= 0.8'} @@ -4312,11 +5117,6 @@ packages: picomatch: 2.3.1 dev: true - /readline-sync@1.4.10: - resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} - engines: {node: '>= 0.8.0'} - dev: true - /real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -4325,12 +5125,14 @@ packages: /redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + dev: false /redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} dependencies: redis-errors: 1.2.0 + dev: false /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} @@ -4377,6 +5179,12 @@ packages: glob: 7.2.3 dev: true + /ripemd160@2.0.2: + resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} + dependencies: + hash-base: 3.1.0 + inherits: 2.0.4 + /rollup@4.12.0: resolution: {integrity: sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4426,6 +5234,10 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + /scrypt-js@3.0.1: + resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + dev: false + /secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} @@ -4465,6 +5277,19 @@ packages: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false + /sha.js@2.4.11: + resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + hasBin: true + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + + /sha3@2.1.4: + resolution: {integrity: sha512-S8cNxbyb0UGUM2VhRD4Poe5N58gJnJsLJ5vC7FYWGUmGhcsj4++WaIOBFVDxlG0W3To6xBuiRh+i0Qp2oNCOtg==} + dependencies: + buffer: 6.0.3 + dev: false + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4547,16 +5372,13 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - /sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - dev: true - /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} dev: true /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false /stat-mode@0.3.0: resolution: {integrity: sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==} @@ -4755,6 +5577,13 @@ packages: engines: {node: '>=12'} dev: false + /tiny-secp256k1@2.2.3: + resolution: {integrity: sha512-SGcL07SxcPN2nGKHTCvRMkQLYPSoeFcvArUSCYtjVARiFAWU44cCIqYS0mYAU6nY7XfvwURuTIGo2Omt3ZQr0Q==} + engines: {node: '>=14.0.0'} + dependencies: + uint8array-tools: 0.0.7 + dev: true + /tinybench@2.6.0: resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==} dev: true @@ -4769,13 +5598,6 @@ packages: engines: {node: '>=14.0.0'} dev: true - /tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - dependencies: - os-tmpdir: 1.0.2 - dev: true - /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -4809,7 +5631,6 @@ packages: /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - dev: true /tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} @@ -4867,6 +5688,10 @@ packages: resolution: {integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==} dev: true + /tslib@2.3.1: + resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} + dev: false + /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: false @@ -4899,6 +5724,11 @@ packages: engines: {node: '>=10'} dev: true + /type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + dev: false + /type-fest@3.13.1: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} @@ -4912,6 +5742,9 @@ packages: mime-types: 2.1.35 dev: false + /typeforce@1.18.0: + resolution: {integrity: sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==} + /typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} @@ -4932,6 +5765,11 @@ packages: resolution: {integrity: sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig==} dev: true + /uint8array-tools@0.0.7: + resolution: {integrity: sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==} + engines: {node: '>=14.0.0'} + dev: true + /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} dev: true @@ -4990,10 +5828,25 @@ packages: hasBin: true dev: true + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: false + + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} dev: true + /varuint-bitcoin@1.1.2: + resolution: {integrity: sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==} + dependencies: + safe-buffer: 5.2.1 + /vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -5143,14 +5996,12 @@ packages: /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - dev: true /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 - dev: true /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} @@ -5174,6 +6025,11 @@ packages: string-width: 4.2.3 dev: true + /wif@2.0.6: + resolution: {integrity: sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==} + dependencies: + bs58check: 2.1.2 + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} diff --git a/src/@types/fastify/index.d.ts b/src/@types/fastify/index.d.ts index 66ef10eb..f890aaf9 100644 --- a/src/@types/fastify/index.d.ts +++ b/src/@types/fastify/index.d.ts @@ -1,18 +1,22 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import fastify from 'fastify'; 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 { Indexer, RPC } from '@ckb-lumos/lumos'; declare module 'fastify' { - export interface FastifyInstance< - HttpServer = Server, - HttpRequest = IncomingMessage, - HttpResponse = ServerResponse, - > extends FastifyJwtNamespace<{ namespace: 'security' }> { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface FastifyInstance + extends FastifyJwtNamespace<{ namespace: 'security' }> { container: AwilixContainer; + ckbRPC: RPC; + ckbIndexer: Indexer; electrs: ElectrsAPI; bitcoind: Bitcoind; + bitcoinSPV: BitcoinSPV; + paymaster: Paymaster; + transactionManager: TransactionManager; } } diff --git a/src/app.ts b/src/app.ts index 7e99c12a..b57f1d35 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,8 @@ import options from './options'; import { serializerCompiler, validatorCompiler, ZodTypeProvider } from 'fastify-type-provider-zod'; import cors from './plugins/cors'; import { NetworkType } from './constants'; +import rgbppRoutes from './routes/rgbpp'; +import cronRoutes from './routes/cron'; if (env.SENTRY_DSN_URL && env.NODE_ENV !== 'development') { Sentry.init({ @@ -33,9 +35,6 @@ const isTokenRoutesEnable = env.NODE_ENV === 'production' ? env.ADMIN_USERNAME & async function routes(fastify: FastifyInstance) { fastify.log.info(`Process env: ${JSON.stringify(getSafeEnvs(), null, 2)}`); - container.register({ logger: asValue(fastify.log) }); - fastify.decorate('container', container); - await fastify.register(cors); fastify.register(sensible); fastify.register(compress); @@ -53,6 +52,8 @@ async function routes(fastify: FastifyInstance) { fastify.register(tokenRoutes, { prefix: '/token' }); } fastify.register(bitcoinRoutes, { prefix: '/bitcoin/v1' }); + fastify.register(rgbppRoutes, { prefix: '/rgbpp/v1' }); + fastify.register(cronRoutes, { prefix: '/cron' }); fastify.setErrorHandler((error, _, reply) => { fastify.log.error(error); @@ -70,6 +71,10 @@ export function buildFastify() { const app = fastify(options).withTypeProvider(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); + + container.register({ logger: asValue(app.log) }); + app.decorate('container', container); + app.register(routes); return app; } diff --git a/src/constants.ts b/src/constants.ts index bfc87f0b..8d743c5a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,4 +13,7 @@ export enum ApiCacheStatus { Miss = 'MISS', } -export const JWT_IGNORE_URLS = ['/token', '/docs']; +export const JWT_IGNORE_URLS = ['/token', '/docs', '/cron']; +export const SWAGGER_PROD_IGNORE_URLS = ['/token', '/cron']; + +export const VERCEL_MAX_DURATION = 300; diff --git a/src/container.ts b/src/container.ts index 5eab294c..9872251a 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,16 +1,27 @@ -import { createContainer, InjectionMode, asValue, asClass, asFunction, Lifetime } from 'awilix'; +import { createContainer, InjectionMode, asValue, asClass, asFunction } 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 { RPC as CkbRPC, Indexer as CkbIndexer } from '@ckb-lumos/lumos'; +import Unlocker from './services/unlocker'; +import BitcoinSPV from './services/spv'; export interface Cradle { env: typeof env; logger: pino.BaseLogger; - redis: Redis | undefined; + redis: Redis; + ckbRpc: CkbRPC; + ckbIndexer: CkbIndexer; bitcoind: Bitcoind; electrs: ElectrsAPI; + bitcoinSPV: BitcoinSPV; + paymaster: Paymaster; + unlocker: Unlocker; + transactionManager: TransactionManager; } const container = createContainer({ @@ -21,9 +32,19 @@ const container = createContainer({ container.register({ env: asValue(env), logger: asValue(pino()), - redis: asFunction(() => (env.REDIS_URL ? new Redis(env.REDIS_URL) : undefined)), - bitcoind: asClass(Bitcoind, { lifetime: Lifetime.SINGLETON }), - electrs: asClass(ElectrsAPI, { lifetime: Lifetime.SINGLETON }), + redis: asValue( + new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + }), + ), + ckbRpc: asFunction(() => new CkbRPC(env.CKB_RPC_URL)).singleton(), + ckbIndexer: asFunction(() => new CkbIndexer(env.CKB_RPC_URL)).singleton(), + bitcoind: asClass(Bitcoind).singleton(), + electrs: asClass(ElectrsAPI).singleton(), + bitcoinSPV: asClass(BitcoinSPV).singleton(), + paymaster: asClass(Paymaster).singleton(), + transactionManager: asClass(TransactionManager).singleton(), + unlocker: asClass(Unlocker).singleton(), }); export default container; diff --git a/src/env.ts b/src/env.ts index abf50f35..4fd7831d 100644 --- a/src/env.ts +++ b/src/env.ts @@ -12,7 +12,7 @@ const envSchema = z.object({ DOMAIN: z.string().optional(), SENTRY_DSN_URL: z.string().optional(), - REDIS_URL: z.string().optional(), + REDIS_URL: z.string(), RATE_LIMIT_PER_MINUTE: z.number().default(100), ADMIN_USERNAME: z.string().optional(), @@ -35,6 +35,28 @@ const envSchema = z.object({ * It is used to query the Bitcoin blockchain (balance, transactions, etc). */ BITCOIN_ELECTRS_API_URL: z.string(), + + /** + * 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: z.coerce.number().default(220), + PAYMASTER_CELL_PRESET_COUNT: z.coerce.number().default(500), + PAYMASTER_CELL_REFILL_THRESHOLD: z.coerce.number().default(0.3), + + UNLOCKER_CRON_SCHEDULE: z.string().default('*/5 * * * *'), + UNLOCKER_CELL_BATCH_SIZE: z.coerce.number().default(100), + + TRANSACTION_QUEUE_JOB_DELAY: z.coerce.number().default(120 * 1000), }); export type Env = z.infer; diff --git a/src/index.ts b/src/index.ts index 79b5188d..c8317ace 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,21 @@ import process from 'node:process'; import { env } from './env'; import { buildFastify } from './app'; +import cron from './plugins/cron'; const port = parseInt(env.PORT || '3000', 10); const host = env.ADDRESS || '0.0.0.0'; const app = buildFastify(); +app.register(cron); app.listen({ port, host }, (err, address) => { if (err) { console.error(err); process.exit(1); } + + app.cron.startAllJobs(); // eslint-disable-next-line no-console console.log(`Server listening at ${address}`); }); diff --git a/src/options.ts b/src/options.ts index 75da589c..7283adec 100644 --- a/src/options.ts +++ b/src/options.ts @@ -4,8 +4,8 @@ import { Server } from 'http'; import { env } from './env'; const envToLogger = { - development: - provider !== 'vercel' + development: { + ...(provider !== 'vercel' ? { transport: { target: 'pino-pretty', @@ -15,8 +15,12 @@ const envToLogger = { }, }, } - : true, - production: true, + : {}), + level: 'debug', + }, + production: { + level: 'info', + }, }; const options: FastifyHttpOptions = { diff --git a/src/plugins/cron.ts b/src/plugins/cron.ts new file mode 100644 index 00000000..7274851c --- /dev/null +++ b/src/plugins/cron.ts @@ -0,0 +1,49 @@ +import fp from 'fastify-plugin'; +import * as Sentry from '@sentry/node'; +import TransactionManager from '../services/transaction'; +import cron from 'fastify-cron'; +import { Env } from '../env'; + +export default fp(async (fastify) => { + try { + const env: Env = fastify.container.resolve('env'); + + // processing rgb++ ckb transaction + const transactionManager: TransactionManager = fastify.container.resolve('transactionManager'); + fastify.addHook('onReady', async () => { + transactionManager.startProcess({ + onActive: (job) => { + fastify.log.info(`Job active: ${job.id}`); + }, + onCompleted: (job) => { + fastify.log.info(`Job completed: ${job.id}`); + }, + }); + }); + fastify.addHook('onClose', async () => { + transactionManager.closeProcess(); + }); + + // processing unlock BTC_TIME_LOCK cells + const unlocker = fastify.container.resolve('unlocker'); + fastify.register(cron, { + jobs: [ + { + name: 'unlock-btc-time-lock-cells', + cronTime: env.UNLOCKER_CRON_SCHEDULE, + onTick: async () => { + try { + await unlocker.unlockCells(); + } catch (err) { + fastify.log.error(err); + Sentry.captureException(err); + } + }, + }, + ], + }); + } catch (err) { + fastify.log.error(err); + Sentry.captureException(err); + } +}); diff --git a/src/plugins/swagger.ts b/src/plugins/swagger.ts index 7d9171e0..2d607255 100644 --- a/src/plugins/swagger.ts +++ b/src/plugins/swagger.ts @@ -3,6 +3,7 @@ import swagger from '@fastify/swagger'; import swaggerUI from '@fastify/swagger-ui'; import { jsonSchemaTransform } from 'fastify-type-provider-zod'; import { env } from '../env'; +import { SWAGGER_PROD_IGNORE_URLS } from '../constants'; export const DOCS_ROUTE_PREFIX = '/docs'; @@ -30,7 +31,7 @@ export default fp(async (fastify) => { if (env.NODE_ENV === 'production') { const { paths = {} } = swaggerObject; const newPaths = Object.entries(paths).reduce((acc, [path, methods]) => { - if (path.startsWith('/token')) { + if (SWAGGER_PROD_IGNORE_URLS.some((ignorePath) => path.startsWith(ignorePath))) { return acc; } return { ...acc, [path]: methods }; diff --git a/src/routes/bitcoin/address.ts b/src/routes/bitcoin/address.ts index 0af4b71c..8439e5e9 100644 --- a/src/routes/bitcoin/address.ts +++ b/src/routes/bitcoin/address.ts @@ -1,6 +1,6 @@ import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; -import { Balance, BalanceType, Transaction, UTXO, UTXOType } from './types'; +import { Balance, Transaction, UTXO } from './types'; import validateBitcoinAddress from '../../utils/validators'; import { ZodTypeProvider } from 'fastify-type-provider-zod'; import z from 'zod'; @@ -36,7 +36,7 @@ const addressRoutes: FastifyPluginCallback, Server, ZodType const { min_satoshi } = request.query; const utxos = await fastify.electrs.getUtxoByAddress(address); return utxos.reduce( - (acc: BalanceType, utxo: UTXOType) => { + (acc: Balance, utxo: UTXO) => { if (utxo.status.confirmed) { if (min_satoshi && utxo.value < min_satoshi) { acc.dust_satoshi += utxo.value; diff --git a/src/routes/bitcoin/block.ts b/src/routes/bitcoin/block.ts index 0c54a214..bdcc2925 100644 --- a/src/routes/bitcoin/block.ts +++ b/src/routes/bitcoin/block.ts @@ -28,6 +28,30 @@ const blockRoutes: FastifyPluginCallback, Server, ZodTypePr }, ); + fastify.get( + '/:hash/txids', + { + schema: { + description: 'Get block transaction ids by its hash', + tags: ['Bitcoin'], + params: z.object({ + hash: z.string().describe('The Bitcoin block hash'), + }), + response: { + 200: z.object({ + txids: z.array(z.string()) + }), + }, + }, + }, + async (request, reply) => { + const { hash } = request.params; + const txids = await fastify.electrs.getBlockTxIdsByHash(hash); + reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); + return { txids }; + }, + ); + fastify.get( '/:hash/header', { diff --git a/src/routes/bitcoin/types.ts b/src/routes/bitcoin/types.ts index 1e13cc5b..fea7c1e0 100644 --- a/src/routes/bitcoin/types.ts +++ b/src/routes/bitcoin/types.ts @@ -78,8 +78,8 @@ export const Transaction = z.object({ status: Status, }); -export type ChainInfoType = z.infer; -export type BlockType = z.infer; -export type BalanceType = z.infer; -export type UTXOType = z.infer; -export type TransactionType = z.infer; +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; diff --git a/src/routes/cron/index.ts b/src/routes/cron/index.ts new file mode 100644 index 00000000..72e2b527 --- /dev/null +++ b/src/routes/cron/index.ts @@ -0,0 +1,13 @@ +import { FastifyPluginCallback } from "fastify"; +import { ZodTypeProvider } from "fastify-type-provider-zod"; +import { Server } from "http"; +import processTransactionsCronRoute from "./process-transactions"; +import unlockCellsCronRoute from "./unlock-cells"; + +const cronRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.register(processTransactionsCronRoute); + fastify.register(unlockCellsCronRoute); + done(); +}; + +export default cronRoutes; diff --git a/src/routes/cron/process-transactions.ts b/src/routes/cron/process-transactions.ts new file mode 100644 index 00000000..02ce05a6 --- /dev/null +++ b/src/routes/cron/process-transactions.ts @@ -0,0 +1,49 @@ +import pino from 'pino'; +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 { VERCEL_MAX_DURATION } from '../../constants'; +import * as Sentry from '@sentry/node'; + +const processTransactionsCronRoute: FastifyPluginCallback, Server, ZodTypeProvider> = ( + fastify, + _, + done, +) => { + fastify.get( + '/process-transactions', + { + schema: { + tags: ['Cron Task'], + description: 'Run RGB++ CKB transaction cron task, used for serverless deployment', + }, + }, + async () => { + const logger = container.resolve('logger'); + const transactionManager: TransactionManager = container.resolve('transactionManager'); + try { + await new Promise((resolve) => { + setTimeout(resolve, (VERCEL_MAX_DURATION - 10) * 1000); + transactionManager.startProcess({ + onActive: (job) => { + logger.info(`Job active: ${job.id}`); + }, + onCompleted: (job) => { + logger.info(`Job completed: ${job.id}`); + }, + }); + }); + await transactionManager.pauseProcess(); + await transactionManager.closeProcess(); + } catch (err) { + logger.error(err); + Sentry.captureException(err); + } + }, + ); + done(); +}; + +export default processTransactionsCronRoute; diff --git a/src/routes/cron/unlock-cells.ts b/src/routes/cron/unlock-cells.ts new file mode 100644 index 00000000..e94e4d11 --- /dev/null +++ b/src/routes/cron/unlock-cells.ts @@ -0,0 +1,36 @@ +import pino from 'pino'; +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import container from '../../container'; +import Unlocker from '../../services/unlocker'; +import * as Sentry from '@sentry/node'; + +const unlockCellsCronRoute: FastifyPluginCallback, Server, ZodTypeProvider> = ( + fastify, + _, + done, +) => { + fastify.get( + '/unlock-cells', + { + schema: { + tags: ['Cron Task'], + description: 'Run BTC_TIME_LOCK cells unlock cron task, used for serverless deployment', + }, + }, + async () => { + const logger = container.resolve('logger'); + const unlocker: Unlocker = container.resolve('unlocker'); + try { + await unlocker.unlockCells(); + } catch (err) { + logger.error(err); + Sentry.captureException(err); + } + }, + ); + done(); +}; + +export default unlockCellsCronRoute; diff --git a/src/routes/rgbpp/address.ts b/src/routes/rgbpp/address.ts new file mode 100644 index 00000000..9fcd2dda --- /dev/null +++ b/src/routes/rgbpp/address.ts @@ -0,0 +1,73 @@ +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import validateBitcoinAddress from '../../utils/validators'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { Cell, Script } from './types'; +import { buildRgbppLockArgs, genRgbppLockScript } from '@rgbpp-sdk/ckb/lib/utils/rgbpp'; +import { CKBIndexerQueryOptions } from '@ckb-lumos/ckb-indexer/lib/type'; +import { blockchain } from '@ckb-lumos/base' +import z from 'zod'; + +const addressRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.addHook('preHandler', async (request) => { + const { btc_address } = request.params as { btc_address: string }; + const valid = validateBitcoinAddress(btc_address); + if (!valid) { + throw fastify.httpErrors.badRequest('Invalid bitcoin address'); + } + }); + + fastify.get( + '/:btc_address/assets', + { + schema: { + description: 'Get RGB++ assets by btc address', + tags: ['RGB++'], + params: z.object({ + btc_address: z.string(), + }), + querystring: z.object({ + type_script: Script.or(z.string()).optional(), + }), + response: { + 200: z.array(Cell), + }, + }, + }, + async (request) => { + const { btc_address } = request.params; + const { type_script } = request.query; + const utxos = await fastify.electrs.getUtxoByAddress(btc_address); + const cells = await Promise.all( + utxos.map(async (utxo) => { + const { txid, vout } = utxo; + const args = buildRgbppLockArgs(vout, txid); + + const query: CKBIndexerQueryOptions = { + lock: genRgbppLockScript(args, process.env.NETWORK === 'mainnet'), + }; + + if (type_script) { + if (typeof type_script === 'string') { + query.type = blockchain.Script.unpack(type_script); + } else { + query.type = type_script; + } + } + + const collector = fastify.ckbIndexer.collector(query).collect(); + const cells: Cell[] = []; + for await (const cell of collector) { + cells.push(cell); + } + return cells; + }), + ); + return cells.flat(); + }, + ); + + done(); +}; + +export default addressRoutes; diff --git a/src/routes/rgbpp/assets.ts b/src/routes/rgbpp/assets.ts new file mode 100644 index 00000000..e0e720c4 --- /dev/null +++ b/src/routes/rgbpp/assets.ts @@ -0,0 +1,79 @@ +import { FastifyPluginCallback } from 'fastify'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { Server } from 'http'; +import z from 'zod'; +import { buildRgbppLockArgs, genRgbppLockScript } from '@rgbpp-sdk/ckb/lib/utils/rgbpp'; +import { Cell } from './types'; +import { CKBIndexerQueryOptions } from '@ckb-lumos/ckb-indexer/lib/type'; + +const assetsRoute: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.get( + '/:btc_txid', + { + schema: { + description: `Get RGB++ assets by BTC txid.`, + tags: ['RGB++'], + params: z.object({ + btc_txid: z.string(), + }), + response: { + 200: z.array(Cell), + }, + }, + }, + async (request) => { + const { btc_txid } = request.params; + const transaction = await fastify.electrs.getTransaction(btc_txid); + const cells: Cell[] = []; + for (let index = 0; index < transaction.vout.length; index++) { + const args = buildRgbppLockArgs(index, btc_txid); + const query: CKBIndexerQueryOptions = { + lock: genRgbppLockScript(args, process.env.NETWORK === 'mainnet'), + }; + const collector = fastify.ckbIndexer.collector(query).collect(); + for await (const cell of collector) { + cells.push(cell); + } + } + return cells; + }, + ); + + + fastify.get( + '/:btc_txid/:vout', + { + schema: { + description: 'Get RGB++ assets by btc txid and vout', + tags: ['RGB++'], + params: z.object({ + btc_txid: z.string(), + vout: z.coerce.number(), + }), + response: { + 200: z.array(Cell), + }, + }, + }, + async (request) => { + const { btc_txid, vout } = request.params; + const args = buildRgbppLockArgs(vout, btc_txid); + const lockScript = genRgbppLockScript(args, process.env.NETWORK === 'mainnet'); + + const collector = fastify.ckbIndexer.collector({ + lock: lockScript, + }); + + const collect = collector.collect(); + const cells: Cell[] = []; + for await (const cell of collect) { + cells.push(cell); + } + return cells; + }, + ); + + done(); +}; + +export default assetsRoute; diff --git a/src/routes/rgbpp/index.ts b/src/routes/rgbpp/index.ts new file mode 100644 index 00000000..0d9b8f66 --- /dev/null +++ b/src/routes/rgbpp/index.ts @@ -0,0 +1,24 @@ +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import container from '../../container'; +import transactionRoutes from './transaction'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import assetsRoute from './assets'; +import addressRoutes from './address'; +import spvRoute from './spv'; + +const rgbppRoutes: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.decorate('transactionManager', container.resolve('transactionManager')); + fastify.decorate('ckbRPC', container.resolve('ckbRpc')); + fastify.decorate('ckbIndexer', container.resolve('ckbIndexer')); + fastify.decorate('electrs', container.resolve('electrs')); + fastify.decorate('bitcoinSPV', container.resolve('bitcoinSPV')); + + fastify.register(transactionRoutes, { prefix: '/transaction' }); + fastify.register(assetsRoute, { prefix: '/assets' }); + fastify.register(addressRoutes, { prefix: '/address' }); + fastify.register(spvRoute, { prefix: '/btc-spv' }); + done(); +}; + +export default rgbppRoutes; diff --git a/src/routes/rgbpp/spv.ts b/src/routes/rgbpp/spv.ts new file mode 100644 index 00000000..844fb003 --- /dev/null +++ b/src/routes/rgbpp/spv.ts @@ -0,0 +1,38 @@ +import { FastifyPluginCallback } from 'fastify'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { Server } from 'http'; +import z from 'zod'; +import { TxProof } from '../../services/spv'; +import { CUSTOM_HEADERS } from '../../constants'; + +const spvRoute: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.get( + '/proof', + { + schema: { + description: 'Get proof of a Bitcoin transaction from SPV service', + tags: ['RGB++'], + querystring: z.object({ + txid: z.string().describe('The Bitcoin transaction id'), + confirmations: z.coerce.number().describe('The number of confirmations'), + }), + response: { + 200: TxProof, + }, + }, + }, + async (request, reply) => { + const { txid, confirmations } = request.query; + const btcTx = await fastify.electrs.getTransaction(txid); + const txids = await fastify.electrs.getBlockTxIdsByHash(btcTx.status.block_hash!); + const index = txids.findIndex((id) => id === txid); + const proof = await fastify.bitcoinSPV.getTxProof(txid, index, confirmations); + reply.header(CUSTOM_HEADERS.ResponseCacheable, 'true'); + return proof; + }, + ); + + done(); +}; + +export default spvRoute; diff --git a/src/routes/rgbpp/transaction.ts b/src/routes/rgbpp/transaction.ts new file mode 100644 index 00000000..adcc4b8f --- /dev/null +++ b/src/routes/rgbpp/transaction.ts @@ -0,0 +1,140 @@ +import { FastifyPluginCallback } from 'fastify'; +import { ZodTypeProvider } from 'fastify-type-provider-zod'; +import { Server } from 'http'; +import z from 'zod'; +import { CKBVirtualResult } from './types'; +import { Job } from 'bullmq'; +import { + btcTxIdFromBtcTimeLockArgs, + buildRgbppLockArgs, + genRgbppLockScript, + getBtcTimeLockScript, +} from '@rgbpp-sdk/ckb'; +import { remove0x } from '@rgbpp-sdk/btc'; + +const transactionRoute: FastifyPluginCallback, Server, ZodTypeProvider> = (fastify, _, done) => { + fastify.post( + '/ckb-tx', + { + schema: { + description: 'Submit a RGB++ CKB transaction', + tags: ['RGB++'], + body: z.object({ + btc_txid: z.string(), + ckb_virtual_result: CKBVirtualResult, + }), + response: { + 200: z.object({ + state: z.string().describe('The state of the transaction, waiting by default'), + }), + }, + }, + }, + async (request, reply) => { + const { btc_txid, ckb_virtual_result } = request.body; + const job: Job = await fastify.transactionManager.enqueueTransaction({ + txid: btc_txid, + ckbVirtualResult: ckb_virtual_result, + }); + const state = await job.getState(); + reply.send({ state }); + }, + ); + + fastify.get( + '/:btc_txid', + { + schema: { + description: `Get the CKB transaction hash by BTC txid.`, + tags: ['RGB++'], + params: z.object({ + btc_txid: z.string(), + }), + response: { + 200: z.object({ + txhash: z.string().describe('The CKB transaction hash'), + }), + }, + }, + }, + async (request, reply) => { + const { btc_txid } = request.params; + const isMainnet = process.env.NETWORK === 'mainnet'; + const transaction = await fastify.electrs.getTransaction(btc_txid); + // query CKB transaction hash by RGBPP_LOCK cells + for (let index = 0; index < transaction.vout.length; index++) { + const args = buildRgbppLockArgs(index, btc_txid); + const collector = fastify.ckbIndexer + .collector({ + lock: genRgbppLockScript(args, isMainnet), + }) + .collect(); + for await (const cell of collector) { + if (cell) { + return { txhash: cell.outPoint!.txHash }; + } + } + } + + // query CKB transaction hash by BTC_TIME_LOCK cells + const btcTimeLockScript = getBtcTimeLockScript(isMainnet); + const timeLockCollector = fastify.ckbIndexer + .collector({ + lock: { + codeHash: btcTimeLockScript.codeHash, + hashType: btcTimeLockScript.hashType, + args: '0x', + }, + }) + .collect(); + for await (const cell of timeLockCollector) { + const btcTxid = btcTxIdFromBtcTimeLockArgs(cell.cellOutput.lock.args); + if (remove0x(btcTxid) === btc_txid) { + return { txhash: cell.outPoint!.txHash }; + } + } + + reply.status(404); + }, + ); + + fastify.get( + '/:btc_txid/job', + { + schema: { + description: ` + Get the job state of a transaction by BTC txid. + + * completed: The CKB transaction has been sent and confirmed. + * failed: Something went wrong during the process, and it has failed. + * delayed: The transaction has not been confirmed yet and is waiting for confirmation. + * active: The transaction is currently being processed. + * waiting: The transaction is pending and is waiting to be processed. + `, + tags: ['RGB++'], + params: z.object({ + btc_txid: z.string(), + }), + response: { + 200: z.object({ + state: z.string().describe('The state of the transaction'), + }), + }, + }, + }, + async (request, reply) => { + const { btc_txid } = request.params; + const job = await fastify.transactionManager.getTransactionRequest(btc_txid); + if (!job) { + reply.status(404); + return; + } + const state = await job.getState(); + return { state }; + }, + ); + + done(); +}; + +export default transactionRoute; diff --git a/src/routes/rgbpp/types.ts b/src/routes/rgbpp/types.ts new file mode 100644 index 00000000..88e6c9b8 --- /dev/null +++ b/src/routes/rgbpp/types.ts @@ -0,0 +1,82 @@ +import z from 'zod'; + +export const Script = z.object({ + codeHash: z.string(), + args: z.string(), + hashType: z.enum(['type', 'data', 'data1', 'data2']), +}); +export type Script = z.infer; + +export const CellDep = z.object({ + outPoint: z + .object({ + txHash: z.string(), + index: z.string(), + }) + .or(z.null()), + depType: z.enum(['depGroup', 'code']), +}); +export type CellDep = z.infer; + +export const InputCell = z.object({ + previousOutput: z.object({ + txHash: z.string(), + index: z.string(), + }).or(z.null()), + since: z.string(), +}); +export type InputCell = z.infer; + +export const OutputCell = z.object({ + capacity: z.string(), + lock: Script, + type: Script.or(z.null()).optional(), +}); +export type OutputCell = z.infer; + +export const Cell = z.object({ + cellOutput: OutputCell, + data: z.string(), + outPoint: z + .object({ + txHash: z.string(), + index: z.string(), + }) + .or(z.null()) + .optional(), + blockHash: z.string().optional(), + blockNumber: z.string().optional(), + txIndex: z.string().optional(), +}); +export type Cell = z.infer; + +export const CKBRawTransaction = z.object({ + version: z.string(), + cellDeps: z.array(CellDep), + headerDeps: z.array(z.string()), + inputs: z.array(InputCell), + outputs: z.array(OutputCell), + outputsData: z.array(z.string()), + witnesses: z.array(z.string()).default([]), +}); +export type CKBRawTransaction = z.infer; + +export const CKBTransaction = z.object({ + cellDeps: z.array(CellDep), + inputs: z.array(InputCell), + outputs: z.array(OutputCell), + outputsData: z.array(z.string()), + headerDeps: z.array(z.string()), + hash: z.string(), + version: z.string(), + witnesses: z.array(z.string()), +}); +export type CKBTransaction = z.infer; + +export const CKBVirtualResult = z.object({ + ckbRawTx: CKBRawTransaction, + commitment: z.string(), + needPaymasterCell: z.boolean(), + sumInputsCapacity: z.string(), +}); +export type CKBVirtualResult = z.infer; diff --git a/src/services/bitcoind.ts b/src/services/bitcoind.ts index addea255..ae600066 100644 --- a/src/services/bitcoind.ts +++ b/src/services/bitcoind.ts @@ -1,10 +1,14 @@ -import { ChainInfoType } from '../routes/bitcoin/types'; +import { ChainInfo } from '../routes/bitcoin/types'; import axios, { 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'; +/** + * Bitcoind, a wrapper for Bitcoin Core JSON-RPC + */ export default class Bitcoind { private request: AxiosInstance; @@ -28,12 +32,16 @@ export default class Bitcoind { private async callMethod(method: string, params: unknown): Promise { return Sentry.startSpan({ op: this.constructor.name, name: method }, async () => { + const id = randomUUID(); const response = await this.request.post('', { jsonrpc: '1.0', - id: Date.now(), + id, method, params, }); + if (response.data.error) { + throw new Error(response.data.error.message); + } return response.data.result; }); } @@ -57,7 +65,7 @@ export default class Bitcoind { // https://developer.bitcoin.org/reference/rpc/getblockchaininfo.html public async getBlockchainInfo() { - return this.callMethod('getblockchaininfo', []); + return this.callMethod('getblockchaininfo', []); } // https://developer.bitcoin.org/reference/rpc/sendrawtransaction.html diff --git a/src/services/electrs.ts b/src/services/electrs.ts index be35d84f..2f1bbb15 100644 --- a/src/services/electrs.ts +++ b/src/services/electrs.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import { BlockType, TransactionType, UTXOType } from '../routes/bitcoin/types'; +import { Block, Transaction, UTXO } from '../routes/bitcoin/types'; import * as Sentry from '@sentry/node'; import { Cradle } from '../container'; import { addLoggerInterceptor } from '../utils/interceptors'; @@ -18,6 +18,9 @@ export default class ElectrsAPI { private async get(path: string): Promise> { return Sentry.startSpan({ op: this.constructor.name, name: path }, async () => { const response = await this.request.get(path); + if (response.data.error) { + throw new Error(response.data.error.message); + } return response; }); } @@ -43,25 +46,31 @@ export default class ElectrsAPI { // https://github.com/blockstream/esplora/blob/master/API.md#get-addressaddressutxo public async getUtxoByAddress(address: string) { - const response = await this.get(`/address/${address}/utxo`); + 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`); + 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}`); + 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}`); + const response = await this.get(`/block/${hash}`); return response.data; } @@ -76,4 +85,10 @@ export default class ElectrsAPI { 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; + } } diff --git a/src/services/paymaster.ts b/src/services/paymaster.ts new file mode 100644 index 00000000..ecbc3d2c --- /dev/null +++ b/src/services/paymaster.ts @@ -0,0 +1,223 @@ +import { Cell, helpers } from '@ckb-lumos/lumos'; +import { Cradle } from '../container'; +import { Queue, Worker } from 'bullmq'; +import { AppendPaymasterCellAndSignTxParams, IndexerCell, appendPaymasterCellAndSignCkbTx } from '@rgbpp-sdk/ckb'; +import { hd, config, BI } from '@ckb-lumos/lumos'; +import * as Sentry from '@sentry/node'; + +interface IPaymaster { + getNextCell(token: string): Promise; + refillCellQueue(): Promise; + appendCellAndSignTx( + txid: string, + params: Pick, + ): ReturnType; + markPaymasterCellAsSpent(txid: string, signedTx: CKBComponents.RawTransaction): Promise; +} + +export const PAYMASTER_CELL_QUEUE_NAME = 'rgbpp-ckb-paymaster-cell-queue'; + +class PaymasterCellNotEnoughError extends Error { + constructor(message: string) { + super(message); + this.name = 'PaymasterCellNotEnoughError'; + } +} + +/** + * Paymaster + * responsible for managing the paymaster cells and signing the CKB transactions. + */ +export default class Paymaster implements IPaymaster { + private cradle: Cradle; + private queue: Queue; + private worker: Worker; + + private cellCapacity: number; + private presetCount: number; + // the threshold to refill the queue, default is 0.3 + private refillThreshold: number; + // avoid the refilling to be triggered multiple times + private refilling = false; + + constructor(cradle: Cradle) { + this.cradle = cradle; + this.queue = new Queue(PAYMASTER_CELL_QUEUE_NAME, { + connection: cradle.redis, + }); + this.worker = new Worker(PAYMASTER_CELL_QUEUE_NAME, undefined, { + connection: cradle.redis, + lockDuration: 60_000, + removeOnComplete: { count: 0 }, + removeOnFail: { count: 0 }, + }); + this.cellCapacity = this.cradle.env.PAYMASTER_CELL_CAPACITY; + this.presetCount = this.cradle.env.PAYMASTER_CELL_PRESET_COUNT; + this.refillThreshold = this.cradle.env.PAYMASTER_CELL_REFILL_THRESHOLD; + } + + private get lockScript() { + const args = hd.key.privateKeyToBlake160(this.privateKey); + const scripts = + this.cradle.env.NETWORK === 'mainnet' ? config.predefined.LINA.SCRIPTS : config.predefined.AGGRON4.SCRIPTS; + const template = scripts['SECP256K1_BLAKE160']!; + const lockScript = { + codeHash: template.CODE_HASH, + hashType: template.HASH_TYPE, + args: args, + }; + return lockScript; + } + + public get privateKey() { + return this.cradle.env.PAYMASTER_PRIVATE_KEY; + } + + public get address() { + const isMainnet = this.cradle.env.NETWORK === 'mainnet'; + const lumosConfig = isMainnet ? config.predefined.LINA : config.predefined.AGGRON4; + const args = hd.key.privateKeyToBlake160(this.privateKey); + const template = lumosConfig.SCRIPTS['SECP256K1_BLAKE160']; + const lockScript = { + codeHash: template.CODE_HASH, + hashType: template.HASH_TYPE, + args: args, + }; + return helpers.encodeToAddress(lockScript, { + config: lumosConfig, + }); + } + + /** + * Get the next paymaster cell from the queue + * will refill the queue if the count is less than the threshold + * @param token - the token to get the next job, using btc txid by default + */ + public async getNextCell(token: string) { + // avoid the refilling to be triggered multiple times + if (!this.refilling) { + const count = await this.queue.getWaitingCount(); + // refill if it's less than REFILL_THRESHOLD of the preset count + if (count < this.presetCount * this.refillThreshold) { + this.refilling = true; + const filled = await this.refillCellQueue(); + if (filled + count < this.presetCount) { + // 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'); + Sentry.captureException(error); + } + this.refilling = false; + } + } + + let cell: IndexerCell | null = null; + while (!cell) { + const job = await this.worker.getNextJob(token); + if (!job) { + break; + } + + const data = job.data; + const liveCell = await this.cradle.ckbRpc.getLiveCell(data.outPoint!, false); + if (!liveCell || liveCell.status !== 'live') { + job.moveToFailed(new Error('The paymaster cell is not live'), token); + continue; + } + + cell = { + output: data.cellOutput, + outPoint: data.outPoint!, + outputData: data.data, + blockNumber: data.blockNumber!, + txIndex: data.txIndex!, + }; + } + + return cell; + } + + /** + * Refill the paymaster cell queue + * get cells from the indexer and add them to the queue + * make sure the queue has enough cells to use for the next transactions + */ + public async refillCellQueue() { + const queueSize = await this.queue.getWaitingCount(); + let filled = 0; + if (queueSize >= this.presetCount) { + return filled; + } + + const collector = this.cradle.ckbIndexer.collector({ + lock: this.lockScript, + outputCapacityRange: [BI.from(this.cellCapacity).toHexString(), BI.from(this.cellCapacity + 1).toHexString()], + }); + const cells = collector.collect(); + + for await (const cell of cells) { + const outPoint = cell.outPoint!; + this.cradle.logger.info( + `[Paymaster] Refill paymaster cell: ${outPoint.txHash}:${outPoint.index}, ${cell.cellOutput.capacity} CKB`, + ); + await this.queue.add(PAYMASTER_CELL_QUEUE_NAME, cell, { + // use the outPoint as the jobId to avoid duplicate cells + jobId: `${outPoint.txHash}:${outPoint.index}`, + }); + // count the filled cells, it maybe less than the cells we added + // because we may have duplicate cells, but it's work fine + filled += 1; + if (queueSize + filled >= this.presetCount) { + break; + } + } + return filled; + } + + /** + * Append the paymaster cell to the CKB transaction and sign the transactions + * @param token - the token to get the next job, using btc txid by default + * @param params - the ckb transaction parameters + */ + public async appendCellAndSignTx( + token: string, + params: Pick, + ) { + const { ckbRawTx, sumInputsCapacity } = params; + const paymasterCell = await this.getNextCell(token); + this.cradle.logger.info(`[Paymaster] Get paymaster cell: ${JSON.stringify(paymasterCell)}`); + + if (!paymasterCell) { + throw new PaymasterCellNotEnoughError('No paymaster cell available'); + } + + const signedTx = await appendPaymasterCellAndSignCkbTx({ + ckbRawTx, + sumInputsCapacity, + paymasterCell, + secp256k1PrivateKey: this.privateKey, + isMainnet: this.cradle.env.NETWORK === 'mainnet', + }); + this.cradle.logger.info(`[Paymaster] Signed transaction: ${JSON.stringify(signedTx)}`); + return signedTx; + } + + /** + * Mark the paymaster cell as spent after the transaction is confirmed to avoid double spending + * @param token - the job token moved from the queue to the completed + * @param signedTx - the signed transaction to get the paymaster cell input to mark as spent + */ + public async markPaymasterCellAsSpent(token: string, signedTx: CKBComponents.RawTransaction) { + for await (const input of signedTx.inputs) { + const outPoint = input.previousOutput; + if (!outPoint) { + continue; + } + const id = `${outPoint.txHash}:${outPoint.index}`; + const job = await this.queue.getJob(id); + if (job) { + await job.moveToCompleted(null, token, false); + } + } + } +} diff --git a/src/services/spv.ts b/src/services/spv.ts new file mode 100644 index 00000000..9849880b --- /dev/null +++ b/src/services/spv.ts @@ -0,0 +1,52 @@ +import axios, { AxiosInstance } from 'axios'; +import * as Sentry from '@sentry/node'; +import { addLoggerInterceptor } from '../utils/interceptors'; +import { Cradle } from '../container'; +import { randomUUID } from 'node:crypto'; +import * as z from 'zod'; +import { remove0x } from '@rgbpp-sdk/btc'; + +export const TxProof = z.object({ + spv_client: z.object({ + tx_hash: z.string(), + index: z.string(), + }), + proof: z.string(), +}); +export type TxProof = z.infer; + +/** + * Bitcoin SPV service client + */ +export default class BitcoinSPV { + private request: AxiosInstance; + + constructor({ env, logger }: Cradle) { + const { BITCOIN_SPV_SERVICE_URL } = env; + this.request = axios.create({ + baseURL: BITCOIN_SPV_SERVICE_URL, + }); + addLoggerInterceptor(this.request, logger); + } + + private async callMethod(method: string, params: unknown): Promise { + return Sentry.startSpan({ op: this.constructor.name, name: method }, async () => { + const id = randomUUID(); + const response = await this.request.post('', { + jsonrpc: '2.0', + id, + method, + params, + }); + if (response.data.error) { + throw new Error(response.data.error.message); + } + return response.data.result; + }); + } + + // https://github.com/ckb-cell/ckb-bitcoin-spv-service?tab=readme-ov-file#json-rpc-api-reference + public async getTxProof(txid: string, index: number, confirmations: number) { + return this.callMethod('getTxProof', [remove0x(txid), index, confirmations]); + } +} diff --git a/src/services/transaction.ts b/src/services/transaction.ts new file mode 100644 index 00000000..17b927ea --- /dev/null +++ b/src/services/transaction.ts @@ -0,0 +1,425 @@ +import { Cradle } from '../container'; +import { DelayedError, Job, Queue, Worker } from 'bullmq'; +import { AxiosError } from 'axios'; +import { CKBRawTransaction, CKBVirtualResult } from '../routes/rgbpp/types'; +import { opReturnScriptPubKeyToData, transactionToHex } from '@rgbpp-sdk/btc'; +import { + appendCkbTxWitnesses, + SPVService, + sendCkbTx, + Collector, + append0x, + RGBPPLock, + updateCkbTxWithRealBtcTxId, + SpvRpcError, + BTCTimeLock, + getBtcTimeLockScript, + RGBPP_TX_ID_PLACEHOLDER, + getRgbppLockScript, + BTC_JUMP_CONFIRMATION_BLOCKS, +} from '@rgbpp-sdk/ckb'; +import { + btcTxIdFromBtcTimeLockArgs, + buildPreLockArgs, + calculateCommitment, + genBtcTimeLockScript, + genRgbppLockScript, + lockScriptFromBtcTimeLockArgs, +} from '@rgbpp-sdk/ckb/lib/utils/rgbpp'; +import * as Sentry from '@sentry/node'; +import { Transaction } from '../routes/bitcoin/types'; +import { bytes } from '@ckb-lumos/codec'; +import { Transaction as BitcoinTransaction } from 'bitcoinjs-lib'; + +export interface ITransactionRequest { + txid: string; + ckbVirtualResult: CKBVirtualResult; +} + +export interface IProcessCallbacks { + onActive?: (job: Job) => void; + onCompleted?: (job: Job) => void; + onFailed?: (job: Job | undefined, err: Error) => void; +} + +interface ITransactionManager { + enqueueTransaction(request: ITransactionRequest): Promise>; + getTransactionRequest(txid: string): Promise | undefined>; + startProcess(callbacks?: IProcessCallbacks): Promise; + pauseProcess(): Promise; + closeProcess(): Promise; +} + +export const TRANSACTION_QUEUE_NAME = 'rgbpp-ckb-transaction-queue'; + +class InvalidTransactionError extends Error { + public data: ITransactionRequest; + + constructor(data: ITransactionRequest) { + super('Invalid transaction'); + this.name = 'InvalidTransactionError'; + this.data = data; + } +} + +class OpReturnNotFoundError extends Error { + constructor(txid: string) { + super(`OP_RETURN output not found: ${txid}`); + this.name = 'OpReturnNotFoundError'; + } +} + +/** + * TransactionManager + * responsible for processing RGB++ CKB transactions, including: + * - enqueueing transaction requests to the queue + * - verifying transaction requests, including checking the commitment + * - processing transaction when it's confirmed on L1(Bitcoin) + * - generate RGB_lock witness into the CKB transaction + * - 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 { + private cradle: Cradle; + private queue: Queue; + private worker: Worker; + private spvService: SPVService; + + constructor(cradle: Cradle) { + this.cradle = cradle; + this.queue = new Queue(TRANSACTION_QUEUE_NAME, { + connection: cradle.redis, + // retry failed jobs with a delay of 60 seconds, up to 3 time + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'fixed', + delay: cradle.env.TRANSACTION_QUEUE_JOB_DELAY, + }, + }, + }); + this.worker = new Worker(TRANSACTION_QUEUE_NAME, this.process.bind(this), { + connection: cradle.redis, + autorun: false, + concurrency: 10, + }); + this.spvService = new SPVService(cradle.env.BITCOIN_SPV_SERVICE_URL); + } + + private get isMainnet() { + return this.cradle.env.NETWORK === 'mainnet'; + } + + private get rgbppLockScript() { + return getRgbppLockScript(this.isMainnet); + } + + private get btcTimeLockScript() { + return getBtcTimeLockScript(this.isMainnet); + } + + private isRgbppLock(lock: CKBComponents.Script) { + return lock.codeHash === this.rgbppLockScript.codeHash && lock.hashType === this.rgbppLockScript.hashType; + } + + private isBtcTimeLock(lock: CKBComponents.Script) { + return lock.codeHash === this.btcTimeLockScript.codeHash && lock.hashType === this.btcTimeLockScript.hashType; + } + + /** + * Get commitment from Bitcoin transactions + * depended on @rgbpp-sdk/btc opReturnScriptPubKeyToData method + * @param tx - Bitcoin transaction + */ + private async getCommitmentFromBtcTx(tx: Transaction): Promise { + const opReturn = tx.vout.find((vout) => vout.scriptpubkey_type === 'op_return'); + if (!opReturn) { + throw new OpReturnNotFoundError(tx.txid); + } + const buffer = Buffer.from(opReturn.scriptpubkey, 'hex'); + return opReturnScriptPubKeyToData(buffer); + } + + /** + * Clear the btcTxId in the RGBPP_LOCK/BTC_TIME_LOCK script to avoid the mismatch between the CKB and BTC transactions + * @param ckbRawTx - CKB Raw Transaction + * @param txid - Bitcoin transaction id + */ + private async resetOutputLockScript(ckbRawTx: CKBRawTransaction, txid: string) { + const outputs = ckbRawTx.outputs.map((output) => { + const { lock } = output; + if (this.isRgbppLock(lock)) { + const unpack = RGBPPLock.unpack(lock.args); + // https://github.com/ckb-cell/rgbpp-sdk/tree/main/examples/rgbpp#what-you-must-know-about-btc-transaction-id + const btcTxid = bytes.hexify(bytes.bytify(unpack.btcTxid).reverse()); + if (btcTxid !== append0x(txid)) { + return output; + } + return { + ...output, + lock: genRgbppLockScript(buildPreLockArgs(unpack.outIndex), this.isMainnet), + }; + } + if (this.isBtcTimeLock(lock)) { + const btcTxid = btcTxIdFromBtcTimeLockArgs(lock.args); + if (btcTxid !== append0x(txid)) { + return output; + } + const toLock = lockScriptFromBtcTimeLockArgs(lock.args); + return { + ...output, + lock: genBtcTimeLockScript(toLock, this.isMainnet), + }; + } + return output; + }); + return { + ...ckbRawTx, + outputs, + }; + } + + /** + * Verify transaction request + * - check if the commitment matches the Bitcoin transaction + * - check if the CKB Virtual Transaction is valid + * - check if the Bitcoin transaction is confirmed + * @param request - transaction request, including txid and ckbVirtualResult + * @param btcTx - Bitcoin transaction + */ + public async verifyTransaction(request: ITransactionRequest, btcTx: Transaction): Promise { + const { txid, ckbVirtualResult } = request; + const { commitment, ckbRawTx } = ckbVirtualResult; + + // 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}`); + 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}`); + 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}`); + throw new DelayedError(); + } + + this.cradle.logger.info(`[TransactionManager] Transaction Verified: ${txid}`); + return true; + } + + /** + * Move job to delayed + * @param job - the job to move + * @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`); + 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 + throw new DelayedError(); + } + + /** + * Wait for the ckb transaction to be confirmed + * @param txHash - the ckb transaction hash + */ + private waitForTranscationConfirmed(txHash: string) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + const transaction = await this.cradle.ckbRpc.getTransaction(txHash); + const { status } = transaction.txStatus; + if (status === 'committed') { + resolve(txHash); + } else { + setTimeout(() => { + resolve(this.waitForTranscationConfirmed(txHash)); + }, 1000); + } + }); + } + + /** + * Get the CKB Raw Transaction with the real BTC transaction id + * @param ckbVirtualResult - the CKB Virtual Transaction + * @param txid - the real BTC transaction id + */ + private getCkbRawTxWithRealBtcTxid(ckbVirtualResult: CKBVirtualResult, txid: string) { + let ckbRawTx = ckbVirtualResult.ckbRawTx; + const needUpdateCkbTx = ckbRawTx.outputs.some((output) => { + if (this.isRgbppLock(output.lock)) { + const { btcTxid } = RGBPPLock.unpack(output.lock.args); + return ( + output.lock.codeHash === this.rgbppLockScript.codeHash && + output.lock.hashType === this.rgbppLockScript.hashType && + btcTxid === RGBPP_TX_ID_PLACEHOLDER + ); + } + if (this.isBtcTimeLock(output.lock)) { + const { btcTxid, after } = BTCTimeLock.unpack(output.lock.args); + return ( + output.lock.codeHash === this.btcTimeLockScript.codeHash && + output.lock.hashType === this.btcTimeLockScript.hashType && + btcTxid === RGBPP_TX_ID_PLACEHOLDER && + after === BTC_JUMP_CONFIRMATION_BLOCKS + ); + } + return false; + }); + if (needUpdateCkbTx) { + ckbRawTx = updateCkbTxWithRealBtcTxId({ ckbRawTx, btcTxId: txid, isMainnet: this.isMainnet }); + } + return ckbRawTx; + } + + /** + * Process the transaction request, called by the worker + * - get the Bitcoin transaction + * - verify the transaction request + * - append the RGBPP lock witness to the CKB transaction + * - append the paymaster cell and sign the transaction if needed + * - send the CKB transaction to the network and wait for the transaction to be confirmed + * - mark the paymaster cell as spent to avoid double spending + * @param job - the job to process + * @param token - the token to get the next job + */ + public async process(job: Job, token?: string) { + try { + const { ckbVirtualResult, txid } = job.data; + const btcTx = await this.cradle.electrs.getTransaction(txid); + const isVerified = await this.verifyTransaction({ ckbVirtualResult, txid }, btcTx); + if (!isVerified) { + throw new InvalidTransactionError(job.data); + } + + const ckbRawTx = this.getCkbRawTxWithRealBtcTxid(ckbVirtualResult, txid); + // bitcoin JSON-RPC gettransaction is wallet only + // we need to use electrs to get the transaction hex and index in block + const [hex, btcTxIndexInBlock] = await Promise.all([ + this.cradle.electrs.getTransactionHex(txid), + this.cradle.electrs.getBlockTxIdsByHash(btcTx.status.block_hash!).then((txids) => txids.indexOf(txid)), + ]); + // using for spv proof, we need to remove the witness data from the transaction + const hexWithoutWitness = transactionToHex(BitcoinTransaction.fromHex(hex), false); + let signedTx = await appendCkbTxWitnesses({ + ...ckbVirtualResult, + ckbRawTx, + spvService: this.spvService, + btcTxId: txid, + btcTxBytes: hexWithoutWitness, + btcTxIndexInBlock, + })!; + + // append paymaster cell and sign the transaction if needed + if (ckbVirtualResult.needPaymasterCell) { + const tx = await this.cradle.paymaster.appendCellAndSignTx(txid, { + ...ckbVirtualResult, + ckbRawTx: signedTx!, + }); + signedTx = tx as CKBRawTransaction; + } + this.cradle.logger.debug(`[TransactionManager] Transaction signed: ${JSON.stringify(signedTx)}`); + + const txHash = await sendCkbTx({ + collector: new Collector({ + ckbNodeUrl: this.cradle.env.CKB_RPC_URL, + ckbIndexerUrl: this.cradle.env.CKB_RPC_URL, + }), + signedTx, + }); + job.returnvalue = txHash; + this.cradle.logger.info(`[TransactionManager] Transaction sent: ${txHash}`); + + await this.waitForTranscationConfirmed(txHash); + this.cradle.logger.info(`[TransactionManager] 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}`); + await this.cradle.paymaster.markPaymasterCellAsSpent(txid, signedTx!); + } + return txHash; + } catch (err) { + this.cradle.logger.debug(err); + // move the job to delayed queue if the transaction data not found or not confirmed or spv proof not found yet + const transactionDataNotFound = err instanceof AxiosError && err.response?.status === 404; + const transactionNotConfirmed = err instanceof DelayedError; + // XXX: maybe spv service should be provided a api for checking the proof status + const spvProofNotFound = err instanceof SpvRpcError; + if (transactionDataNotFound || transactionNotConfirmed || spvProofNotFound) { + await this.moveJobToDelayed(job, token); + return; + } + if (err instanceof InvalidTransactionError) { + // capture invalid transaction request to Sentry + Sentry.setContext('transaction', err.data); + } + this.cradle.logger.error(err); + Sentry.captureException(err); + throw err; + } + } + + /** + * Enqueue a transaction request to the Queue, waiting for processing + * @param request - the transaction request + */ + public async enqueueTransaction(request: ITransactionRequest): Promise> { + const job = await this.queue.add(request.txid, request, { + jobId: request.txid, + delay: this.cradle.env.TRANSACTION_QUEUE_JOB_DELAY, + }); + return job; + } + + /** + * Get the transaction request from the queue + * @param txid - the transaction id + */ + public async getTransactionRequest(txid: string): Promise | undefined> { + const job = await this.queue.getJob(txid); + return job; + } + + /** + * Start the transaction process + * @param callbacks - the callbacks for the process + * - onCompleted: the callback when the job is completed + * - onFailed: the callback when the job is failed + */ + public async startProcess(callbacks?: IProcessCallbacks): Promise { + if (callbacks?.onActive) { + this.worker.on('active', callbacks?.onActive); + } + if (callbacks?.onCompleted) { + this.worker.on('completed', callbacks.onCompleted); + } + if (callbacks?.onFailed) { + this.worker.on('failed', callbacks.onFailed); + } + await this.worker.run(); + } + + /** + * Pause the transaction process + */ + public async pauseProcess(): Promise { + await this.worker.pause(); + } + + /** + * Close the transaction process + */ + public async closeProcess(): Promise { + await this.worker.close(); + await this.queue.close(); + } +} diff --git a/src/services/unlocker.ts b/src/services/unlocker.ts new file mode 100644 index 00000000..a901b169 --- /dev/null +++ b/src/services/unlocker.ts @@ -0,0 +1,130 @@ +import { CellCollector } from '@ckb-lumos/lumos'; +import { Cradle } from '../container'; +import { + BtcTimeCellPair, + buildBtcTimeCellsSpentTx, + Collector, + IndexerCell, + sendCkbTx, + SPVService, + BTCTimeLock, + getBtcTimeLockScript, + remove0x, + signBtcTimeCellSpentTx, +} from '@rgbpp-sdk/ckb'; +import { btcTxIdFromBtcTimeLockArgs } from '@rgbpp-sdk/ckb/lib/utils/rgbpp'; + +interface IUnlocker { + getNextBatchLockCell(): Promise; + unlockCells(): Promise; +} + +/** + * BTC Time lock cell unlocker + * responsible for unlocking the BTC time lock cells and sending the CKB transactions. + */ +export default class Unlocker implements IUnlocker { + private cradle: Cradle; + private collector: CellCollector; + private spvService: SPVService; + + constructor(cradle: Cradle) { + this.cradle = cradle; + this.collector = this.cradle.ckbIndexer.collector({ + lock: { + ...this.lockScript, + args: '0x', + }, + }) as CellCollector; + this.spvService = new SPVService(this.cradle.env.BITCOIN_SPV_SERVICE_URL); + } + + private get isMainnet() { + return this.cradle.env.NETWORK === 'mainnet'; + } + + private get lockScript() { + return getBtcTimeLockScript(this.isMainnet); + } + + /** + * Get next batch of BTC time lock cells + */ + public async getNextBatchLockCell() { + const collect = this.collector.collect(); + const cells: IndexerCell[] = []; + + const { blocks } = await this.cradle.bitcoind.getBlockchainInfo(); + for await (const cell of collect) { + 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 blockHeight = btcTx.status.block_height; + // skip if btc tx not confirmed $after blocks yet + if (!blockHeight || blocks - blockHeight < after) { + continue; + } + cells.push({ + blockNumber: cell.blockNumber!, + outPoint: cell.outPoint!, + output: cell.cellOutput, + outputData: cell.data, + txIndex: cell.txIndex!, + }); + if (cells.length >= this.cradle.env.UNLOCKER_CELL_BATCH_SIZE) { + break; + } + } + return cells; + } + + /** + * 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 btcTimeCellPairs = await Promise.all( + cells.map(async (cell) => { + const btcTxid = remove0x(btcTxIdFromBtcTimeLockArgs(cell.output.lock.args)); + const btcTx = await this.cradle.electrs.getTransaction(btcTxid); + // get the btc tx index in the block to used for the spv proof + const txids = await this.cradle.electrs.getBlockTxIdsByHash(btcTx.status.block_hash!); + const blockindex = txids.findIndex((txid) => txid === btcTxid); + return { + btcTimeCell: cell, + btcTxIndexInBlock: blockindex, + } as BtcTimeCellPair; + }), + ); + + const collector = new Collector({ + ckbNodeUrl: this.cradle.env.CKB_RPC_URL, + ckbIndexerUrl: this.cradle.env.CKB_RPC_URL, + }); + const ckbRawTx = await buildBtcTimeCellsSpentTx({ + btcTimeCellPairs, + spvService: this.spvService, + isMainnet: this.isMainnet, + }); + const signedTx = await signBtcTimeCellSpentTx({ + secp256k1PrivateKey: this.cradle.paymaster.privateKey, + masterCkbAddress: this.cradle.paymaster.address, + collector, + ckbRawTx, + isMainnet: this.isMainnet, + }); + this.cradle.logger.debug(`[Unlocker] Transaction signed: ${JSON.stringify(signedTx)}`); + + const txHash = await sendCkbTx({ + collector, + signedTx, + }); + this.cradle.logger.info(`[Unlocker] Transaction sent: ${txHash}`); + return txHash; + } +} diff --git a/src/utils/interceptors.ts b/src/utils/interceptors.ts index 08d8b3dc..cf1364d8 100644 --- a/src/utils/interceptors.ts +++ b/src/utils/interceptors.ts @@ -3,13 +3,13 @@ import pino from 'pino'; export function addLoggerInterceptor(request: AxiosInstance, logger: pino.BaseLogger) { request.interceptors.request.use((config) => { - logger.info(`[${config.url}] ${JSON.stringify(config.data)}`); + logger.debug(`[${config.url}] ${JSON.stringify(config.data)}`); return config; }); request.interceptors.response.use( (response) => { - logger.info(`[${response.config.url}] ${response.status} ${JSON.stringify(response.data)}`); + logger.debug(`[${response.config.url}] ${response.status} ${JSON.stringify(response.data)}`); return response; }, (error) => { diff --git a/test/app.test.ts b/test/app.test.ts index eefb58f7..9d1c5f48 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -17,6 +17,7 @@ test('`/docs/json` - 200', async () => { '/token/generate', '/bitcoin/v1/info', '/bitcoin/v1/block/{hash}', + '/bitcoin/v1/block/{hash}/txids', '/bitcoin/v1/block/{hash}/header', '/bitcoin/v1/block/height/{height}', '/bitcoin/v1/transaction', @@ -24,6 +25,15 @@ test('`/docs/json` - 200', async () => { '/bitcoin/v1/address/{address}/balance', '/bitcoin/v1/address/{address}/unspent', '/bitcoin/v1/address/{address}/txs', + '/rgbpp/v1/transaction/ckb-tx', + '/rgbpp/v1/transaction/{btc_txid}', + '/rgbpp/v1/transaction/{btc_txid}/job', + '/rgbpp/v1/assets/{btc_txid}', + '/rgbpp/v1/assets/{btc_txid}/{vout}', + '/rgbpp/v1/address/{btc_address}/assets', + '/rgbpp/v1/btc-spv/proof', + '/cron/process-transactions', + '/cron/unlock-cells', ]); await fastify.close(); diff --git a/test/routes/bitcoind/__snapshots__/address.test.ts.snap b/test/routes/bitcoind/__snapshots__/address.test.ts.snap index b81a2374..2f16abfc 100644 --- a/test/routes/bitcoind/__snapshots__/address.test.ts.snap +++ b/test/routes/bitcoind/__snapshots__/address.test.ts.snap @@ -1,5 +1,25 @@ // 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`] = ` [ { @@ -53,134 +73,5 @@ exports[`/bitcoin/v1/address > Get address transactions 1`] = ` ], "weight": 562, }, - { - "fee": 291, - "locktime": 2579039, - "size": 341, - "status": { - "block_hash": "000000000000000cef4a1d6264fe63f543128518a466d31c7e2a8d6395b52522", - "block_height": 2579043, - "block_time": 1708580004, - "confirmed": true, - }, - "txid": "85fdce5f5d7fd3ff73ce70e3e0a786f50cc1124830cc07341738d76fa7c3a6a9", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "51200ee08c77cfb2d35e6cab21b88e97c3acc5c3a3888237f2a0a6cc8006d8283487", - "scriptpubkey_address": "tb1ppmsgca70ktf4um9tyxuga97r4nzu8gugsgml9g9xejqqdkpgxjrskjx4wu", - "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 0ee08c77cfb2d35e6cab21b88e97c3acc5c3a3888237f2a0a6cc8006d8283487", - "scriptpubkey_type": "v1_p2tr", - "value": 978803861, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967293, - "txid": "e8b3e53f97f5c32a53b3af6c7b6dd3ad29439e41f09ad5bf14d6a970fc626826", - "vout": 1, - "witness": [ - "92aeef07a488114bf9d1383a67846e0d50d48023755bc835278fca2ed42ade5c7f2749de20909b43a02b8222fa1849505df98b7705d575e31db90fdf70400b68", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "51209d18a7c0933c238e1af711891d288db56b64b4e263b33ffdf8e15d8320ff2bf6", - "scriptpubkey_address": "tb1pn5v20syn8s3cuxhhzxy362ydk44kfd8zvwenll0cu9wcxg8l90mq80y9kr", - "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 9d18a7c0933c238e1af711891d288db56b64b4e263b33ffdf8e15d8320ff2bf6", - "scriptpubkey_type": "v1_p2tr", - "value": 978796570, - }, - { - "scriptpubkey": "5120c1976714c66503901967f48227db5c1e98945ddefca53d6c875c3bb50ca842dc", - "scriptpubkey_address": "tb1pcxtkw9xxv5peqxt87jpz0k6ur6vfghw7ljjn6my8tsam2r9ggtwq32vtnk", - "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 c1976714c66503901967f48227db5c1e98945ddefca53d6c875c3bb50ca842dc", - "scriptpubkey_type": "v1_p2tr", - "value": 1000, - }, - { - "scriptpubkey": "001405515fbd2be2e4375262d609c3ddd4db63611e6f", - "scriptpubkey_address": "tb1qq4g4l0ftutjrw5nz6cyu8hw5md3kz8n0hydu7e", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 05515fbd2be2e4375262d609c3ddd4db63611e6f", - "scriptpubkey_type": "v0_p2wpkh", - "value": 2000, - }, - { - "scriptpubkey": "5120e79527cd47ed7cfe1099b630a90da5aabc6c858b4c01f332e42b461bcb5ae043", - "scriptpubkey_address": "tb1pu72j0n28a470uyyekcc2jrd9427xepvtfsqlxvhy9drphj66upps2c9a63", - "scriptpubkey_asm": "OP_PUSHNUM_1 OP_PUSHBYTES_32 e79527cd47ed7cfe1099b630a90da5aabc6c858b4c01f332e42b461bcb5ae043", - "scriptpubkey_type": "v1_p2tr", - "value": 2000, - }, - { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - { - "scriptpubkey": "0014363e9d485ada999864c65c01f46dd2b7be6c35aa", - "scriptpubkey_address": "tb1qxclf6jz6m2vesexxtsqlgmwjk7lxcdd2lt3wxs", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 363e9d485ada999864c65c01f46dd2b7be6c35aa", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1000, - }, - ], - "weight": 1160, - }, - { - "fee": 14100, - "locktime": 2579039, - "size": 222, - "status": { - "block_hash": "000000000000001d5af479c1bcdc1202913a82c652ddc45b8bf7110e4977e4be", - "block_height": 2579040, - "block_time": 1708578362, - "confirmed": true, - }, - "txid": "d0189f19978fc47ddfe33319d01a6a66dea5a521e1f37b7e73469a3549e81531", - "version": 2, - "vin": [ - { - "is_coinbase": false, - "prevout": { - "scriptpubkey": "00143e5700df69430bcf88d7c83980d09d4d5f88ec71", - "scriptpubkey_address": "tb1q8etsphmfgv9ulzxhequcp5yaf40c3mr3vxdxhu", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 3e5700df69430bcf88d7c83980d09d4d5f88ec71", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1818812120, - }, - "scriptsig": "", - "scriptsig_asm": "", - "sequence": 4294967293, - "txid": "e235e27e49adf6deee9e9e91450d3101ba5ff82b90a5584c056c0fdf0f3f9ec0", - "vout": 0, - "witness": [ - "304402204575aad8b34d55c4c4f814c46e741aab8b6c614dcfacf55172b174629e19558002205eca835e9696f0f17655e4458bf3012ac7021aa1b228a8c4fec5e1f2a8444edf01", - "03a9c5a1c05b98a249cc54f9f96f0dbdaa7bd3110ab9f52bd8ee5ac9e0aad9c008", - ], - }, - ], - "vout": [ - { - "scriptpubkey": "0014f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_address": "tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f8d0addc86183d385061ee80c1b16b1975eacf42", - "scriptpubkey_type": "v0_p2wpkh", - "value": 181793, - }, - { - "scriptpubkey": "001424e9ecf624782719673221d69ea24b3aa76d22f3", - "scriptpubkey_address": "tb1qyn57ea3y0qn3jeejy8tfagjt82nk6ghnr9583w", - "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 24e9ecf624782719673221d69ea24b3aa76d22f3", - "scriptpubkey_type": "v0_p2wpkh", - "value": 1818616227, - }, - ], - "weight": 561, - }, ] `; diff --git a/test/routes/bitcoind/__snapshots__/block.test.ts.snap b/test/routes/bitcoind/__snapshots__/block.test.ts.snap index 8f3efa43..0cf064df 100644 --- a/test/routes/bitcoind/__snapshots__/block.test.ts.snap +++ b/test/routes/bitcoind/__snapshots__/block.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Get block by hash 1`] = ` +exports[`/bitcoin/v1/block > Get block by hash 1`] = ` { "bits": 422051352, "difficulty": 107392535.89663602, @@ -18,13 +18,13 @@ exports[`Get block by hash 1`] = ` } `; -exports[`Get block hash by height 1`] = ` +exports[`/bitcoin/v1/block > Get block hash by height 1`] = ` { "hash": "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", } `; -exports[`Get block header by hash 1`] = ` +exports[`/bitcoin/v1/block > Get block header by hash 1`] = ` { "header": "0000202000af62594f79b6390c6ee3de56aad6658d35a9481cd8bb0ce047523800000000652cad111d076bf8aa3417670781154fd79533fb91fe782d70d300e9e95b4c5b6d60dd6518fe27190343494f", } diff --git a/test/routes/bitcoind/address.test.ts b/test/routes/bitcoind/address.test.ts index eddab1e2..f944b1f3 100644 --- a/test/routes/bitcoind/address.test.ts +++ b/test/routes/bitcoind/address.test.ts @@ -1,10 +1,10 @@ -import { describe, beforeAll, expect, test } from 'vitest'; +import { describe, expect, test, beforeEach } from 'vitest'; import { buildFastify } from '../../../src/app'; let token: string; describe('/bitcoin/v1/address', () => { - beforeAll(async () => { + beforeEach(async () => { const fastify = buildFastify(); await fastify.ready(); @@ -28,7 +28,7 @@ describe('/bitcoin/v1/address', () => { const response = await fastify.inject({ method: 'GET', - url: '/bitcoin/v1/address/tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp/balance', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/balance', headers: { Authorization: `Bearer ${token}`, Origin: 'https://test.com', @@ -37,13 +37,7 @@ describe('/bitcoin/v1/address', () => { const data = response.json(); expect(response.statusCode).toBe(200); - expect(data).toStrictEqual({ - address: 'tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp', - satoshi: 181652, - pending_satoshi: 0, - dust_satoshi: 0, - utxo_count: 2, - }); + expect(data).toMatchSnapshot(); await fastify.close(); }); @@ -54,7 +48,7 @@ describe('/bitcoin/v1/address', () => { const response = await fastify.inject({ method: 'GET', - url: '/bitcoin/v1/address/tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp/balance?min_satoshi=10000', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/balance?min_satoshi=10000', headers: { Authorization: `Bearer ${token}`, Origin: 'https://test.com', @@ -63,13 +57,7 @@ describe('/bitcoin/v1/address', () => { const data = response.json(); expect(response.statusCode).toBe(200); - expect(data).toStrictEqual({ - address: 'tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp', - satoshi: 180652, - pending_satoshi: 0, - dust_satoshi: 1000, - utxo_count: 2, - }); + expect(data).toMatchSnapshot(); await fastify.close(); }); @@ -100,7 +88,7 @@ describe('/bitcoin/v1/address', () => { const response = await fastify.inject({ method: 'GET', - url: '/bitcoin/v1/address/tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp/unspent', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/unspent', headers: { Authorization: `Bearer ${token}`, Origin: 'https://test.com', @@ -112,7 +100,6 @@ describe('/bitcoin/v1/address', () => { expect(response.statusCode).toBe(200); expect(txids).toEqual( [ - '85fdce5f5d7fd3ff73ce70e3e0a786f50cc1124830cc07341738d76fa7c3a6a9', '9706131c1e327a068a6aafc16dc69a46c50bc7c65f180513896bdad39a6babfc', ].sort(), ); @@ -126,7 +113,7 @@ describe('/bitcoin/v1/address', () => { const response = await fastify.inject({ method: 'GET', - url: '/bitcoin/v1/address/tb1qlrg2mhyxrq7ns5rpa6qvrvttr9674n6z0trymp/txs', + url: '/bitcoin/v1/address/tb1qm4eyx777203zmajlawz958wn27z08envm2jelm/txs', headers: { Authorization: `Bearer ${token}`, Origin: 'https://test.com', diff --git a/test/routes/bitcoind/block.test.ts b/test/routes/bitcoind/block.test.ts index c56c4218..ef796989 100644 --- a/test/routes/bitcoind/block.test.ts +++ b/test/routes/bitcoind/block.test.ts @@ -1,11 +1,10 @@ -import { beforeAll, expect, test } from 'vitest'; +import { describe, beforeEach, expect, test } from 'vitest'; import { buildFastify } from '../../../src/app'; -import { describe } from 'node:test'; - -let token: string; describe('/bitcoin/v1/block', () => { - beforeAll(async () => { + let token: string; + + beforeEach(async () => { const fastify = buildFastify(); await fastify.ready(); diff --git a/test/routes/bitcoind/info.test.ts b/test/routes/bitcoind/info.test.ts index f9d1c673..4756ed96 100644 --- a/test/routes/bitcoind/info.test.ts +++ b/test/routes/bitcoind/info.test.ts @@ -1,11 +1,11 @@ -import { beforeAll, expect, test } from 'vitest'; +import { beforeEach, expect, test } from 'vitest'; import { buildFastify } from '../../../src/app'; import { describe } from 'node:test'; let token: string; describe('/bitcoin/v1/info', () => { - beforeAll(async () => { + beforeEach(async () => { const fastify = buildFastify(); await fastify.ready(); diff --git a/test/routes/bitcoind/transaction.test.ts b/test/routes/bitcoind/transaction.test.ts index 2165446b..4f1a1676 100644 --- a/test/routes/bitcoind/transaction.test.ts +++ b/test/routes/bitcoind/transaction.test.ts @@ -1,11 +1,11 @@ -import { beforeAll, expect, test } from 'vitest'; +import { beforeEach, expect, test } from 'vitest'; import { buildFastify } from '../../../src/app'; import { describe } from 'node:test'; let token: string; describe('/bitcoin/v1/transaction', () => { - beforeAll(async () => { + beforeEach(async () => { const fastify = buildFastify(); await fastify.ready(); diff --git a/test/services/__snapshots__/transaction.test.ts.snap b/test/services/__snapshots__/transaction.test.ts.snap new file mode 100644 index 00000000..590cc610 --- /dev/null +++ b/test/services/__snapshots__/transaction.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`transactionManager > verifyTransaction: should throw DelayedError for unconfirmed transaction 1`] = `[DelayedError]`; diff --git a/test/services/__snapshots__/unlocker.test.ts.snap b/test/services/__snapshots__/unlocker.test.ts.snap new file mode 100644 index 00000000..25d2e148 --- /dev/null +++ b/test/services/__snapshots__/unlocker.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Unlocker > unlockCells: should unlock cells and send ckb tx 1`] = `[Error: Invalid buffer size, read from header: 0, actual: 36]`; diff --git a/test/services/paymaster.test.ts b/test/services/paymaster.test.ts new file mode 100644 index 00000000..86b4412a --- /dev/null +++ b/test/services/paymaster.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import container from '../../src/container'; +import Paymaster from '../../src/services/paymaster'; +import { Cell, hd } from '@ckb-lumos/lumos'; +import { describe, beforeEach, expect, test, vi } from 'vitest'; +import { Job } from 'bullmq'; +import { asValue } from 'awilix'; + +const { mnemonic, ExtendedPrivateKey, AddressType } = hd; + +function generatePrivateKey() { + const myMnemonic = mnemonic.generateMnemonic(); + const seed = mnemonic.mnemonicToSeedSync(myMnemonic); + const extendedPrivKey = ExtendedPrivateKey.fromSeed(seed); + return extendedPrivKey.privateKeyInfo(AddressType.Receiving, 0).privateKey; +} + +describe('Paymaster', () => { + let paymaster: Paymaster; + + beforeEach(async () => { + const cradle = container.cradle; + cradle.env.PAYMASTER_PRIVATE_KEY = generatePrivateKey(); + cradle.env.PAYMASTER_CELL_CAPACITY = 10; + cradle.env.PAYMASTER_CELL_PRESET_COUNT = 10; + cradle.env.PAYMASTER_CELL_REFILL_THRESHOLD = 0.3; + + paymaster = new Paymaster(cradle); + }); + + test('getNextCell: should not trigger refill if already refilling', async () => { + paymaster['refilling'] = true; + vi.spyOn(paymaster['queue'], 'getWaitingCount').mockResolvedValue(2); + vi.spyOn(paymaster, 'refillCellQueue'); + + await paymaster.getNextCell('token'); + expect(paymaster.refillCellQueue).not.toHaveBeenCalled(); + }); + + test('getNextCell: should return the next job when queue has sufficient jobs', async () => { + container.register('ckbRpc', asValue({ + getLiveCell: vi.fn().mockResolvedValue({ + status: 'live', + }), + })); + vi.spyOn(paymaster['queue'], 'getWaitingCount').mockResolvedValue(10); + vi.spyOn(paymaster['worker'], 'getNextJob').mockResolvedValue( + new Job(paymaster['queue'], 'test-job', { outPoint: {}, cellOutput: {}, data: '0x123' }) as Job, + ); + vi.spyOn(paymaster, 'refillCellQueue'); + + const cell = await paymaster.getNextCell('token'); + expect(cell?.outputData).toEqual('0x123'); + expect(paymaster.refillCellQueue).not.toHaveBeenCalled(); + expect(paymaster['refilling']).toBeFalsy(); + }); + + test('getNextCell: should trigger refill when queue has fewer jobs than threshold', async () => { + container.register('ckbRpc', asValue({ + getLiveCell: vi.fn().mockResolvedValue({ + status: 'live', + }), + })); + vi.spyOn(paymaster['queue'], 'getWaitingCount').mockResolvedValue(2); + vi.spyOn(paymaster, 'refillCellQueue').mockResolvedValue(8); + vi.spyOn(paymaster['worker'], 'getNextJob').mockResolvedValue( + new Job(paymaster['queue'], 'test-job', { outPoint: {}, cellOutput: {}, data: '0x123' }) as Job, + ); + + const cell = await paymaster.getNextCell('token'); + expect(cell?.outputData).toEqual('0x123'); + expect(paymaster.refillCellQueue).toHaveBeenCalled(); + }); + + test('getNextCell: should return a job when queue is empty and refill is successful', async () => { + vi.spyOn(paymaster['queue'], 'getWaitingCount').mockResolvedValue(0); + vi.spyOn(paymaster, 'refillCellQueue').mockResolvedValue(1); + vi.spyOn(paymaster['worker'], 'getNextJob').mockResolvedValue( + new Job(paymaster['queue'], 'refilled-job', {}) as Job, + ); + + await paymaster.getNextCell('token'); + expect(paymaster.refillCellQueue).toHaveBeenCalled(); + }); + + test('getNextCell: should handle error when queue is empty and refill fails', async () => { + vi.spyOn(paymaster['queue'], 'getWaitingCount').mockResolvedValue(0); + vi.spyOn(paymaster, 'refillCellQueue').mockRejectedValue(new Error('Refill failed')); + vi.spyOn(paymaster['worker'], 'getNextJob'); + + await expect(paymaster.getNextCell('token')).rejects.toThrow('Refill failed'); + expect(paymaster.refillCellQueue).toHaveBeenCalled(); + expect(paymaster['worker'].getNextJob).not.toHaveBeenCalled(); + }); + + test('refillCellQueue: should add cells to the queue successfully', async () => { + const mockCells: Cell[] = [ + { + cellOutput: { + capacity: '0xa', + lock: paymaster['lockScript'], + }, + outPoint: { + txHash: '0x123', + index: '0x0', + }, + data: '0x', + }, + { + cellOutput: { + capacity: '0xa', + lock: paymaster['lockScript'], + }, + outPoint: { + txHash: '0x456', + index: '0x0', + }, + data: '0x', + }, + ]; + const mockCollector = { + collect: async function* () { + yield* mockCells; + }, + }; + vi.spyOn(paymaster['cradle'].ckbIndexer, 'collector').mockReturnValue(mockCollector); + vi.spyOn(paymaster['queue'], 'getWaitingCount').mockResolvedValue(9); + vi.spyOn(paymaster['queue'], 'add'); + + const filled = await paymaster.refillCellQueue(); + expect(filled).toBe(1); + expect(paymaster['queue'].add).toHaveBeenCalledTimes(1); + }); + + test('refillCellQueue: should return 0 when no cells are found to add', async () => { + const mockCollector = { + collect: async function* () { + // No cells yielded + }, + }; + vi.spyOn(paymaster['cradle'].ckbIndexer, 'collector').mockReturnValue(mockCollector); + vi.spyOn(paymaster['queue'], 'add'); + + const filled = await paymaster.refillCellQueue(); + expect(filled).toBe(0); + expect(paymaster['queue'].add).not.toHaveBeenCalled(); + }); +}); diff --git a/test/services/transaction.test.ts b/test/services/transaction.test.ts new file mode 100644 index 00000000..83229d79 --- /dev/null +++ b/test/services/transaction.test.ts @@ -0,0 +1,136 @@ +import { describe, beforeEach, expect, test, vi } from 'vitest'; +import TransactionManager, { ITransactionRequest } from '../../src/services/transaction'; +import container from '../../src/container'; +import { CKBVirtualResult, InputCell, OutputCell } from '../../src/routes/rgbpp/types'; +import { Transaction } from '../../src/routes/bitcoin/types'; +import { calculateCommitment } from '@rgbpp-sdk/ckb/lib/utils/rgbpp'; + +const commitment = calculateCommitment({ + inputs: [] as InputCell[], + outputs: [] as OutputCell[], +} as CKBVirtualResult['ckbRawTx']); + +describe('transactionManager', () => { + let transactionManager: TransactionManager; + const cradle = container.cradle; + + beforeEach(async () => { + transactionManager = new TransactionManager(cradle); + }); + + test('verifyTransaction: should return true for valid transaction', async () => { + vi.spyOn( + transactionManager as unknown as { + getCommitmentFromBtcTx: (txid: string) => Promise; + }, + 'getCommitmentFromBtcTx', + ).mockResolvedValueOnce(Buffer.from(commitment, 'hex')); + + const transactionRequest: ITransactionRequest = { + txid: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', + ckbVirtualResult: { + ckbRawTx: { inputs: [] as InputCell[], outputs: [] as OutputCell[] } as CKBVirtualResult['ckbRawTx'], + commitment, + sumInputsCapacity: '1000', + needPaymasterCell: false, + }, + }; + // FIXME: mock electrs getTransaction + const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); + expect(isValid).toBe(true); + }); + + test('verifyTransaction: should return false for mismatch commitment', async () => { + vi.spyOn( + transactionManager as unknown as { + getCommitmentFromBtcTx: (txid: string) => Promise; + }, + 'getCommitmentFromBtcTx', + ).mockResolvedValueOnce(Buffer.from('mismatchcommitment', 'hex')); + + const transactionRequest: ITransactionRequest = { + txid: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', + ckbVirtualResult: { + ckbRawTx: { inputs: [] as InputCell[], outputs: [] as OutputCell[] } as CKBVirtualResult['ckbRawTx'], + commitment, + sumInputsCapacity: '1000', + needPaymasterCell: false, + }, + }; + // FIXME: mock electrs getTransaction + const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const isValid = await transactionManager.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 { + getCommitmentFromBtcTx: (txid: string) => Promise; + }, + 'getCommitmentFromBtcTx', + ).mockResolvedValueOnce(Buffer.from(commitment, 'hex')); + + const transactionRequest: ITransactionRequest = { + txid: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', + ckbVirtualResult: { + ckbRawTx: { inputs: [] as InputCell[], outputs: [] as OutputCell[] } as CKBVirtualResult['ckbRawTx'], + commitment, + sumInputsCapacity: '1000', + needPaymasterCell: false, + }, + }; + // FIXME: mock electrs getTransaction + const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + const isValid = await transactionManager.verifyTransaction(transactionRequest, btcTx); + expect(isValid).toBe(false); + }); + + test('verifyTransaction: should throw DelayedError for unconfirmed transaction', async () => { + vi.spyOn( + transactionManager as unknown as { + getCommitmentFromBtcTx: (txid: string) => Promise; + }, + 'getCommitmentFromBtcTx', + ).mockResolvedValueOnce(Buffer.from(commitment, 'hex')); + vi.spyOn(transactionManager['cradle']['electrs'], 'getTransaction').mockResolvedValueOnce({ + status: { confirmed: false, block_height: 0 }, + } as unknown as Transaction); + + const transactionRequest: ITransactionRequest = { + txid: 'bb8c92f11920824db22b379c0ef491dea2d819e721d5df296bebc67a0568ea0f', + ckbVirtualResult: { + ckbRawTx: { inputs: [] as InputCell[], outputs: [] as OutputCell[] } as CKBVirtualResult['ckbRawTx'], + commitment, + sumInputsCapacity: '1000', + needPaymasterCell: false, + }, + }; + + // FIXME: mock electrs getTransaction + const btcTx = await cradle.electrs.getTransaction(transactionRequest.txid); + await expect( + transactionManager.verifyTransaction(transactionRequest, btcTx), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('enqueueTransaction: should be add transaction request to queue', async () => { + const transactionRequest: ITransactionRequest = { + txid: '0x123', + ckbVirtualResult: { + ckbRawTx: {} as CKBVirtualResult['ckbRawTx'], + commitment: '0x123', + sumInputsCapacity: '1000', + needPaymasterCell: false, + }, + }; + + transactionManager.enqueueTransaction(transactionRequest); + const count = await transactionManager['queue'].getJobCounts(); + const job = await transactionManager['queue'].getJob(transactionRequest.txid); + expect(count.delayed).toBe(1); + expect(job?.delay).toBe(cradle.env.TRANSACTION_QUEUE_JOB_DELAY); + }); +}); diff --git a/test/services/unlocker.test.ts b/test/services/unlocker.test.ts new file mode 100644 index 00000000..2c810fd0 --- /dev/null +++ b/test/services/unlocker.test.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import container from '../../src/container'; +import { describe, test, beforeEach, afterEach, vi, expect } from 'vitest'; +import Unlocker from '../../src/services/unlocker'; +import { Cell } from '@ckb-lumos/lumos'; +import { BTCTimeLock, genBtcTimeLockScript, buildBtcTimeCellsSpentTx } from '@rgbpp-sdk/ckb'; + +vi.mock('@rgbpp-sdk/ckb', async (importOriginal) => { + const original = await importOriginal(); + return { + ...(original as object), + buildBtcTimeCellsSpentTx: vi.fn(), + }; +}); + +describe('Unlocker', () => { + let unlocker: Unlocker; + + beforeEach(async () => { + const cradle = container.cradle; + // TODO: mock env.TRANSACTION_SPV_SERVICE_URL + unlocker = new Unlocker(cradle); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function mockBtcTimeLockCell() { + vi.spyOn(BTCTimeLock, 'unpack').mockReturnValue({ + after: 6, + btcTxid: '0x12345', + lockScript: {} as unknown as CKBComponents.Script, + }); + vi.spyOn(unlocker['collector'], 'collect').mockImplementation(async function* () { + const toLock: CKBComponents.Script = { + args: '0xc0a45d9d7c024adcc8076c18b3f07c08de7c42120cdb7e6cbc05a28266b15b5f', + codeHash: '0x28e83a1277d48add8e72fadaa9248559e1b632bab2bd60b27955ebc4c03800a5', + hashType: 'data', + }; + yield { + blockNumber: '0x123', + outPoint: { + txHash: '0x', + index: '0x0', + }, + cellOutput: { + lock: genBtcTimeLockScript(toLock, false), + capacity: '0x123', + }, + data: '0x', + } as Cell; + yield { + blockNumber: '0x456', + outPoint: { + txHash: '0x', + index: '0x0', + }, + cellOutput: { + lock: genBtcTimeLockScript(toLock, false), + capacity: '0x456', + }, + data: '0x', + } as Cell; + }); + } + + test('getNextBatchLockCell: should skip unconfirmed btc tx', async () => { + // @ts-expect-error + vi.spyOn(unlocker['cradle'].bitcoind, 'getBlockchainInfo').mockResolvedValue({ blocks: 100 }); + // @ts-expect-error + vi.spyOn(unlocker['cradle'].electrs, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + mockBtcTimeLockCell(); + + const cells = await unlocker.getNextBatchLockCell(); + expect(cells).toHaveLength(0); + }); + + test('getNextBatchLockCell: should return cells when btc tx is confirmed', async () => { + // @ts-expect-error + vi.spyOn(unlocker['cradle'].bitcoind, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); + // @ts-expect-error + vi.spyOn(unlocker['cradle'].electrs, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + mockBtcTimeLockCell(); + + const cells = await unlocker.getNextBatchLockCell(); + expect(cells).toHaveLength(2); + }); + + test('getNextBatchLockCell: should break when cells reach batch size', async () => { + unlocker['cradle'].env.UNLOCKER_CELL_BATCH_SIZE = 1; + + // @ts-expect-error + vi.spyOn(unlocker['cradle'].bitcoind, 'getBlockchainInfo').mockResolvedValue({ blocks: 101 }); + // @ts-expect-error + vi.spyOn(unlocker['cradle'].electrs, 'getTransaction').mockResolvedValue({ status: { block_height: 95 } }); + mockBtcTimeLockCell(); + + const cells = await unlocker.getNextBatchLockCell(); + expect(cells).toHaveLength(1); + }); + + test('unlockCells: should do nothing when no cells to unlock', async () => { + vi.spyOn(unlocker, 'getNextBatchLockCell').mockResolvedValue([]); + await unlocker.unlockCells(); + expect(buildBtcTimeCellsSpentTx).not.toHaveBeenCalled(); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 78ba53ca..df7dadf4 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,8 +1,10 @@ -import { vi } from 'vitest'; -import Redis from 'ioredis-mock'; +import { afterAll, vi } from 'vitest'; +import container from '../src/container'; -vi.stubEnv('REDIS_URL', ''); +if (process.env.CI_REDIS_URL) { + vi.stubEnv('REDIS_URL', process.env.CI_REDIS_URL); +} -vi.mock('ioredis', () => ({ - Redis, -})); +afterAll(async () => { + container.cradle.redis.flushall(); +}); diff --git a/tsconfig.json b/tsconfig.json index ded547d6..32ff9065 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, + "resolveJsonModule": true, "skipLibCheck": true, "outDir": "dist", "typeRoots": [ diff --git a/vercel.json b/vercel.json index e0e926d2..417fd242 100644 --- a/vercel.json +++ b/vercel.json @@ -4,5 +4,15 @@ "source": "/(.*)", "destination": "/api/serverless.ts" } + ], + "crons": [ + { + "path": "/cron/process-transactions", + "schedule": "*/2 * * * *" + }, + { + "path": "/cron/unlock-cells", + "schedule": "*/5 * * * *" + } ] }