diff --git a/.env b/.env index 095a6907..ed6d6d26 100644 --- a/.env +++ b/.env @@ -122,35 +122,18 @@ STACKS_NODE_TYPE=L1 # Override the default file path for the proxy cache control file # STACKS_API_PROXY_CACHE_CONTROL_FILE=/path/to/.proxy-cache-control.json -# Enable token metadata processing. Disabled by default. +# Enable FT metadata processing for Rosetta operations display. Disabled by default. # STACKS_API_ENABLE_FT_METADATA=1 -# STACKS_API_ENABLE_NFT_METADATA=1 -# If token metadata processing is enabled, this variable determines how the API reacts to metadata processing failures. -# When strict mode is enabled, any failures caused by recoverable errors will be retried indefinitely. Otherwise, -# the API will give up after `STACKS_API_TOKEN_METADATA_MAX_RETRIES` is reached for that smart contract. -# STACKS_API_TOKEN_METADATA_STRICT_MODE=1 - -# Maximum number of times we'll try processing FT/NFT metadata for a specific smart contract if we've failed -# because of a recoverable error. -# Only used if `STACKS_API_TOKEN_METADATA_STRICT_MODE` is disabled. -# STACKS_API_TOKEN_METADATA_MAX_RETRIES=5 - -# Controls the token metadata error handling mode. The possible values are: -# * `warning`: If required metadata is not found, the API will issue a warning and not display data for that token. -# * `error`: If required metadata is not found, the API will throw an error. -# If not specified or any other value is provided, the mode will be set to `warning`. +# The Rosetta API endpoints require FT metadata to display operations with the proper `symbol` and +# `decimals` values. If FT metadata is enabled, this variable controls the token metadata error +# handling mode when metadata is not found. +# The possible values are: +# * `warning`: The API will issue a warning and not display data for that token. +# * `error`: The API will throw an error. If not specified or any other value is provided, the mode +# will be set to `warning`. # STACKS_API_TOKEN_METADATA_ERROR_MODE=warning -# Configure a script to handle image URLs during token metadata processing. -# This example script uses the `imgix.net` service to create CDN URLs. -# Must be an executable script that accepts the URL as the first program argument -# and outputs a result URL to stdout. -# STACKS_API_IMAGE_CACHE_PROCESSOR=./config/token-metadata-image-cache-imgix.js -# Env vars needed for the above sample `imgix` script: -# IMGIX_DOMAIN=https://.imgix.net -# IMGIX_TOKEN= - # Web Socket ping interval to determine client availability, in seconds. # STACKS_API_WS_PING_INTERVAL=5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ac6bc54..23bd5373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -848,96 +848,6 @@ jobs: flag-name: run-${{ github.job }} parallel: true - test-tokens-metadata: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: ".nvmrc" - - - name: Install deps - run: npm ci - - - name: Setup env vars - run: echo "STACKS_CORE_EVENT_HOST=http://0.0.0.0" >> $GITHUB_ENV - - - name: Setup integration environment - run: | - sudo ufw disable - npm run devenv:deploy-krypton -- -d - npm run devenv:logs-krypton -- --no-color &> docker-compose-logs.txt & - - - name: Run tokens tests - run: npm run test:tokens-metadata - - - name: Print integration environment logs - run: cat docker-compose-logs.txt - if: failure() - - - name: Teardown integration environment - run: npm run devenv:stop-krypton - if: always() - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - if: always() - - - name: Upload coverage to Coveralls - uses: coverallsapp/github-action@master - if: ${{ false }} - with: - github-token: ${{ secrets.github_token }} - flag-name: run-${{ github.job }} - parallel: true - - test-tokens-strict: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version-file: ".nvmrc" - - - name: Install deps - run: npm ci - - - name: Setup env vars - run: echo "STACKS_CORE_EVENT_HOST=http://0.0.0.0" >> $GITHUB_ENV - - - name: Setup integration environment - run: | - sudo ufw disable - npm run devenv:deploy-krypton -- -d - npm run devenv:logs-krypton -- --no-color &> docker-compose-logs.txt & - - - name: Run tokens tests - run: npm run test:tokens-strict - - - name: Print integration environment logs - run: cat docker-compose-logs.txt - if: failure() - - - name: Teardown integration environment - run: npm run devenv:stop-krypton - if: always() - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - if: always() - - - name: Upload coverage to Coveralls - uses: coverallsapp/github-action@master - if: ${{ false }} - with: - github-token: ${{ secrets.github_token }} - flag-name: run-${{ github.job }} - parallel: true - build-publish: runs-on: ubuntu-latest needs: @@ -948,7 +858,6 @@ jobs: - test-bns - test-rosetta - test-rosetta-cli-construction - - test-tokens-strict steps: - uses: actions/checkout@v3 with: diff --git a/.vscode/launch.json b/.vscode/launch.json index ced0a3a5..6ef212dc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -346,40 +346,6 @@ "preLaunchTask": "deploy:krypton", "postDebugTask": "stop:krypton", }, - { - "type": "node", - "request": "launch", - "name": "Jest: Tokens - strict mode", - "program": "${workspaceFolder}/node_modules/.bin/jest", - "args": [ - "--testTimeout=3600000", - "--runInBand", - "--no-cache", - "--config", - "${workspaceRoot}/tests/jest.config.tokens-strict.js", - ], - "outputCapture": "std", - "console": "integratedTerminal", - "preLaunchTask": "deploy:krypton", - "postDebugTask": "stop:krypton", - }, - { - "type": "node", - "request": "launch", - "name": "Jest: Tokens - metadata", - "program": "${workspaceFolder}/node_modules/.bin/jest", - "args": [ - "--testTimeout=3600000", - "--runInBand", - "--no-cache", - "--config", - "${workspaceRoot}/tests/jest.config.tokens-metadata.js", - ], - "outputCapture": "std", - "console": "integratedTerminal", - "preLaunchTask": "deploy:krypton", - "postDebugTask": "stop:krypton", - }, { "type": "node", "request": "launch", diff --git a/migrations/1699540187362_remove-token-metadata.js b/migrations/1699540187362_remove-token-metadata.js new file mode 100644 index 00000000..bef655ef --- /dev/null +++ b/migrations/1699540187362_remove-token-metadata.js @@ -0,0 +1,132 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = pgm => { + pgm.dropTable('token_metadata_queue'); + pgm.dropTable('nft_metadata'); + pgm.dropTable('ft_metadata'); +}; + +exports.down = pgm => { + pgm.createTable('token_metadata_queue', { + queue_id: { + type: 'serial', + primaryKey: true, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + contract_id: { + type: 'string', + notNull: true, + }, + contract_abi: { + type: 'string', + notNull: true, + }, + block_height: { + type: 'integer', + notNull: true, + }, + processed: { + type: 'boolean', + notNull: true, + }, + retry_count: { + type: 'integer', + notNull: true, + default: 0, + } + }); + pgm.createIndex('token_metadata_queue', [{ name: 'block_height', sort: 'DESC' }]); + pgm.createTable('nft_metadata', { + id: { + type: 'serial', + primaryKey: true, + }, + name: { + type: 'string', + notNull: true, + }, + token_uri: { + type: 'string', + notNull: true, + }, + description: { + type: 'string', + notNull: true, + }, + image_uri: { + type: 'string', + notNull: true, + }, + image_canonical_uri: { + type: 'string', + notNull: true, + }, + contract_id: { + type: 'string', + notNull: true, + unique: true, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + sender_address: { + type: 'string', + notNull: true, + } + }); + pgm.createIndex('nft_metadata', 'contract_id', { method: 'hash' }); + pgm.createTable('ft_metadata', { + id: { + type: 'serial', + primaryKey: true, + }, + name: { + type: 'string', + notNull: true, + }, + token_uri: { + type: 'string', + notNull: true, + }, + description: { + type: 'string', + notNull: true, + }, + image_uri: { + type: 'string', + notNull: true, + }, + image_canonical_uri: { + type: 'string', + notNull: true, + }, + contract_id: { + type: 'string', + notNull: true, + unique: true, + }, + symbol: { + type: 'string', + notNull: true, + }, + decimals: { + type: 'integer', + notNull: true, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + sender_address: { + type: 'string', + notNull: true, + } + }); + pgm.createIndex('ft_metadata', 'contract_id', { method: 'hash' }); +} diff --git a/package.json b/package.json index defce13e..f1d69a96 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,6 @@ "test:integration:rosetta-cli:construction": "concurrently \"npm:devenv:deploy-krypton\" \"cross-env NODE_ENV=test STACKS_CHAIN_ID=0x80000000 jest --config ./tests/jest.config.rosetta-cli-construction.js --no-cache --runInBand; npm run devenv:stop-krypton\"", "test:integration:bns": "concurrently \"npm:devenv:deploy-krypton\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.bns.js --no-cache --runInBand; npm run devenv:stop-krypton\"", "test:integration:bns-e2e": "concurrently \"npm:devenv:deploy-krypton\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.bns-e2e.js --no-cache --runInBand; npm run devenv:stop-krypton\"", - "test:integration:tokens-strict": "concurrently \"npm:devenv:deploy-krypton\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.tokens-strict.js --no-cache --runInBand; npm run devenv:stop-krypton\"", - "test:integration:tokens-metadata": "concurrently \"npm:devenv:deploy-krypton\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.tokens-metadata.js --no-cache --runInBand; npm run devenv:stop-krypton\"", "test:integration:rpc": "concurrently \"npm:devenv:deploy-krypton\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.rpc.js --no-cache --runInBand; npm run devenv:stop-krypton\"", "test:integration:event-replay": "concurrently \"docker compose -f docker/docker-compose.dev.postgres.yml up --force-recreate -V\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.event-replay.js --no-cache --runInBand; npm run devenv:stop\"", "test:integration:btc-faucet": "concurrently \"docker compose -f docker/docker-compose.dev.postgres.yml -f docker/docker-compose.dev.bitcoind.yml up --force-recreate -V\" \"cross-env NODE_ENV=test jest --config ./tests/jest.config.btc-faucet.js --no-cache --runInBand; npm run devenv:stop\"", diff --git a/src/api/controllers/db-controller.ts b/src/api/controllers/db-controller.ts index 8eca6563..20ba98bd 100644 --- a/src/api/controllers/db-controller.ts +++ b/src/api/controllers/db-controller.ts @@ -63,7 +63,7 @@ import { } from '../../datastore/common'; import { unwrapOptional, FoundOrNot, unixEpochToIso, EMPTY_HASH_256, ChainID } from '../../helpers'; import { serializePostCondition, serializePostConditionMode } from '../serializers/post-conditions'; -import { getOperations, parseTransactionMemo } from '../../rosetta-helpers'; +import { getOperations, parseTransactionMemo } from '../../rosetta/rosetta-helpers'; import { PgStore } from '../../datastore/pg-store'; import { Pox2EventName } from '../../pox-helpers'; import { logger } from '../../logger'; @@ -164,30 +164,6 @@ export function getTxStatus(txStatus: DbTxStatus | string): string { } } -type EventTypeString = - | 'smart_contract_log' - | 'stx_asset' - | 'fungible_token_asset' - | 'non_fungible_token_asset' - | 'stx_lock'; - -export function getEventTypeString(eventTypeId: DbEventTypeId): EventTypeString { - switch (eventTypeId) { - case DbEventTypeId.SmartContractLog: - return 'smart_contract_log'; - case DbEventTypeId.StxAsset: - return 'stx_asset'; - case DbEventTypeId.FungibleTokenAsset: - return 'fungible_token_asset'; - case DbEventTypeId.NonFungibleTokenAsset: - return 'non_fungible_token_asset'; - case DbEventTypeId.StxLock: - return 'stx_lock'; - default: - throw new Error(`Unexpected DbEventTypeId: ${eventTypeId}`); - } -} - export function getAssetEventTypeString( assetEventTypeId: DbAssetEventTypeId ): 'transfer' | 'mint' | 'burn' { diff --git a/src/api/init.ts b/src/api/init.ts index f549296a..d2494e13 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -31,7 +31,7 @@ import * as expressListEndpoints from 'express-list-endpoints'; import { createMiddleware as createPrometheusMiddleware } from '@promster/express'; import { createMicroblockRouter } from './routes/microblock'; import { createStatusRouter } from './routes/status'; -import { createTokenRouter } from './routes/tokens/tokens'; +import { createTokenRouter } from './routes/tokens'; import { createFeeRateRouter } from './routes/fee-rate'; import { setResponseNonCacheable } from './controllers/cache-controller'; diff --git a/src/api/routes/rosetta/account.ts b/src/api/routes/rosetta/account.ts index c333e5f4..469a40fd 100644 --- a/src/api/routes/rosetta/account.ts +++ b/src/api/routes/rosetta/account.ts @@ -13,9 +13,8 @@ import { } from '@stacks/stacks-blockchain-api-types'; import { RosettaErrors, RosettaConstants, RosettaErrorsTypes } from '../../rosetta-constants'; import { rosettaValidateRequest, ValidSchema, makeRosettaError } from '../../rosetta-validate'; -import { getValidatedFtMetadata } from '../../../rosetta-helpers'; -import { isFtMetadataEnabled } from '../../../token-metadata/helpers'; import { has0xPrefix } from '@hirosystems/api-toolkit'; +import { RosettaFtMetadataClient } from '../../../rosetta/rosetta-ft-metadata-client'; export function createRosettaAccountRouter(db: PgStore, chainId: ChainID): express.Router { const router = express.Router(); @@ -122,22 +121,21 @@ export function createRosettaAccountRouter(db: PgStore, chainId: ChainID): expre ]; // Add Fungible Token balances. - if (isFtMetadataEnabled()) { - const ftBalances = await db.getFungibleTokenBalances({ - stxAddress: accountIdentifier.address, - untilBlock: block.block_height, - }); - for (const [ftAssetIdentifier, ftBalance] of ftBalances) { - const ftMetadata = await getValidatedFtMetadata(db, ftAssetIdentifier); - if (ftMetadata) { - balances.push({ - value: ftBalance.balance.toString(), - currency: { - symbol: ftMetadata.symbol, - decimals: ftMetadata.decimals, - }, - }); - } + const ftBalances = await db.getFungibleTokenBalances({ + stxAddress: accountIdentifier.address, + untilBlock: block.block_height, + }); + const metadataClient = new RosettaFtMetadataClient(chainId); + for (const [ftAssetIdentifier, ftBalance] of ftBalances) { + const ftMetadata = await metadataClient.getFtMetadata(ftAssetIdentifier); + if (ftMetadata) { + balances.push({ + value: ftBalance.balance.toString(), + currency: { + symbol: ftMetadata.symbol, + decimals: ftMetadata.decimals, + }, + }); } } diff --git a/src/api/routes/rosetta/construction.ts b/src/api/routes/rosetta/construction.ts index 49a9e2f0..011f2ee1 100644 --- a/src/api/routes/rosetta/construction.ts +++ b/src/api/routes/rosetta/construction.ts @@ -80,7 +80,7 @@ import { rawTxToBaseTx, rawTxToStacksTransaction, verifySignature, -} from './../../../rosetta-helpers'; +} from '../../../rosetta/rosetta-helpers'; import { makeRosettaError, rosettaValidateRequest, ValidSchema } from './../../rosetta-validate'; import { has0xPrefix, hexToBuffer } from '@hirosystems/api-toolkit'; diff --git a/src/api/routes/rosetta/mempool.ts b/src/api/routes/rosetta/mempool.ts index 657fcaef..bdaee0b4 100644 --- a/src/api/routes/rosetta/mempool.ts +++ b/src/api/routes/rosetta/mempool.ts @@ -8,7 +8,7 @@ import { RosettaMempoolTransactionResponse, RosettaTransaction, } from '@stacks/stacks-blockchain-api-types'; -import { getOperations, parseTransactionMemo } from '../../../rosetta-helpers'; +import { getOperations, parseTransactionMemo } from '../../../rosetta/rosetta-helpers'; import { RosettaErrors, RosettaErrorsTypes } from '../../rosetta-constants'; import { has0xPrefix } from '@hirosystems/api-toolkit'; diff --git a/src/api/routes/tokens/tokens.ts b/src/api/routes/tokens.ts similarity index 65% rename from src/api/routes/tokens/tokens.ts rename to src/api/routes/tokens.ts index ba365a49..50bec6ff 100644 --- a/src/api/routes/tokens/tokens.ts +++ b/src/api/routes/tokens.ts @@ -1,25 +1,20 @@ -import { asyncHandler } from '../../async-handler'; +import { asyncHandler } from '../async-handler'; import * as express from 'express'; import { - FungibleTokenMetadata, - FungibleTokensMetadataList, NonFungibleTokenHistoryEvent, NonFungibleTokenHistoryEventList, NonFungibleTokenHolding, NonFungibleTokenHoldingsList, - NonFungibleTokenMetadata, NonFungibleTokenMint, NonFungibleTokenMintList, - NonFungibleTokensMetadataList, } from '@stacks/stacks-blockchain-api-types'; -import { getPagingQueryLimit, parsePagingQueryInput, ResourceType } from './../../pagination'; -import { isFtMetadataEnabled, isNftMetadataEnabled } from '../../../token-metadata/helpers'; -import { isValidPrincipal } from '../../../helpers'; -import { booleanValueForParam, isUnanchoredRequest } from '../../../api/query-helpers'; +import { getPagingQueryLimit, parsePagingQueryInput, ResourceType } from '../pagination'; +import { isValidPrincipal } from '../../helpers'; +import { booleanValueForParam, isUnanchoredRequest } from '../query-helpers'; import { decodeClarityValueToRepr } from 'stacks-encoding-native-js'; -import { getAssetEventTypeString, parseDbTx } from '../../controllers/db-controller'; -import { getETagCacheHandler, setETagCacheHeaders } from '../../controllers/cache-controller'; -import { PgStore } from '../../../datastore/pg-store'; +import { getAssetEventTypeString, parseDbTx } from '../controllers/db-controller'; +import { getETagCacheHandler, setETagCacheHeaders } from '../controllers/cache-controller'; +import { PgStore } from '../../datastore/pg-store'; import { has0xPrefix } from '@hirosystems/api-toolkit'; export function createTokenRouter(db: PgStore): express.Router { @@ -229,149 +224,5 @@ export function createTokenRouter(db: PgStore): express.Router { }) ); - router.get( - '/ft/metadata', - asyncHandler(async (req, res) => { - if (!isFtMetadataEnabled()) { - res.status(500).json({ - error: 'FT metadata processing is not enabled on this server', - }); - return; - } - - const limit = getPagingQueryLimit(ResourceType.Token, req.query.limit); - const offset = parsePagingQueryInput(req.query.offset ?? 0); - - const { results, total } = await db.getFtMetadataList({ offset, limit }); - - const response: FungibleTokensMetadataList = { - limit: limit, - offset: offset, - total: total, - results: results, - }; - - res.status(200).json(response); - }) - ); - - router.get( - '/nft/metadata', - asyncHandler(async (req, res) => { - if (!isNftMetadataEnabled()) { - res.status(500).json({ - error: 'NFT metadata processing is not enabled on this server', - }); - return; - } - - let limit: number; - try { - limit = getPagingQueryLimit(ResourceType.Token, req.query.limit); - } catch (error: any) { - res.status(400).json({ error: error.message }); - return; - } - - const offset = parsePagingQueryInput(req.query.offset ?? 0); - - const { results, total } = await db.getNftMetadataList({ offset, limit }); - - const response: NonFungibleTokensMetadataList = { - limit: limit, - offset: offset, - total: total, - results: results, - }; - - res.status(200).json(response); - }) - ); - - router.get( - '/:contractId/ft/metadata', - asyncHandler(async (req, res) => { - if (!isFtMetadataEnabled()) { - res.status(500).json({ - error: 'FT metadata processing is not enabled on this server', - }); - return; - } - - const { contractId } = req.params; - - const metadata = await db.getFtMetadata(contractId); - if (!metadata.found) { - res.status(404).json({ error: 'tokens not found' }); - return; - } - - const { - token_uri, - name, - description, - image_uri, - image_canonical_uri, - symbol, - decimals, - tx_id, - sender_address, - } = metadata.result; - - const response: FungibleTokenMetadata = { - token_uri: token_uri, - name: name, - description: description, - image_uri: image_uri, - image_canonical_uri: image_canonical_uri, - symbol: symbol, - decimals: decimals, - tx_id: tx_id, - sender_address: sender_address, - }; - res.status(200).json(response); - }) - ); - - router.get( - '/:contractId/nft/metadata', - asyncHandler(async (req, res) => { - if (!isNftMetadataEnabled()) { - res.status(500).json({ - error: 'NFT metadata processing is not enabled on this server', - }); - return; - } - - const { contractId } = req.params; - const metadata = await db.getNftMetadata(contractId); - - if (!metadata.found) { - res.status(404).json({ error: 'tokens not found' }); - return; - } - const { - token_uri, - name, - description, - image_uri, - image_canonical_uri, - tx_id, - sender_address, - } = metadata.result; - - const response: NonFungibleTokenMetadata = { - token_uri: token_uri, - name: name, - description: description, - image_uri: image_uri, - image_canonical_uri: image_canonical_uri, - tx_id: tx_id, - sender_address: sender_address, - }; - res.status(200).json(response); - }) - ); - return router; } diff --git a/src/datastore/common.ts b/src/datastore/common.ts index c9373f32..be4fd1ee 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -546,12 +546,6 @@ export interface AddressNftEventIdentifier { asset_event_type_id: number; } -export interface TokenMetadataUpdateInfo { - queueId: number; - txId: string; - contractId: string; -} - export interface DataStoreBlockUpdateData { block: DbBlock; microblocks: DbMicroblock[]; @@ -750,40 +744,6 @@ export type BlockIdentifier = | { burnBlockHash: string } | { burnBlockHeight: number }; -export interface DbNonFungibleTokenMetadata { - token_uri: string; - name: string; - description: string; - image_uri: string; - image_canonical_uri: string; - contract_id: string; - tx_id: string; - sender_address: string; -} - -export interface DbFungibleTokenMetadata { - token_uri: string; - name: string; - description: string; - image_uri: string; - image_canonical_uri: string; - contract_id: string; - symbol: string; - decimals: number; - tx_id: string; - sender_address: string; -} - -export interface DbTokenMetadataQueueEntry { - queueId: number; - txId: string; - contractId: string; - contractAbi: ClarityAbi; - blockHeight: number; - processed: boolean; - retry_count: number; -} - export interface DbChainTip { blockHeight: number; indexBlockHash: string; @@ -1016,40 +976,6 @@ export interface BlocksWithMetadata { total: number; } -export interface NonFungibleTokenMetadataQueryResult { - token_uri: string; - name: string; - description: string; - image_uri: string; - image_canonical_uri: string; - contract_id: string; - tx_id: string; - sender_address: string; -} - -export interface FungibleTokenMetadataQueryResult { - token_uri: string; - name: string; - description: string; - image_uri: string; - image_canonical_uri: string; - contract_id: string; - symbol: string; - decimals: number; - tx_id: string; - sender_address: string; -} - -export interface DbTokenMetadataQueueEntryQuery { - queue_id: number; - tx_id: string; - contract_id: string; - contract_abi: string; - block_height: number; - processed: boolean; - retry_count: number; -} - export interface RawTxQueryResult { raw_tx: string; } @@ -1519,38 +1445,6 @@ export interface RewardSlotHolderInsertValues { slot_index: number; } -export interface TokenMetadataQueueEntryInsertValues { - tx_id: PgBytea; - contract_id: string; - contract_abi: string; - block_height: number; - processed: boolean; -} - -export interface NftMetadataInsertValues { - token_uri: string; - name: string; - description: string; - image_uri: string; - image_canonical_uri: string; - contract_id: string; - tx_id: PgBytea; - sender_address: string; -} - -export interface FtMetadataInsertValues { - token_uri: string; - name: string; - description: string; - image_uri: string; - image_canonical_uri: string; - contract_id: string; - symbol: string; - decimals: number; - tx_id: PgBytea; - sender_address: string; -} - export interface SmartContractInsertValues { tx_id: PgBytea; canonical: boolean; diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 2f3e114b..1e2e6327 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -35,7 +35,6 @@ import { DbEventTypeId, DbFtBalance, DbFtEvent, - DbFungibleTokenMetadata, DbGetBlockWithMetadataOpts, DbGetBlockWithMetadataResponse, DbInboundStxTransfer, @@ -44,7 +43,6 @@ import { DbMicroblock, DbMinerReward, DbNftEvent, - DbNonFungibleTokenMetadata, DbPox2Event, DbPox3Event, DbPox3Stacker, @@ -55,8 +53,6 @@ import { DbStxBalance, DbStxEvent, DbStxLockEvent, - DbTokenMetadataQueueEntry, - DbTokenMetadataQueueEntryQuery, DbTokenOfferingLocked, DbTx, DbTxGlobalStatus, @@ -64,13 +60,11 @@ import { DbTxTypeId, DbTxWithAssetTransfers, FaucetRequestQueryResult, - FungibleTokenMetadataQueryResult, MempoolTxQueryResult, MicroblockQueryResult, NftEventWithTxMetadata, NftHoldingInfo, NftHoldingInfoWithTxMetadata, - NonFungibleTokenMetadataQueryResult, Pox2EventQueryResult, Pox3EventQueryResult, RawTxQueryResult, @@ -1849,63 +1843,6 @@ export class PgStore extends BasePgStore { }); } - /** - * Returns a single entry from the `token_metadata_queue` table. - * @param queueId - queue entry id - */ - async getTokenMetadataQueueEntry( - queueId: number - ): Promise> { - const result = await this.sql` - SELECT * FROM token_metadata_queue WHERE queue_id = ${queueId} - `; - if (result.length === 0) { - return { found: false }; - } - const row = result[0]; - const entry: DbTokenMetadataQueueEntry = { - queueId: row.queue_id, - txId: row.tx_id, - contractId: row.contract_id, - contractAbi: JSON.parse(row.contract_abi), - blockHeight: row.block_height, - processed: row.processed, - retry_count: row.retry_count, - }; - return { found: true, result: entry }; - } - - async getTokenMetadataQueue( - limit: number, - excludingEntries: number[] - ): Promise { - const result = await this.sql` - SELECT * - FROM token_metadata_queue - WHERE ${ - excludingEntries.length - ? this.sql`NOT (queue_id IN ${this.sql(excludingEntries)})` - : this.sql`TRUE` - } - AND processed = false - ORDER BY block_height ASC, queue_id ASC - LIMIT ${limit} - `; - const entries = result.map(row => { - const entry: DbTokenMetadataQueueEntry = { - queueId: row.queue_id, - txId: row.tx_id, - contractId: row.contract_id, - contractAbi: JSON.parse(row.contract_abi), - blockHeight: row.block_height, - processed: row.processed, - retry_count: row.retry_count, - }; - return entry; - }); - return entries; - } - async getSmartContract(contractId: string) { const result = await this.sql< { @@ -4293,132 +4230,4 @@ export class PgStore extends BasePgStore { } return result; } - - async getFtMetadata(contractId: string): Promise> { - const queryResult = await this.sql` - SELECT token_uri, name, description, image_uri, image_canonical_uri, symbol, decimals, contract_id, tx_id, sender_address - FROM ft_metadata - WHERE contract_id = ${contractId} - LIMIT 1 - `; - if (queryResult.length > 0) { - const metadata: DbFungibleTokenMetadata = { - token_uri: queryResult[0].token_uri, - name: queryResult[0].name, - description: queryResult[0].description, - image_uri: queryResult[0].image_uri, - image_canonical_uri: queryResult[0].image_canonical_uri, - symbol: queryResult[0].symbol, - decimals: queryResult[0].decimals, - contract_id: queryResult[0].contract_id, - tx_id: queryResult[0].tx_id, - sender_address: queryResult[0].sender_address, - }; - return { - found: true, - result: metadata, - }; - } else { - return { found: false } as const; - } - } - - async getNftMetadata(contractId: string): Promise> { - const queryResult = await this.sql` - SELECT token_uri, name, description, image_uri, image_canonical_uri, contract_id, tx_id, sender_address - FROM nft_metadata - WHERE contract_id = ${contractId} - LIMIT 1 - `; - if (queryResult.length > 0) { - const metadata: DbNonFungibleTokenMetadata = { - token_uri: queryResult[0].token_uri, - name: queryResult[0].name, - description: queryResult[0].description, - image_uri: queryResult[0].image_uri, - image_canonical_uri: queryResult[0].image_canonical_uri, - contract_id: queryResult[0].contract_id, - tx_id: queryResult[0].tx_id, - sender_address: queryResult[0].sender_address, - }; - return { - found: true, - result: metadata, - }; - } else { - return { found: false } as const; - } - } - - async getFtMetadataList({ - limit, - offset, - }: { - limit: number; - offset: number; - }): Promise<{ results: DbFungibleTokenMetadata[]; total: number }> { - return await this.sqlTransaction(async sql => { - const totalQuery = await sql<{ count: number }[]>` - SELECT COUNT(*)::integer - FROM ft_metadata - `; - const resultQuery = await sql` - SELECT * - FROM ft_metadata - LIMIT ${limit} - OFFSET ${offset} - `; - const parsed = resultQuery.map(r => { - const metadata: DbFungibleTokenMetadata = { - name: r.name, - description: r.description, - token_uri: r.token_uri, - image_uri: r.image_uri, - image_canonical_uri: r.image_canonical_uri, - decimals: r.decimals, - symbol: r.symbol, - contract_id: r.contract_id, - tx_id: r.tx_id, - sender_address: r.sender_address, - }; - return metadata; - }); - return { results: parsed, total: totalQuery[0].count }; - }); - } - - async getNftMetadataList({ - limit, - offset, - }: { - limit: number; - offset: number; - }): Promise<{ results: DbNonFungibleTokenMetadata[]; total: number }> { - return await this.sqlTransaction(async sql => { - const totalQuery = await sql<{ count: number }[]>` - SELECT COUNT(*)::integer - FROM nft_metadata - `; - const resultQuery = await sql` - SELECT * - FROM nft_metadata - LIMIT ${limit} - OFFSET ${offset} - `; - const parsed = resultQuery.map(r => { - const metadata: DbNonFungibleTokenMetadata = { - name: r.name, - description: r.description, - token_uri: r.token_uri, - image_uri: r.image_uri, - image_canonical_uri: r.image_canonical_uri, - contract_id: r.contract_id, - tx_id: r.tx_id, - sender_address: r.sender_address, - }; - return metadata; - }); - return { results: parsed, total: totalQuery[0].count }; - }); - } } diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index ff86ad0e..6cd51a0c 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -22,9 +22,6 @@ import { DataStoreMicroblockUpdateData, DbMicroblock, DataStoreTxEventData, - DbNonFungibleTokenMetadata, - DbFungibleTokenMetadata, - DbTokenMetadataQueueEntry, DbFaucetRequest, MinerRewardInsertValues, BlockInsertValues, @@ -42,12 +39,9 @@ import { TxInsertValues, MempoolTxInsertValues, MempoolTxQueryResult, - TokenMetadataQueueEntryInsertValues, SmartContractInsertValues, BnsNameInsertValues, BnsNamespaceInsertValues, - FtMetadataInsertValues, - NftMetadataInsertValues, FaucetRequestInsertValues, MicroblockInsertValues, TxQueryResult, @@ -66,7 +60,6 @@ import { IndexesState, NftCustodyInsertValues, } from './common'; -import { ClarityAbi } from '@stacks/transactions'; import { BLOCK_COLUMNS, convertTxQueryResultToDbMempoolTx, @@ -82,7 +75,6 @@ import { } from './helpers'; import { PgNotifier } from './pg-notifier'; import { MIGRATIONS_DIR, PgStore } from './pg-store'; -import { isProcessableTokenMetadata } from '../token-metadata/helpers'; import * as zoneFileParser from 'zone-file'; import { parseResolver, parseZoneFileTxt } from '../event-stream/bns/bns-helpers'; import { Pox2EventName } from '../pox-helpers'; @@ -200,7 +192,6 @@ export class PgWriteStore extends PgStore { } async update(data: DataStoreBlockUpdateData): Promise { - const tokenMetadataQueueEntries: DbTokenMetadataQueueEntry[] = []; let garbageCollectedMempoolTxs: string[] = []; let batchedTxData: DataStoreTxEventData[] = []; const deployedSmartContracts: DbSmartContract[] = []; @@ -418,34 +409,6 @@ export class PgWriteStore extends PgStore { logger.debug(`Garbage collected ${mempoolGarbageResults.deletedTxs.length} mempool txs`); } garbageCollectedMempoolTxs = mempoolGarbageResults.deletedTxs; - - const tokenContractDeployments = data.txs - .filter( - entry => - entry.tx.type_id === DbTxTypeId.SmartContract || - entry.tx.type_id === DbTxTypeId.VersionedSmartContract - ) - .filter(entry => entry.tx.status === DbTxStatus.Success) - .filter(entry => entry.smartContracts[0].abi && entry.smartContracts[0].abi !== 'null') - .map(entry => { - const smartContract = entry.smartContracts[0]; - const contractAbi: ClarityAbi = JSON.parse(smartContract.abi as string); - const queueEntry: DbTokenMetadataQueueEntry = { - queueId: -1, - txId: entry.tx.tx_id, - contractId: smartContract.contract_id, - contractAbi: contractAbi, - blockHeight: entry.tx.block_height, - processed: false, - retry_count: 0, - }; - return queueEntry; - }) - .filter(entry => isProcessableTokenMetadata(entry.contractAbi)); - for (const pendingQueueEntry of tokenContractDeployments) { - const queueEntry = await this.updateTokenMetadataQueue(sql, pendingQueueEntry); - tokenMetadataQueueEntries.push(queueEntry); - } } if (!this.isEventReplay) { @@ -490,9 +453,6 @@ export class PgWriteStore extends PgStore { eventIndex: nftEvent.event_index, }); } - for (const tokenMetadataQueueEntry of tokenMetadataQueueEntries) { - await this.notifier.sendTokenMetadata({ queueId: tokenMetadataQueueEntry.queueId }); - } } } @@ -1841,28 +1801,6 @@ export class PgWriteStore extends PgStore { } } - async updateTokenMetadataQueue( - sql: PgSqlClient, - entry: DbTokenMetadataQueueEntry - ): Promise { - const values: TokenMetadataQueueEntryInsertValues = { - tx_id: entry.txId, - contract_id: entry.contractId, - contract_abi: JSON.stringify(entry.contractAbi), - block_height: entry.blockHeight, - processed: false, - }; - const queryResult = await sql<{ queue_id: number }[]>` - INSERT INTO token_metadata_queue ${sql(values)} - RETURNING queue_id - `; - const result: DbTokenMetadataQueueEntry = { - ...entry, - queueId: queryResult[0].queue_id, - }; - return result; - } - async updateSmartContract(sql: PgSqlClient, tx: DbTx, smartContract: DbSmartContract) { const values: SmartContractInsertValues = { tx_id: smartContract.tx_id, @@ -2061,87 +1999,6 @@ export class PgWriteStore extends PgStore { `; } - async updateFtMetadata(ftMetadata: DbFungibleTokenMetadata, dbQueueId: number): Promise { - const length = await this.sqlWriteTransaction(async sql => { - const values: FtMetadataInsertValues = { - token_uri: ftMetadata.token_uri, - name: ftMetadata.name, - description: ftMetadata.description, - image_uri: ftMetadata.image_uri, - image_canonical_uri: ftMetadata.image_canonical_uri, - contract_id: ftMetadata.contract_id, - symbol: ftMetadata.symbol, - decimals: ftMetadata.decimals, - tx_id: ftMetadata.tx_id, - sender_address: ftMetadata.sender_address, - }; - const result = await sql` - INSERT INTO ft_metadata ${sql(values)} - ON CONFLICT (contract_id) - DO - UPDATE SET ${sql(values)} - `; - await sql` - UPDATE token_metadata_queue - SET processed = true - WHERE queue_id = ${dbQueueId} - `; - return result.count; - }); - await this.notifier?.sendTokens({ contractID: ftMetadata.contract_id }); - return length; - } - - async updateNFtMetadata( - nftMetadata: DbNonFungibleTokenMetadata, - dbQueueId: number - ): Promise { - const length = await this.sqlWriteTransaction(async sql => { - const values: NftMetadataInsertValues = { - token_uri: nftMetadata.token_uri, - name: nftMetadata.name, - description: nftMetadata.description, - image_uri: nftMetadata.image_uri, - image_canonical_uri: nftMetadata.image_canonical_uri, - contract_id: nftMetadata.contract_id, - tx_id: nftMetadata.tx_id, - sender_address: nftMetadata.sender_address, - }; - const result = await sql` - INSERT INTO nft_metadata ${sql(values)} - ON CONFLICT (contract_id) - DO - UPDATE SET ${sql(values)} - `; - await sql` - UPDATE token_metadata_queue - SET processed = true - WHERE queue_id = ${dbQueueId} - `; - return result.count; - }); - await this.notifier?.sendTokens({ contractID: nftMetadata.contract_id }); - return length; - } - - async updateProcessedTokenMetadataQueueEntry(queueId: number): Promise { - await this.sql` - UPDATE token_metadata_queue - SET processed = true - WHERE queue_id = ${queueId} - `; - } - - async increaseTokenMetadataQueueEntryRetryCount(queueId: number): Promise { - const result = await this.sql<{ retry_count: number }[]>` - UPDATE token_metadata_queue - SET retry_count = retry_count + 1 - WHERE queue_id = ${queueId} - RETURNING retry_count - `; - return result[0].retry_count; - } - async updateBatchTokenOfferingLocked(sql: PgSqlClient, lockedInfos: DbTokenOfferingLocked[]) { try { const res = await sql` diff --git a/src/helpers.ts b/src/helpers.ts index 41e30ee0..ac8ab821 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -646,49 +646,6 @@ export function normalizeHashString(input: string): string | false { return `0x${hashBuffer.toString('hex')}`; } -export function parseDataUrl( - s: string -): - | { mediaType?: string; contentType?: string; charset?: string; base64: boolean; data: string } - | false { - try { - const url = new URL(s); - if (url.protocol !== 'data:') { - return false; - } - const validDataUrlRegex = - /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z0-9-.!#$%*+.{}|~`]+=[a-z0-9-.!#$%*+.{}()|~`]+)*)?(;base64)?,(.*)$/i; - const parts = validDataUrlRegex.exec(s.trim()); - if (parts === null) { - return false; - } - const parsed: { - mediaType?: string; - contentType?: string; - charset?: string; - base64: boolean; - data: string; - } = { - base64: false, - data: '', - }; - if (parts[1]) { - parsed.mediaType = parts[1].toLowerCase(); - const mediaTypeParts = parts[1].split(';').map(x => x.toLowerCase()); - parsed.contentType = mediaTypeParts[0]; - mediaTypeParts.slice(1).forEach(attribute => { - const p = attribute.split('='); - Object.assign(parsed, { [p[0]]: p[1] }); - }); - } - parsed.base64 = !!parts[parts.length - 2]; - parsed.data = parts[parts.length - 1] || ''; - return parsed; - } catch (e) { - return false; - } -} - /** * Unsigned 32-bit integer. * - Mainnet: 0x00000001 diff --git a/src/index.ts b/src/index.ts index 0e94cc7c..cf220a55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,8 +18,6 @@ import { injectC32addressEncodeCache } from './c32-addr-cache'; import { exportEventsAsTsv, importEventsFromTsv } from './event-replay/event-replay'; import { PgStore } from './datastore/pg-store'; import { PgWriteStore } from './datastore/pg-write-store'; -import { isFtMetadataEnabled, isNftMetadataEnabled } from './token-metadata/helpers'; -import { TokensProcessorQueue } from './token-metadata/tokens-processor-queue'; import { registerMempoolPromStats } from './datastore/helpers'; import { logger } from './logger'; import { ReplayController } from './event-replay/parquet-based/replay-controller'; @@ -162,23 +160,6 @@ async function init(): Promise { monitorCoreRpcConnection().catch(error => { logger.error(error, 'Error monitoring RPC connection'); }); - - if (!isFtMetadataEnabled()) { - logger.warn('Fungible Token metadata processing is not enabled.'); - } - if (!isNftMetadataEnabled()) { - logger.warn('Non-Fungible Token metadata processing is not enabled.'); - } - if (isFtMetadataEnabled() || isNftMetadataEnabled()) { - const tokenMetadataProcessor = new TokensProcessorQueue(dbWriteStore, configuredChainID); - registerShutdownConfig({ - name: 'Token Metadata Processor', - handler: () => tokenMetadataProcessor.close(), - forceKillable: true, - }); - // Enqueue a batch of pending token metadata processors, if any. - await tokenMetadataProcessor.checkDbQueue(); - } } if ( diff --git a/src/rosetta/rosetta-ft-metadata-client.ts b/src/rosetta/rosetta-ft-metadata-client.ts new file mode 100644 index 00000000..8d11618d --- /dev/null +++ b/src/rosetta/rosetta-ft-metadata-client.ts @@ -0,0 +1,204 @@ +import { + ClarityType, + ClarityValue, + getAddressFromPrivateKey, + hexToCV, + makeRandomPrivKey, + TransactionVersion, + UIntCV, +} from '@stacks/transactions'; +import { ChainID, getChainIDNetwork } from '../helpers'; +import { ReadOnlyContractCallResponse, StacksCoreRpcClient } from '../core-rpc/client'; +import { logger } from '../logger'; +import * as LRUCache from 'lru-cache'; + +/** Fungible token metadata for Rosetta operations display */ +export interface RosettaFtMetadata { + symbol: string; + decimals: number; +} + +interface RosettaFtContractCallParams { + address: string; + contractAddress: string; + contractName: string; + functionName: string; +} + +enum RosettaTokenMetadataErrorMode { + /** Default mode. If a required token metadata is not found when it is needed for a response, the + * API will issue a warning. */ + warning, + /** If a required token metadata is not found, the API will throw an error. */ + error, +} + +/** + * Determines the token metadata error handling mode based on .env values. + * @returns TokenMetadataMode + */ +function tokenMetadataErrorMode(): RosettaTokenMetadataErrorMode { + switch (process.env['STACKS_API_TOKEN_METADATA_ERROR_MODE']) { + case 'error': + return RosettaTokenMetadataErrorMode.error; + default: + return RosettaTokenMetadataErrorMode.warning; + } +} + +function isFtMetadataEnabled() { + const opt = process.env['STACKS_API_ENABLE_FT_METADATA']?.toLowerCase().trim(); + return opt === '1' || opt === 'true'; +} + +/** + * LRU cache that keeps `RosettaFtMetadata` entries for FTs used in the Stacks chain and retrieved + * by the Rosetta endpoints. + */ +const ftMetadataCache = new LRUCache>({ + max: 5_000, +}); + +/** + * Retrieves FT metadata for tokens used by Rosetta. Keeps data in cache for faster future + * retrieval. + */ +export class RosettaFtMetadataClient { + private readonly chainId: ChainID; + private readonly nodeRpcClient: StacksCoreRpcClient; + + constructor(chainId: ChainID) { + this.chainId = chainId; + this.nodeRpcClient = new StacksCoreRpcClient(); + } + + getFtMetadata(assetIdentifier: string): Promise { + if (!isFtMetadataEnabled()) return Promise.resolve(undefined); + const cachedMetadata = ftMetadataCache.get(assetIdentifier); + if (cachedMetadata) return cachedMetadata; + + const resolvePromise = this.resolveFtMetadata(assetIdentifier); + ftMetadataCache.set(assetIdentifier, resolvePromise); + // If the promise is rejected, remove the entry from the cache so that it can be retried later. + resolvePromise.catch(_ => { + ftMetadataCache.del(assetIdentifier); + }); + return resolvePromise; + } + + private async resolveFtMetadata(assetIdentifier: string): Promise { + const tokenContractId = assetIdentifier.split('::')[0]; + const [contractAddress, contractName] = tokenContractId.split('.'); + try { + const address = getAddressFromPrivateKey( + makeRandomPrivKey().data, + getChainIDNetwork(this.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + const symbol = await this.readStringFromContract({ + functionName: 'get-symbol', + contractAddress, + contractName, + address, + }); + const decimals = await this.readUIntFromContract({ + functionName: 'get-decimals', + contractAddress, + contractName, + address, + }); + if (symbol !== undefined && decimals !== undefined) { + const metadata = { symbol, decimals: parseInt(decimals.toString()) }; + return metadata; + } + } catch (error) { + if (tokenMetadataErrorMode() === RosettaTokenMetadataErrorMode.warning) { + logger.warn(error, `FT metadata not found for token: ${assetIdentifier}`); + } else { + throw new Error(`FT metadata not found for token: ${assetIdentifier}`); + } + } + } + + private async readStringFromContract( + args: RosettaFtContractCallParams + ): Promise { + const clarityValue = await this.makeReadOnlyContractCall(args); + return this.checkAndParseString(clarityValue); + } + + private async readUIntFromContract( + args: RosettaFtContractCallParams + ): Promise { + const clarityValue = await this.makeReadOnlyContractCall(args); + const uintVal = this.checkAndParseUintCV(clarityValue); + try { + return BigInt(uintVal.value.toString()); + } catch (error) { + throw new Error(`Invalid uint value '${uintVal}'`); + } + } + + private unwrapClarityType(clarityValue: ClarityValue): ClarityValue { + let unwrappedClarityValue: ClarityValue = clarityValue; + while ( + unwrappedClarityValue.type === ClarityType.ResponseOk || + unwrappedClarityValue.type === ClarityType.OptionalSome + ) { + unwrappedClarityValue = unwrappedClarityValue.value; + } + return unwrappedClarityValue; + } + + private checkAndParseUintCV(responseCV: ClarityValue): UIntCV { + const unwrappedClarityValue = this.unwrapClarityType(responseCV); + if (unwrappedClarityValue.type === ClarityType.UInt) { + return unwrappedClarityValue; + } + throw new Error( + `Unexpected Clarity type '${unwrappedClarityValue.type}' while unwrapping uint` + ); + } + + private checkAndParseString(responseCV: ClarityValue): string | undefined { + const unwrappedClarityValue = this.unwrapClarityType(responseCV); + if ( + unwrappedClarityValue.type === ClarityType.StringASCII || + unwrappedClarityValue.type === ClarityType.StringUTF8 + ) { + return unwrappedClarityValue.data; + } else if (unwrappedClarityValue.type === ClarityType.OptionalNone) { + return undefined; + } + throw new Error( + `Unexpected Clarity type '${unwrappedClarityValue.type}' while unwrapping string` + ); + } + + private async makeReadOnlyContractCall(args: RosettaFtContractCallParams): Promise { + let result: ReadOnlyContractCallResponse; + try { + result = await this.nodeRpcClient.sendReadOnlyContractCall( + args.contractAddress, + args.contractName, + args.functionName, + args.address, + [] + ); + } catch (error) { + throw new Error(`Error making read-only contract call: ${error}`); + } + if (!result.okay) { + // Only runtime errors reported by the Stacks node should be retryable. + if ( + result.cause.startsWith('Runtime') || + result.cause.startsWith('Unchecked(NoSuchContract') + ) { + throw new Error(`Runtime error while calling read-only function ${args.functionName}`); + } + throw new Error(`Error calling read-only function ${args.functionName}`); + } + return hexToCV(result.result); + } +} diff --git a/src/rosetta-helpers.ts b/src/rosetta/rosetta-helpers.ts similarity index 95% rename from src/rosetta-helpers.ts rename to src/rosetta/rosetta-helpers.ts index 50cb6f9f..ebeb48f9 100644 --- a/src/rosetta-helpers.ts +++ b/src/rosetta/rosetta-helpers.ts @@ -25,26 +25,23 @@ import { StacksMainnet, StacksTestnet } from '@stacks/network'; import { ec as EC } from 'elliptic'; import * as btc from 'bitcoinjs-lib'; import { - getAssetEventTypeString, - getEventTypeString, getTxFromDataStore, getTxStatus, getTxTypeString, parseContractCallMetadata, -} from './api/controllers/db-controller'; +} from '../api/controllers/db-controller'; import { PoxContractIdentifier, RosettaConstants, RosettaNetworks, RosettaOperationType, -} from './api/rosetta-constants'; +} from '../api/rosetta-constants'; import { BaseTx, DbAssetEventTypeId, DbEvent, DbEventTypeId, DbFtEvent, - DbFungibleTokenMetadata, DbMempoolTx, DbMinerReward, DbStxEvent, @@ -53,12 +50,11 @@ import { DbTxStatus, DbTxTypeId, StxUnlockEvent, -} from './datastore/common'; -import { getTxSenderAddress, getTxSponsorAddress } from './event-stream/reader'; -import { unwrapOptional, getSendManyContract } from './helpers'; +} from '../datastore/common'; +import { getTxSenderAddress, getTxSponsorAddress } from '../event-stream/reader'; +import { unwrapOptional, getSendManyContract } from '../helpers'; -import { getCoreNodeEndpoint } from './core-rpc/client'; -import { TokenMetadataErrorMode } from './token-metadata/tokens-contract-handler'; +import { getCoreNodeEndpoint } from '../core-rpc/client'; import { ClarityTypeID, decodeClarityValue, @@ -76,12 +72,12 @@ import { ClarityValue, ClarityValueList, } from 'stacks-encoding-native-js'; -import { PgStore } from './datastore/pg-store'; -import { isFtMetadataEnabled, tokenMetadataErrorMode } from './token-metadata/helpers'; +import { PgStore } from '../datastore/pg-store'; import { poxAddressToBtcAddress } from '@stacks/stacking'; import { parseRecoverableSignatureVrs } from '@stacks/common'; -import { logger } from './logger'; +import { logger } from '../logger'; import { hexToBuffer } from '@hirosystems/api-toolkit'; +import { RosettaFtMetadata, RosettaFtMetadataClient } from './rosetta-ft-metadata-client'; enum CoinAction { CoinSpent = 'coin_spent', @@ -243,6 +239,7 @@ async function processEvents( // match them by index. const sendManyMemos = decodeSendManyContractCallMemos(baseTx, chainID); let sendManyStxTransferEventIndex = 0; + const metadataClient = new RosettaFtMetadataClient(chainID); for (const event of events) { const txEventType = event.event_type; @@ -298,7 +295,7 @@ async function processEvents( case DbEventTypeId.NonFungibleTokenAsset: break; case DbEventTypeId.FungibleTokenAsset: - const ftMetadata = await getValidatedFtMetadata(db, event.asset_identifier); + const ftMetadata = await metadataClient.getFtMetadata(event.asset_identifier); if (!ftMetadata) { break; } @@ -435,7 +432,7 @@ function makeBurnOperation(tx: DbStxEvent, baseTx: BaseTx, index: number): Roset function makeFtBurnOperation( ftEvent: DbFtEvent, - ftMetadata: FungibleTokenMetadata, + ftMetadata: RosettaFtMetadata, baseTx: BaseTx, index: number ): RosettaOperation { @@ -482,7 +479,7 @@ function makeMintOperation(tx: DbStxEvent, baseTx: BaseTx, index: number): Roset function makeFtMintOperation( ftEvent: DbFtEvent, - ftMetadata: FungibleTokenMetadata, + ftMetadata: RosettaFtMetadata, baseTx: BaseTx, index: number ): RosettaOperation { @@ -548,7 +545,7 @@ function makeSenderOperation( function makeFtSenderOperation( ftEvent: DbFtEvent, - ftMetadata: FungibleTokenMetadata, + ftMetadata: RosettaFtMetadata, tx: BaseTx, index: number ): RosettaOperation { @@ -621,7 +618,7 @@ function makeReceiverOperation( function makeFtReceiverOperation( ftEvent: DbFtEvent, - ftMetadata: FungibleTokenMetadata, + ftMetadata: RosettaFtMetadata, tx: BaseTx, index: number ): RosettaOperation { @@ -1148,28 +1145,6 @@ export function rawTxToBaseTx(raw_tx: string): BaseTx { return dbTx; } -export async function getValidatedFtMetadata( - db: PgStore, - assetIdentifier: string -): Promise { - if (!isFtMetadataEnabled()) { - return; - } - const tokenContractId = assetIdentifier.split('::')[0]; - const ftMetadata = await db.getFtMetadata(tokenContractId); - if (!ftMetadata.found) { - if (tokenMetadataErrorMode() === TokenMetadataErrorMode.warning) { - logger.warn(`FT metadata not found for token: ${assetIdentifier}`); - } else { - // TODO: Check if the metadata wasn't found because the contract ABI is not SIP-010 - // compliant or because there was a recoverable error that prevented the metadata - // from being processed. - throw new Error(`FT metadata not found for token: ${assetIdentifier}`); - } - } - return ftMetadata.result; -} - export function getSigners(transaction: StacksTransaction): RosettaAccountIdentifier[] | undefined { let address; if (transaction.payload.payloadType == PayloadType.TokenTransfer) { @@ -1218,7 +1193,7 @@ export function getSigners(transaction: StacksTransaction): RosettaAccountIdenti return account_identifier_signers; } -export function getStacksTestnetNetwork() { +function getStacksTestnetNetwork() { return new StacksTestnet({ url: `http://${getCoreNodeEndpoint()}`, }); diff --git a/src/tests-rosetta-construction/construction.ts b/src/tests-rosetta-construction/construction.ts index 4a6eacfa..3c40d9d4 100644 --- a/src/tests-rosetta-construction/construction.ts +++ b/src/tests-rosetta-construction/construction.ts @@ -50,7 +50,7 @@ import { RosettaOperationTypes, } from '../api/rosetta-constants'; import { getStacksTestnetNetwork, testnetKeys } from '../api/routes/debug'; -import { getSignature, getStacksNetwork } from '../rosetta-helpers'; +import { getSignature, getStacksNetwork } from '../rosetta/rosetta-helpers'; import { makeSigHashPreSign, MessageSignature } from '@stacks/transactions'; import { PgWriteStore } from '../datastore/pg-write-store'; import { decodeBtcAddress } from '@stacks/stacking'; diff --git a/src/tests-rosetta/account-tests.ts b/src/tests-rosetta/account-tests.ts index 4dbb795b..53de71ef 100644 --- a/src/tests-rosetta/account-tests.ts +++ b/src/tests-rosetta/account-tests.ts @@ -1,10 +1,11 @@ import * as supertest from 'supertest'; -import { ChainID } from '@stacks/transactions'; +import { ChainID, cvToHex, stringUtf8CV, uintCV } from '@stacks/transactions'; import { ApiServer, startApiServer } from '../api/init'; import { TestBlockBuilder } from '../test-utils/test-builders'; -import { DbAssetEventTypeId, DbFungibleTokenMetadata } from '../datastore/common'; +import { DbAssetEventTypeId } from '../datastore/common'; import { PgWriteStore } from '../datastore/pg-write-store'; import { migrate } from '../test-utils/test-helpers'; +import nock = require('nock'); describe('/account tests', () => { let db: PgWriteStore; @@ -27,20 +28,21 @@ describe('/account tests', () => { const addr1 = 'SP3WV3VC6GM1WF215SDHP0MESQ3BNXHB1N6TPB70S'; const addr2 = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y'; - // Declare fungible token - const ftMetadata: DbFungibleTokenMetadata = { - token_uri: 'https://cdn.citycoins.co/metadata/newyorkcitycoin.json', - name: 'newyorkcitycoin', - description: 'A CityCoin for New York City, ticker is NYC, Stack it to earn Stacks (STX)', - image_uri: 'https://stacks-api.imgix.net/https%3A%2F%2Fcdn.citycoins.co%2Flogos%2Fnewyorkcitycoin.png?s=38a8d89aa6b4ef3fcc9958da3eb34480', - image_canonical_uri: 'https://cdn.citycoins.co/logos/newyorkcitycoin.png', - symbol: 'NYC', - decimals: 0, - tx_id: '0x9c8ddc44fcfdfc67af5425c4174833fc5814627936d573fe38fc29a46ba746e6', - sender_address: 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5', - contract_id: 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token' - }; - await db.updateFtMetadata(ftMetadata, 1); + const nodeUrl = `http://${process.env['STACKS_CORE_RPC_HOST']}:${process.env['STACKS_CORE_RPC_PORT']}`; + nock(nodeUrl) + .persist() + .post('/v2/contracts/call-read/SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5/newyorkcitycoin-token/get-decimals') + .reply(200, { + okay: true, + result: cvToHex(uintCV(0)), + }); + nock(nodeUrl) + .persist() + .post('/v2/contracts/call-read/SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5/newyorkcitycoin-token/get-symbol') + .reply(200, { + okay: true, + result: cvToHex(stringUtf8CV('NYC')), + }); // FT transfer const block1 = new TestBlockBuilder({ @@ -98,5 +100,6 @@ describe('/account tests', () => { value: '7500' } ]); + nock.cleanAll(); }); }); diff --git a/src/tests-rosetta/block-tests.ts b/src/tests-rosetta/block-tests.ts index 8d662355..d8e2d677 100644 --- a/src/tests-rosetta/block-tests.ts +++ b/src/tests-rosetta/block-tests.ts @@ -1,13 +1,14 @@ import * as supertest from 'supertest'; -import { bufferCV, ChainID, cvToHex, listCV, stringAsciiCV, tupleCV, uintCV } from '@stacks/transactions'; +import { bufferCV, ChainID, cvToHex, listCV, stringAsciiCV, stringUtf8CV, tupleCV, uintCV } from '@stacks/transactions'; import { ApiServer, startApiServer } from '../api/init'; import { TestBlockBuilder } from '../test-utils/test-builders'; -import { DbAssetEventTypeId, DbFungibleTokenMetadata, DbTxTypeId } from '../datastore/common'; +import { DbAssetEventTypeId, DbTxTypeId } from '../datastore/common'; import { createClarityValueArray } from '../stacks-encoding-helpers'; import { PgWriteStore } from '../datastore/pg-write-store'; import { principalCV } from '@stacks/transactions/dist/clarity/types/principalCV'; import { migrate } from '../test-utils/test-helpers'; import { bufferToHex } from '@hirosystems/api-toolkit'; +import nock = require('nock'); describe('/block tests', () => { let db: PgWriteStore; @@ -116,20 +117,21 @@ describe('/block tests', () => { const addr1 = 'SP3WV3VC6GM1WF215SDHP0MESQ3BNXHB1N6TPB70S'; const addr2 = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y'; - // Declare fungible token - const ftMetadata: DbFungibleTokenMetadata = { - token_uri: 'https://cdn.citycoins.co/metadata/newyorkcitycoin.json', - name: 'newyorkcitycoin', - description: 'A CityCoin for New York City, ticker is NYC, Stack it to earn Stacks (STX)', - image_uri: 'https://stacks-api.imgix.net/https%3A%2F%2Fcdn.citycoins.co%2Flogos%2Fnewyorkcitycoin.png?s=38a8d89aa6b4ef3fcc9958da3eb34480', - image_canonical_uri: 'https://cdn.citycoins.co/logos/newyorkcitycoin.png', - symbol: 'NYC', - decimals: 0, - tx_id: '0x9c8ddc44fcfdfc67af5425c4174833fc5814627936d573fe38fc29a46ba746e6', - sender_address: 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5', - contract_id: 'SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5.newyorkcitycoin-token' - }; - await db.updateFtMetadata(ftMetadata, 1); + const nodeUrl = `http://${process.env['STACKS_CORE_RPC_HOST']}:${process.env['STACKS_CORE_RPC_PORT']}`; + nock(nodeUrl) + .persist() + .post('/v2/contracts/call-read/SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5/newyorkcitycoin-token/get-decimals') + .reply(200, { + okay: true, + result: cvToHex(uintCV(0)), + }); + nock(nodeUrl) + .persist() + .post('/v2/contracts/call-read/SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5/newyorkcitycoin-token/get-symbol') + .reply(200, { + okay: true, + result: cvToHex(stringUtf8CV('NYC')), + }); // FT transfer const block1 = new TestBlockBuilder({ diff --git a/src/tests-rosetta/offline-api-tests.ts b/src/tests-rosetta/offline-api-tests.ts index 2887db0b..73577fa3 100644 --- a/src/tests-rosetta/offline-api-tests.ts +++ b/src/tests-rosetta/offline-api-tests.ts @@ -48,7 +48,7 @@ import { } from '../api/rosetta-constants'; import { OfflineDummyStore } from '../datastore/offline-dummy-store'; import { getStacksTestnetNetwork, testnetKeys } from '../api/routes/debug'; -import { getSignature, getStacksNetwork, publicKeyToBitcoinAddress } from '../rosetta-helpers'; +import { getSignature, getStacksNetwork, publicKeyToBitcoinAddress } from '../rosetta/rosetta-helpers'; import * as nock from 'nock'; import { PgStore } from '../datastore/pg-store'; import { decodeBtcAddress } from '@stacks/stacking'; diff --git a/src/tests-tokens-metadata/setup.ts b/src/tests-tokens-metadata/setup.ts deleted file mode 100644 index f2d1cf88..00000000 --- a/src/tests-tokens-metadata/setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { loadDotEnv } from '../helpers'; - -// ts-unused-exports:disable-next-line -export default () => { - console.log('Jest - setup..'); - if (!process.env.NODE_ENV) { - process.env.NODE_ENV = 'test'; - } - loadDotEnv(); - process.env.PG_DATABASE = 'postgres'; - process.env.STACKS_CHAIN_ID = '0x80000000'; - console.log('Jest - setup done'); -}; diff --git a/src/tests-tokens-metadata/teardown.ts b/src/tests-tokens-metadata/teardown.ts deleted file mode 100644 index 8638723d..00000000 --- a/src/tests-tokens-metadata/teardown.ts +++ /dev/null @@ -1,5 +0,0 @@ -// ts-unused-exports:disable-next-line -export default () => { - console.log('Jest - teardown..'); - console.log('Jest - teardown done'); -}; diff --git a/src/tests-tokens-metadata/test-contracts/beeple-data-url-a.clar b/src/tests-tokens-metadata/test-contracts/beeple-data-url-a.clar deleted file mode 100644 index a773a93d..00000000 --- a/src/tests-tokens-metadata/test-contracts/beeple-data-url-a.clar +++ /dev/null @@ -1,58 +0,0 @@ -;; (impl-trait 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6.nft-trait.nft-trait) -(define-non-fungible-token beeple uint) - -;; Public functions -(define-constant nft-not-owned-err (err u401)) ;; unauthorized -(define-constant nft-not-found-err (err u404)) ;; not found -(define-constant sender-equals-recipient-err (err u405)) ;; method not allowed - -(define-private (nft-transfer-err (code uint)) - (if (is-eq u1 code) - nft-not-owned-err - (if (is-eq u2 code) - sender-equals-recipient-err - (if (is-eq u3 code) - nft-not-found-err - (err code))))) - -;; Transfers tokens to a specified principal. -(define-public (transfer (token-id uint) (sender principal) (recipient principal)) - (if (and - (is-eq tx-sender (unwrap! (nft-get-owner? beeple token-id) nft-not-found-err)) - (is-eq tx-sender sender) - (not (is-eq recipient sender))) - (match (nft-transfer? beeple token-id sender recipient) - success (ok success) - error (nft-transfer-err error)) - nft-not-owned-err)) - -;; Gets the owner of the specified token ID. -(define-read-only (get-owner (token-id uint)) - (ok (nft-get-owner? beeple token-id))) - -;; Gets the owner of the specified token ID. -(define-read-only (get-last-token-id) - (ok u1)) - -(define-read-only (get-token-uri (token-id uint)) - (ok (some "data:,%7B%22name%22%3A%22Heystack%22%2C%22description%22%3A%22Heystack%20is%20a%20SIP-010-compliant%20fungible%20token%22%2C%22imageUrl%22%3A%22https%3A%2F%2Fheystack.xyz%2Fassets%2FStacks128w.png%22%7D"))) - -(define-read-only (get-meta (token-id uint)) - (if (is-eq token-id u1) - (ok (some {name: "EVERYDAYS: THE FIRST 5000 DAYS", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"})) - (ok none))) - -(define-read-only (get-nft-meta) - (ok (some {name: "beeple", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"}))) - -(define-read-only (get-errstr (code uint)) - (ok (if (is-eq u401 code) - "nft-not-owned" - (if (is-eq u404 code) - "nft-not-found" - (if (is-eq u405 code) - "sender-equals-recipient" - "unknown-error"))))) - -;; Initialize the contract -(try! (nft-mint? beeple u1 tx-sender)) diff --git a/src/tests-tokens-metadata/test-contracts/beeple-data-url-b.clar b/src/tests-tokens-metadata/test-contracts/beeple-data-url-b.clar deleted file mode 100644 index a8dfbf21..00000000 --- a/src/tests-tokens-metadata/test-contracts/beeple-data-url-b.clar +++ /dev/null @@ -1,58 +0,0 @@ -;; (impl-trait 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6.nft-trait.nft-trait) -(define-non-fungible-token beeple uint) - -;; Public functions -(define-constant nft-not-owned-err (err u401)) ;; unauthorized -(define-constant nft-not-found-err (err u404)) ;; not found -(define-constant sender-equals-recipient-err (err u405)) ;; method not allowed - -(define-private (nft-transfer-err (code uint)) - (if (is-eq u1 code) - nft-not-owned-err - (if (is-eq u2 code) - sender-equals-recipient-err - (if (is-eq u3 code) - nft-not-found-err - (err code))))) - -;; Transfers tokens to a specified principal. -(define-public (transfer (token-id uint) (sender principal) (recipient principal)) - (if (and - (is-eq tx-sender (unwrap! (nft-get-owner? beeple token-id) nft-not-found-err)) - (is-eq tx-sender sender) - (not (is-eq recipient sender))) - (match (nft-transfer? beeple token-id sender recipient) - success (ok success) - error (nft-transfer-err error)) - nft-not-owned-err)) - -;; Gets the owner of the specified token ID. -(define-read-only (get-owner (token-id uint)) - (ok (nft-get-owner? beeple token-id))) - -;; Gets the owner of the specified token ID. -(define-read-only (get-last-token-id) - (ok u1)) - -(define-read-only (get-token-uri (token-id uint)) - (ok (some "data:;base64,eyJuYW1lIjoiSGV5c3RhY2siLCJkZXNjcmlwdGlvbiI6IkhleXN0YWNrIGlzIGEgU0lQLTAxMC1jb21wbGlhbnQgZnVuZ2libGUgdG9rZW4iLCJpbWFnZVVybCI6Imh0dHBzOi8vaGV5c3RhY2sueHl6L2Fzc2V0cy9TdGFja3MxMjh3LnBuZyJ9"))) - -(define-read-only (get-meta (token-id uint)) - (if (is-eq token-id u1) - (ok (some {name: "EVERYDAYS: THE FIRST 5000 DAYS", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"})) - (ok none))) - -(define-read-only (get-nft-meta) - (ok (some {name: "beeple", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"}))) - -(define-read-only (get-errstr (code uint)) - (ok (if (is-eq u401 code) - "nft-not-owned" - (if (is-eq u404 code) - "nft-not-found" - (if (is-eq u405 code) - "sender-equals-recipient" - "unknown-error"))))) - -;; Initialize the contract -(try! (nft-mint? beeple u1 tx-sender)) diff --git a/src/tests-tokens-metadata/test-contracts/beeple-data-url-c.clar b/src/tests-tokens-metadata/test-contracts/beeple-data-url-c.clar deleted file mode 100644 index fa335337..00000000 --- a/src/tests-tokens-metadata/test-contracts/beeple-data-url-c.clar +++ /dev/null @@ -1,58 +0,0 @@ -;; (impl-trait 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6.nft-trait.nft-trait) -(define-non-fungible-token beeple uint) - -;; Public functions -(define-constant nft-not-owned-err (err u401)) ;; unauthorized -(define-constant nft-not-found-err (err u404)) ;; not found -(define-constant sender-equals-recipient-err (err u405)) ;; method not allowed - -(define-private (nft-transfer-err (code uint)) - (if (is-eq u1 code) - nft-not-owned-err - (if (is-eq u2 code) - sender-equals-recipient-err - (if (is-eq u3 code) - nft-not-found-err - (err code))))) - -;; Transfers tokens to a specified principal. -(define-public (transfer (token-id uint) (sender principal) (recipient principal)) - (if (and - (is-eq tx-sender (unwrap! (nft-get-owner? beeple token-id) nft-not-found-err)) - (is-eq tx-sender sender) - (not (is-eq recipient sender))) - (match (nft-transfer? beeple token-id sender recipient) - success (ok success) - error (nft-transfer-err error)) - nft-not-owned-err)) - -;; Gets the owner of the specified token ID. -(define-read-only (get-owner (token-id uint)) - (ok (nft-get-owner? beeple token-id))) - -;; Gets the owner of the specified token ID. -(define-read-only (get-last-token-id) - (ok u1)) - -(define-read-only (get-token-uri (token-id uint)) - (ok (some "data:application/json,{\"name\":\"Heystack\",\"description\":\"Heystack is a SIP-010-compliant fungible token\",\"imageUrl\":\"https://heystack.xyz/assets/Stacks128w.png\"}"))) - ;; (ok (some "data:text/html,"))) -(define-read-only (get-meta (token-id uint)) - (if (is-eq token-id u1) - (ok (some {name: "EVERYDAYS: THE FIRST 5000 DAYS", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"})) - (ok none))) - -(define-read-only (get-nft-meta) - (ok (some {name: "beeple", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"}))) - -(define-read-only (get-errstr (code uint)) - (ok (if (is-eq u401 code) - "nft-not-owned" - (if (is-eq u404 code) - "nft-not-found" - (if (is-eq u405 code) - "sender-equals-recipient" - "unknown-error"))))) - -;; Initialize the contract -(try! (nft-mint? beeple u1 tx-sender)) diff --git a/src/tests-tokens-metadata/test-contracts/beeple.clar b/src/tests-tokens-metadata/test-contracts/beeple.clar deleted file mode 100644 index d7ea8b69..00000000 --- a/src/tests-tokens-metadata/test-contracts/beeple.clar +++ /dev/null @@ -1,58 +0,0 @@ -(impl-trait 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6.nft-trait.nft-trait) -(define-non-fungible-token beeple uint) - -;; Public functions -(define-constant nft-not-owned-err (err u401)) ;; unauthorized -(define-constant nft-not-found-err (err u404)) ;; not found -(define-constant sender-equals-recipient-err (err u405)) ;; method not allowed - -(define-private (nft-transfer-err (code uint)) - (if (is-eq u1 code) - nft-not-owned-err - (if (is-eq u2 code) - sender-equals-recipient-err - (if (is-eq u3 code) - nft-not-found-err - (err code))))) - -;; Transfers tokens to a specified principal. -(define-public (transfer (token-id uint) (sender principal) (recipient principal)) - (if (and - (is-eq tx-sender (unwrap! (nft-get-owner? beeple token-id) nft-not-found-err)) - (is-eq tx-sender sender) - (not (is-eq recipient sender))) - (match (nft-transfer? beeple token-id sender recipient) - success (ok success) - error (nft-transfer-err error)) - nft-not-owned-err)) - -;; Gets the owner of the specified token ID. -(define-read-only (get-owner (token-id uint)) - (ok (nft-get-owner? beeple token-id))) - -;; Gets the owner of the specified token ID. -(define-read-only (get-last-token-id) - (ok u1)) - -(define-read-only (get-token-uri (token-id uint)) - (ok (some "ipfs://ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUz"))) - -(define-read-only (get-meta (token-id uint)) - (if (is-eq token-id u1) - (ok (some {name: "EVERYDAYS: THE FIRST 5000 DAYS", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"})) - (ok none))) - -(define-read-only (get-nft-meta) - (ok (some {name: "beeple", uri: "https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq", mime-type: "image/jpeg"}))) - -(define-read-only (get-errstr (code uint)) - (ok (if (is-eq u401 code) - "nft-not-owned" - (if (is-eq u404 code) - "nft-not-found" - (if (is-eq u405 code) - "sender-equals-recipient" - "unknown-error"))))) - -;; Initialize the contract -(try! (nft-mint? beeple u1 tx-sender)) diff --git a/src/tests-tokens-metadata/test-contracts/ft-trait.clar b/src/tests-tokens-metadata/test-contracts/ft-trait.clar deleted file mode 100644 index 69255cf9..00000000 --- a/src/tests-tokens-metadata/test-contracts/ft-trait.clar +++ /dev/null @@ -1,24 +0,0 @@ -(define-trait sip-010-trait - ( - ;; Transfer from the caller to a new principal - (transfer (uint principal principal (optional (buff 34))) (response bool uint)) - - ;; the human readable name of the token - (get-name () (response (string-ascii 32) uint)) - - ;; the ticker symbol, or empty if none - (get-symbol () (response (string-ascii 32) uint)) - - ;; the number of decimals used, e.g. 6 would mean 1_000_000 represents 1 token - (get-decimals () (response uint uint)) - - ;; the balance of the passed principal - (get-balance (principal) (response uint uint)) - - ;; the current total supply (which does not need to be a constant) - (get-total-supply () (response uint uint)) - - ;; an optional URI that represents metadata of this token - (get-token-uri () (response (optional (string-utf8 256)) uint)) - ) -) diff --git a/src/tests-tokens-metadata/test-contracts/hey-token.clar b/src/tests-tokens-metadata/test-contracts/hey-token.clar deleted file mode 100644 index bd9a50c5..00000000 --- a/src/tests-tokens-metadata/test-contracts/hey-token.clar +++ /dev/null @@ -1,58 +0,0 @@ -;; Implement the `ft-trait` trait defined in the `ft-trait` contract -;; https://github.com/hstove/stacks-fungible-token -(impl-trait 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6.ft-trait.sip-010-trait) - -(define-constant contract-creator tx-sender) - -(define-fungible-token hey-token) - -;; Mint developer tokens -(ft-mint? hey-token u10000 contract-creator) -(ft-mint? hey-token u10000 'ST399W7Z9WS0GMSNQGJGME5JADNKN56R65VGM5KGA) ;; fara -(ft-mint? hey-token u10000 'ST1X6M947Z7E58CNE0H8YJVJTVKS9VW0PHEG3NHN3) ;; thomas -(ft-mint? hey-token u10000 'ST1NY8TXACV7D74886MK05SYW2XA72XJMDVPF3F3D) ;; kyran -(ft-mint? hey-token u10000 'ST34XEPDJJFJKFPT87CCZQCPGXR4PJ8ERFRP0F3GX) ;; jasper -(ft-mint? hey-token u10000 'ST3AGWHGAZKQS4JQ67WQZW5X8HZYZ4ZBWPPNWNMKF) ;; andres -(ft-mint? hey-token u10000 'ST17YZQB1228EK9MPHQXA8GC4G3HVWZ66X779FEBY) ;; esh -(ft-mint? hey-token u10000 'ST3Q0M9WAVBW633CG72VHNFZM2H82D2BJMBX85WP4) ;; mark - -;; get the token balance of owner -(define-read-only (get-balance (owner principal)) - (begin - (ok (ft-get-balance hey-token owner)))) - -;; returns the total number of tokens -(define-read-only (get-total-supply) - (ok (ft-get-supply hey-token))) - -;; returns the token name -(define-read-only (get-name) - (ok "Heystack Token")) - -;; the symbol or "ticker" for this token -(define-read-only (get-symbol) - (ok "HEY")) - -;; the number of decimals used -(define-read-only (get-decimals) - (ok u0)) - -;; Transfers tokens to a recipient -(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) - (if (is-eq tx-sender sender) - (begin - (try! (ft-transfer? hey-token amount sender recipient)) - (print memo) - (ok true) - ) - (err u4))) - -(define-read-only (get-token-uri) - (ok (some u"https://heystack.xyz/token-metadata.json"))) - -(define-public (gift-tokens (recipient principal)) - (begin - (asserts! (is-eq tx-sender recipient) (err u0)) - (ft-mint? hey-token u1 recipient) - ) -) diff --git a/src/tests-tokens-metadata/test-contracts/nft-trait.clar b/src/tests-tokens-metadata/test-contracts/nft-trait.clar deleted file mode 100644 index cc558fdb..00000000 --- a/src/tests-tokens-metadata/test-contracts/nft-trait.clar +++ /dev/null @@ -1,15 +0,0 @@ -(define-trait nft-trait - ( - ;; Last token ID, limited to uint range - (get-last-token-id () (response uint uint)) - - ;; URI for metadata associated with the token - (get-token-uri (uint) (response (optional (string-ascii 256)) uint)) - - ;; Owner of a given token identifier - (get-owner (uint) (response (optional principal) uint)) - - ;; Transfer from the sender to a new principal - (transfer (uint principal principal) (response bool uint)) - ) -) diff --git a/src/tests-tokens-metadata/tokens-metadata-tests.ts b/src/tests-tokens-metadata/tokens-metadata-tests.ts deleted file mode 100644 index 1bc4c23f..00000000 --- a/src/tests-tokens-metadata/tokens-metadata-tests.ts +++ /dev/null @@ -1,404 +0,0 @@ -import * as supertest from 'supertest'; -import { - makeContractDeploy, - ChainID, - getAddressFromPrivateKey, - PostConditionMode, - AnchorMode, -} from '@stacks/transactions'; -import { DbFungibleTokenMetadata, DbNonFungibleTokenMetadata } from '../datastore/common'; -import { startApiServer, ApiServer } from '../api/init'; -import * as fs from 'fs'; -import { EventStreamServer, startEventServer } from '../event-stream/event-server'; -import { getStacksTestnetNetwork } from '../rosetta-helpers'; -import { StacksCoreRpcClient } from '../core-rpc/client'; -import * as nock from 'nock'; -import { PgWriteStore } from '../datastore/pg-write-store'; -import { TokensProcessorQueue } from '../token-metadata/tokens-processor-queue'; -import { performFetch } from '../token-metadata/helpers'; -import { getPagingQueryLimit, ResourceType } from '../api/pagination'; -import { migrate, standByForTx as standByForTxShared } from '../test-utils/test-helpers'; -import { logger } from '../logger'; -import { Waiter, waiter, timeout } from '@hirosystems/api-toolkit'; - -const deploymentAddr = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; -const pKey = 'cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01'; -const stacksNetwork = getStacksTestnetNetwork(); - -describe('tokens metadata tests', () => { - let db: PgWriteStore; - let api: ApiServer; - let eventServer: EventStreamServer; - let tokensProcessorQueue: TokensProcessorQueue; - - const standByForTx = (expectedTxId: string) => standByForTxShared(expectedTxId, api); - - function standByForTokens(id: string): Promise { - const contractId = new Promise(resolve => { - tokensProcessorQueue.processEndEvent.attachOnce( - token => token.contractId === id, - () => resolve() - ); - }); - - return contractId; - } - - async function sendCoreTx(serializedTx: Buffer): Promise<{ txId: string }> { - try { - const submitResult = await new StacksCoreRpcClient().sendTransaction(serializedTx); - return submitResult; - } catch (error) { - logger.error(error); - } - return Promise.resolve({ txId: '' }); - } - - async function deployContract(contractName: string, senderPk: string, sourceFile: string) { - const senderAddress = getAddressFromPrivateKey(senderPk, stacksNetwork.version); - const source = fs.readFileSync(sourceFile).toString(); - const normalized_contract_source = source.replace(/\r/g, '').replace(/\t/g, ' '); - - const contractDeployTx = await makeContractDeploy({ - contractName: contractName, - codeBody: normalized_contract_source, - senderKey: senderPk, - network: stacksNetwork, - postConditionMode: PostConditionMode.Allow, - sponsored: false, - anchorMode: AnchorMode.Any, - fee: 100000, - }); - - const contractId = senderAddress + '.' + contractName; - - // const feeRateReq = await fetch(stacksNetwork.getTransferFeeEstimateApiUrl()); - // const feeRateResult = await feeRateReq.text(); - // const txBytes = BigInt(Buffer.from(contractDeployTx.serialize()).byteLength); - // const feeRate = BigInt(feeRateResult); - // const fee = feeRate * txBytes; - // contractDeployTx.setFee(fee); - const { txId } = await sendCoreTx(Buffer.from(contractDeployTx.serialize())); - return { txId, contractId }; - } - - beforeAll(async () => { - await migrate('up'); - db = await PgWriteStore.connect({ usageName: 'tests', skipMigrations: true }); - eventServer = await startEventServer({ datastore: db, chainId: ChainID.Testnet }); - api = await startApiServer({ datastore: db, chainId: ChainID.Testnet }); - tokensProcessorQueue = new TokensProcessorQueue(db, ChainID.Testnet); - await new StacksCoreRpcClient().waitForConnection(60000); - }); - - beforeEach(() => { - process.env['STACKS_API_ENABLE_FT_METADATA'] = '1'; - process.env['STACKS_API_ENABLE_NFT_METADATA'] = '1'; - process.env['STACKS_API_TOKEN_METADATA_STRICT_MODE'] = '1'; - nock.cleanAll(); - }); - - afterAll(async () => { - await eventServer.closeAsync(); - tokensProcessorQueue.close(); - await api.forceKill(); - await db?.close(); - await migrate('down'); - }); - - test('metadata disabled', async () => { - process.env['STACKS_API_ENABLE_FT_METADATA'] = '0'; - process.env['STACKS_API_ENABLE_NFT_METADATA'] = '0'; - const query1 = await supertest(api.server).get(`/extended/v1/tokens/nft/metadata`); - expect(query1.status).toBe(500); - expect(query1.body.error).toMatch(/not enabled/); - const query2 = await supertest(api.server).get(`/extended/v1/tokens/ft/metadata`); - expect(query2.status).toBe(500); - expect(query2.body.error).toMatch(/not enabled/); - const query3 = await supertest(api.server).get(`/extended/v1/tokens/example/nft/metadata`); - expect(query3.status).toBe(500); - expect(query3.body.error).toMatch(/not enabled/); - const query4 = await supertest(api.server).get(`/extended/v1/tokens/example/ft/metadata`); - expect(query4.status).toBe(500); - expect(query4.body.error).toMatch(/not enabled/); - }); - - test('token nft-metadata data URL plain percent-encoded', async () => { - const standByPromise = standByForTokens(`${deploymentAddr}.beeple-a`); - const contract1 = await deployContract( - 'beeple-a', - pKey, - 'src/tests-tokens-metadata/test-contracts/beeple-data-url-a.clar' - ); - await standByPromise; - await tokensProcessorQueue.drainDbQueue(); - - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/${contract1.contractId}/nft/metadata` - ); - expect(query1.status).toBe(200); - expect(query1.body).toHaveProperty('token_uri'); - expect(query1.body).toHaveProperty('name'); - expect(query1.body).toHaveProperty('description'); - expect(query1.body).toHaveProperty('image_uri'); - expect(query1.body).toHaveProperty('image_canonical_uri'); - expect(query1.body).toHaveProperty('tx_id'); - expect(query1.body).toHaveProperty('sender_address'); - }); - - test('failed processing is retried in next block', async () => { - const entryProcessedWaiter: Waiter = waiter(); - const blockHandler = async (blockHash: string) => { - const entry = await db.getTokenMetadataQueueEntry(1); - if (entry.result?.processed) { - entryProcessedWaiter.finish(blockHash); - } - }; - db.eventEmitter.on('blockUpdate', blockHandler); - // Set as not processed. - await db.sql` - UPDATE token_metadata_queue - SET processed = false - WHERE queue_id = 1 - `; - // This will resolve when processed is true again. - await entryProcessedWaiter; - db.eventEmitter.off('blockUpdate', blockHandler); - }); - - test('token nft-metadata data URL base64 w/o media type', async () => { - const standByPromise = standByForTokens(`${deploymentAddr}.beeple-b`); - const contract1 = await deployContract( - 'beeple-b', - pKey, - 'src/tests-tokens-metadata/test-contracts/beeple-data-url-b.clar' - ); - - await standByPromise; - await tokensProcessorQueue.drainDbQueue(); - - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/${contract1.contractId}/nft/metadata` - ); - expect(query1.status).toBe(200); - expect(query1.body).toHaveProperty('token_uri'); - expect(query1.body).toHaveProperty('name'); - expect(query1.body).toHaveProperty('description'); - expect(query1.body).toHaveProperty('image_uri'); - expect(query1.body).toHaveProperty('image_canonical_uri'); - expect(query1.body).toHaveProperty('tx_id'); - expect(query1.body).toHaveProperty('sender_address'); - }); - - test('token nft-metadata data URL plain non-encoded', async () => { - const standByPromise = standByForTokens(`${deploymentAddr}.beeple-c`); - const contract1 = await deployContract( - 'beeple-c', - pKey, - 'src/tests-tokens-metadata/test-contracts/beeple-data-url-c.clar' - ); - - await standByPromise; - await tokensProcessorQueue.drainDbQueue(); - - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/${contract1.contractId}/nft/metadata` - ); - expect(query1.status).toBe(200); - expect(query1.body).toHaveProperty('token_uri'); - expect(query1.body).toHaveProperty('name'); - expect(query1.body).toHaveProperty('description'); - expect(query1.body).toHaveProperty('image_uri'); - expect(query1.body).toHaveProperty('image_canonical_uri'); - expect(query1.body).toHaveProperty('tx_id'); - expect(query1.body).toHaveProperty('sender_address'); - }); - - test('token nft-metadata', async () => { - //mock the response - const nftMetadata = { - name: 'EVERYDAYS: THE FIRST 5000 DAYS', - imageUrl: - 'https://ipfsgateway.makersplace.com/ipfs/QmZ15eQX8FPjfrtdX3QYbrhZxJpbLpvDpsgb2p3VEH8Bqq', - description: - 'I made a picture from start to finish every single day from May 1st, 2007 - January 7th, 2021. This is every motherfucking one of those pictures.', - }; - nock('https://ipfs.io') - .persist() - .get('/ipfs/QmPAg1mjxcEQPPtqsLoEcauVedaeMH81WXDPvPx3VC5zUz') - .reply(200, nftMetadata); - - const contract = await deployContract( - 'nft-trait', - pKey, - 'src/tests-tokens-metadata/test-contracts/nft-trait.clar' - ); - const tx = await standByForTx(contract.txId); - if (tx.status != 1) logger.error(tx, 'contract deploy error'); - - const standByPromise = standByForTokens(`${deploymentAddr}.beeple`); - const contract1 = await deployContract( - 'beeple', - pKey, - 'src/tests-tokens-metadata/test-contracts/beeple.clar' - ); - - await standByPromise; - await tokensProcessorQueue.drainDbQueue(); - - const senderAddress = getAddressFromPrivateKey(pKey, stacksNetwork.version); - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/${senderAddress}.beeple/nft/metadata` - ); - expect(query1.status).toBe(200); - expect(query1.body).toHaveProperty('token_uri'); - expect(query1.body.name).toBe(nftMetadata.name); - expect(query1.body.description).toBe(nftMetadata.description); - expect(query1.body.image_uri).toBe(nftMetadata.imageUrl); - expect(query1.body).toHaveProperty('image_canonical_uri'); - expect(query1.body).toHaveProperty('tx_id'); - expect(query1.body).toHaveProperty('sender_address'); - }); - - test('token ft-metadata tests', async () => { - //mock the response - const ftMetadata = { - name: 'Heystack', - description: - 'Heystack is a SIP-010-compliant fungible token on the Stacks Blockchain, used on the Heystack app', - image: 'https://heystack.xyz/assets/Stacks128w.png', - }; - - // https://heystack.xyz/token-metadata.json - nock('https://heystack.xyz').persist().get('/token-metadata.json').reply(200, ftMetadata); - - const contract = await deployContract( - 'ft-trait', - pKey, - 'src/tests-tokens-metadata/test-contracts/ft-trait.clar' - ); - - const tx = await standByForTx(contract.txId); - if (tx.status != 1) logger.error(tx, 'contract deploy error'); - - const standByPromise = standByForTokens(`${deploymentAddr}.hey-token`); - const contract1 = await deployContract( - 'hey-token', - pKey, - 'src/tests-tokens-metadata/test-contracts/hey-token.clar' - ); - - await standByPromise; - await tokensProcessorQueue.drainDbQueue(); - - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/${contract1.contractId}/ft/metadata` - ); - - expect(query1.body).toHaveProperty('token_uri'); - expect(query1.body).toHaveProperty('name'); - expect(query1.body.description).toBe(ftMetadata.description); - expect(query1.body.image_uri).toBe(ftMetadata.image); - expect(query1.body).toHaveProperty('image_canonical_uri'); - expect(query1.body).toHaveProperty('tx_id'); - expect(query1.body).toHaveProperty('sender_address'); - }); - - test('token ft-metadata list', async () => { - for (let i = 0; i < 200; i++) { - const ftMetadata: DbFungibleTokenMetadata = { - token_uri: 'ft-token', - name: 'ft-metadata' + i, - description: 'ft -metadata description', - symbol: 'stx', - decimals: 5, - image_uri: 'ft-metadata image uri example', - image_canonical_uri: 'ft-metadata image canonical uri example', - contract_id: 'ABCDEFGHIJ.ft-metadata' + i, - tx_id: '0x123456', - sender_address: 'ABCDEFGHIJ', - }; - await db.updateFtMetadata(ftMetadata, 1); - } - - const query = await supertest(api.server).get(`/extended/v1/tokens/ft/metadata`); - expect(query.status).toBe(200); - expect(query.body.total).toBeGreaterThan(getPagingQueryLimit(ResourceType.Token)); - expect(query.body.limit).toStrictEqual(getPagingQueryLimit(ResourceType.Token)); - expect(query.body.offset).toStrictEqual(0); - expect(query.body.results.length).toStrictEqual(getPagingQueryLimit(ResourceType.Token)); - - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/ft/metadata?limit=20&offset=10` - ); - expect(query1.status).toBe(200); - expect(query1.body.total).toBeGreaterThanOrEqual(200); - expect(query1.body.limit).toStrictEqual(20); - expect(query1.body.offset).toStrictEqual(10); - expect(query1.body.results.length).toStrictEqual(20); - }); - - test('token nft-metadata list', async () => { - for (let i = 0; i < 200; i++) { - const nftMetadata: DbNonFungibleTokenMetadata = { - token_uri: 'nft-tokenuri', - name: 'nft-metadata' + i, - description: 'nft -metadata description' + i, - image_uri: 'nft-metadata image uri example', - image_canonical_uri: 'nft-metadata image canonical uri example', - contract_id: 'ABCDEFGHIJ.nft-metadata' + i, - tx_id: '0x12345678', - sender_address: 'ABCDEFGHIJ', - }; - - await db.updateNFtMetadata(nftMetadata, 1); - } - - const query = await supertest(api.server).get(`/extended/v1/tokens/nft/metadata`); - expect(query.status).toBe(200); - expect(query.body.total).toBeGreaterThan(getPagingQueryLimit(ResourceType.Token)); - expect(query.body.limit).toStrictEqual(getPagingQueryLimit(ResourceType.Token)); - expect(query.body.offset).toStrictEqual(0); - expect(query.body.results.length).toStrictEqual(getPagingQueryLimit(ResourceType.Token)); - - const query1 = await supertest(api.server).get( - `/extended/v1/tokens/nft/metadata?limit=20&offset=10` - ); - expect(query1.status).toBe(200); - expect(query1.body.total).toBeGreaterThanOrEqual(200); - expect(query1.body.limit).toStrictEqual(20); - expect(query1.body.offset).toStrictEqual(10); - expect(query1.body.results.length).toStrictEqual(20); - }); - - test('large metadata payload test', async () => { - //mock the response - const maxResponseBytes = 10_000; - const randomData = Buffer.alloc(maxResponseBytes + 100, 'x', 'utf8'); - nock('https://example.com').persist().get('/large_payload').reply(200, randomData.toString()); - - await expect(async () => { - await performFetch('https://example.com/large_payload', { - maxResponseBytes: maxResponseBytes, - }); - }).rejects.toThrow(/over limit/); - }); - - test('timeout metadata payload test', async () => { - //mock the response - const responseTimeout = 100; - nock('https://example.com') - .persist() - .get('/timeout_payload') - .reply(200, async (_uri, _requestBody, cb) => { - await timeout(responseTimeout + 200); - cb(null, '{"hello":"world"}'); - }); - - await expect(async () => { - await performFetch('https://example.com/timeout_payload', { - timeoutMs: responseTimeout, - }); - }).rejects.toThrow(/network timeout/); - }); -}); diff --git a/src/tests-tokens-strict/setup.ts b/src/tests-tokens-strict/setup.ts deleted file mode 100644 index 7cb3c98c..00000000 --- a/src/tests-tokens-strict/setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defaultSetupInit } from '../test-utils/shared-setup'; - -// ts-unused-exports:disable-next-line -export default async () => { - console.log('Jest - setup..'); - await defaultSetupInit(); - console.log('Jest - setup done'); -}; diff --git a/src/tests-tokens-strict/strict-mode-tests.ts b/src/tests-tokens-strict/strict-mode-tests.ts deleted file mode 100644 index a57f94a8..00000000 --- a/src/tests-tokens-strict/strict-mode-tests.ts +++ /dev/null @@ -1,388 +0,0 @@ -import * as nock from 'nock'; -import { ChainID, ClarityAbi, cvToHex, noneCV, uintCV } from '@stacks/transactions'; -import { PoolClient } from 'pg'; -import { TestBlockBuilder } from '../test-utils/test-builders'; -import { ApiServer, startApiServer } from '../api/init'; -import { - METADATA_MAX_PAYLOAD_BYTE_SIZE, - TokensContractHandler, -} from '../token-metadata/tokens-contract-handler'; -import { DbTxTypeId } from '../datastore/common'; -import { stringCV } from '@stacks/transactions/dist/clarity/types/stringCV'; -import { getTokenMetadataFetchTimeoutMs } from '../token-metadata/helpers'; -import { PgWriteStore } from '../datastore/pg-write-store'; -import { TokensProcessorQueue } from '../token-metadata/tokens-processor-queue'; -import { migrate } from '../test-utils/test-helpers'; - -const NFT_CONTRACT_ABI: ClarityAbi = { - maps: [], - functions: [ - { - access: 'read_only', - args: [], - name: 'get-last-token-id', - outputs: { - type: { - response: { - ok: 'uint128', - error: 'uint128', - }, - }, - }, - }, - { - access: 'read_only', - args: [{ name: 'any', type: 'uint128' }], - name: 'get-token-uri', - outputs: { - type: { - response: { - ok: { - optional: { 'string-ascii': { length: 256 } }, - }, - error: 'uint128', - }, - }, - }, - }, - { - access: 'read_only', - args: [{ type: 'uint128', name: 'any' }], - name: 'get-owner', - outputs: { - type: { - response: { - ok: { - optional: 'principal', - }, - error: 'uint128', - }, - }, - }, - }, - { - access: 'public', - args: [ - { type: 'uint128', name: 'id' }, - { type: 'principal', name: 'sender' }, - { type: 'principal', name: 'recipient' }, - ], - name: 'transfer', - outputs: { - type: { - response: { - ok: 'bool', - error: { - tuple: [ - { type: { 'string-ascii': { length: 32 } }, name: 'kind' }, - { type: 'uint128', name: 'code' }, - ], - }, - }, - }, - }, - }, - ], - variables: [ - { - name: 'nft-not-found-err', - type: { response: { ok: 'none', error: 'uint128' } }, - access: 'constant', - }, - { - name: 'nft-not-owned-err', - type: { response: { ok: 'none', error: 'uint128' } }, - access: 'constant', - }, - { - name: 'sender-equals-recipient-err', - type: { response: { ok: 'none', error: 'uint128' } }, - access: 'constant', - }, - ], - fungible_tokens: [], - non_fungible_tokens: [{ name: 'beeple', type: 'uint128' }], -}; - -describe('token metadata strict mode', () => { - let db: PgWriteStore; - let api: ApiServer; - - const contractId = 'SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD.project-indigo-act1'; - const contractTxId = '0x1f1f'; - - beforeEach(async () => { - await migrate('up'); - db = await PgWriteStore.connect({ usageName: 'tests', withNotifier: false }); - api = await startApiServer({ datastore: db, chainId: ChainID.Testnet }); - - process.env['STACKS_API_ENABLE_FT_METADATA'] = '1'; - process.env['STACKS_API_ENABLE_NFT_METADATA'] = '1'; - process.env['STACKS_CORE_RPC_PORT'] = '20443'; - nock.cleanAll(); - - const block = new TestBlockBuilder({ block_height: 1, index_block_hash: '0x01' }) - .addTx() - .addTx({ - tx_id: contractTxId, - type_id: DbTxTypeId.SmartContract, - smart_contract_contract_id: contractId, - smart_contract_source_code: '(source)', - }) - .addTxSmartContract({ - contract_id: contractId, - contract_source: '(source)', - abi: JSON.stringify(NFT_CONTRACT_ABI), - }) - .build(); - await db.update(block); - }); - - afterEach(async () => { - await api.terminate(); - await db?.close(); - await migrate('down'); - }); - - test('retryable error increases retry_count', async () => { - process.env['STACKS_CORE_RPC_PORT'] = '11111'; // Make node unreachable - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toBe(1); - expect(entry.result?.processed).toBe(false); - }); - - test('retry_count limit reached marks entry as processed', async () => { - process.env['STACKS_CORE_RPC_PORT'] = '11111'; // Make node unreachable - process.env['STACKS_API_TOKEN_METADATA_MAX_RETRIES'] = '0'; - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(1); - expect(entry.result?.processed).toBe(true); - }); - - test('strict mode ignores retry_count limit', async () => { - process.env['STACKS_CORE_RPC_PORT'] = '11111'; // Make node unreachable - process.env['STACKS_API_TOKEN_METADATA_STRICT_MODE'] = '1'; - process.env['STACKS_API_TOKEN_METADATA_MAX_RETRIES'] = '0'; - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(1); - expect(entry.result?.processed).toBe(false); - }); - - test('db errors are handled gracefully in contract handler', async () => { - process.env['STACKS_CORE_RPC_PORT'] = '11111'; // Make node unreachable - process.env['STACKS_API_TOKEN_METADATA_STRICT_MODE'] = '1'; - process.env['STACKS_API_TOKEN_METADATA_MAX_RETRIES'] = '0'; - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await db.close(); // End connection to trigger postgres error - await expect(handler.start()).resolves.not.toThrow(); - }); - - test('db errors are handled gracefully in queue', async () => { - const queue = new TokensProcessorQueue(db, ChainID.Testnet); - await db.close(); // End connection to trigger postgres error - await expect(queue.checkDbQueue()).resolves.not.toThrow(); - await expect(queue.drainDbQueue()).resolves.not.toThrow(); - await expect(queue.queueNotificationHandler(1)).resolves.not.toThrow(); - await expect( - queue.queueHandler({ queueId: 1, txId: '0x11', contractId: 'test' }) - ).resolves.not.toThrow(); - }); - - test('node runtime errors get retried', async () => { - const mockResponse = { - okay: false, - cause: 'Runtime(Foo(Bar))', - }; - nock('http://127.0.0.1:20443') - .post( - '/v2/contracts/call-read/SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD/project-indigo-act1/get-token-uri' - ) - .reply(200, mockResponse); - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(1); - expect(entry.result?.processed).toBe(false); - }); - - test('other node errors fail immediately', async () => { - const mockResponse = { - okay: false, - cause: 'Unchecked(Foo(Bar))', - }; - nock('http://127.0.0.1:20443') - .post( - '/v2/contracts/call-read/SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD/project-indigo-act1/get-token-uri' - ) - .reply(200, mockResponse); - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(0); - expect(entry.result?.processed).toBe(true); - }); - - test('clarity value parse errors get retried', async () => { - const mockResponse = { - okay: true, - result: cvToHex(uintCV(5)), // `get-token-uri` will fail because this is a `uint` - }; - nock('http://127.0.0.1:20443') - .post( - '/v2/contracts/call-read/SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD/project-indigo-act1/get-token-uri' - ) - .reply(200, mockResponse); - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(1); - expect(entry.result?.processed).toBe(false); - }); - - test('incorrect none uri strings are parsed as undefined', async () => { - const mockResponse = { - okay: true, - result: cvToHex(noneCV()), - }; - nock('http://127.0.0.1:20443') - .post( - '/v2/contracts/call-read/SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD/project-indigo-act1/get-token-uri' - ) - .reply(200, mockResponse); - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(0); - expect(entry.result?.processed).toBe(true); - const metadata = await db.getNftMetadata( - 'SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD.project-indigo-act1' - ); - expect(metadata.result?.token_uri).toEqual(''); - }); - - test('metadata timeout errors get retried immediately', async () => { - process.env['STACKS_API_TOKEN_METADATA_FETCH_TIMEOUT_MS'] = '500'; - const mockTokenUri = { - okay: true, - result: cvToHex(stringCV('http://indigo.com/nft.jpeg', 'ascii')), - }; - nock('http://127.0.0.1:20443') - .post( - '/v2/contracts/call-read/SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD/project-indigo-act1/get-token-uri' - ) - .reply(200, mockTokenUri); - // Timeout first time. - nock('http://indigo.com') - .get('/nft.jpeg') - .times(1) - .delay(getTokenMetadataFetchTimeoutMs() + 100) - .reply(200); - // Correct second time. - nock('http://indigo.com').get('/nft.jpeg').reply(200, {}); - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(0); - expect(entry.result?.processed).toBe(true); - }); - - test('metadata size exceeded errors fail immediately', async () => { - const mockTokenUri = { - okay: true, - result: cvToHex(stringCV('http://indigo.com/nft.jpeg', 'ascii')), - }; - nock('http://127.0.0.1:20443') - .post( - '/v2/contracts/call-read/SP176ZMV706NZGDDX8VSQRGMB7QN33BBDVZ6BMNHD/project-indigo-act1/get-token-uri' - ) - .reply(200, mockTokenUri); - const bigAssBuffer = Buffer.alloc(METADATA_MAX_PAYLOAD_BYTE_SIZE + 100); - nock('http://indigo.com').get('/nft.jpeg').reply(200, bigAssBuffer); - const handler = new TokensContractHandler({ - contractId: contractId, - smartContractAbi: NFT_CONTRACT_ABI, - datastore: db, - chainId: ChainID.Testnet, - txId: contractTxId, - dbQueueId: 1, - }); - await handler.start(); - const entry = await db.getTokenMetadataQueueEntry(1); - expect(entry.result?.retry_count).toEqual(0); - expect(entry.result?.processed).toBe(true); - - // Metadata still contains the rest of the data. - const metadata = await db.getNftMetadata(contractId); - expect(metadata.found).toBe(true); - expect(metadata.result?.token_uri).toBe('http://indigo.com/nft.jpeg'); - }); -}); diff --git a/src/tests-tokens-strict/teardown.ts b/src/tests-tokens-strict/teardown.ts deleted file mode 100644 index 53858b84..00000000 --- a/src/tests-tokens-strict/teardown.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defaultSetupTeardown } from '../test-utils/shared-setup'; - -// ts-unused-exports:disable-next-line -export default async () => { - console.log('Jest - teardown..'); - await defaultSetupTeardown(); - console.log('Jest - teardown done'); -}; diff --git a/src/tests/datastore-tests.ts b/src/tests/datastore-tests.ts index d08f8648..197ef2f1 100644 --- a/src/tests/datastore-tests.ts +++ b/src/tests/datastore-tests.ts @@ -18,8 +18,6 @@ import { DbBnsName, DbBnsSubdomain, DbTokenOfferingLocked, - DbNonFungibleTokenMetadata, - DbFungibleTokenMetadata, DbTx, } from '../datastore/common'; import { getBlocksWithMetadata, parseDbEvent } from '../api/controllers/db-controller'; @@ -4918,48 +4916,6 @@ describe('postgres datastore', () => { expect(results.found).toBe(false); }); - test('pg token nft-metadata', async () => { - const nftMetadata: DbNonFungibleTokenMetadata = { - token_uri: 'nft-tokenuri', - name: 'nft-metadata', - description: 'nft -metadata description', - image_uri: 'nft-metadata image uri example', - image_canonical_uri: 'nft-metadata image canonical uri example', - contract_id: 'ABCDEFGHIJ.nft-metadata', - tx_id: '0x1234', - sender_address: 'sender-addr-test', - }; - - const rowCount = await db.updateNFtMetadata(nftMetadata, 1); - expect(rowCount).toBe(1); - - const query = await db.getNftMetadata(nftMetadata.contract_id); - expect(query.found).toBe(true); - if (query.found) expect(query.result).toStrictEqual(nftMetadata); - }); - - test('pg token ft-metadata', async () => { - const ftMetadata: DbFungibleTokenMetadata = { - token_uri: 'ft-token', - name: 'ft-metadata', - description: 'ft -metadata description', - symbol: 'stx', - decimals: 5, - image_uri: 'ft-metadata image uri example', - image_canonical_uri: 'ft-metadata image canonical uri example', - contract_id: 'ABCDEFGHIJ.ft-metadata', - tx_id: '0x1234', - sender_address: 'sender-addr-test', - }; - - const rowCount = await db.updateFtMetadata(ftMetadata, 1); - expect(rowCount).toBe(1); - - const query = await db.getFtMetadata(ftMetadata.contract_id); - expect(query.found).toBe(true); - if (query.found) expect(query.result).toStrictEqual(ftMetadata); - }); - test('empty parameter lists are handled correctly', async () => { const block = new TestBlockBuilder({ block_height: 1 }).addTx().build(); await db.update(block); diff --git a/src/token-metadata/helpers.ts b/src/token-metadata/helpers.ts deleted file mode 100644 index 17154bae..00000000 --- a/src/token-metadata/helpers.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { ClarityAbi, ClarityAbiFunction } from '@stacks/transactions'; -import { - METADATA_MAX_PAYLOAD_BYTE_SIZE, - TokenMetadataErrorMode, - TokenMetadataProcessingMode, -} from './tokens-contract-handler'; -import fetch from 'node-fetch'; -import { parseBoolean } from '@hirosystems/api-toolkit'; - -export function isFtMetadataEnabled() { - const opt = process.env['STACKS_API_ENABLE_FT_METADATA']?.toLowerCase().trim(); - return opt === '1' || opt === 'true'; -} - -export function isNftMetadataEnabled() { - const opt = process.env['STACKS_API_ENABLE_NFT_METADATA']?.toLowerCase().trim(); - return opt === '1' || opt === 'true'; -} - -/** - * Determines the token metadata processing mode based on .env values. - * @returns TokenMetadataProcessingMode - */ -export function getTokenMetadataProcessingMode(): TokenMetadataProcessingMode { - if (parseBoolean(process.env['STACKS_API_TOKEN_METADATA_STRICT_MODE'])) { - return TokenMetadataProcessingMode.strict; - } - return TokenMetadataProcessingMode.default; -} - -export function getTokenMetadataMaxRetries() { - const opt = process.env['STACKS_API_TOKEN_METADATA_MAX_RETRIES'] ?? '5'; - return parseInt(opt); -} - -export function getTokenMetadataFetchTimeoutMs() { - const opt = process.env['STACKS_API_TOKEN_METADATA_FETCH_TIMEOUT_MS'] ?? '10000'; - return parseInt(opt); -} - -/** - * Determines the token metadata error handling mode based on .env values. - * @returns TokenMetadataMode - */ -export function tokenMetadataErrorMode(): TokenMetadataErrorMode { - switch (process.env['STACKS_API_TOKEN_METADATA_ERROR_MODE']) { - case 'error': - return TokenMetadataErrorMode.error; - default: - return TokenMetadataErrorMode.warning; - } -} - -const FT_FUNCTIONS: ClarityAbiFunction[] = [ - { - access: 'public', - args: [ - { type: 'uint128', name: 'amount' }, - { type: 'principal', name: 'sender' }, - { type: 'principal', name: 'recipient' }, - { type: { optional: { buffer: { length: 34 } } }, name: 'memo' }, - ], - name: 'transfer', - outputs: { type: { response: { ok: 'bool', error: 'uint128' } } }, - }, - { - access: 'read_only', - args: [], - name: 'get-name', - outputs: { type: { response: { ok: { 'string-ascii': { length: 32 } }, error: 'uint128' } } }, - }, - { - access: 'read_only', - args: [], - name: 'get-symbol', - outputs: { type: { response: { ok: { 'string-ascii': { length: 32 } }, error: 'uint128' } } }, - }, - { - access: 'read_only', - args: [], - name: 'get-decimals', - outputs: { type: { response: { ok: 'uint128', error: 'uint128' } } }, - }, - { - access: 'read_only', - args: [{ type: 'principal', name: 'address' }], - name: 'get-balance', - outputs: { type: { response: { ok: 'uint128', error: 'uint128' } } }, - }, - { - access: 'read_only', - args: [], - name: 'get-total-supply', - outputs: { type: { response: { ok: 'uint128', error: 'uint128' } } }, - }, - { - access: 'read_only', - args: [], - name: 'get-token-uri', - outputs: { - type: { - response: { - ok: { - optional: { 'string-ascii': { length: 256 } }, - }, - error: 'uint128', - }, - }, - }, - }, -]; - -const NFT_FUNCTIONS: ClarityAbiFunction[] = [ - { - access: 'read_only', - args: [], - name: 'get-last-token-id', - outputs: { - type: { - response: { - ok: 'uint128', - error: 'uint128', - }, - }, - }, - }, - { - access: 'read_only', - args: [{ name: 'any', type: 'uint128' }], - name: 'get-token-uri', - outputs: { - type: { - response: { - ok: { - optional: { 'string-ascii': { length: 256 } }, - }, - error: 'uint128', - }, - }, - }, - }, - { - access: 'read_only', - args: [{ type: 'uint128', name: 'any' }], - name: 'get-owner', - outputs: { - type: { - response: { - ok: { - optional: 'principal', - }, - error: 'uint128', - }, - }, - }, - }, - { - access: 'public', - args: [ - { type: 'uint128', name: 'id' }, - { type: 'principal', name: 'sender' }, - { type: 'principal', name: 'recipient' }, - ], - name: 'transfer', - outputs: { - type: { - response: { - ok: 'bool', - error: { - tuple: [ - { type: { 'string-ascii': { length: 32 } }, name: 'kind' }, - { type: 'uint128', name: 'code' }, - ], - }, - }, - }, - }, - }, -]; - -/** - * Checks if the given ABI contains functions from FT or NFT metadata standards (e.g. sip-09, sip-10) which can be resolved. - * The function also checks if the server has FT and/or NFT metadata processing enabled. - */ -export function isProcessableTokenMetadata(abi: ClarityAbi): boolean { - return ( - (isFtMetadataEnabled() && isCompliantFt(abi)) || (isNftMetadataEnabled() && isCompliantNft(abi)) - ); -} - -export function isCompliantNft(abi: ClarityAbi): boolean { - if (abi.non_fungible_tokens.length > 0) { - if (abiContains(abi, NFT_FUNCTIONS)) { - return true; - } - } - return false; -} - -export function isCompliantFt(abi: ClarityAbi): boolean { - if (abi.fungible_tokens.length > 0) { - if (abiContains(abi, FT_FUNCTIONS)) { - return true; - } - } - return false; -} - -/** - * This method check if the contract is compliance with sip-09 and sip-10 - * Ref: https://github.com/stacksgov/sips/tree/main/sips - */ -function abiContains(abi: ClarityAbi, standardFunction: ClarityAbiFunction[]): boolean { - return standardFunction.every(abiFun => findFunction(abiFun, abi.functions)); -} - -/** - * check if the fun exist in the function list - * @param fun - function to be found - * @param functionList - list of functions - * @returns - true if function is in the list false otherwise - */ -function findFunction(fun: ClarityAbiFunction, functionList: ClarityAbiFunction[]): boolean { - const found = functionList.find(standardFunction => { - if (standardFunction.name !== fun.name || standardFunction.args.length !== fun.args.length) - return false; - for (let i = 0; i < fun.args.length; i++) { - if (standardFunction.args[i].type.toString() !== fun.args[i].type.toString()) { - return false; - } - } - return true; - }); - return found !== undefined; -} - -export async function performFetch( - url: string, - opts?: { - timeoutMs?: number; - maxResponseBytes?: number; - } -): Promise { - const result = await fetch(url, { - size: opts?.maxResponseBytes ?? METADATA_MAX_PAYLOAD_BYTE_SIZE, - timeout: opts?.timeoutMs ?? getTokenMetadataFetchTimeoutMs(), - }); - if (!result.ok) { - let msg = ''; - try { - msg = await result.text(); - } catch (error) { - // ignore errors from fetching error text - } - throw new Error(`Response ${result.status}: ${result.statusText} fetching ${url} - ${msg}`); - } - const resultString = await result.text(); - try { - return JSON.parse(resultString) as Type; - } catch (error) { - throw new Error(`Error parsing response from ${url} as JSON: ${error}`); - } -} diff --git a/src/token-metadata/tokens-contract-handler.ts b/src/token-metadata/tokens-contract-handler.ts deleted file mode 100644 index 5a75b470..00000000 --- a/src/token-metadata/tokens-contract-handler.ts +++ /dev/null @@ -1,535 +0,0 @@ -import * as child_process from 'child_process'; -import { DbFungibleTokenMetadata, DbNonFungibleTokenMetadata } from '../datastore/common'; -import { - ClarityAbi, - ClarityType, - ClarityValue, - getAddressFromPrivateKey, - hexToCV, - makeRandomPrivKey, - TransactionVersion, - uintCV, - UIntCV, -} from '@stacks/transactions'; -import { ChainID, getChainIDNetwork, parseDataUrl, REPO_DIR } from '../helpers'; -import * as querystring from 'querystring'; -import { - getTokenMetadataFetchTimeoutMs, - getTokenMetadataMaxRetries, - getTokenMetadataProcessingMode, - isCompliantFt, - isCompliantNft, - performFetch, -} from './helpers'; -import { ReadOnlyContractCallResponse, StacksCoreRpcClient } from '../core-rpc/client'; -import { FetchError } from 'node-fetch'; -import { PgWriteStore } from '../datastore/pg-write-store'; -import { logger } from '../logger'; -import { stopwatch } from '@hirosystems/api-toolkit'; - -/** - * The maximum number of bytes of metadata to fetch. - * If the fetch encounters more bytes than this limit it throws and the metadata is not processed. - */ -export const METADATA_MAX_PAYLOAD_BYTE_SIZE = 1_000_000; // 1 megabyte - -/** - * The max number of immediate attempts that will be made to retrieve metadata from external URIs before declaring - * the failure as a non-retryable error. - */ -const METADATA_MAX_IMMEDIATE_RETRY_COUNT = 5; - -const PUBLIC_IPFS = 'https://ipfs.io'; - -export enum TokenMetadataProcessingMode { - /** If a recoverable processing error occurs, we'll try again until the max retry attempt is reached. See `.env` */ - default, - /** If a recoverable processing error occurs, we'll try again indefinitely. */ - strict, -} - -export enum TokenMetadataErrorMode { - /** Default mode. If a required token metadata is not found when it is needed for a response, the API will issue a warning. */ - warning, - /** If a required token metadata is not found, the API will throw an error. */ - error, -} - -/** - * A token metadata fetch/process error caused by something that we can try to do again later. - */ -class RetryableTokenMetadataError extends Error { - constructor(message: string) { - super(message); - this.message = message; - this.name = this.constructor.name; - } -} - -interface NftTokenMetadata { - name: string; - imageUri: string; - description: string; -} - -interface FtTokenMetadata { - name: string; - imageUri: string; - description: string; -} - -interface TokenHandlerArgs { - contractId: string; - smartContractAbi: ClarityAbi; - datastore: PgWriteStore; - chainId: ChainID; - txId: string; - dbQueueId: number; -} - -/** - * This class downloads, parses and indexes metadata info for a Fungible or Non-Fungible token in the Stacks blockchain - * by calling read-only functions in SIP-009 and SIP-010 compliant smart contracts. - */ -export class TokensContractHandler { - readonly contractAddress: string; - readonly contractName: string; - readonly contractId: string; - readonly txId: string; - readonly dbQueueId: number; - private readonly db: PgWriteStore; - private readonly randomPrivKey = makeRandomPrivKey(); - private readonly chainId: ChainID; - private readonly address: string; - private readonly tokenKind: 'ft' | 'nft'; - private readonly nodeRpcClient: StacksCoreRpcClient; - - constructor(args: TokenHandlerArgs) { - [this.contractAddress, this.contractName] = args.contractId.split('.'); - this.contractId = args.contractId; - this.db = args.datastore; - this.chainId = args.chainId; - this.txId = args.txId; - this.dbQueueId = args.dbQueueId; - this.nodeRpcClient = new StacksCoreRpcClient(); - - this.address = getAddressFromPrivateKey( - this.randomPrivKey.data, - getChainIDNetwork(this.chainId) === 'mainnet' - ? TransactionVersion.Mainnet - : TransactionVersion.Testnet - ); - if (isCompliantFt(args.smartContractAbi)) { - this.tokenKind = 'ft'; - } else if (isCompliantNft(args.smartContractAbi)) { - this.tokenKind = 'nft'; - } else { - throw new Error( - `TokenContractHandler passed an ABI that isn't compliant to FT or NFT standards` - ); - } - } - - async start() { - logger.info( - `[token-metadata] found ${ - this.tokenKind === 'ft' ? 'sip-010-ft-standard' : 'sip-009-nft-standard' - } compliant contract ${this.contractId} in tx ${this.txId}, begin retrieving metadata...` - ); - const sw = stopwatch(); - // This try/catch block will catch any and all errors that are generated while processing metadata - // (contract call errors, parse errors, timeouts, etc.). Fortunately, each of them were previously tagged - // as retryable or not retryable so we'll make a decision here about what to do in each case. - // If we choose to retry, this queue entry will simply not be marked as `processed = true` so it can be - // picked up by the `TokensProcessorQueue` at a later time. - let processingFinished = false; - try { - if (this.tokenKind === 'ft') { - await this.handleFtContract(); - } else if (this.tokenKind === 'nft') { - await this.handleNftContract(); - } - processingFinished = true; - } catch (error) { - if (error instanceof RetryableTokenMetadataError) { - try { - const retries = await this.db.increaseTokenMetadataQueueEntryRetryCount(this.dbQueueId); - if ( - getTokenMetadataProcessingMode() === TokenMetadataProcessingMode.strict || - retries <= getTokenMetadataMaxRetries() - ) { - logger.info( - `[token-metadata] a recoverable error happened while processing ${this.contractId}, trying again later: ${error}` - ); - } else { - logger.warn( - `[token-metadata] max retries reached while processing ${this.contractId}, giving up: ${error}` - ); - processingFinished = true; - } - } catch (error) { - logger.error(error); - processingFinished = true; - } - } else { - // Something more serious happened, mark this contract as done. - logger.error(error); - processingFinished = true; - } - } finally { - if (processingFinished) { - try { - await this.db.updateProcessedTokenMetadataQueueEntry(this.dbQueueId); - logger.info( - `[token-metadata] finished processing ${this.contractId} in ${sw.getElapsed()} ms` - ); - } catch (error) { - logger.error(error); - } - } - } - } - - /** - * fetch Fungible contract metadata - */ - private async handleFtContract() { - const contractCallName = await this.readStringFromContract('get-name'); - const contractCallUri = await this.readStringFromContract('get-token-uri'); - const contractCallSymbol = await this.readStringFromContract('get-symbol'); - - let contractCallDecimals: number | undefined; - const decimalsResult = await this.readUIntFromContract('get-decimals'); - if (decimalsResult) { - contractCallDecimals = Number(decimalsResult.toString()); - } - - let metadata: FtTokenMetadata | undefined; - if (contractCallUri) { - try { - metadata = await this.getMetadataFromUri(contractCallUri); - metadata = this.patchTokenMetadataImageUri(metadata); - } catch (error) { - // An unavailable external service failed to provide reasonable data (images, etc.). - // We will ignore these and fill out the remaining SIP-compliant metadata. - logger.warn( - `[token-metadata] ft metadata fetch error while processing ${this.contractId}: ${error}` - ); - } - } - let imgUrl: string | undefined; - if (metadata?.imageUri) { - const normalizedUrl = this.getImageUrl(metadata.imageUri); - imgUrl = await this.processImageUrl(normalizedUrl); - } - - const fungibleTokenMetadata: DbFungibleTokenMetadata = { - token_uri: contractCallUri ?? '', - name: contractCallName ?? metadata?.name ?? '', // prefer the on-chain name - description: metadata?.description ?? '', - image_uri: imgUrl ?? '', - image_canonical_uri: metadata?.imageUri ?? '', - symbol: contractCallSymbol ?? '', - decimals: contractCallDecimals ?? 0, - contract_id: this.contractId, - tx_id: this.txId, - sender_address: this.contractAddress, - }; - await this.db.updateFtMetadata(fungibleTokenMetadata, this.dbQueueId); - } - - /** - * fetch Non Fungible contract metadata - */ - private async handleNftContract() { - // TODO: This is incorrectly attempting to fetch the metadata for a specific - // NFT and applying it to the entire NFT type/contract. A new SIP needs created - // to define how generic metadata for an NFT type/contract should be retrieved. - // In the meantime, this will often fail or result in weird data, but at least - // the NFT type enumeration endpoints will have data like the contract ID and txid. - - // TODO: this should instead use the SIP-012 draft https://github.com/stacksgov/sips/pull/18 - // function `(get-nft-meta () (response (optional {name: (string-uft8 30), image: (string-ascii 255)}) uint))` - - let metadata: NftTokenMetadata | undefined; - const contractCallUri = await this.readStringFromContract('get-token-uri', [uintCV(0)]); - if (contractCallUri) { - try { - metadata = await this.getMetadataFromUri(contractCallUri); - metadata = this.patchTokenMetadataImageUri(metadata); - } catch (error) { - // An unavailable external service failed to provide reasonable data (images, etc.). - // We will ignore these and fill out the remaining SIP-compliant metadata. - logger.warn( - `[token-metadata] nft metadata fetch error while processing ${this.contractId}: ${error}` - ); - } - } - let imgUrl: string | undefined; - if (metadata?.imageUri) { - const normalizedUrl = this.getImageUrl(metadata.imageUri); - imgUrl = await this.processImageUrl(normalizedUrl); - } - - const nonFungibleTokenMetadata: DbNonFungibleTokenMetadata = { - token_uri: contractCallUri ?? '', - name: metadata?.name ?? '', - description: metadata?.description ?? '', - image_uri: imgUrl ?? '', - image_canonical_uri: metadata?.imageUri ?? '', - contract_id: `${this.contractId}`, - tx_id: this.txId, - sender_address: this.contractAddress, - }; - await this.db.updateNFtMetadata(nonFungibleTokenMetadata, this.dbQueueId); - } - - /** - * Token metadata schema for 'image uri' is not well defined or adhered to. - * This function looks for a handful of possible properties that could be used to - * specify the image, and returns a metadata object with a normalized image property. - */ - private patchTokenMetadataImageUri(metadata: T): T { - // compare using lowercase - const allowedImageProperties = ['image', 'imageurl', 'imageuri', 'image_url', 'image_uri']; - const objectKeys = new Map(Object.keys(metadata).map(prop => [prop.toLowerCase(), prop])); - for (const possibleProp of allowedImageProperties) { - const existingProp = objectKeys.get(possibleProp); - if (existingProp) { - const imageUriVal = (metadata as Record)[existingProp]; - if (typeof imageUriVal !== 'string') { - continue; - } - return { - ...metadata, - imageUri: imageUriVal, - }; - } - } - return { ...metadata }; - } - - /** - * If an external image processor script is configured, then it will process the given image URL for the purpose - * of caching on a CDN (or whatever else it may be created to do). The script is expected to return a new URL - * for the image. - * If the script is not configured, then the original URL is returned immediately. - * If a data-uri is passed, it is also immediately returned without being passed to the script. - */ - private async processImageUrl(imgUrl: string): Promise { - const imageCacheProcessor = process.env['STACKS_API_IMAGE_CACHE_PROCESSOR']; - if (!imageCacheProcessor) { - return imgUrl; - } - if (imgUrl.startsWith('data:')) { - return imgUrl; - } - const { code, stdout, stderr } = await new Promise<{ - code: number; - stdout: string; - stderr: string; - }>((resolve, reject) => { - const cp = child_process.spawn(imageCacheProcessor, [imgUrl], { cwd: REPO_DIR }); - let stdout = ''; - let stderr = ''; - cp.stdout.on('data', data => (stdout += data)); - cp.stderr.on('data', data => (stderr += data)); - cp.on('close', code => resolve({ code: code ?? 0, stdout, stderr })); - cp.on('error', error => reject(error)); - }); - if (code !== 0 && stderr) { - console.warn(`[token-metadata] stderr from STACKS_API_IMAGE_CACHE_PROCESSOR: ${stderr}`); - } - const result = stdout.trim(); - try { - const url = new URL(result); - return url.toString(); - } catch (error) { - throw new Error( - `Image processing script returned an invalid url for ${imgUrl}: ${result}, stderr: ${stderr}` - ); - } - } - - /** - * Helper method for creating http/s url for supported protocols. - * URLs with `http` or `https` protocols are returned as-is. - * URLs with `ipfs` or `ipns` protocols are returned with as an `https` url - * using a public IPFS gateway. - */ - private getFetchableUrl(uri: string): URL { - const parsedUri = new URL(uri); - if (parsedUri.protocol === 'http:' || parsedUri.protocol === 'https:') return parsedUri; - if (parsedUri.protocol === 'ipfs:') - return new URL(`${PUBLIC_IPFS}/${parsedUri.host}${parsedUri.pathname}`); - - if (parsedUri.protocol === 'ipns:') - return new URL(`${PUBLIC_IPFS}/${parsedUri.host}${parsedUri.pathname}`); - - throw new Error(`Unsupported uri protocol: ${uri}`); - } - - private getImageUrl(uri: string): string { - // Support images embedded in a Data URL - if (new URL(uri).protocol === 'data:') { - // const dataUrl = ParseDataUrl(uri); - const dataUrl = parseDataUrl(uri); - if (!dataUrl) { - throw new Error(`Data URL could not be parsed: ${uri}`); - } - if (!dataUrl.mediaType?.startsWith('image/')) { - throw new Error(`Token image is a Data URL with a non-image media type: ${uri}`); - } - return uri; - } - const fetchableUrl = this.getFetchableUrl(uri); - return fetchableUrl.toString(); - } - - /** - * Fetch metadata from uri - */ - private async getMetadataFromUri(token_uri: string): Promise { - // Support JSON embedded in a Data URL - if (new URL(token_uri).protocol === 'data:') { - const dataUrl = parseDataUrl(token_uri); - if (!dataUrl) { - throw new Error(`Data URL could not be parsed: ${token_uri}`); - } - let content: string; - // If media type is omitted it should default to percent-encoded `text/plain;charset=US-ASCII` - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs#syntax - // If media type is specified but without base64 then encoding is ambiguous, so check for - // percent-encoding or assume a literal string compatible with utf8. Because we're expecting - // a JSON object we can reliable check for a leading `%` char, otherwise assume unescaped JSON. - if (dataUrl.base64) { - content = Buffer.from(dataUrl.data, 'base64').toString('utf8'); - } else if (dataUrl.data.startsWith('%')) { - content = querystring.unescape(dataUrl.data); - } else { - content = dataUrl.data; - } - try { - return JSON.parse(content) as Type; - } catch (error) { - throw new Error(`Data URL could not be parsed as JSON: ${token_uri}`); - } - } - const httpUrl = this.getFetchableUrl(token_uri); - - let fetchImmediateRetryCount = 0; - let result: Type | undefined; - // We'll try to fetch metadata and give it `METADATA_MAX_IMMEDIATE_RETRY_COUNT` attempts - // for the external service to return a reasonable response, otherwise we'll consider the - // metadata as dead. - do { - try { - result = await performFetch(httpUrl.toString(), { - timeoutMs: getTokenMetadataFetchTimeoutMs(), - maxResponseBytes: METADATA_MAX_PAYLOAD_BYTE_SIZE, - }); - break; - } catch (error) { - fetchImmediateRetryCount++; - if ( - (error instanceof FetchError && error.type === 'max-size') || - fetchImmediateRetryCount >= METADATA_MAX_IMMEDIATE_RETRY_COUNT - ) { - throw error; - } - } - } while (fetchImmediateRetryCount < METADATA_MAX_IMMEDIATE_RETRY_COUNT); - if (result) { - return result; - } - throw new Error(`Unable to fetch metadata from ${token_uri}`); - } - - private async makeReadOnlyContractCall( - functionName: string, - functionArgs: ClarityValue[] - ): Promise { - let result: ReadOnlyContractCallResponse; - try { - result = await this.nodeRpcClient.sendReadOnlyContractCall( - this.contractAddress, - this.contractName, - functionName, - this.address, - functionArgs - ); - } catch (error) { - throw new RetryableTokenMetadataError(`Error making read-only contract call: ${error}`); - } - if (!result.okay) { - // Only runtime errors reported by the Stacks node should be retryable. - if ( - result.cause.startsWith('Runtime') || - result.cause.startsWith('Unchecked(NoSuchContract') - ) { - throw new RetryableTokenMetadataError( - `Runtime error while calling read-only function ${functionName}` - ); - } - throw new Error(`Error calling read-only function ${functionName}`); - } - return hexToCV(result.result); - } - - private async readStringFromContract( - functionName: string, - functionArgs: ClarityValue[] = [] - ): Promise { - const clarityValue = await this.makeReadOnlyContractCall(functionName, functionArgs); - return this.checkAndParseString(clarityValue); - } - - private async readUIntFromContract( - functionName: string, - functionArgs: ClarityValue[] = [] - ): Promise { - const clarityValue = await this.makeReadOnlyContractCall(functionName, functionArgs); - const uintVal = this.checkAndParseUintCV(clarityValue); - try { - return BigInt(uintVal.value.toString()); - } catch (error) { - throw new RetryableTokenMetadataError(`Invalid uint value '${uintVal}'`); - } - } - - private unwrapClarityType(clarityValue: ClarityValue): ClarityValue { - let unwrappedClarityValue: ClarityValue = clarityValue; - while ( - unwrappedClarityValue.type === ClarityType.ResponseOk || - unwrappedClarityValue.type === ClarityType.OptionalSome - ) { - unwrappedClarityValue = unwrappedClarityValue.value; - } - return unwrappedClarityValue; - } - - private checkAndParseUintCV(responseCV: ClarityValue): UIntCV { - const unwrappedClarityValue = this.unwrapClarityType(responseCV); - if (unwrappedClarityValue.type === ClarityType.UInt) { - return unwrappedClarityValue; - } - throw new RetryableTokenMetadataError( - `Unexpected Clarity type '${unwrappedClarityValue.type}' while unwrapping uint` - ); - } - - private checkAndParseString(responseCV: ClarityValue): string | undefined { - const unwrappedClarityValue = this.unwrapClarityType(responseCV); - if ( - unwrappedClarityValue.type === ClarityType.StringASCII || - unwrappedClarityValue.type === ClarityType.StringUTF8 - ) { - return unwrappedClarityValue.data; - } else if (unwrappedClarityValue.type === ClarityType.OptionalNone) { - return undefined; - } - throw new RetryableTokenMetadataError( - `Unexpected Clarity type '${unwrappedClarityValue.type}' while unwrapping string` - ); - } -} diff --git a/src/token-metadata/tokens-processor-queue.ts b/src/token-metadata/tokens-processor-queue.ts deleted file mode 100644 index 2596e41a..00000000 --- a/src/token-metadata/tokens-processor-queue.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { ChainID, FoundOrNot } from '../helpers'; -import { Evt } from 'evt'; -import PQueue from 'p-queue'; -import { DbTokenMetadataQueueEntry, TokenMetadataUpdateInfo } from '../datastore/common'; -import { ClarityAbi } from '@stacks/transactions'; -import { TokensContractHandler } from './tokens-contract-handler'; -import { PgWriteStore } from '../datastore/pg-write-store'; -import { logger } from '../logger'; - -/** - * The maximum number of token metadata parsing operations that can be ran concurrently before - * being added to a FIFO queue. - */ -const TOKEN_METADATA_PARSING_CONCURRENCY_LIMIT = 5; - -export class TokensProcessorQueue { - readonly queue: PQueue; - readonly db: PgWriteStore; - readonly chainId: ChainID; - - readonly processStartedEvent: Evt<{ - contractId: string; - txId: string; - }> = new Evt(); - - readonly processEndEvent: Evt<{ - contractId: string; - txId: string; - }> = new Evt(); - - /** The entries currently queued for processing in memory, keyed by the queue entry db id. */ - readonly queuedEntries: Map = new Map(); - - readonly onTokenMetadataUpdateQueued: (queueId: number) => void; - readonly onBlockUpdate: (blockHash: string) => void; - - constructor(db: PgWriteStore, chainId: ChainID) { - this.db = db; - this.chainId = chainId; - this.queue = new PQueue({ concurrency: TOKEN_METADATA_PARSING_CONCURRENCY_LIMIT }); - this.onTokenMetadataUpdateQueued = entry => this.queueNotificationHandler(entry); - this.db.eventEmitter.on('tokenMetadataUpdateQueued', this.onTokenMetadataUpdateQueued); - this.onBlockUpdate = blockHash => this.blockNotificationHandler(blockHash); - this.db.eventEmitter.on('blockUpdate', this.onBlockUpdate); - } - - close() { - this.db.eventEmitter.off('tokenMetadataUpdateQueued', this.onTokenMetadataUpdateQueued); - this.db.eventEmitter.off('blockUpdate', this.onBlockUpdate); - this.queue.pause(); - this.queue.clear(); - } - - async drainDbQueue(): Promise { - let entries: DbTokenMetadataQueueEntry[] = []; - do { - if (this.queue.isPaused) { - return; - } - const queuedEntries = [...this.queuedEntries.keys()]; - try { - entries = await this.db.getTokenMetadataQueue( - TOKEN_METADATA_PARSING_CONCURRENCY_LIMIT, - queuedEntries - ); - } catch (error) { - logger.error(error); - } - for (const entry of entries) { - await this.queueHandler(entry); - } - await this.queue.onEmpty(); - } while (entries.length > 0 || this.queuedEntries.size > 0); - } - - async checkDbQueue(): Promise { - if (this.queue.isPaused) { - return; - } - const queuedEntries = [...this.queuedEntries.keys()]; - const limit = TOKEN_METADATA_PARSING_CONCURRENCY_LIMIT - this.queuedEntries.size; - if (limit > 0) { - let entries: DbTokenMetadataQueueEntry[]; - try { - entries = await this.db.getTokenMetadataQueue( - TOKEN_METADATA_PARSING_CONCURRENCY_LIMIT, - queuedEntries - ); - } catch (error) { - logger.error(error); - return; - } - for (const entry of entries) { - await this.queueHandler(entry); - } - } - } - - async queueNotificationHandler(queueId: number) { - let queueEntry: FoundOrNot; - try { - queueEntry = await this.db.getTokenMetadataQueueEntry(queueId); - } catch (error) { - logger.error(error); - return; - } - if (queueEntry.found) { - await this.queueHandler(queueEntry.result); - } - } - - async blockNotificationHandler(_: string) { - await this.checkDbQueue(); - } - - async queueHandler(queueEntry: TokenMetadataUpdateInfo) { - if ( - this.queuedEntries.has(queueEntry.queueId) || - this.queuedEntries.size >= this.queue.concurrency - ) { - return; - } - let abi: string; - try { - const contractQuery = await this.db.getSmartContract(queueEntry.contractId); - if (!contractQuery.found || !contractQuery.result.abi) { - return; - } - abi = contractQuery.result.abi; - } catch (error) { - logger.error(error); - return; - } - logger.info( - `[token-metadata] queueing token contract for processing: ${queueEntry.contractId} from tx ${queueEntry.txId}` - ); - this.queuedEntries.set(queueEntry.queueId, queueEntry); - - const contractAbi: ClarityAbi = JSON.parse(abi); - - const tokenContractHandler = new TokensContractHandler({ - contractId: queueEntry.contractId, - smartContractAbi: contractAbi, - datastore: this.db, - chainId: this.chainId, - txId: queueEntry.txId, - dbQueueId: queueEntry.queueId, - }); - - void this.queue - .add(async () => { - this.processStartedEvent.post({ - contractId: queueEntry.contractId, - txId: queueEntry.txId, - }); - await tokenContractHandler.start(); - }) - .catch(error => { - logger.error( - error, - `[token-metadata] error processing token contract: ${tokenContractHandler.contractAddress} ${tokenContractHandler.contractName} from tx ${tokenContractHandler.txId}` - ); - }) - .finally(() => { - this.queuedEntries.delete(queueEntry.queueId); - this.processEndEvent.post({ - contractId: queueEntry.contractId, - txId: queueEntry.txId, - }); - if (this.queuedEntries.size < this.queue.concurrency) { - void this.checkDbQueue(); - } - }); - } -} diff --git a/tests/jest.config.tokens-metadata.js b/tests/jest.config.tokens-metadata.js deleted file mode 100644 index b6fa9876..00000000 --- a/tests/jest.config.tokens-metadata.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - preset: 'ts-jest', - rootDir: `${require('path').dirname(__dirname)}/src`, - testMatch: ['/tests-tokens-metadata/*.ts'], - testPathIgnorePatterns: ['/tests-tokens-metadata/setup.ts', '/tests-tokens-metadata/teardown.ts'], - collectCoverageFrom: ['/**/*.ts'], - coveragePathIgnorePatterns: ['/tests*'], - coverageDirectory: '/../coverage', - globalSetup: '/tests-tokens-metadata/setup.ts', - globalTeardown: '/tests-tokens-metadata/teardown.ts', - testTimeout: 60000, - verbose: true, - } diff --git a/tests/jest.config.tokens-strict.js b/tests/jest.config.tokens-strict.js deleted file mode 100644 index 419d8e04..00000000 --- a/tests/jest.config.tokens-strict.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - preset: 'ts-jest', - rootDir: `${require('path').dirname(__dirname)}/src`, - testMatch: ['/tests-tokens-strict/*.ts'], - testPathIgnorePatterns: ['/tests-tokens-strict/setup.ts', '/tests-tokens-strict/teardown.ts'], - collectCoverageFrom: ['/**/*.ts'], - coveragePathIgnorePatterns: ['/tests*'], - coverageDirectory: '/../coverage', - globalSetup: '/tests-tokens-strict/setup.ts', - globalTeardown: '/tests-tokens-strict/teardown.ts', - testTimeout: 60000, - verbose: true, - } diff --git a/tests/jest.config.tokens.js b/tests/jest.config.tokens.js deleted file mode 100644 index c84cd3d6..00000000 --- a/tests/jest.config.tokens.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - preset: 'ts-jest', - rootDir: `${require('path').dirname(__dirname)}/src`, - testMatch: ['/tests-tokens/*.ts'], - testPathIgnorePatterns: ['/tests-tokens/setup.ts', '/tests-tokens/teardown.ts'], - collectCoverageFrom: ['/**/*.ts'], - coveragePathIgnorePatterns: ['/tests*'], - coverageDirectory: '/../coverage', - globalSetup: '/tests-tokens/setup.ts', - globalTeardown: '/tests-tokens/teardown.ts', - testTimeout: 60000, - verbose: true, - }