diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35853d17..72828daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,15 +18,15 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: cache-node-modules with: @@ -63,17 +63,17 @@ jobs: PGPASSWORD: postgres PGDATABASE: postgres steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v4 env: cache-name: cache-node-modules with: @@ -99,7 +99,9 @@ jobs: run: npm run test:${{ matrix.suite }} -- --coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} - name: Print integration environment logs run: cat docker-compose-logs.txt @@ -115,14 +117,14 @@ jobs: - lint - test steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} fetch-depth: 0 persist-credentials: false - name: Semantic Release - uses: cycjimmy/semantic-release-action@v3 + uses: cycjimmy/semantic-release-action@v4 id: semantic # Only run on non-PR events or only PRs that aren't from forks if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -137,11 +139,11 @@ jobs: conventional-changelog-conventionalcommits@6.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 - name: Docker Meta id: meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v5 with: images: | hirosystems/${{ github.event.repository.name }} @@ -152,13 +154,13 @@ jobs: type=semver,pattern={{major}}.{{minor}},value=${{ steps.semantic.outputs.new_release_version }},enable=${{ steps.semantic.outputs.new_release_version != '' }} - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Build/Tag/Push Image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml index 6eaae6bb..d253cd7a 100644 --- a/.github/workflows/vercel.yml +++ b/.github/workflows/vercel.yml @@ -19,13 +19,9 @@ jobs: vercel: runs-on: ubuntu-latest - env: - VERCEL_ENV: ${{ contains(github.ref_name, 'refs/tags') && 'production' || 'preview' }} - VERCEL_PROD: ${{ contains(github.ref_name, 'refs/tags') && '--prod' || '' }} - environment: - name: ${{ contains(github.ref_name, 'refs/tags') && 'Production' || 'Preview' }} - url: ${{ contains(github.ref_name, 'refs/tags') && 'https://ordinals-api.vercel.app/' || 'https://ordinals-api-pbcblockstack-blockstack.vercel.app/' }} + name: ${{ github.ref_name == 'master' && 'Production' || 'Preview' }} + url: ${{ github.ref_name == 'master' && 'https://ordinals-api.vercel.app/' || 'https://ordinals-api-pbcblockstack-blockstack.vercel.app/' }} steps: - uses: actions/checkout@v2 @@ -57,14 +53,14 @@ jobs: run: npm install --global vercel@latest - name: Pull Vercel environment information - run: vercel pull --yes --environment=${{ env.VERCEL_ENV }} --token=${{ secrets.VERCEL_TOKEN }} + run: vercel pull --yes --environment=${{ github.ref_name == 'master' && 'production' || 'preview' }} --token=${{ secrets.VERCEL_TOKEN }} - name: Build project artifacts - run: vercel build ${{ env.VERCEL_PROD }} --token=${{ secrets.VERCEL_TOKEN }} + run: vercel build ${{ github.ref_name == 'master' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy project artifacts to Vercel id: deploy - run: vercel deploy ${{ env.VERCEL_PROD }} --prebuilt --token=${{ secrets.VERCEL_TOKEN }} | awk '{print "deployment_url="$1}' >> $GITHUB_OUTPUT + run: vercel ${{ github.ref_name == 'master' && '--prod' || 'deploy' }} --prebuilt --token=${{ secrets.VERCEL_TOKEN }} | awk '{print "deployment_url="$1}' >> $GITHUB_OUTPUT - name: Trigger docs.hiro.so deployment if: github.ref_name == 'master' diff --git a/CHANGELOG.md b/CHANGELOG.md index 7444d9bc..cc0219a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,160 @@ +## [3.1.0](https://github.com/hirosystems/ordinals-api/compare/v3.0.1...v3.1.0) (2024-04-23) + + +### Features + +* support self minted 5-byte wide BRC-20 tokens ([#338](https://github.com/hirosystems/ordinals-api/issues/338)) ([60f46d3](https://github.com/hirosystems/ordinals-api/commit/60f46d3533e837843e8aa6094b4673a3bc84c124)) + + +### Bug Fixes + +* calculate transfer type correctly upon reveal ([#326](https://github.com/hirosystems/ordinals-api/issues/326)) ([6c4c54b](https://github.com/hirosystems/ordinals-api/commit/6c4c54b45a74744c4e61a2437632f390080a9624)) +* check only the first blessed inscription in next block on gap detection ([#325](https://github.com/hirosystems/ordinals-api/issues/325)) ([9cad6c1](https://github.com/hirosystems/ordinals-api/commit/9cad6c16d34fdd11c1d9f473b2f3802a8da464d8)) +* filter correct content types for brc20 ([#323](https://github.com/hirosystems/ordinals-api/issues/323)) ([4d52b48](https://github.com/hirosystems/ordinals-api/commit/4d52b4820e5f1a36264977dc4a6c4ca324864108)) +* ignore spent as fee on gap check ([#328](https://github.com/hirosystems/ordinals-api/issues/328)) ([a1277cf](https://github.com/hirosystems/ordinals-api/commit/a1277cf39eb61e548f55bd8e524054db6a11c843)) +* remove gap detection ([#330](https://github.com/hirosystems/ordinals-api/issues/330)) ([040ee04](https://github.com/hirosystems/ordinals-api/commit/040ee04b0906106fdece3f00d34fb4817d7f318e)) +* use brc20_total_balances table when inserting new transfer ([#321](https://github.com/hirosystems/ordinals-api/issues/321)) ([925fb0e](https://github.com/hirosystems/ordinals-api/commit/925fb0e05a982eeec802bee6f53a957bc5ea3acf)) + +## [3.1.0-beta.1](https://github.com/hirosystems/ordinals-api/compare/v3.0.2-beta.6...v3.1.0-beta.1) (2024-03-27) + + +### Features + +* support self minted 5-byte wide BRC-20 tokens ([#338](https://github.com/hirosystems/ordinals-api/issues/338)) ([60f46d3](https://github.com/hirosystems/ordinals-api/commit/60f46d3533e837843e8aa6094b4673a3bc84c124)) + +## [3.0.2-beta.6](https://github.com/hirosystems/ordinals-api/compare/v3.0.2-beta.5...v3.0.2-beta.6) (2024-03-09) + + +### Bug Fixes + +* remove gap detection ([#330](https://github.com/hirosystems/ordinals-api/issues/330)) ([040ee04](https://github.com/hirosystems/ordinals-api/commit/040ee04b0906106fdece3f00d34fb4817d7f318e)) + +## [3.0.2-beta.5](https://github.com/hirosystems/ordinals-api/compare/v3.0.2-beta.4...v3.0.2-beta.5) (2024-03-09) + + +### Bug Fixes + +* ignore spent as fee on gap check ([#328](https://github.com/hirosystems/ordinals-api/issues/328)) ([a1277cf](https://github.com/hirosystems/ordinals-api/commit/a1277cf39eb61e548f55bd8e524054db6a11c843)) + +## [3.0.2-beta.4](https://github.com/hirosystems/ordinals-api/compare/v3.0.2-beta.3...v3.0.2-beta.4) (2024-03-08) + + +### Bug Fixes + +* calculate transfer type correctly upon reveal ([#326](https://github.com/hirosystems/ordinals-api/issues/326)) ([6c4c54b](https://github.com/hirosystems/ordinals-api/commit/6c4c54b45a74744c4e61a2437632f390080a9624)) + +## [3.0.2-beta.3](https://github.com/hirosystems/ordinals-api/compare/v3.0.2-beta.2...v3.0.2-beta.3) (2024-03-07) + + +### Bug Fixes + +* check only the first blessed inscription in next block on gap detection ([#325](https://github.com/hirosystems/ordinals-api/issues/325)) ([9cad6c1](https://github.com/hirosystems/ordinals-api/commit/9cad6c16d34fdd11c1d9f473b2f3802a8da464d8)) + +## [3.0.2-beta.2](https://github.com/hirosystems/ordinals-api/compare/v3.0.2-beta.1...v3.0.2-beta.2) (2024-03-07) + + +### Bug Fixes + +* filter correct content types for brc20 ([#323](https://github.com/hirosystems/ordinals-api/issues/323)) ([4d52b48](https://github.com/hirosystems/ordinals-api/commit/4d52b4820e5f1a36264977dc4a6c4ca324864108)) + +## [3.0.2-beta.1](https://github.com/hirosystems/ordinals-api/compare/v3.0.1...v3.0.2-beta.1) (2024-03-07) + + +### Bug Fixes + +* use brc20_total_balances table when inserting new transfer ([#321](https://github.com/hirosystems/ordinals-api/issues/321)) ([925fb0e](https://github.com/hirosystems/ordinals-api/commit/925fb0e05a982eeec802bee6f53a957bc5ea3acf)) + +## [3.0.1](https://github.com/hirosystems/ordinals-api/compare/v3.0.0...v3.0.1) (2024-03-04) + + +### Bug Fixes + +* transfers for inscriptions created immediately before ([#320](https://github.com/hirosystems/ordinals-api/issues/320)) ([66ba744](https://github.com/hirosystems/ordinals-api/commit/66ba744f96a101c6d0e23122ae7aa41fb1933ba4)) + +## [3.0.0](https://github.com/hirosystems/ordinals-api/compare/v2.2.0...v3.0.0) (2024-02-28) + + +### ⚠ BREAKING CHANGES + +* rename chainhook env vars to ordhook (#314) + +### Features + +* add `metadata` and `parent` columns to inscriptions table ([#305](https://github.com/hirosystems/ordinals-api/issues/305)) ([d71e93a](https://github.com/hirosystems/ordinals-api/commit/d71e93a54ec8018c15bbfc2f88d18bab6606949e)) +* add ordhook debug server ([#306](https://github.com/hirosystems/ordinals-api/issues/306)) ([88ad130](https://github.com/hirosystems/ordinals-api/commit/88ad1302924b8da27b8d98caf3d3351149a45f91)) +* shutdown gracefully after finishing replaying blocks ([#315](https://github.com/hirosystems/ordinals-api/issues/315)) ([72fd3fd](https://github.com/hirosystems/ordinals-api/commit/72fd3fda24919f14218cbbf02a552dfcf4d8ea52)) + + +### Bug Fixes + +* batch inscription location updates ([47525c9](https://github.com/hirosystems/ordinals-api/commit/47525c93a0e71bd9a7df2ea1596d7c90f59ccaac)) +* batch location pointer inserts ([#308](https://github.com/hirosystems/ordinals-api/issues/308)) ([33f8cb2](https://github.com/hirosystems/ordinals-api/commit/33f8cb2576115695d8e54fcc989086afef42ddc7)) +* only update timestamp when streaming blocks ([#309](https://github.com/hirosystems/ordinals-api/issues/309)) ([2c9ff17](https://github.com/hirosystems/ordinals-api/commit/2c9ff17ebe8805f3de59046b25387b871debee49)) +* remove unused pg indexes ([#311](https://github.com/hirosystems/ordinals-api/issues/311)) ([94d98d4](https://github.com/hirosystems/ordinals-api/commit/94d98d4e945aef28be7ec19fdb07d121c0ba2ea2)) +* rename chainhook env vars to ordhook ([#314](https://github.com/hirosystems/ordinals-api/issues/314)) ([ae4ec01](https://github.com/hirosystems/ordinals-api/commit/ae4ec01326779af00b89907751a6946effe60536)) +* standardize insert batch size ([b9b2448](https://github.com/hirosystems/ordinals-api/commit/b9b2448a1fa1bef16f0a1eed80158ca9b8ca9133)) + +## [3.0.0-beta.2](https://github.com/hirosystems/ordinals-api/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2024-02-21) + + +### Features + +* shutdown gracefully after finishing replaying blocks ([#315](https://github.com/hirosystems/ordinals-api/issues/315)) ([72fd3fd](https://github.com/hirosystems/ordinals-api/commit/72fd3fda24919f14218cbbf02a552dfcf4d8ea52)) + +## [3.0.0-beta.1](https://github.com/hirosystems/ordinals-api/compare/v2.3.0-beta.6...v3.0.0-beta.1) (2024-02-21) + + +### ⚠ BREAKING CHANGES + +* rename chainhook env vars to ordhook (#314) + +### Bug Fixes + +* rename chainhook env vars to ordhook ([#314](https://github.com/hirosystems/ordinals-api/issues/314)) ([ae4ec01](https://github.com/hirosystems/ordinals-api/commit/ae4ec01326779af00b89907751a6946effe60536)) + +## [2.3.0-beta.6](https://github.com/hirosystems/ordinals-api/compare/v2.3.0-beta.5...v2.3.0-beta.6) (2024-02-20) + + +### Bug Fixes + +* remove unused pg indexes ([#311](https://github.com/hirosystems/ordinals-api/issues/311)) ([94d98d4](https://github.com/hirosystems/ordinals-api/commit/94d98d4e945aef28be7ec19fdb07d121c0ba2ea2)) + +## [2.3.0-beta.5](https://github.com/hirosystems/ordinals-api/compare/v2.3.0-beta.4...v2.3.0-beta.5) (2024-02-19) + + +### Bug Fixes + +* only update timestamp when streaming blocks ([#309](https://github.com/hirosystems/ordinals-api/issues/309)) ([2c9ff17](https://github.com/hirosystems/ordinals-api/commit/2c9ff17ebe8805f3de59046b25387b871debee49)) + +## [2.3.0-beta.4](https://github.com/hirosystems/ordinals-api/compare/v2.3.0-beta.3...v2.3.0-beta.4) (2024-02-17) + + +### Bug Fixes + +* standardize insert batch size ([b9b2448](https://github.com/hirosystems/ordinals-api/commit/b9b2448a1fa1bef16f0a1eed80158ca9b8ca9133)) + +## [2.3.0-beta.3](https://github.com/hirosystems/ordinals-api/compare/v2.3.0-beta.2...v2.3.0-beta.3) (2024-02-17) + + +### Bug Fixes + +* batch inscription location updates ([47525c9](https://github.com/hirosystems/ordinals-api/commit/47525c93a0e71bd9a7df2ea1596d7c90f59ccaac)) + +## [2.3.0-beta.2](https://github.com/hirosystems/ordinals-api/compare/v2.3.0-beta.1...v2.3.0-beta.2) (2024-02-17) + + +### Bug Fixes + +* batch location pointer inserts ([#308](https://github.com/hirosystems/ordinals-api/issues/308)) ([33f8cb2](https://github.com/hirosystems/ordinals-api/commit/33f8cb2576115695d8e54fcc989086afef42ddc7)) + +## [2.3.0-beta.1](https://github.com/hirosystems/ordinals-api/compare/v2.2.0...v2.3.0-beta.1) (2024-02-12) + + +### Features + +* add `metadata` and `parent` columns to inscriptions table ([#305](https://github.com/hirosystems/ordinals-api/issues/305)) ([d71e93a](https://github.com/hirosystems/ordinals-api/commit/d71e93a54ec8018c15bbfc2f88d18bab6606949e)) +* add ordhook debug server ([#306](https://github.com/hirosystems/ordinals-api/issues/306)) ([88ad130](https://github.com/hirosystems/ordinals-api/commit/88ad1302924b8da27b8d98caf3d3351149a45f91)) + ## [2.2.0](https://github.com/hirosystems/ordinals-api/compare/v2.1.1...v2.2.0) (2024-02-02) diff --git a/migrations/1708471015438_remove-unused-indexes.ts b/migrations/1708471015438_remove-unused-indexes.ts new file mode 100644 index 00000000..2ba978b7 --- /dev/null +++ b/migrations/1708471015438_remove-unused-indexes.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +export function up(pgm: MigrationBuilder): void { + pgm.dropIndex('locations', ['prev_output']); + pgm.dropIndex('locations', ['address']); + pgm.dropIndex('current_locations', ['block_height']); + pgm.dropIndex('brc20_mints', ['address']); + pgm.dropIndex('brc20_mints', ['block_height']); + pgm.dropIndex('brc20_mints', ['brc20_deploy_id']); + pgm.dropIndex('brc20_transfers', ['to_address']); + pgm.dropIndex('brc20_transfers', ['from_address']); + pgm.dropIndex('brc20_transfers', ['brc20_deploy_id']); + pgm.dropIndex('brc20_transfers', ['block_height']); + pgm.dropIndex('brc20_deploys', ['address']); + pgm.dropIndex('brc20_deploys', ['block_height']); + pgm.dropIndex('inscription_recursions', ['ref_inscription_genesis_id']); +} + +export function down(pgm: MigrationBuilder): void { + pgm.createIndex('locations', ['prev_output']); + pgm.createIndex('locations', ['address']); + pgm.createIndex('current_locations', ['block_height']); + pgm.createIndex('brc20_mints', ['address']); + pgm.createIndex('brc20_mints', ['block_height']); + pgm.createIndex('brc20_mints', ['brc20_deploy_id']); + pgm.createIndex('brc20_transfers', ['to_address']); + pgm.createIndex('brc20_transfers', ['from_address']); + pgm.createIndex('brc20_transfers', ['brc20_deploy_id']); + pgm.createIndex('brc20_transfers', ['block_height']); + pgm.createIndex('brc20_deploys', ['address']); + pgm.createIndex('brc20_deploys', ['block_height']); + pgm.createIndex('inscription_recursions', ['ref_inscription_genesis_id']); +} diff --git a/migrations/1711465842961_brc20-deploy-self-mint.ts b/migrations/1711465842961_brc20-deploy-self-mint.ts new file mode 100644 index 00000000..8cacf691 --- /dev/null +++ b/migrations/1711465842961_brc20-deploy-self-mint.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +export function up(pgm: MigrationBuilder): void { + pgm.addColumn('brc20_deploys', { + self_mint: { + type: 'boolean', + default: 'false', + }, + }); + pgm.sql(`UPDATE brc20_deploys SET self_mint = false`); + pgm.alterColumn('brc20_deploys', 'self_mint', { notNull: true }); +} + +export function down(pgm: MigrationBuilder): void { + pgm.dropColumn('brc20_deploys', ['self_mint']); +} diff --git a/package-lock.json b/package-lock.json index 869f446c..28f91c25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@fastify/multipart": "^7.1.0", "@fastify/swagger": "^8.3.1", "@fastify/type-provider-typebox": "^3.2.0", - "@hirosystems/api-toolkit": "^1.3.1", + "@hirosystems/api-toolkit": "^1.4.0", "@hirosystems/chainhook-client": "^1.7.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^10.0.4", @@ -1278,9 +1278,9 @@ } }, "node_modules/@hirosystems/api-toolkit": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@hirosystems/api-toolkit/-/api-toolkit-1.3.1.tgz", - "integrity": "sha512-uUOqWcJlaxnlW30RyZ1UdidzFy29esd4bG0UxwnsJH+M+qtvV4V/NaHLRUFbnJkoF5b6vckZh1mldyBNY7aL1Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hirosystems/api-toolkit/-/api-toolkit-1.4.0.tgz", + "integrity": "sha512-n1LF5roEQ7LkfAvKw0Wucmbo+XhvQBp4ED9N/AyD76/wHQeU59nocDkVAoKSJzWzzCfHa2+G32d3zB3m6oMbIQ==", "dependencies": { "@fastify/cors": "^8.0.0", "@fastify/swagger": "^8.3.1", @@ -19728,9 +19728,9 @@ "requires": {} }, "@hirosystems/api-toolkit": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@hirosystems/api-toolkit/-/api-toolkit-1.3.1.tgz", - "integrity": "sha512-uUOqWcJlaxnlW30RyZ1UdidzFy29esd4bG0UxwnsJH+M+qtvV4V/NaHLRUFbnJkoF5b6vckZh1mldyBNY7aL1Q==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hirosystems/api-toolkit/-/api-toolkit-1.4.0.tgz", + "integrity": "sha512-n1LF5roEQ7LkfAvKw0Wucmbo+XhvQBp4ED9N/AyD76/wHQeU59nocDkVAoKSJzWzzCfHa2+G32d3zB3m6oMbIQ==", "requires": { "@fastify/cors": "^8.0.0", "@fastify/swagger": "^8.3.1", diff --git a/package.json b/package.json index 1ac289fb..08d55d38 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@fastify/multipart": "^7.1.0", "@fastify/swagger": "^8.3.1", "@fastify/type-provider-typebox": "^3.2.0", - "@hirosystems/api-toolkit": "^1.3.1", + "@hirosystems/api-toolkit": "^1.4.0", "@hirosystems/chainhook-client": "^1.7.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^10.0.4", diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 973ba9f6..053f38d3 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -488,6 +488,7 @@ export const Brc20TokenResponseSchema = Type.Object( deploy_timestamp: Type.Integer({ examples: [1677733170000] }), minted_supply: Type.String({ examples: ['1000000'] }), tx_count: Type.Integer({ examples: [300000] }), + self_mint: Type.Boolean(), }, { title: 'BRC-20 Token Response' } ); diff --git a/src/api/util/helpers.ts b/src/api/util/helpers.ts index 173e7672..9b7815eb 100644 --- a/src/api/util/helpers.ts +++ b/src/api/util/helpers.ts @@ -120,6 +120,7 @@ export function parseBrc20Tokens(items: DbBrc20Token[]): Brc20TokenResponse[] { deploy_timestamp: i.timestamp.valueOf(), minted_supply: decimals(i.minted_supply, i.decimals), tx_count: parseInt(i.tx_count), + self_mint: i.self_mint, })); } diff --git a/src/env.ts b/src/env.ts index 88f64f65..956d42f3 100644 --- a/src/env.ts +++ b/src/env.ts @@ -28,19 +28,31 @@ const schema = Type.Object({ EXTERNAL_HOSTNAME: Type.String({ default: '127.0.0.1' }), /** Hostname of the ordhook node we'll use to register predicates */ - CHAINHOOK_NODE_RPC_HOST: Type.String({ default: '127.0.0.1' }), + ORDHOOK_NODE_RPC_HOST: Type.String({ default: '127.0.0.1' }), /** Control port of the ordhook node */ - CHAINHOOK_NODE_RPC_PORT: Type.Number({ default: 20456, minimum: 0, maximum: 65535 }), + ORDHOOK_NODE_RPC_PORT: Type.Number({ default: 20456, minimum: 0, maximum: 65535 }), /** * Authorization token that the ordhook node must send with every event to make sure it's * coming from the valid instance */ - CHAINHOOK_NODE_AUTH_TOKEN: Type.String(), + ORDHOOK_NODE_AUTH_TOKEN: Type.String(), /** * Register ordhook predicates automatically when the API is first launched. Set this to `false` * if you're configuring your predicates manually for any reason. */ - CHAINHOOK_AUTO_PREDICATE_REGISTRATION: Type.Boolean({ default: true }), + ORDHOOK_AUTO_PREDICATE_REGISTRATION: Type.Boolean({ default: true }), + /** + * Ordhook ingestion mode. Controls the API's Ordhook payload ingestion behavior: + * * `default`: The API will stay running and will listen for payloads indefinitely + * * `replay`: The API will stay running and listening only for payloads marked as "not streaming" + * by Ordhook (historical replays). Once Ordhook starts streaming recent blocks from its chain + * tip, the API will shut down. Recommended for deployments meant to sync the ordinals chain + * from genesis. + */ + ORDHOOK_INGESTION_MODE: Type.Enum( + { default: 'default', replay: 'replay' }, + { default: 'default' } + ), PGHOST: Type.String(), PGPORT: Type.Number({ default: 5432, minimum: 0, maximum: 65535 }), @@ -55,8 +67,6 @@ const schema = Type.Object({ /** Enables BRC-20 processing in write mode APIs */ BRC20_BLOCK_SCAN_ENABLED: Type.Boolean({ default: true }), - /** Enables inscription gap detection to prevent ingesting unordered blocks */ - INSCRIPTION_GAP_DETECTION_ENABLED: Type.Boolean({ default: true }), }); type Env = Static; diff --git a/src/ordhook/server.ts b/src/ordhook/server.ts index b7fae7b0..a414da32 100644 --- a/src/ordhook/server.ts +++ b/src/ordhook/server.ts @@ -8,9 +8,9 @@ import { ServerOptions, ServerPredicate, } from '@hirosystems/chainhook-client'; -import { logger } from '@hirosystems/api-toolkit'; +import { logger, shutdown } from '@hirosystems/api-toolkit'; -export const ORDHOOK_BASE_PATH = `http://${ENV.CHAINHOOK_NODE_RPC_HOST}:${ENV.CHAINHOOK_NODE_RPC_PORT}`; +export const ORDHOOK_BASE_PATH = `http://${ENV.ORDHOOK_NODE_RPC_HOST}:${ENV.ORDHOOK_NODE_RPC_PORT}`; export const PREDICATE_UUID = randomUUID(); /** @@ -20,7 +20,7 @@ export const PREDICATE_UUID = randomUUID(); */ export async function startOrdhookServer(args: { db: PgStore }): Promise { const predicates: ServerPredicate[] = []; - if (ENV.CHAINHOOK_AUTO_PREDICATE_REGISTRATION) { + if (ENV.ORDHOOK_AUTO_PREDICATE_REGISTRATION) { const blockHeight = await args.db.getChainTipBlockHeight(); logger.info(`Ordinals predicate starting from block ${blockHeight}...`); predicates.push({ @@ -43,9 +43,9 @@ export async function startOrdhookServer(args: { db: PgStore }): Promise { - logger.info(`OrdhookServer received payload from predicate ${uuid}`); + const streamed = payload.chainhook.is_streaming_blocks; + if (ENV.ORDHOOK_INGESTION_MODE === 'replay' && streamed) { + logger.info(`OrdhookServer finished replaying blocks, shutting down`); + return shutdown(); + } + logger.info( + `OrdhookServer received ${streamed ? 'streamed' : 'replay'} payload from predicate ${uuid}` + ); await args.db.updateInscriptions(payload); }); return server; diff --git a/src/pg/brc20/brc20-pg-store.ts b/src/pg/brc20/brc20-pg-store.ts index 357795a4..8641a620 100644 --- a/src/pg/brc20/brc20-pg-store.ts +++ b/src/pg/brc20/brc20-pg-store.ts @@ -1,6 +1,5 @@ import { BasePgStoreModule, logger } from '@hirosystems/api-toolkit'; import * as postgres from 'postgres'; -import { hexToBuffer } from '../../api/util/helpers'; import { DbInscriptionIndexPaging, InscriptionData, @@ -27,7 +26,7 @@ import { DbBrc20TokenWithSupply, DbBrc20TransferEvent, } from './types'; -import { Brc20Deploy, Brc20Mint, Brc20Transfer, brc20FromInscriptionContent } from './helpers'; +import { Brc20Deploy, Brc20Mint, Brc20Transfer, UINT64_MAX, brc20FromInscription } from './helpers'; import { Brc20TokenOrderBy } from '../../api/schemas'; import { objRemoveUndefinedValues } from '../helpers'; @@ -47,15 +46,7 @@ export class Brc20PgStore extends BasePgStoreModule { const pointer = args.pointers[i]; if (parseInt(pointer.block_height) < BRC20_GENESIS_BLOCK) continue; if ('inscription' in reveal) { - if ( - reveal.inscription.classic_number < 0 || - reveal.inscription.number < 0 || - reveal.location.transfer_type != DbLocationTransferType.transferred - ) - continue; - const brc20 = brc20FromInscriptionContent( - hexToBuffer(reveal.inscription.content as string).toString('utf-8') - ); + const brc20 = brc20FromInscription(reveal); if (brc20) { switch (brc20.op) { case 'deploy': @@ -208,7 +199,7 @@ export class Brc20PgStore extends BasePgStoreModule { private async insertDeploy(deploy: { brc20: Brc20Deploy; - reveal: InscriptionEventData; + reveal: InscriptionRevealData; pointer: DbLocationPointerInsert; }): Promise { if (deploy.reveal.location.transfer_type != DbLocationTransferType.transferred) return; @@ -218,10 +209,11 @@ export class Brc20PgStore extends BasePgStoreModule { tx_id: deploy.reveal.location.tx_id, address: deploy.pointer.address as string, ticker: deploy.brc20.tick, - max: deploy.brc20.max, + max: deploy.brc20.max === '0' ? UINT64_MAX.toString() : deploy.brc20.max, limit: deploy.brc20.lim ?? null, decimals: deploy.brc20.dec ?? '18', tx_count: 1, + self_mint: deploy.brc20.self_mint === 'true', }; const deployRes = await this.sql` WITH deploy_insert AS ( @@ -258,19 +250,21 @@ export class Brc20PgStore extends BasePgStoreModule { private async insertMint(mint: { brc20: Brc20Mint; - reveal: InscriptionEventData; + reveal: InscriptionRevealData; pointer: DbLocationPointerInsert; }): Promise { if (mint.reveal.location.transfer_type != DbLocationTransferType.transferred) return; // Check the following conditions: // * Is the mint amount within the allowed token limits? + // * Is this a self_mint with the correct parent inscription? // * Is the number of decimals correct? // * Does the mint amount exceed remaining supply? const mintRes = await this.sql` WITH mint_data AS ( - SELECT id, decimals, "limit", max, minted_supply - FROM brc20_deploys - WHERE ticker_lower = LOWER(${mint.brc20.tick}) AND minted_supply < max + SELECT d.id, d.decimals, d."limit", d.max, d.minted_supply, d.self_mint, i.genesis_id + FROM brc20_deploys d + INNER JOIN inscriptions i ON i.id = d.inscription_id + WHERE d.ticker_lower = LOWER(${mint.brc20.tick}) AND d.minted_supply < d.max ), validated_mint AS ( SELECT @@ -279,6 +273,10 @@ export class Brc20PgStore extends BasePgStoreModule { FROM mint_data WHERE ("limit" IS NULL OR ${mint.brc20.amt}::numeric <= "limit") AND (SCALE(${mint.brc20.amt}::numeric) <= decimals) + AND ( + self_mint = FALSE OR + (self_mint = TRUE AND genesis_id = ${mint.reveal.inscription.parent}) + ) ), mint_insert AS ( INSERT INTO brc20_mints (inscription_id, brc20_deploy_id, block_height, tx_id, address, amount) ( @@ -340,20 +338,13 @@ export class Brc20PgStore extends BasePgStoreModule { pointer: DbLocationPointerInsert; }): Promise { if (transfer.reveal.location.transfer_type != DbLocationTransferType.transferred) return; - // Check the following conditions: - // * Do we have enough available balance to do this transfer? const transferRes = await this.sql` - WITH balance_data AS ( - SELECT b.brc20_deploy_id, COALESCE(SUM(b.avail_balance), 0) AS avail_balance - FROM brc20_balances AS b - INNER JOIN brc20_deploys AS d ON b.brc20_deploy_id = d.id - WHERE d.ticker_lower = LOWER(${transfer.brc20.tick}) - AND b.address = ${transfer.pointer.address} - GROUP BY b.brc20_deploy_id - ), - validated_transfer AS ( - SELECT * FROM balance_data - WHERE avail_balance >= ${transfer.brc20.amt}::numeric + WITH validated_transfer AS ( + SELECT brc20_deploy_id, avail_balance + FROM brc20_total_balances + WHERE brc20_deploy_id = (SELECT id FROM brc20_deploys WHERE ticker_lower = LOWER(${transfer.brc20.tick})) + AND address = ${transfer.pointer.address} + AND avail_balance >= ${transfer.brc20.amt}::numeric ), transfer_insert AS ( INSERT INTO brc20_transfers (inscription_id, brc20_deploy_id, block_height, tx_id, from_address, to_address, amount) ( diff --git a/src/pg/brc20/helpers.ts b/src/pg/brc20/helpers.ts index 3b1f1958..6aec1697 100644 --- a/src/pg/brc20/helpers.ts +++ b/src/pg/brc20/helpers.ts @@ -2,7 +2,7 @@ import { Static, Type } from '@fastify/type-provider-typebox'; import { TypeCompiler } from '@sinclair/typebox/compiler'; import BigNumber from 'bignumber.js'; import { hexToBuffer } from '../../api/util/helpers'; -import { InscriptionData } from '../types'; +import { DbLocationTransferType, InscriptionRevealData } from '../types'; const Brc20TickerSchema = Type.String({ minLength: 1 }); const Brc20NumberSchema = Type.RegEx(/^((\d+)|(\d*\.?\d+))$/); @@ -15,6 +15,7 @@ const Brc20DeploySchema = Type.Object( max: Brc20NumberSchema, lim: Type.Optional(Brc20NumberSchema), dec: Type.Optional(Type.RegEx(/^\d+$/)), + self_mint: Type.Optional(Type.Literal('true')), }, { additionalProperties: true } ); @@ -46,28 +47,42 @@ const Brc20Schema = Type.Union([Brc20DeploySchema, Brc20MintSchema, Brc20Transfe const Brc20C = TypeCompiler.Compile(Brc20Schema); export type Brc20 = Static; -const UINT64_MAX = BigNumber('18446744073709551615'); // 20 digits +export const UINT64_MAX = BigNumber('18446744073709551615'); // 20 digits // Only compare against `UINT64_MAX` if the number is at least the same number of digits. const numExceedsMax = (num: string) => num.length >= 20 && UINT64_MAX.isLessThan(num); -// For testing only -export function brc20FromInscription(inscription: InscriptionData): Brc20 | undefined { - if (inscription.number < 0) return; - if (inscription.mime_type !== 'text/plain' && inscription.mime_type !== 'application/json') - return; - const buf = hexToBuffer(inscription.content as string).toString('utf-8'); - return brc20FromInscriptionContent(buf); -} +/** + * Activation block height for + * https://l1f.discourse.group/t/brc-20-proposal-for-issuance-and-burn-enhancements-brc20-ip-1/621/1 + */ +export const BRC20_SELF_MINT_ACTIVATION_BLOCK = 837090; -export function brc20FromInscriptionContent(content: string): Brc20 | undefined { +export function brc20FromInscription(reveal: InscriptionRevealData): Brc20 | undefined { + if ( + reveal.inscription.classic_number < 0 || + reveal.inscription.number < 0 || + reveal.location.transfer_type != DbLocationTransferType.transferred || + !['text/plain', 'application/json'].includes(reveal.inscription.mime_type) + ) + return; try { - const json = JSON.parse(content); + const json = JSON.parse(hexToBuffer(reveal.inscription.content as string).toString('utf-8')); if (Brc20C.Check(json)) { // Check ticker byte length - if (Buffer.from(json.tick).length !== 4) return; + const tick = Buffer.from(json.tick); + if (json.op === 'deploy') { + if ( + tick.length === 5 && + (reveal.location.block_height < BRC20_SELF_MINT_ACTIVATION_BLOCK || + json.self_mint !== 'true') + ) + return; + } + if (tick.length < 4 || tick.length > 5) return; // Check numeric values. if (json.op === 'deploy') { - if (parseFloat(json.max) == 0 || numExceedsMax(json.max)) return; + if ((parseFloat(json.max) == 0 && json.self_mint !== 'true') || numExceedsMax(json.max)) + return; if (json.lim && (parseFloat(json.lim) == 0 || numExceedsMax(json.lim))) return; if (json.dec && parseFloat(json.dec) > 18) return; } else { diff --git a/src/pg/brc20/types.ts b/src/pg/brc20/types.ts index 269397da..5b28258a 100644 --- a/src/pg/brc20/types.ts +++ b/src/pg/brc20/types.ts @@ -20,6 +20,7 @@ export type DbBrc20DeployInsert = { decimals: string; limit: string | null; tx_count: number; + self_mint: boolean; }; export type DbBrc20MintInsert = { @@ -78,6 +79,7 @@ export type DbBrc20Token = { timestamp: number; minted_supply: string; tx_count: string; + self_mint: boolean; }; export type DbBrc20TokenWithSupply = DbBrc20Token & { @@ -188,6 +190,7 @@ export const BRC20_DEPLOYS_COLUMNS = [ 'limit', 'minted_supply', 'tx_count', + 'self_mint', ]; export const BRC20_TRANSFERS_COLUMNS = [ diff --git a/src/pg/helpers.ts b/src/pg/helpers.ts index 25d5d504..033a1ece 100644 --- a/src/pg/helpers.ts +++ b/src/pg/helpers.ts @@ -1,12 +1,10 @@ -import { PgBytea, toEnumValue } from '@hirosystems/api-toolkit'; +import { PgBytea, logger, toEnumValue } from '@hirosystems/api-toolkit'; import { hexToBuffer, normalizedHexString, parseSatPoint } from '../api/util/helpers'; import { - BadPayloadRequestError, BitcoinEvent, BitcoinInscriptionRevealed, BitcoinInscriptionTransferred, } from '@hirosystems/chainhook-client'; -import { ENV } from '../env'; import { DbLocationTransferType, InscriptionEventData, @@ -15,29 +13,6 @@ import { } from './types'; import { OrdinalSatoshi } from '../api/util/ordinal-satoshi'; -/** - * Check if writing a block would create an inscription number gap - * @param currentNumber - Current max blessed number - * @param newNumbers - New blessed numbers to be inserted - */ -export function assertNoBlockInscriptionGap(args: { - currentNumber: number; - newNumbers: number[]; - currentBlockHeight: number; - newBlockHeight: number; -}) { - if (!ENV.INSCRIPTION_GAP_DETECTION_ENABLED) return; - args.newNumbers.sort((a, b) => a - b); - for (let n = 0; n < args.newNumbers.length; n++) { - const curr = args.currentNumber + n; - const next = args.newNumbers[n]; - if (next !== curr + 1) - throw new BadPayloadRequestError( - `Block inscription gap detected: Attempting to insert #${next} (${args.newBlockHeight}) but current max is #${curr}. Chain tip is at ${args.currentBlockHeight}.` - ); - } -} - /** * Returns a list of referenced inscription ids from inscription content. * @param content - Inscription content @@ -98,12 +73,25 @@ function updateFromOrdhookInscriptionRevealed(args: { const satoshi = new OrdinalSatoshi(args.reveal.ordinal_number); const satpoint = parseSatPoint(args.reveal.satpoint_post_inscription); const recursive_refs = getInscriptionRecursion(args.reveal.content_bytes); - const contentType = removeNullBytes(args.reveal.content_type); + const content_type = removeNullBytes(args.reveal.content_type); + let transfer_type = DbLocationTransferType.transferred; + if (args.reveal.inscriber_address == null || args.reveal.inscriber_address == '') { + if (args.reveal.inscription_output_value == 0) { + if (args.reveal.inscription_pointer !== 0 && args.reveal.inscription_pointer !== null) { + logger.warn( + `Detected inscription reveal with no address and no output value but a valid pointer ${args.reveal.inscription_id}` + ); + } + transfer_type = DbLocationTransferType.spentInFees; + } else { + transfer_type = DbLocationTransferType.burnt; + } + } return { inscription: { genesis_id: args.reveal.inscription_id, - mime_type: contentType.split(';')[0], - content_type: contentType, + mime_type: content_type.split(';')[0], + content_type, content_length: args.reveal.content_length, number: args.reveal.inscription_number.jubilee, classic_number: args.reveal.inscription_number.classic, @@ -131,7 +119,7 @@ function updateFromOrdhookInscriptionRevealed(args: { prev_offset: null, value: args.reveal.inscription_output_value.toString(), timestamp: args.timestamp, - transfer_type: DbLocationTransferType.transferred, + transfer_type, }, recursive_refs, }; diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index cb16e755..f08df0c2 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -17,7 +17,7 @@ import { ENV } from '../env'; import { Brc20PgStore } from './brc20/brc20-pg-store'; import { CountsPgStore } from './counts/counts-pg-store'; import { getIndexResultCountType } from './counts/helpers'; -import { assertNoBlockInscriptionGap, revealInsertsFromOrdhookEvent } from './helpers'; +import { revealInsertsFromOrdhookEvent } from './helpers'; import { DbFullyLocatedInscriptionResult, DbInscriptionContent, @@ -33,7 +33,6 @@ import { DbPaginatedResult, InscriptionEventData, LOCATIONS_COLUMNS, - InscriptionRevealData, InscriptionInsert, LocationInsert, LocationData, @@ -41,6 +40,7 @@ import { export const MIGRATIONS_DIR = path.join(__dirname, '../../migrations'); export const ORDINALS_GENESIS_BLOCK = 767430; +const INSERT_BATCH_SIZE = 4000; type InscriptionIdentifier = { genesis_id: string } | { number: number }; @@ -108,11 +108,9 @@ export class PgStore extends BasePgStore { // Check where we're at in terms of ingestion, e.g. block height and max blessed inscription // number. This will let us determine if we should skip ingesting this block or throw an // error if a gap is detected. - const currentBlessedNumber = (await this.getMaxInscriptionNumber()) ?? -1; const currentBlockHeight = await this.getChainTipBlockHeight(); const event = applyEvent as BitcoinEvent; if ( - ENV.INSCRIPTION_GAP_DETECTION_ENABLED && event.block_identifier.index <= currentBlockHeight && event.block_identifier.index !== ORDINALS_GENESIS_BLOCK ) { @@ -124,17 +122,8 @@ export class PgStore extends BasePgStore { logger.info(`PgStore ingesting block ${event.block_identifier.index}`); const time = stopwatch(); const writes = revealInsertsFromOrdhookEvent(event); - const newBlessedNumbers = writes - .filter(w => 'inscription' in w && w.inscription.number >= 0) - .map(w => (w as InscriptionRevealData).inscription.number ?? 0); - assertNoBlockInscriptionGap({ - currentNumber: currentBlessedNumber, - newNumbers: newBlessedNumbers, - currentBlockHeight: currentBlockHeight, - newBlockHeight: event.block_identifier.index, - }); - for (const writeChunk of batchIterate(writes, 4000)) - await this.insertInscriptions(writeChunk); + for (const writeChunk of batchIterate(writes, INSERT_BATCH_SIZE)) + await this.insertInscriptions(writeChunk, payload.chainhook.is_streaming_blocks); updatedBlockHeightMin = Math.min(updatedBlockHeightMin, event.block_identifier.index); logger.info( `PgStore ingested block ${event.block_identifier.index} in ${time.getElapsedSeconds()}s` @@ -486,17 +475,38 @@ export class PgStore extends BasePgStore { `; // roughly 35 days of blocks, assuming 10 minute block times on a full database } - private async insertInscriptions(reveals: InscriptionEventData[]): Promise { + private async insertInscriptions( + reveals: InscriptionEventData[], + streamed: boolean + ): Promise { if (reveals.length === 0) return; await this.sqlWriteTransaction(async sql => { + // 1. Write inscription reveals const inscriptionInserts: InscriptionInsert[] = []; + for (const r of reveals) if ('inscription' in r) inscriptionInserts.push(r.inscription); + if (inscriptionInserts.length) + await sql` + INSERT INTO inscriptions ${sql(inscriptionInserts)} + ON CONFLICT ON CONSTRAINT inscriptions_number_unique DO UPDATE SET + genesis_id = EXCLUDED.genesis_id, + mime_type = EXCLUDED.mime_type, + content_type = EXCLUDED.content_type, + content_length = EXCLUDED.content_length, + content = EXCLUDED.content, + fee = EXCLUDED.fee, + sat_ordinal = EXCLUDED.sat_ordinal, + sat_rarity = EXCLUDED.sat_rarity, + sat_coinbase_height = EXCLUDED.sat_coinbase_height, + updated_at = NOW() + `; + + // 2. Write locations and transfers const locationInserts: LocationInsert[] = []; const revealOutputs: InscriptionEventData[] = []; const transferredOrdinalNumbersSet = new Set(); for (const r of reveals) if ('inscription' in r) { revealOutputs.push(r); - inscriptionInserts.push(r.inscription); locationInserts.push({ ...r.location, inscription_id: sql`(SELECT id FROM inscriptions WHERE genesis_id = ${r.location.genesis_id})`, @@ -531,43 +541,31 @@ export class PgStore extends BasePgStore { }); } } - const transferredOrdinalNumbers = [...transferredOrdinalNumbersSet]; - if (inscriptionInserts.length) - await sql` - INSERT INTO inscriptions ${sql(inscriptionInserts)} - ON CONFLICT ON CONSTRAINT inscriptions_number_unique DO UPDATE SET + const pointers: DbLocationPointerInsert[] = []; + for (const batch of batchIterate(locationInserts, INSERT_BATCH_SIZE)) { + const pointerBatch = await sql` + INSERT INTO locations ${sql(batch)} + ON CONFLICT ON CONSTRAINT locations_inscription_id_block_height_tx_index_unique DO UPDATE SET genesis_id = EXCLUDED.genesis_id, - mime_type = EXCLUDED.mime_type, - content_type = EXCLUDED.content_type, - content_length = EXCLUDED.content_length, - content = EXCLUDED.content, - fee = EXCLUDED.fee, - sat_ordinal = EXCLUDED.sat_ordinal, - sat_rarity = EXCLUDED.sat_rarity, - sat_coinbase_height = EXCLUDED.sat_coinbase_height, - updated_at = NOW() + block_hash = EXCLUDED.block_hash, + tx_id = EXCLUDED.tx_id, + address = EXCLUDED.address, + value = EXCLUDED.value, + output = EXCLUDED.output, + "offset" = EXCLUDED.offset, + timestamp = EXCLUDED.timestamp + RETURNING inscription_id, id AS location_id, block_height, tx_index, address `; - const pointers = await sql` - INSERT INTO locations ${sql(locationInserts)} - ON CONFLICT ON CONSTRAINT locations_inscription_id_block_height_tx_index_unique DO UPDATE SET - genesis_id = EXCLUDED.genesis_id, - block_hash = EXCLUDED.block_hash, - tx_id = EXCLUDED.tx_id, - address = EXCLUDED.address, - value = EXCLUDED.value, - output = EXCLUDED.output, - "offset" = EXCLUDED.offset, - timestamp = EXCLUDED.timestamp - RETURNING inscription_id, id AS location_id, block_height, tx_index, address - `; - await this.updateInscriptionRecursions(reveals); - if (transferredOrdinalNumbers.length) + await this.updateInscriptionLocationPointers(pointerBatch); + pointers.push(...pointerBatch); + } + if (streamed && transferredOrdinalNumbersSet.size) await sql` UPDATE inscriptions SET updated_at = NOW() - WHERE sat_ordinal IN ${sql(transferredOrdinalNumbers)} + WHERE sat_ordinal IN ${sql([...transferredOrdinalNumbersSet])} `; - await this.updateInscriptionLocationPointers(pointers); + for (const reveal of reveals) { const action = 'inscription' in reveal @@ -575,6 +573,9 @@ export class PgStore extends BasePgStore { : `transfer sat ${reveal.location.ordinal_number}`; logger.info(`PgStore ${action} at block ${reveal.location.block_height}`); } + + // 3. Recursions, Counts and BRC-20 + await this.updateInscriptionRecursions(reveals); await this.counts.applyInscriptions(inscriptionInserts); if (ENV.BRC20_BLOCK_SCAN_ENABLED) await this.brc20.insertOperations({ reveals: revealOutputs, pointers }); diff --git a/tests/brc-20/brc20.test.ts b/tests/brc-20/brc20.test.ts index 9f5a8608..8f2132f3 100644 --- a/tests/brc-20/brc20.test.ts +++ b/tests/brc-20/brc20.test.ts @@ -1,9 +1,9 @@ import { runMigrations } from '@hirosystems/api-toolkit'; import { buildApiServer } from '../../src/api/init'; import { Brc20ActivityResponse, Brc20TokenResponse } from '../../src/api/schemas'; -import { brc20FromInscription } from '../../src/pg/brc20/helpers'; +import { BRC20_SELF_MINT_ACTIVATION_BLOCK, brc20FromInscription } from '../../src/pg/brc20/helpers'; import { MIGRATIONS_DIR, PgStore } from '../../src/pg/pg-store'; -import { InscriptionData } from '../../src/pg/types'; +import { DbLocationTransferType, InscriptionRevealData } from '../../src/pg/types'; import { TestChainhookPayloadBuilder, TestFastifyServer, @@ -86,26 +86,44 @@ describe('BRC-20', () => { }); describe('token standard validation', () => { - const testInsert = (json: any): InscriptionData => { + const testInsert = (json: any, block_height: number = 830000): InscriptionRevealData => { const content = Buffer.from(JSON.stringify(json), 'utf-8'); - const insert: InscriptionData = { - genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - number: 0, - classic_number: 0, - mime_type: 'application/json', - content_type: 'application/json', - content_length: content.length, - content: `0x${content.toString('hex')}`, - fee: '200', - curse_type: null, - sat_ordinal: '2000000', - sat_rarity: 'common', - sat_coinbase_height: 110, - recursive: false, - metadata: null, - parent: null, + return { + inscription: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 0, + classic_number: 0, + mime_type: 'application/json', + content_type: 'application/json', + content_length: content.length, + content: `0x${content.toString('hex')}`, + fee: '200', + curse_type: null, + sat_ordinal: '2000000', + sat_rarity: 'common', + sat_coinbase_height: 110, + recursive: false, + metadata: null, + parent: null, + }, + recursive_refs: [], + location: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + block_height, + block_hash: '00000000000000000002c5c0aba96f981642a6dca109e6b3564925c21a98aa3e', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + tx_index: 0, + address: 'bc1pdjd6q33l0ca9nuudu2hr5qrs9u5dt6nl0z7fvu8kv4y8w4fzdpysc80028', + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + offset: '0', + prev_output: null, + prev_offset: null, + value: '9999', + transfer_type: DbLocationTransferType.transferred, + block_transfer_index: null, + timestamp: 1091091019, + }, }; - return insert; }; test('ignores incorrect MIME type', () => { @@ -118,29 +136,48 @@ describe('BRC-20', () => { }), 'utf-8' ); - const insert: InscriptionData = { - genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - number: 0, - classic_number: 0, - mime_type: 'foo/bar', - content_type: 'foo/bar;x=1', - content_length: content.length, - content: `0x${content.toString('hex')}`, - fee: '200', - curse_type: null, - sat_ordinal: '2000000', - sat_rarity: 'common', - sat_coinbase_height: 110, - recursive: false, - metadata: null, - parent: null, + const insert: InscriptionRevealData = { + inscription: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 0, + classic_number: 0, + mime_type: 'foo/bar', + content_type: 'foo/bar;x=1', + content_length: content.length, + content: `0x${content.toString('hex')}`, + fee: '200', + curse_type: null, + sat_ordinal: '2000000', + sat_rarity: 'common', + sat_coinbase_height: 110, + recursive: false, + metadata: null, + parent: null, + }, + recursive_refs: [], + location: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + block_height: 830000, + block_hash: '00000000000000000002c5c0aba96f981642a6dca109e6b3564925c21a98aa3e', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + tx_index: 0, + address: 'bc1pdjd6q33l0ca9nuudu2hr5qrs9u5dt6nl0z7fvu8kv4y8w4fzdpysc80028', + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + offset: '0', + prev_output: null, + prev_offset: null, + value: '9999', + transfer_type: DbLocationTransferType.transferred, + block_transfer_index: null, + timestamp: 1091091019, + }, }; expect(brc20FromInscription(insert)).toBeUndefined(); - insert.content_type = 'application/json'; - insert.mime_type = 'application/json'; + insert.inscription.content_type = 'application/json'; + insert.inscription.mime_type = 'application/json'; expect(brc20FromInscription(insert)).not.toBeUndefined(); - insert.content_type = 'text/plain;charset=utf-8'; - insert.mime_type = 'text/plain'; + insert.inscription.content_type = 'text/plain;charset=utf-8'; + insert.inscription.mime_type = 'text/plain'; expect(brc20FromInscription(insert)).not.toBeUndefined(); }); @@ -149,22 +186,139 @@ describe('BRC-20', () => { '{"p": "brc-20", "op": "deploy", "tick": "PEPE", "max": "21000000"', 'utf-8' ); - const insert: InscriptionData = { - genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - number: 0, - classic_number: 0, - mime_type: 'application/json', - content_type: 'application/json', - content_length: content.length, - content: `0x${content.toString('hex')}`, - fee: '200', - curse_type: null, - sat_ordinal: '2000000', - sat_rarity: 'common', - sat_coinbase_height: 110, - recursive: false, - metadata: null, - parent: null, + const insert: InscriptionRevealData = { + inscription: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 0, + classic_number: 0, + mime_type: 'application/json', + content_type: 'application/json', + content_length: content.length, + content: `0x${content.toString('hex')}`, + fee: '200', + curse_type: null, + sat_ordinal: '2000000', + sat_rarity: 'common', + sat_coinbase_height: 110, + recursive: false, + metadata: null, + parent: null, + }, + recursive_refs: [], + location: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + block_height: 830000, + block_hash: '00000000000000000002c5c0aba96f981642a6dca109e6b3564925c21a98aa3e', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + tx_index: 0, + address: 'bc1pdjd6q33l0ca9nuudu2hr5qrs9u5dt6nl0z7fvu8kv4y8w4fzdpysc80028', + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + offset: '0', + prev_output: null, + prev_offset: null, + value: '9999', + transfer_type: DbLocationTransferType.transferred, + block_transfer_index: null, + timestamp: 1091091019, + }, + }; + expect(brc20FromInscription(insert)).toBeUndefined(); + }); + + test('ignores inscriptions spent as fees', () => { + const content = Buffer.from( + JSON.stringify({ + p: 'brc-20', + op: 'deploy', + tick: 'PEPE', + max: '21000000', + }), + 'utf-8' + ); + const insert: InscriptionRevealData = { + inscription: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 0, + classic_number: 0, + mime_type: 'application/json', + content_type: 'application/json', + content_length: content.length, + content: `0x${content.toString('hex')}`, + fee: '200', + curse_type: null, + sat_ordinal: '2000000', + sat_rarity: 'common', + sat_coinbase_height: 110, + recursive: false, + metadata: null, + parent: null, + }, + recursive_refs: [], + location: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + block_height: 830000, + block_hash: '00000000000000000002c5c0aba96f981642a6dca109e6b3564925c21a98aa3e', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + tx_index: 0, + address: '', + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + offset: '0', + prev_output: null, + prev_offset: null, + value: '0', + transfer_type: DbLocationTransferType.spentInFees, + block_transfer_index: null, + timestamp: 1091091019, + }, + }; + expect(brc20FromInscription(insert)).toBeUndefined(); + }); + + test('ignores burnt inscriptions', () => { + const content = Buffer.from( + JSON.stringify({ + p: 'brc-20', + op: 'deploy', + tick: 'PEPE', + max: '21000000', + }), + 'utf-8' + ); + const insert: InscriptionRevealData = { + inscription: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 0, + classic_number: 0, + mime_type: 'application/json', + content_type: 'application/json', + content_length: content.length, + content: `0x${content.toString('hex')}`, + fee: '200', + curse_type: null, + sat_ordinal: '2000000', + sat_rarity: 'common', + sat_coinbase_height: 110, + recursive: false, + metadata: null, + parent: null, + }, + recursive_refs: [], + location: { + genesis_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + block_height: 830000, + block_hash: '00000000000000000002c5c0aba96f981642a6dca109e6b3564925c21a98aa3e', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + tx_index: 0, + address: '', + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + offset: '0', + prev_output: null, + prev_offset: null, + value: '1000', + transfer_type: DbLocationTransferType.burnt, + block_transfer_index: null, + timestamp: 1091091019, + }, }; expect(brc20FromInscription(insert)).toBeUndefined(); }); @@ -189,7 +343,7 @@ describe('BRC-20', () => { expect(brc20FromInscription(insert)).toBeUndefined(); }); - test('tick must be 4 bytes wide', () => { + test('tick must be 4 or 5 bytes wide', () => { const insert = testInsert({ p: 'brc-20', op: 'deploy', @@ -220,6 +374,41 @@ describe('BRC-20', () => { expect(brc20FromInscription(insert4)).toBeUndefined(); }); + test('deploy self_mint tick must be 5 bytes wide', () => { + const insert = testInsert( + { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', // 5 bytes + max: '21000000', + self_mint: 'true', + }, + 840000 + ); + expect(brc20FromInscription(insert)).not.toBeUndefined(); + const insert2 = testInsert( + { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', // 5 bytes but no self_mint + max: '21000000', + }, + 840000 + ); + expect(brc20FromInscription(insert2)).toBeUndefined(); + const insert4 = testInsert( + { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', // Correct but earlier than activation + max: '21000000', + self_mint: 'true', + }, + 820000 + ); + expect(brc20FromInscription(insert4)).toBeUndefined(); + }); + test('all fields must be strings', () => { const insert1 = testInsert({ p: 'brc-20', @@ -347,6 +536,17 @@ describe('BRC-20', () => { }); // `dec` can have a value of 0 expect(brc20FromInscription(insert1c)).not.toBeUndefined(); + const insert1d = testInsert( + { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', + max: '0', // self mints can be max 0 + self_mint: 'true', + }, + 840000 + ); + expect(brc20FromInscription(insert1d)).not.toBeUndefined(); const insert2a = testInsert({ p: 'brc-20', op: 'mint', @@ -529,10 +729,102 @@ describe('BRC-20', () => { deploy_timestamp: 1677811111000, minted_supply: '0.000000000000000000', tx_count: 1, + self_mint: false, }, ]); }); + test('deploy with self_mint is ignored before activation height', async () => { + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK, + hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + timestamp: 1677811111, + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', + max: '21000000', + self_mint: 'true', + }, + number: 0, + ordinal_number: 0, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + }) + ) + .build() + ); + const response1 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/tokens?ticker=$PEPE`, + }); + expect(response1.statusCode).toBe(200); + const responseJson1 = response1.json(); + expect(responseJson1.total).toBe(0); + }); + + test('deploy with self_mint is saved', async () => { + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_SELF_MINT_ACTIVATION_BLOCK, + hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + timestamp: 1677811111, + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', + max: '21000000', + self_mint: 'true', + }, + number: 0, + ordinal_number: 0, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + }) + ) + .build() + ); + const response1 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/tokens?ticker=$PEPE`, + }); + expect(response1.statusCode).toBe(200); + const responseJson1 = response1.json(); + expect(responseJson1.total).toBe(1); + expect(responseJson1.results[0]).toStrictEqual({ + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + block_height: 837090, + decimals: 18, + deploy_timestamp: 1677811111000, + id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + max_supply: '21000000.000000000000000000', + mint_limit: null, + self_mint: true, + minted_supply: '0.000000000000000000', + number: 0, + ticker: '$PEPE', + tx_count: 1, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }); + }); + test('ignores deploys for existing token', async () => { await db.updateInscriptions( new TestChainhookPayloadBuilder() @@ -607,6 +899,7 @@ describe('BRC-20', () => { deploy_timestamp: 1677803510000, minted_supply: '0.000000000000000000', tx_count: 1, + self_mint: false, }, ]); }); @@ -685,6 +978,7 @@ describe('BRC-20', () => { deploy_timestamp: 1677803510000, minted_supply: '0.000000000000000000', tx_count: 1, + self_mint: false, }, ]); const response2 = await fastify.inject({ @@ -708,6 +1002,7 @@ describe('BRC-20', () => { deploy_timestamp: 1677803510000, minted_supply: '0.000000000000000000', tx_count: 1, + self_mint: false, }, ]); }); @@ -881,13 +1176,13 @@ describe('BRC-20', () => { ); }); - test('rollback mints deduct balance correctly', async () => { + test('valid self mints are saved and balance reflected', async () => { const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; await db.updateInscriptions( new TestChainhookPayloadBuilder() .apply() .block({ - height: BRC20_GENESIS_BLOCK, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK, hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', }) .transaction({ @@ -898,8 +1193,9 @@ describe('BRC-20', () => { json: { p: 'brc-20', op: 'deploy', - tick: 'PEPE', + tick: '$PEPE', max: '21000000', + self_mint: 'true', }, number: 0, ordinal_number: 0, @@ -913,7 +1209,7 @@ describe('BRC-20', () => { new TestChainhookPayloadBuilder() .apply() .block({ - height: BRC20_GENESIS_BLOCK + 1, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 1, hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', }) .transaction({ @@ -924,40 +1220,59 @@ describe('BRC-20', () => { json: { p: 'brc-20', op: 'mint', - tick: 'PEPE', + tick: '$PEPE', amt: '250000', }, number: 1, ordinal_number: 1, tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', address: address, + parent: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', }) ) .build() ); - // Rollback + + const response1 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address}`, + }); + expect(response1.statusCode).toBe(200); + const responseJson1 = response1.json(); + expect(responseJson1.total).toBe(1); + expect(responseJson1.results).toStrictEqual([ + { + ticker: '$PEPE', + available_balance: '250000.000000000000000000', + overall_balance: '250000.000000000000000000', + transferrable_balance: '0.000000000000000000', + }, + ]); + + // New mint await db.updateInscriptions( new TestChainhookPayloadBuilder() - .rollback() + .apply() .block({ - height: BRC20_GENESIS_BLOCK + 2, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 2, hash: '0000000000000000000077163227125e51d838787d6af031bc9b55a3a1cc1b2c', }) .transaction({ - hash: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + hash: '7a1adbc3e93ddf8d7c4e0ba75aa11c98c431521dd850be8b955feedb716d8bec', }) .inscriptionRevealed( brc20Reveal({ json: { p: 'brc-20', op: 'mint', - tick: 'PEPE', - amt: '250000', + tick: '$pepe', + amt: '100000', }, - number: 1, - ordinal_number: 1, - tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', - address: address, + number: 2, + ordinal_number: 2, + tx_id: '7a1adbc3e93ddf8d7c4e0ba75aa11c98c431521dd850be8b955feedb716d8bec', + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + parent: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', }) ) .build() @@ -969,23 +1284,37 @@ describe('BRC-20', () => { }); expect(response2.statusCode).toBe(200); const responseJson2 = response2.json(); - expect(responseJson2.total).toBe(0); - expect(responseJson2.results).toStrictEqual([]); + expect(responseJson2.total).toBe(1); + expect(responseJson2.results).toStrictEqual([ + { + ticker: '$PEPE', + available_balance: '350000.000000000000000000', + overall_balance: '350000.000000000000000000', + transferrable_balance: '0.000000000000000000', + }, + ]); const response3 = await fastify.inject({ method: 'GET', - url: `/ordinals/brc-20/tokens/PEPE`, + url: `/ordinals/brc-20/tokens?ticker=$PEPE`, }); - expect(response3.json().token.minted_supply).toBe('0.000000000000000000'); + expect(response3.statusCode).toBe(200); + const responseJson3 = response3.json(); + expect(responseJson3.total).toBe(1); + expect(responseJson3.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ticker: '$PEPE', minted_supply: '350000.000000000000000000' }), + ]) + ); }); - test('numbers should not have more decimal digits than "dec" of ticker', async () => { + test('self mints with invalid parent inscription are ignored', async () => { const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; await db.updateInscriptions( new TestChainhookPayloadBuilder() .apply() .block({ - height: BRC20_GENESIS_BLOCK, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK, hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', }) .transaction({ @@ -996,9 +1325,9 @@ describe('BRC-20', () => { json: { p: 'brc-20', op: 'deploy', - tick: 'PEPE', + tick: '$PEPE', max: '21000000', - dec: '1', + self_mint: 'true', }, number: 0, ordinal_number: 0, @@ -1012,7 +1341,7 @@ describe('BRC-20', () => { new TestChainhookPayloadBuilder() .apply() .block({ - height: BRC20_GENESIS_BLOCK + 1, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 1, hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', }) .transaction({ @@ -1023,35 +1352,48 @@ describe('BRC-20', () => { json: { p: 'brc-20', op: 'mint', - tick: 'PEPE', - amt: '250000.000', // Invalid decimal count + tick: '$PEPE', + amt: '250000', }, number: 1, ordinal_number: 1, tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', address: address, + // no parent }) ) .build() ); - const response2 = await fastify.inject({ + const response1 = await fastify.inject({ method: 'GET', url: `/ordinals/brc-20/balances/${address}`, }); - expect(response2.statusCode).toBe(200); - const responseJson2 = response2.json(); - expect(responseJson2.total).toBe(0); - expect(responseJson2.results).toStrictEqual([]); + expect(response1.statusCode).toBe(200); + const responseJson1 = response1.json(); + expect(responseJson1.total).toBe(0); + + const response3 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/tokens?ticker=$PEPE`, + }); + expect(response3.statusCode).toBe(200); + const responseJson3 = response3.json(); + expect(responseJson3.total).toBe(1); + expect(responseJson3.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ticker: '$PEPE', minted_supply: '0.000000000000000000' }), + ]) + ); }); - test('mint exceeds token supply', async () => { + test('valid self mints for tokens with max 0 are saved', async () => { const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; await db.updateInscriptions( new TestChainhookPayloadBuilder() .apply() .block({ - height: BRC20_GENESIS_BLOCK, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK, hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', }) .transaction({ @@ -1062,9 +1404,9 @@ describe('BRC-20', () => { json: { p: 'brc-20', op: 'deploy', - tick: 'PEPE', - max: '2500', - dec: '1', + tick: '$PEPE', + max: '0', + self_mint: 'true', }, number: 0, ordinal_number: 0, @@ -1078,32 +1420,328 @@ describe('BRC-20', () => { new TestChainhookPayloadBuilder() .apply() .block({ - height: BRC20_GENESIS_BLOCK + 1, + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 1, hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', }) .transaction({ - hash: '3b55f624eaa4f8de6c42e0c490176b67123a83094384f658611faf7bfb85dd0f', + hash: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', }) .inscriptionRevealed( brc20Reveal({ json: { p: 'brc-20', op: 'mint', - tick: 'PEPE', - amt: '1000', + tick: '$PEPE', + amt: '250000', }, number: 1, ordinal_number: 1, - tx_id: '3b55f624eaa4f8de6c42e0c490176b67123a83094384f658611faf7bfb85dd0f', + tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', address: address, + parent: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', }) ) - .transaction({ - hash: '7e09bda2cba34bca648cca6d79a074940d39b6137150d3a3edcf80c0e01419a5', - }) - .inscriptionRevealed( - brc20Reveal({ - json: { + .build() + ); + + const response1 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address}`, + }); + expect(response1.statusCode).toBe(200); + const responseJson1 = response1.json(); + expect(responseJson1.total).toBe(1); + expect(responseJson1.results).toStrictEqual([ + { + ticker: '$PEPE', + available_balance: '250000.000000000000000000', + overall_balance: '250000.000000000000000000', + transferrable_balance: '0.000000000000000000', + }, + ]); + + // New mint + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 2, + hash: '0000000000000000000077163227125e51d838787d6af031bc9b55a3a1cc1b2c', + }) + .transaction({ + hash: '7a1adbc3e93ddf8d7c4e0ba75aa11c98c431521dd850be8b955feedb716d8bec', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'mint', + tick: '$pepe', + amt: '100000', + }, + number: 2, + ordinal_number: 2, + tx_id: '7a1adbc3e93ddf8d7c4e0ba75aa11c98c431521dd850be8b955feedb716d8bec', + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + parent: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + }) + ) + .build() + ); + + const response2 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address}`, + }); + expect(response2.statusCode).toBe(200); + const responseJson2 = response2.json(); + expect(responseJson2.total).toBe(1); + expect(responseJson2.results).toStrictEqual([ + { + ticker: '$PEPE', + available_balance: '350000.000000000000000000', + overall_balance: '350000.000000000000000000', + transferrable_balance: '0.000000000000000000', + }, + ]); + + const response3 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/tokens?ticker=$PEPE`, + }); + expect(response3.statusCode).toBe(200); + const responseJson3 = response3.json(); + expect(responseJson3.total).toBe(1); + expect(responseJson3.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ticker: '$PEPE', minted_supply: '350000.000000000000000000' }), + ]) + ); + }); + + test('rollback mints deduct balance correctly', async () => { + const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK, + hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'deploy', + tick: 'PEPE', + max: '21000000', + }, + number: 0, + ordinal_number: 0, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + address: address, + }) + ) + .build() + ); + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK + 1, + hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', + }) + .transaction({ + hash: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'mint', + tick: 'PEPE', + amt: '250000', + }, + number: 1, + ordinal_number: 1, + tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + address: address, + }) + ) + .build() + ); + // Rollback + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .rollback() + .block({ + height: BRC20_GENESIS_BLOCK + 2, + hash: '0000000000000000000077163227125e51d838787d6af031bc9b55a3a1cc1b2c', + }) + .transaction({ + hash: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'mint', + tick: 'PEPE', + amt: '250000', + }, + number: 1, + ordinal_number: 1, + tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + address: address, + }) + ) + .build() + ); + + const response2 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address}`, + }); + expect(response2.statusCode).toBe(200); + const responseJson2 = response2.json(); + expect(responseJson2.total).toBe(0); + expect(responseJson2.results).toStrictEqual([]); + + const response3 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/tokens/PEPE`, + }); + expect(response3.json().token.minted_supply).toBe('0.000000000000000000'); + }); + + test('numbers should not have more decimal digits than "dec" of ticker', async () => { + const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK, + hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'deploy', + tick: 'PEPE', + max: '21000000', + dec: '1', + }, + number: 0, + ordinal_number: 0, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + address: address, + }) + ) + .build() + ); + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK + 1, + hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', + }) + .transaction({ + hash: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'mint', + tick: 'PEPE', + amt: '250000.000', // Invalid decimal count + }, + number: 1, + ordinal_number: 1, + tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f', + address: address, + }) + ) + .build() + ); + + const response2 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address}`, + }); + expect(response2.statusCode).toBe(200); + const responseJson2 = response2.json(); + expect(responseJson2.total).toBe(0); + expect(responseJson2.results).toStrictEqual([]); + }); + + test('mint exceeds token supply', async () => { + const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK, + hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'deploy', + tick: 'PEPE', + max: '2500', + dec: '1', + }, + number: 0, + ordinal_number: 0, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + address: address, + }) + ) + .build() + ); + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_GENESIS_BLOCK + 1, + hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', + }) + .transaction({ + hash: '3b55f624eaa4f8de6c42e0c490176b67123a83094384f658611faf7bfb85dd0f', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'mint', + tick: 'PEPE', + amt: '1000', + }, + number: 1, + ordinal_number: 1, + tx_id: '3b55f624eaa4f8de6c42e0c490176b67123a83094384f658611faf7bfb85dd0f', + address: address, + }) + ) + .transaction({ + hash: '7e09bda2cba34bca648cca6d79a074940d39b6137150d3a3edcf80c0e01419a5', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { p: 'brc-20', op: 'mint', tick: 'PEPE', @@ -1609,6 +2247,145 @@ describe('BRC-20', () => { expect(prevBlockJson2.results[0]).toBeUndefined(); }); + test('send balance for self_mint token to address', async () => { + const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; + const address2 = '3QNjwPDRafjBm9XxJpshgk3ksMJh3TFxTU'; + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_SELF_MINT_ACTIVATION_BLOCK, + hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'deploy', + tick: '$PEPE', + max: '0', + self_mint: 'true', + }, + number: 0, + ordinal_number: 0, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + address: address, + }) + ) + .build() + ); + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 1, + hash: '0000000000000000000098d8f2663891d439f6bb7de230d4e9f6bcc2e85452bf', + }) + .transaction({ + hash: '3b55f624eaa4f8de6c42e0c490176b67123a83094384f658611faf7bfb85dd0f', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'mint', + tick: '$PEPE', + amt: '10000', + }, + number: 1, + ordinal_number: 1, + tx_id: '3b55f624eaa4f8de6c42e0c490176b67123a83094384f658611faf7bfb85dd0f', + address: address, + parent: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + }) + ) + .build() + ); + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 2, + hash: '00000000000000000002b14f0c5dde0b2fc74d022e860696bd64f1f652756674', + }) + .transaction({ + hash: 'eee52b22397ea4a4aefe6a39931315e93a157091f5a994216c0aa9c8c6fef47a', + }) + .inscriptionRevealed( + brc20Reveal({ + json: { + p: 'brc-20', + op: 'transfer', + tick: '$PEPE', + amt: '9000', + }, + number: 2, + ordinal_number: 2, + tx_id: 'eee52b22397ea4a4aefe6a39931315e93a157091f5a994216c0aa9c8c6fef47a', + address: address, + }) + ) + .build() + ); + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: BRC20_SELF_MINT_ACTIVATION_BLOCK + 3, + hash: '00000000000000000003feae13d107f0f2c4fb4dd08fb2a8b1ab553512e77f03', + }) + .transaction({ + hash: '7edaa48337a94da327b6262830505f116775a32db5ad4ad46e87ecea33f21bac', + }) + .inscriptionTransferred({ + ordinal_number: 2, + destination: { type: 'transferred', value: address2 }, + satpoint_pre_transfer: + 'eee52b22397ea4a4aefe6a39931315e93a157091f5a994216c0aa9c8c6fef47a:0:0', + satpoint_post_transfer: + '7edaa48337a94da327b6262830505f116775a32db5ad4ad46e87ecea33f21bac:0:0', + post_transfer_output_value: null, + tx_index: 0, + }) + .build() + ); + + const response1 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address}`, + }); + expect(response1.statusCode).toBe(200); + const json1 = response1.json(); + expect(json1.total).toBe(1); + expect(json1.results).toStrictEqual([ + { + available_balance: '1000.000000000000000000', + overall_balance: '1000.000000000000000000', + ticker: '$PEPE', + transferrable_balance: '0.000000000000000000', + }, + ]); + + const response2 = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${address2}`, + }); + expect(response2.statusCode).toBe(200); + const json2 = response2.json(); + expect(json2.total).toBe(1); + expect(json2.results).toStrictEqual([ + { + available_balance: '9000.000000000000000000', + overall_balance: '9000.000000000000000000', + ticker: '$PEPE', + transferrable_balance: '0.000000000000000000', + }, + ]); + }); + test('sending transfer as fee returns amount to sender', async () => { const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td'; await deployAndMintPEPE(address); @@ -2042,6 +2819,7 @@ describe('BRC-20', () => { deploy_timestamp: 1677803510000, minted_supply: '0.000000000000000000', tx_count: 1, + self_mint: false, }, supply: { max_supply: '21000000.000000000000000000', diff --git a/tests/helpers.ts b/tests/helpers.ts index f26f86b3..f2c70674 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -115,6 +115,7 @@ export function brc20Reveal(args: { address: string; tx_id: string; ordinal_number: number; + parent?: string; }): BitcoinInscriptionRevealed { const content = Buffer.from(JSON.stringify(args.json), 'utf-8'); const reveal: BitcoinInscriptionRevealed = { @@ -141,7 +142,7 @@ export function brc20Reveal(args: { delegate: null, metaprotocol: null, metadata: undefined, - parent: null, + parent: args.parent ?? null, }; return reveal; } diff --git a/tests/ordhook/replay.test.ts b/tests/ordhook/replay.test.ts new file mode 100644 index 00000000..1e07d625 --- /dev/null +++ b/tests/ordhook/replay.test.ts @@ -0,0 +1,78 @@ +import { runMigrations } from '@hirosystems/api-toolkit'; +import { ChainhookEventObserver } from '@hirosystems/chainhook-client'; +import { buildApiServer } from '../../src/api/init'; +import { ENV } from '../../src/env'; +import { startOrdhookServer } from '../../src/ordhook/server'; +import { PgStore, MIGRATIONS_DIR } from '../../src/pg/pg-store'; +import { TestChainhookPayloadBuilder, TestFastifyServer } from '../helpers'; + +describe('Replay', () => { + let db: PgStore; + let server: ChainhookEventObserver; + let fastify: TestFastifyServer; + + beforeEach(async () => { + await runMigrations(MIGRATIONS_DIR, 'up'); + ENV.ORDHOOK_AUTO_PREDICATE_REGISTRATION = false; + ENV.ORDHOOK_INGESTION_MODE = 'replay'; + db = await PgStore.connect({ skipMigrations: true }); + server = await startOrdhookServer({ db }); + fastify = await buildApiServer({ db }); + }); + + test('shuts down when streaming on replay mode', async () => { + const payload1 = new TestChainhookPayloadBuilder() + .apply() + .block({ + height: 767430, + hash: '0x163de66dc9c0949905bfe8e148bde04600223cf88d19f26fdbeba1d6e6fa0f88', + timestamp: 1676913207, + }) + .transaction({ + hash: '0x0268dd9743c862d80ab02cb1d0228036cfe172522850eb96be60cfee14b31fb8', + }) + .inscriptionRevealed({ + content_bytes: '0x303030303030303030303030', + content_type: 'text/plain;charset=utf-8', + content_length: 12, + inscription_number: { classic: 0, jubilee: 0 }, + inscription_fee: 3425, + inscription_output_value: 10000, + inscription_id: '0268dd9743c862d80ab02cb1d0228036cfe172522850eb96be60cfee14b31fb8i0', + inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + ordinal_number: 125348773618236, + ordinal_block_height: 566462, + ordinal_offset: 0, + satpoint_post_inscription: + '0x0268dd9743c862d80ab02cb1d0228036cfe172522850eb96be60cfee14b31fb8:0:0', + inscription_input_index: 0, + transfers_pre_inscription: 0, + tx_index: 0, + curse_type: null, + inscription_pointer: null, + delegate: null, + metaprotocol: null, + metadata: null, + parent: null, + }) + .build(); + + const mockExit = jest.spyOn(process, 'exit').mockImplementation(); + const response = await server['fastify'].inject({ + method: 'POST', + url: `/payload`, + headers: { authorization: `Bearer ${ENV.ORDHOOK_NODE_AUTH_TOKEN}` }, + payload: payload1, + }); + expect(response.statusCode).toBe(200); + expect(mockExit).toHaveBeenCalled(); + mockExit.mockRestore(); + }); + + afterEach(async () => { + await server.close(); + await fastify.close(); + await db.close(); + await runMigrations(MIGRATIONS_DIR, 'down'); + }); +}); diff --git a/tests/ordhook/server.test.ts b/tests/ordhook/server.test.ts index cdc3a761..e8035854 100644 --- a/tests/ordhook/server.test.ts +++ b/tests/ordhook/server.test.ts @@ -18,7 +18,7 @@ describe('EventServer', () => { beforeEach(async () => { await runMigrations(MIGRATIONS_DIR, 'up'); - ENV.CHAINHOOK_AUTO_PREDICATE_REGISTRATION = false; + ENV.ORDHOOK_AUTO_PREDICATE_REGISTRATION = false; db = await PgStore.connect({ skipMigrations: true }); server = await startOrdhookServer({ db }); fastify = await buildApiServer({ db }); @@ -74,7 +74,7 @@ describe('EventServer', () => { const response = await server['fastify'].inject({ method: 'POST', url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, + headers: { authorization: `Bearer ${ENV.ORDHOOK_NODE_AUTH_TOKEN}` }, payload: payload1, }); expect(response.statusCode).toBe(200); @@ -134,7 +134,7 @@ describe('EventServer', () => { const response2 = await server['fastify'].inject({ method: 'POST', url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, + headers: { authorization: `Bearer ${ENV.ORDHOOK_NODE_AUTH_TOKEN}` }, payload: payload2, }); expect(response2.statusCode).toBe(200); @@ -214,7 +214,7 @@ describe('EventServer', () => { const response = await server['fastify'].inject({ method: 'POST', url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, + headers: { authorization: `Bearer ${ENV.ORDHOOK_NODE_AUTH_TOKEN}` }, payload: payload1, }); expect(response.statusCode).toBe(200); @@ -274,7 +274,7 @@ describe('EventServer', () => { const response2 = await server['fastify'].inject({ method: 'POST', url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, + headers: { authorization: `Bearer ${ENV.ORDHOOK_NODE_AUTH_TOKEN}` }, payload: payload2, }); expect(response2.statusCode).toBe(200); @@ -345,307 +345,207 @@ describe('EventServer', () => { const json = response.json(); expect(json.genesis_address).toBe(address); }); - }); - describe('gap detection', () => { - test('server rejects payload with first inscription gap', async () => { + test('inscriptions revealed and immediately transferred in the same block', async () => { await db.updateInscriptions( new TestChainhookPayloadBuilder() .apply() .block({ - height: 778575, - hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, + height: 832574, + hash: '000000000000000000020c8145de25b1e1e0a6312e377827a3015e15fdd574cd', }) .transaction({ - hash: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201', + hash: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a', }) .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', + content_bytes: + '0x7b2270223a226272632d3230222c226f70223a227472616e73666572222c227469636b223a224d544d54222c22616d74223a2231303030227d', + content_length: 57, content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 0, jubilee: 0 }, - inscription_fee: 705, - inscription_id: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201i0', - inscription_output_value: 10000, - inscriber_address: 'bc1pscktlmn99gyzlvymvrezh6vwd0l4kg06tg5rvssw0czg8873gz5sdkteqj', - ordinal_number: 257418248345364, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, + delegate: '', + inscriber_address: 'bc1pgfkgsz2gv8cy42csdfgnuepx5g2sm0y3nsccvehjpjnev8990pms7jp9n5', + inscription_fee: 11322, + inscription_id: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0', + inscription_input_index: 0, + inscription_number: { + classic: 0, + jubilee: 0, + }, + inscription_output_value: 8834, + inscription_pointer: 0, metadata: null, - parent: null, + metaprotocol: '', + ordinal_block_height: 149412, + ordinal_number: 747064132806533, + ordinal_offset: 4132806533, + parent: '', + satpoint_post_inscription: + '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a:0:0', + transfers_pre_inscription: 0, + tx_index: 2613, + }) + .transaction({ + hash: '5252157e270d1d405fa5d58249832ca3aa706b84e4dad2a31e7f52373aec2b7b', + }) + .inscriptionTransferred({ + destination: { + type: 'transferred', + value: 'bc1p80sw4ug55q7p4ha5gsk40d2tszqy7cendt9yksmf4nswzzrq58msp6t7qe', + }, + ordinal_number: 747064132806533, + post_transfer_output_value: 546, + satpoint_post_transfer: + '5252157e270d1d405fa5d58249832ca3aa706b84e4dad2a31e7f52373aec2b7b:0:0', + satpoint_pre_transfer: + '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a:0:0', + tx_index: 2614, }) .build() ); - const errorPayload = new TestChainhookPayloadBuilder() - .apply() - .block({ - height: 778576, - hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, - }) - .transaction({ - hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', - }) - .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 5, jubilee: 5 }, // Gap at 5 - inscription_fee: 705, - inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - inscription_output_value: 10000, - inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', - ordinal_number: 1050000000000000, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, - curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, - metadata: null, - parent: null, - }) - .build(); - await expect(db.updateInscriptions(errorPayload)).rejects.toThrow(BadPayloadRequestError); - const response = await server['fastify'].inject({ - method: 'POST', - url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, - payload: errorPayload, + const response = await fastify.inject({ + method: 'GET', + url: `/ordinals/inscriptions/53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0/transfers`, }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(200); + const json = response.json(); + expect(json.total).toBe(2); + expect(json.results).toStrictEqual([ + { + address: 'bc1p80sw4ug55q7p4ha5gsk40d2tszqy7cendt9yksmf4nswzzrq58msp6t7qe', + block_hash: '000000000000000000020c8145de25b1e1e0a6312e377827a3015e15fdd574cd', + block_height: 832574, + location: '5252157e270d1d405fa5d58249832ca3aa706b84e4dad2a31e7f52373aec2b7b:0:0', + offset: '0', + output: '5252157e270d1d405fa5d58249832ca3aa706b84e4dad2a31e7f52373aec2b7b:0', + timestamp: 1677803510000, + tx_id: '5252157e270d1d405fa5d58249832ca3aa706b84e4dad2a31e7f52373aec2b7b', + value: '546', + }, + { + address: 'bc1pgfkgsz2gv8cy42csdfgnuepx5g2sm0y3nsccvehjpjnev8990pms7jp9n5', + block_hash: '000000000000000000020c8145de25b1e1e0a6312e377827a3015e15fdd574cd', + block_height: 832574, + location: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a:0:0', + offset: '0', + output: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a:0', + timestamp: 1677803510000, + tx_id: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a', + value: '8834', + }, + ]); }); - test('server rejects payload with intermediate inscription gap', async () => { + test('inscriptions revealed as fee', async () => { await db.updateInscriptions( new TestChainhookPayloadBuilder() .apply() .block({ - height: 778575, - hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, + height: 832574, + hash: '000000000000000000020c8145de25b1e1e0a6312e377827a3015e15fdd574cd', }) .transaction({ - hash: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201', + hash: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a', }) .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 0, jubilee: 0 }, - inscription_fee: 705, - inscription_id: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201i0', - inscription_output_value: 10000, - inscriber_address: 'bc1pscktlmn99gyzlvymvrezh6vwd0l4kg06tg5rvssw0czg8873gz5sdkteqj', - ordinal_number: 257418248345364, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, + content_bytes: + '0x7b2270223a226272632d3230222c226f70223a226d696e74222c227469636b223a22656f7262222c22616d74223a223130227d', + content_length: 51, + content_type: 'text/plain', curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, + delegate: '', + inscriber_address: '', + inscription_fee: 3210, + inscription_id: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0', + inscription_input_index: 0, + inscription_number: { + classic: 0, + jubilee: 0, + }, + inscription_output_value: 0, + inscription_pointer: 1, metadata: null, - parent: null, + metaprotocol: '', + ordinal_block_height: 203651, + ordinal_number: 1018259086681705, + ordinal_offset: 4086681705, + parent: '', + satpoint_post_inscription: + '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a:0:665136296', + transfers_pre_inscription: 0, + tx_index: 2486, }) .build() ); - const errorPayload = new TestChainhookPayloadBuilder() - .apply() - .block({ - height: 778576, - hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, - }) - .transaction({ - hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', - }) - .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 1, jubilee: 1 }, - inscription_fee: 705, - inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - inscription_output_value: 10000, - inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', - ordinal_number: 1050000000000000, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, - curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, - metadata: null, - parent: null, - }) - .transaction({ - hash: '6891d374a17ba85f6b5514f2f7edc301c1c860284dff5a5c6e88ab3a20fcd8a5', - }) - .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 4, jubilee: 4 }, // Gap - inscription_fee: 705, - inscription_id: '6891d374a17ba85f6b5514f2f7edc301c1c860284dff5a5c6e88ab3a20fcd8a5o0', - inscription_output_value: 10000, - inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', - ordinal_number: 1050000000000000, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '6891d374a17ba85f6b5514f2f7edc301c1c860284dff5a5c6e88ab3a20fcd8a5:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, - curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, - metadata: null, - parent: null, - }) - .build(); - await expect(db.updateInscriptions(errorPayload)).rejects.toThrow(BadPayloadRequestError); - const response = await server['fastify'].inject({ - method: 'POST', - url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, - payload: errorPayload, + const response = await fastify.inject({ + method: 'GET', + url: `/ordinals/inscriptions/53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0`, }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(200); + const status = await db.sql<{ transfer_type: string }[]>` + SELECT transfer_type + FROM locations + WHERE genesis_id = '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0' + `; + expect(status[0].transfer_type).toBe('spent_in_fees'); }); - test('server accepts payload with unordered unbound inscriptions', async () => { + test('inscriptions revealed as burnt', async () => { await db.updateInscriptions( new TestChainhookPayloadBuilder() .apply() .block({ - height: 778575, - hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, + height: 832574, + hash: '000000000000000000020c8145de25b1e1e0a6312e377827a3015e15fdd574cd', }) .transaction({ - hash: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201', + hash: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a', }) .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 0, jubilee: 0 }, - inscription_fee: 705, - inscription_id: '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201i0', - inscription_output_value: 10000, - inscriber_address: 'bc1pscktlmn99gyzlvymvrezh6vwd0l4kg06tg5rvssw0czg8873gz5sdkteqj', - ordinal_number: 257418248345364, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '9f4a9b73b0713c5da01c0a47f97c6c001af9028d6bdd9e264dfacbc4e6790201:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, + content_bytes: + '0x7b2270223a226272632d3230222c226f70223a226d696e74222c227469636b223a22656f7262222c22616d74223a223130227d', + content_length: 51, + content_type: 'text/plain', curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, + delegate: '', + inscriber_address: '', + inscription_fee: 3210, + inscription_id: '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0', + inscription_input_index: 0, + inscription_number: { + classic: 0, + jubilee: 0, + }, + inscription_output_value: 1000, + inscription_pointer: 0, metadata: null, - parent: null, + metaprotocol: '', + ordinal_block_height: 203651, + ordinal_number: 1018259086681705, + ordinal_offset: 4086681705, + parent: '', + satpoint_post_inscription: + '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4a:0:665136296', + transfers_pre_inscription: 0, + tx_index: 2486, }) .build() ); - const unboundPayload = new TestChainhookPayloadBuilder() - .apply() - .block({ - height: 778576, - hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, - }) - .transaction({ - hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', - }) - .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 2, jubilee: 2 }, - inscription_fee: 705, - inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', - inscription_output_value: 10000, - inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', - ordinal_number: 1050000000000000, - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, - curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, - metadata: null, - parent: null, - }) - .transaction({ - hash: '6891d374a17ba85f6b5514f2f7edc301c1c860284dff5a5c6e88ab3a20fcd8a5', - }) - .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'text/plain;charset=utf-8', - content_length: 5, - inscription_number: { classic: 1, jubilee: 1 }, - inscription_fee: 705, - inscription_id: '6891d374a17ba85f6b5514f2f7edc301c1c860284dff5a5c6e88ab3a20fcd8a5o0', - inscription_output_value: 10000, - inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', - ordinal_number: 0, // Unbounded - ordinal_block_height: 650000, - ordinal_offset: 0, - satpoint_post_inscription: - '6891d374a17ba85f6b5514f2f7edc301c1c860284dff5a5c6e88ab3a20fcd8a5:0:0', - inscription_input_index: 0, - transfers_pre_inscription: 0, - tx_index: 0, - curse_type: null, - inscription_pointer: null, - delegate: null, - metaprotocol: null, - metadata: null, - parent: null, - }) - .build(); - await expect(db.updateInscriptions(unboundPayload)).resolves.not.toThrow( - BadPayloadRequestError - ); + const response = await fastify.inject({ + method: 'GET', + url: `/ordinals/inscriptions/53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0`, + }); + expect(response.statusCode).toBe(200); + const status = await db.sql<{ transfer_type: string }[]>` + SELECT transfer_type + FROM locations + WHERE genesis_id = '53957f47697096cef4ad24dae6357b3d7ffe1e3eb9216ce0bb01d6b6a2c8cf4ai0' + `; + expect(status[0].transfer_type).toBe('burnt'); }); + }); + describe('gap detection', () => { test('server ignores past blocks', async () => { const payload = new TestChainhookPayloadBuilder() .apply() @@ -687,7 +587,7 @@ describe('EventServer', () => { const response = await server['fastify'].inject({ method: 'POST', url: `/payload`, - headers: { authorization: `Bearer ${ENV.CHAINHOOK_NODE_AUTH_TOKEN}` }, + headers: { authorization: `Bearer ${ENV.ORDHOOK_NODE_AUTH_TOKEN}` }, payload: payload, }); expect(response.statusCode).toBe(200); diff --git a/tests/setup.ts b/tests/setup.ts index c2ec989f..95438bc2 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,6 +1,8 @@ // ts-unused-exports:disable-next-line export default (): void => { - process.env.CHAINHOOK_NODE_AUTH_TOKEN = 'test'; - process.env.CHAINHOOK_NODE_RPC_HOST = 'test.chainhooks.com'; + process.env.ORDHOOK_NODE_AUTH_TOKEN = 'test'; + process.env.ORDHOOK_NODE_RPC_HOST = 'test.chainhooks.com'; + process.env.ORDHOOK_NODE_RPC_PORT = '13370'; + process.env.ORDHOOK_INGESTION_MODE = 'default'; process.env.PGDATABASE = 'postgres'; }; diff --git a/util/debug-server.ts b/util/debug-server.ts index 1c2026ef..3537408a 100644 --- a/util/debug-server.ts +++ b/util/debug-server.ts @@ -23,7 +23,7 @@ import * as path from 'path'; const serverOpts: ServerOptions = { hostname: ENV.API_HOST, port: ENV.EVENT_PORT, - auth_token: ENV.CHAINHOOK_NODE_AUTH_TOKEN, + auth_token: ENV.ORDHOOK_NODE_AUTH_TOKEN, external_base_url: `http://${ENV.EXTERNAL_HOSTNAME}`, wait_for_chainhook_node: false, validate_chainhook_payloads: false,