diff --git a/migrations/20240912154500_add_bns_v2_names_table.js b/migrations/20240912154500_add_bns_v2_names_table.js new file mode 100644 index 0000000000..cd312a5802 --- /dev/null +++ b/migrations/20240912154500_add_bns_v2_names_table.js @@ -0,0 +1,106 @@ +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.up = pgm => { + pgm.createTable('names_v2', { + id: { + type: 'serial', + primaryKey: true, + }, + fullName: { + type: 'string', + notNull: true, + }, + name: { + type: 'string', + notNull: true, + }, + namespace_id: { + type: 'string', + notNull: true, + }, + registered_at: { + type: 'integer', + notNull: false, + }, + imported_at: { + type: 'integer', + notNull: false, + }, + hashed_salted_fqn_preorder: { + type: 'string', + notNull: false, + }, + preordered_by: { + type: 'string', + notNull: false, + }, + renewal_height: { + type: 'integer', + notNull: true, + }, + stx_burn: { + type: 'bigint', + notNull: true, + }, + owner: { + type: 'string', + notNull: true, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + tx_index: { + type: 'smallint', + notNull: true, + }, + event_index: 'integer', + status: { + type: 'string', + notNull: false, + }, + canonical: { + type: 'boolean', + notNull: true, + default: true, + }, + index_block_hash: { + type: 'bytea', + notNull: true, + }, + parent_index_block_hash: { + type: 'bytea', + notNull: true, + }, + microblock_hash: { + type: 'bytea', + notNull: true, + }, + microblock_sequence: { + type: 'integer', + notNull: true, + }, + microblock_canonical: { + type: 'boolean', + notNull: true, + }, + }); + + pgm.createIndex('names_v2', 'namespace_id'); + pgm.createIndex('names_v2', 'index_block_hash'); + pgm.createIndex('names_v2', [ + { name: 'registered_at', sort: 'DESC' }, + { name: 'microblock_sequence', sort: 'DESC' }, + { name: 'tx_index', sort: 'DESC' }, + { name: 'event_index', sort: 'DESC' }, + ]); + pgm.addConstraint( + 'names_v2', + 'unique_name_v2_tx_id_index_block_hash_microblock_hash_event_index', + 'UNIQUE(fullName, tx_id, index_block_hash, microblock_hash, event_index)' + ); + pgm.addConstraint('names_v2', 'unique_fullname', 'UNIQUE(fullName)'); +}; + +exports.down = pgm => { + pgm.dropTable('names_v2'); +}; diff --git a/migrations/20240912154500_add_bns_v2_namespaces_table.js b/migrations/20240912154500_add_bns_v2_namespaces_table.js new file mode 100644 index 0000000000..e67f356c77 --- /dev/null +++ b/migrations/20240912154500_add_bns_v2_namespaces_table.js @@ -0,0 +1,123 @@ +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.up = pgm => { + pgm.createTable('namespaces_v2', { + id: { + type: 'serial', + primaryKey: true, + }, + namespace_id: { + type: 'string', + notNull: true, + }, + namespace_manager: { + type: 'string', + notNull: false, + }, + manager_transferable: { + type: 'boolean', + notNull: true, + }, + manager_frozen: { + type: 'boolean', + notNull: true, + }, + namespace_import: { + type: 'string', + notNull: true, + }, + reveal_block: { + type: 'integer', + notNull: true, + }, + launched_at: { + type: 'integer', + notNull: false, + }, + launch_block: { + type: 'integer', + notNull: true, + }, + lifetime: { + type: 'integer', + notNull: true, + }, + can_update_price_function: { + type: 'boolean', + notNull: true, + }, + buckets: { + type: 'string', + notNull: true, + }, + base: { + type: 'numeric', + notNull: true, + }, + coeff: { + type: 'numeric', + notNull: true, + }, + nonalpha_discount: { + type: 'numeric', + notNull: true, + }, + no_vowel_discount: { + type: 'numeric', + notNull: true, + }, + status: { + type: 'string', + notNull: false, + }, + tx_id: { + type: 'bytea', + notNull: true, + }, + tx_index: { + type: 'smallint', + notNull: true, + }, + canonical: { + type: 'boolean', + notNull: true, + default: true, + }, + index_block_hash: { + type: 'bytea', + notNull: true, + }, + parent_index_block_hash: { + type: 'bytea', + notNull: true, + }, + microblock_hash: { + type: 'bytea', + notNull: true, + }, + microblock_sequence: { + type: 'integer', + notNull: true, + }, + microblock_canonical: { + type: 'boolean', + notNull: true, + }, + }); + + pgm.createIndex('namespaces_v2', 'index_block_hash'); + pgm.createIndex('namespaces_v2', [ + { name: 'launch_block', sort: 'DESC' }, + { name: 'microblock_sequence', sort: 'DESC' }, + { name: 'tx_index', sort: 'DESC' }, + ]); + pgm.addConstraint( + 'namespaces_v2', + 'unique_namespace_v2_id_tx_id_index_block_hash_microblock_hash', + 'UNIQUE(namespace_id, tx_id, index_block_hash, microblock_hash)' + ); + pgm.addConstraint('namespaces_v2', 'unique_namespace_id', 'UNIQUE(namespace_id)'); +}; + +exports.down = pgm => { + pgm.dropTable('namespaces_v2'); +}; diff --git a/src/api/init.ts b/src/api/init.ts index 84d21429cd..d8d3993a3d 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -57,6 +57,11 @@ import FastifyMetrics from 'fastify-metrics'; import FastifyCors from '@fastify/cors'; import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import * as promClient from 'prom-client'; +import { BnsV2NameRoutes } from './routes/bnsV2/names'; +import { BnsV2NamespaceRoutes } from './routes/bnsV2/namespaces'; +import { BnsV2AddressRoutes } from './routes/bnsV2/addresses'; +import { BnsV2PriceRoutes } from './routes/bnsV2/pricing'; +import { BnsV2ReadRoutes } from './routes/bnsV2/reads'; export interface ApiServer { fastifyApp: FastifyInstance; @@ -112,6 +117,11 @@ export const StacksApiRoutes: FastifyPluginAsync< await fastify.register(BnsNamespaceRoutes, { prefix: '/v1/namespaces' }); await fastify.register(BnsAddressRoutes, { prefix: '/v1/addresses' }); await fastify.register(BnsPriceRoutes, { prefix: '/v2/prices' }); + await fastify.register(BnsV2NameRoutes, { prefix: '/v2/names' }); + await fastify.register(BnsV2NamespaceRoutes, { prefix: '/v2/namespaces' }); + await fastify.register(BnsV2AddressRoutes, { prefix: '/v2/addresses' }); + await fastify.register(BnsV2ReadRoutes, { prefix: '/v2/read' }); + await fastify.register(BnsV2PriceRoutes, { prefix: '/v3/prices' }); await Promise.resolve(); }; diff --git a/src/api/routes/bnsV2/addresses.ts b/src/api/routes/bnsV2/addresses.ts new file mode 100644 index 0000000000..339b5f6246 --- /dev/null +++ b/src/api/routes/bnsV2/addresses.ts @@ -0,0 +1,80 @@ +import { handleChainTipCache } from '../../../api/controllers/cache-controller'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { UnanchoredParamSchema } from '../../schemas/params'; +import { InvalidRequestError, InvalidRequestErrorType } from '../../../errors'; + +const SUPPORTED_BLOCKCHAINS = ['stacks']; + +export const BnsV2AddressRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/:blockchain/:address', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_names_owned_by_address', + summary: 'Get Names Owned by Address', + description: `Retrieves a list of names owned by the address provided.`, + tags: ['Names'], + params: Type.Object({ + blockchain: Type.String({ + description: 'The layer-1 blockchain for the address', + examples: ['stacks'], + }), + address: Type.String({ + description: 'The address to lookup', + examples: ['SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7'], + }), + }), + querystring: Type.Object({ + unanchored: UnanchoredParamSchema, + }), + response: { + 200: Type.Object( + { + names: Type.Array( + Type.String({ + examples: ['muneeb.id'], + }) + ), + }, + { + title: 'BnsNamesOwnByAddressResponse', + description: 'Retrieves a list of names owned by the address provided.', + } + ), + }, + }, + }, + async (req, reply) => { + const { blockchain, address } = req.params; + + if (!SUPPORTED_BLOCKCHAINS.includes(blockchain)) { + throw new InvalidRequestError( + 'Unsupported blockchain', + InvalidRequestErrorType.bad_request + ); + } + + const includeUnanchored = req.query.unanchored ?? false; + + const namesV2ByAddress = await fastify.db.getNamesV2ByAddressList({ + address: address, + includeUnanchored, + chainId: fastify.chainId, + }); + + if (namesV2ByAddress.found) { + await reply.send({ names: namesV2ByAddress.result }); + } else { + await reply.send({ names: [] }); + } + } + ); + await Promise.resolve(); +}; diff --git a/src/api/routes/bnsV2/names.ts b/src/api/routes/bnsV2/names.ts new file mode 100644 index 0000000000..fb9fa98ac7 --- /dev/null +++ b/src/api/routes/bnsV2/names.ts @@ -0,0 +1,133 @@ +import { parsePagingQueryInput } from '../../../api/pagination'; +import { bnsBlockchain, BnsErrors } from '../../../event-stream/bns/bns-constants'; +import { handleChainTipCache } from '../../../api/controllers/cache-controller'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { UnanchoredParamSchema } from '../../schemas/params'; + +class NameRedirectError extends Error { + constructor(message: string) { + super(message); + this.message = message; + this.name = this.constructor.name; + } +} + +export const BnsV2NameRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_all_names', + summary: 'Get All Names', + description: `Retrieves a list of all names known to the node.`, + tags: ['Names'], + querystring: Type.Object({ + unanchored: UnanchoredParamSchema, + page: Type.Optional( + Type.Integer({ + minimum: 0, + default: 0, + description: + "names are defaulted to page 1 with 100 results. You can query specific page results by using the 'page' query parameter.", + }) + ), + }), + response: { + 200: Type.Array(Type.String(), { + title: 'BnsGetAllNamesResponse', + description: 'Fetch a list of all names known to the node.', + examples: [ + 'aldenquimby.id', + 'aldeoryn.id', + 'alderete.id', + 'aldert.id', + 'aldi.id', + 'aldighieri.id', + ], + }), + '4xx': Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const page = parsePagingQueryInput(req.query.page ?? 0); + const includeUnanchored = req.query.unanchored ?? false; + const { results } = await fastify.db.getNamesV2List({ page, includeUnanchored }); + if (results.length === 0 && req.query.page) { + await reply.status(400).send(BnsErrors.InvalidPageNumber); + } else { + await reply.send(results); + } + } + ); + + fastify.get( + '/:name', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_name_info', + summary: 'Get Name Details', + description: `Retrieves all details of a given name from the BNS V2 system.`, + tags: ['Names'], + params: Type.Object({ + name: Type.String({ description: 'fully-qualified name', examples: ['muneeb.id'] }), + }), + querystring: Type.Object({ + unanchored: UnanchoredParamSchema, + }), + response: { + 200: Type.Object( + { + id: Type.Optional(Type.Integer()), + fullName: Type.String(), + name: Type.String(), + namespace_id: Type.String(), + registered_at: Type.Optional(Type.Integer()), + imported_at: Type.Optional(Type.Integer()), + hashed_salted_fqn_preorder: Type.Optional(Type.String()), + preordered_by: Type.Optional(Type.String()), + renewal_height: Type.Integer(), + stx_burn: Type.Integer(), + owner: Type.String(), + }, + { + title: 'BnsGetNameInfoResponseV2', + description: 'Get name details for BNS V2', + } + ), + 404: Type.Object({ error: Type.String() }, { description: 'Name not found' }), + }, + }, + }, + async (req, reply) => { + const { name } = req.params; + const includeUnanchored = req.query.unanchored ?? false; + + const nameQuery = await fastify.db.getNameV2({ + name, + includeUnanchored, + }); + if (!nameQuery.found) { + return reply.status(404).send({ error: `cannot find name ${name}` }); + } + + const { result } = nameQuery; + const nameInfoResponse = { + ...result, + blockchain: bnsBlockchain, + }; + + await reply.send(nameInfoResponse); + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/routes/bnsV2/namespaces.ts b/src/api/routes/bnsV2/namespaces.ts new file mode 100644 index 0000000000..b171cdf516 --- /dev/null +++ b/src/api/routes/bnsV2/namespaces.ts @@ -0,0 +1,187 @@ +import { parsePagingQueryInput } from '../../../api/pagination'; +import { BnsErrors } from '../../../event-stream/bns/bns-constants'; +import { handleChainTipCache } from '../../../api/controllers/cache-controller'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { UnanchoredParamSchema } from '../../schemas/params'; + +export const BnsV2NamespaceRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_all_namespaces', + summary: 'Get All Namespaces', + description: `Retrieves a list of all namespaces known to the node.`, + tags: ['Names'], + querystring: Type.Object({ + unanchored: UnanchoredParamSchema, + }), + response: { + 200: Type.Object({ + namespaces: Type.Array(Type.String(), { + title: 'BnsGetAllNamespacesResponse', + description: 'Fetch a list of all namespaces known to the node.', + }), + }), + }, + }, + }, + async (req, reply) => { + const includeUnanchored = req.query.unanchored ?? false; + const { results } = await fastify.db.getNamespacesV2List({ includeUnanchored }); + const response = { + namespaces: results, + }; + await reply.send(response); + } + ); + + fastify.get( + '/:tld', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_namespace_info', + summary: 'Get Namespace Details', + description: `Retrieves details of a given namespace.`, + tags: ['Names'], + params: Type.Object({ + tld: Type.String({ description: 'the namespace to fetch', examples: ['id'] }), + }), + querystring: Type.Object({ + unanchored: UnanchoredParamSchema, + }), + response: { + 200: Type.Object( + { + namespace_id: Type.String(), + namespace_manager: Type.Optional(Type.String()), + manager_transferable: Type.Boolean(), + manager_frozen: Type.Boolean(), + namespace_import: Type.String(), + reveal_block: Type.Integer(), + launched_at: Type.Optional(Type.Integer()), + launch_block: Type.Integer(), + lifetime: Type.Integer(), + can_update_price_function: Type.Boolean(), + buckets: Type.String(), + base: Type.String(), + coeff: Type.String(), + nonalpha_discount: Type.String(), + no_vowel_discount: Type.String(), + status: Type.Optional(Type.String()), + }, + { + title: 'BnsGetNamespaceInfoResponse', + description: 'Get namespace details', + } + ), + 404: Type.Object({ error: Type.String() }, { description: 'Namespace not found' }), + }, + }, + }, + async (req, reply) => { + const { tld } = req.params; + const includeUnanchored = req.query.unanchored ?? false; + + const namespaceQuery = await fastify.db.getNamespaceV2({ + namespace: tld, + includeUnanchored, + }); + + if (!namespaceQuery.found) { + return reply.status(404).send({ error: `cannot find namespace ${tld}` }); + } + + const result = { + ...namespaceQuery.result, + base: namespaceQuery.result.base.toString(), + coeff: namespaceQuery.result.coeff.toString(), + nonalpha_discount: namespaceQuery.result.nonalpha_discount.toString(), + no_vowel_discount: namespaceQuery.result.no_vowel_discount.toString(), + }; + + await reply.send(result); + } + ); + + fastify.get( + '/:tld/names', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_namespace_names', + summary: 'Get Namespace Names', + description: `Retrieves a list of names within a given namespace.`, + tags: ['Names'], + params: Type.Object({ + tld: Type.String({ description: 'the namespace to fetch names from.', examples: ['id'] }), + }), + querystring: Type.Object({ + page: Type.Optional( + Type.Number({ + description: + "namespace values are defaulted to page 1 with 100 results. You can query specific page results by using the 'page' query parameter.", + examples: [22], + }) + ), + unanchored: UnanchoredParamSchema, + }), + response: { + 200: Type.Array(Type.String(), { + title: 'BnsGetAllNamespacesNamesResponse', + description: 'Fetch a list of names from the namespace.', + examples: [ + [ + 'aldenquimby.id', + 'aldeoryn.id', + 'alderete.id', + 'aldert.id', + 'aldi.id', + 'aldighieri.id', + ], + ], + }), + }, + }, + }, + async (req, reply) => { + const { tld } = req.params; + const page = parsePagingQueryInput(req.query.page ?? 0); + const includeUnanchored = req.query.unanchored ?? false; + await fastify.db + .sqlTransaction(async sql => { + const response = await fastify.db.getNamespaceV2({ namespace: tld, includeUnanchored }); + if (!response.found) { + throw BnsErrors.NoSuchNamespace; + } else { + const { results } = await fastify.db.getNamespacesV2NamesList({ + namespace: tld, + page, + includeUnanchored, + }); + if (results.length === 0 && req.query.page) { + throw BnsErrors.InvalidPageNumber; + } else { + return results; + } + } + }) + .then(async results => { + await reply.send(results); + }) + .catch(async error => { + await reply.status(400).send(error); + }); + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/routes/bnsV2/pricing.ts b/src/api/routes/bnsV2/pricing.ts new file mode 100644 index 0000000000..3df1d4e33f --- /dev/null +++ b/src/api/routes/bnsV2/pricing.ts @@ -0,0 +1,168 @@ +import { + makeRandomPrivKey, + getAddressFromPrivateKey, + TransactionVersion, + ReadOnlyFunctionOptions, + bufferCVFromString, + callReadOnlyFunction, + ClarityType, +} from '@stacks/transactions'; +import { getChainIDNetwork, isValidPrincipal } from './../../../helpers'; +import { GetStacksNetwork } from '../../../event-stream/bns/bns-helpers'; +import { logger } from '../../../logger'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { handleChainTipCache } from '../../controllers/cache-controller'; +import { getBnsV2ContractID } from 'src/event-stream/bnsV2/bnsV2-helpers'; + +export const BnsV2PriceRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/namespaces/:tld', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_namespace_price', + summary: 'Get Namespace Price', + description: `Retrieves the price of a namespace. The \`amount\` given will be in the smallest possible units of the currency.`, + tags: ['Names'], + params: Type.Object({ + tld: Type.String({ description: 'the namespace to fetch price for', examples: ['id'] }), + }), + response: { + 200: Type.Object( + { units: Type.String(), amount: Type.String() }, + { title: 'BnsGetNamespacePriceResponse', description: 'Fetch price for namespace.' } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const namespace = req.params.tld; + if (namespace.length > 20) { + await reply.status(400).send({ error: 'Invalid namespace' }); + return; + } + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-namespace-price', + functionArgs: [bufferCVFromString(namespace)], + network: GetStacksNetwork(fastify.chainId), + }; + const contractCallTx = await callReadOnlyFunction(txOptions); + if ( + contractCallTx.type == ClarityType.ResponseOk && + contractCallTx.value.type == ClarityType.UInt + ) { + const response = { + units: 'STX', + amount: contractCallTx.value.value.toString(10), + }; + await reply.send(response); + } else { + await reply.status(400).send({ error: 'Invalid namespace' }); + } + } + ); + + fastify.get( + '/names/:name', + { + preHandler: handleChainTipCache, + schema: { + operationId: 'get_name_price', + summary: 'Get Name Price', + description: `Retrieves the price of a name. The \`amount\` given will be in the smallest possible units of the currency.`, + tags: ['Names'], + params: Type.Object({ + name: Type.String({ + description: 'the name to query price information for', + examples: ['muneeb.id'], + }), + }), + response: { + 200: Type.Object( + { units: Type.String(), amount: Type.String() }, + { title: 'BnsGetNamePriceResponse', description: 'Fetch price for name.' } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const input = req.params.name; + if (!input.includes('.')) { + await reply.status(400).send({ error: 'Invalid name' }); + return; + } + const split = input.split('.'); + if (split.length != 2) { + await reply.status(400).send({ error: 'Invalid name' }); + return; + } + const name = split[0]; + const namespace = split[1]; + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-name-price', + functionArgs: [bufferCVFromString(namespace), bufferCVFromString(name)], + network: GetStacksNetwork(fastify.chainId), + }; + + const contractCall = await callReadOnlyFunction(txOptions); + if ( + contractCall.type == ClarityType.ResponseOk && + contractCall.value.type == ClarityType.UInt + ) { + const response = { + units: 'STX', + amount: contractCall.value.value.toString(10), + }; + await reply.send(response); + } else { + await reply.status(400).send({ error: 'Invalid name' }); + } + } + ); + + await Promise.resolve(); +}; diff --git a/src/api/routes/bnsV2/reads.ts b/src/api/routes/bnsV2/reads.ts new file mode 100644 index 0000000000..e2506ba71e --- /dev/null +++ b/src/api/routes/bnsV2/reads.ts @@ -0,0 +1,655 @@ +import { + makeRandomPrivKey, + getAddressFromPrivateKey, + TransactionVersion, + ReadOnlyFunctionOptions, + bufferCVFromString, + callReadOnlyFunction, + ClarityType, + standardPrincipalCV, + uintCV, +} from '@stacks/transactions'; +import { getChainIDNetwork, isValidPrincipal } from '../../../helpers'; +import { GetStacksNetwork } from '../../../event-stream/bns/bns-helpers'; +import { logger } from '../../../logger'; +import { FastifyPluginAsync } from 'fastify'; +import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Server } from 'node:http'; +import { getBnsV2ContractID } from 'src/event-stream/bnsV2/bnsV2-helpers'; + +export const BnsV2ReadRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + fastify.get( + '/renewal-height/:id', + { + schema: { + operationId: 'get_renewal_height', + summary: 'Get Renewal Height', + description: `Retrieves the renewal height for a given name ID.`, + tags: ['Names'], + params: Type.Object({ + id: Type.String({ description: 'The ID of the name', examples: ['123'] }), + }), + response: { + 200: Type.Object( + { renewal_height: Type.Union([Type.Number(), Type.Null()]) }, + { + title: 'BnsGetRenewalHeightResponse', + description: 'Fetch renewal height for a name ID.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const id = req.params.id; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-renewal-height', + functionArgs: [uintCV(id)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.ResponseOk && result.value.type === ClarityType.UInt) { + await reply.send({ renewal_height: Number(result.value.value) }); + } else if (result.type === ClarityType.ResponseErr) { + await reply.status(400).send({ error: 'Name not found or other contract error' }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling get-renewal-height for ID ${id}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + + fastify.get( + '/can-resolve-name/:namespace/:name', + { + schema: { + operationId: 'can_resolve_name', + summary: 'Can Resolve Name', + description: `Checks if a given name can be resolved and returns its renewal height and owner.`, + tags: ['Names'], + params: Type.Object({ + namespace: Type.String({ description: 'The namespace of the name', examples: ['id'] }), + name: Type.String({ description: 'The name to check', examples: ['satoshi'] }), + }), + response: { + 200: Type.Object( + { + can_resolve: Type.Boolean(), + renewal: Type.Number(), + owner: Type.String(), + }, + { + title: 'BnsCanResolveNameResponse', + description: 'Result of checking if a name can be resolved.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const { namespace, name } = req.params; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'can-resolve-name', + functionArgs: [bufferCVFromString(namespace), bufferCVFromString(name)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.ResponseOk && result.value.type === ClarityType.Tuple) { + const { renewal, owner } = result.value.data; + await reply.send({ + can_resolve: true, + renewal: renewal.type === ClarityType.UInt ? Number(renewal.value) : 0, + owner: + owner.type === ClarityType.PrincipalStandard || + owner.type === ClarityType.PrincipalContract + ? owner.address.toString() + : owner.type === ClarityType.StringASCII + ? owner.data + : 'Unknown', + }); + } else if (result.type === ClarityType.ResponseErr) { + await reply.status(400).send({ error: 'Name not found or other contract error' }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling can-resolve-name for ${namespace}.${name}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + + fastify.get( + '/owner/:id', + { + schema: { + operationId: 'get_owner', + summary: 'Get Token Owner', + description: `Retrieves the owner of a token for a given token ID.`, + tags: ['Names'], + params: Type.Object({ + id: Type.String({ description: 'The ID of the token', examples: ['123'] }), + }), + response: { + 200: Type.Object( + { owner: Type.Union([Type.String(), Type.Null()]) }, + { + title: 'BnsGetOwnerResponse', + description: 'Fetch owner for a given token ID.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const id = req.params.id; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-owner', + functionArgs: [uintCV(id)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.ResponseOk) { + if (result.value.type === ClarityType.OptionalSome) { + const owner = result.value.value; + if ( + owner.type === ClarityType.PrincipalStandard || + owner.type === ClarityType.PrincipalContract + ) { + await reply.send({ owner: owner.address.toString() }); + } else { + throw new Error('Unexpected owner type'); + } + } else { + await reply.send({ owner: null }); + } + } else if (result.type === ClarityType.ResponseErr) { + await reply.status(400).send({ error: 'Token not found or other contract error' }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling get-owner for ID ${id}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + fastify.get( + '/can-namespace-be-registered/:namespace', + { + schema: { + operationId: 'can_namespace_be_registered', + summary: 'Can Namespace Be Registered', + description: `Checks if a given namespace can be registered.`, + tags: ['Names'], + params: Type.Object({ + namespace: Type.String({ description: 'The namespace to check', examples: ['app'] }), + }), + response: { + 200: Type.Object( + { + can_be_registered: Type.Boolean(), + }, + { + title: 'BnsCanNamespaceBeRegisteredResponse', + description: 'Result of checking if a namespace can be registered.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const { namespace } = req.params; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'can-namespace-be-registered', + functionArgs: [bufferCVFromString(namespace)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.ResponseOk && result.value.type === ClarityType.BoolTrue) { + await reply.send({ can_be_registered: true }); + } else if ( + result.type === ClarityType.ResponseOk && + result.value.type === ClarityType.BoolFalse + ) { + await reply.send({ can_be_registered: false }); + } else if (result.type === ClarityType.ResponseErr) { + await reply.status(400).send({ error: 'Namespace check failed or other contract error' }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling can-namespace-be-registered for ${namespace}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + fastify.get( + '/get-bns-info/:namespace/:name', + { + schema: { + operationId: 'get_bns_info', + summary: 'Get BNS Info', + description: `Retrieves the properties of a BNS name.`, + tags: ['Names'], + params: Type.Object({ + namespace: Type.String({ description: 'The namespace of the name', examples: ['id'] }), + name: Type.String({ description: 'The name to get info for', examples: ['satoshi'] }), + }), + response: { + 200: Type.Object( + { + registered_at: Type.Union([Type.Number(), Type.Null()]), + imported_at: Type.Union([Type.Number(), Type.Null()]), + hashed_salted_fqn_preorder: Type.Union([Type.String(), Type.Null()]), + preordered_by: Type.Union([Type.String(), Type.Null()]), + renewal_height: Type.Number(), + stx_burn: Type.String(), + owner: Type.String(), + }, + { + title: 'BnsGetBnsInfoResponse', + description: 'Properties of a BNS name.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const { namespace, name } = req.params; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-bns-info', + functionArgs: [bufferCVFromString(name), bufferCVFromString(namespace)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.OptionalSome && result.value.type === ClarityType.Tuple) { + const { + 'registered-at': registeredAt, + 'imported-at': importedAt, + 'hashed-salted-fqn-preorder': hashedSaltedFqnPreorder, + 'preordered-by': preorderedBy, + 'renewal-height': renewalHeight, + 'stx-burn': stxBurn, + owner, + } = result.value.data; + + await reply.send({ + registered_at: + registeredAt.type === ClarityType.OptionalSome ? Number(registeredAt.value) : null, + imported_at: + importedAt.type === ClarityType.OptionalSome ? Number(importedAt.value) : null, + hashed_salted_fqn_preorder: + hashedSaltedFqnPreorder.type === ClarityType.OptionalSome + ? hashedSaltedFqnPreorder.value.toString() + : null, + preordered_by: + preorderedBy.type === ClarityType.OptionalSome ? preorderedBy.value.toString() : null, + renewal_height: Number(renewalHeight), + stx_burn: stxBurn.toString(), + owner: owner.toString(), + }); + } else if (result.type === ClarityType.OptionalNone) { + await reply.status(404).send({ error: 'BNS name not found' }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling get-bns-info for ${namespace}.${name}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + + fastify.get( + '/get-id-from-bns/:namespace/:name', + { + schema: { + operationId: 'get_id_from_bns', + summary: 'Get ID from BNS', + description: `Retrieves the unique ID of a BNS name.`, + tags: ['Names'], + params: Type.Object({ + namespace: Type.String({ description: 'The namespace of the name', examples: ['id'] }), + name: Type.String({ description: 'The name to get the ID for', examples: ['satoshi'] }), + }), + response: { + 200: Type.Object( + { + id: Type.Union([Type.Number(), Type.Null()]), + }, + { + title: 'BnsGetIdFromBnsResponse', + description: 'Unique ID of a BNS name.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const { namespace, name } = req.params; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-id-from-bns', + functionArgs: [bufferCVFromString(name), bufferCVFromString(namespace)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.OptionalSome && result.value.type === ClarityType.UInt) { + await reply.send({ id: Number(result.value.value) }); + } else if (result.type === ClarityType.OptionalNone) { + await reply.send({ id: null }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling get-id-from-bns for ${namespace}.${name}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + + fastify.get( + '/get-bns-from-id/:id', + { + schema: { + operationId: 'get_bns_from_id', + summary: 'Get BNS from ID', + description: `Retrieves the BNS name and namespace given a unique ID.`, + tags: ['Names'], + params: Type.Object({ + id: Type.String({ description: 'The unique ID of the BNS name', examples: ['123'] }), + }), + response: { + 200: Type.Object( + { + name: Type.Union([Type.String(), Type.Null()]), + namespace: Type.Union([Type.String(), Type.Null()]), + }, + { + title: 'BnsGetBnsFromIdResponse', + description: 'BNS name and namespace for a given ID.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const { id } = req.params; + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-bns-from-id', + functionArgs: [uintCV(id)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.OptionalSome && result.value.type === ClarityType.Tuple) { + const { name, namespace } = result.value.data; + await reply.send({ + name: name.toString(), + namespace: namespace.toString(), + }); + } else if (result.type === ClarityType.OptionalNone) { + await reply.send({ name: null, namespace: null }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling get-bns-from-id for ID ${id}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); + fastify.get( + '/primary-name/:owner', + { + schema: { + operationId: 'get_primary_name', + summary: 'Get Primary Name', + description: `Retrieves the primary name associated with the given principal (owner address).`, + tags: ['Names'], + params: Type.Object({ + owner: Type.String({ + description: 'The principal (owner address) to fetch the primary name for', + examples: ['SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7'], + }), + }), + response: { + 200: Type.Object( + { name: Type.Union([Type.String(), Type.Null()]) }, + { + title: 'BnsGetPrimaryNameResponse', + description: 'Fetch primary name for a principal.', + } + ), + 400: Type.Object({ error: Type.String() }, { title: 'BnsError', description: 'Error' }), + }, + }, + }, + async (req, reply) => { + const owner = req.params.owner; + + if (!isValidPrincipal(owner)) { + await reply.status(400).send({ error: 'Invalid principal address' }); + return; + } + + const randomPrivKey = makeRandomPrivKey(); + const address = getAddressFromPrivateKey( + randomPrivKey.data, + getChainIDNetwork(fastify.chainId) === 'mainnet' + ? TransactionVersion.Mainnet + : TransactionVersion.Testnet + ); + + const bnsV2ContractIdentifier = getBnsV2ContractID(fastify.chainId); + if (!bnsV2ContractIdentifier || !isValidPrincipal(bnsV2ContractIdentifier)) { + logger.error('BNS contract ID not properly configured'); + throw new Error('BNS contract ID not properly configured'); + } + + const [bnsV2ContractAddress, bnsV2ContractName] = bnsV2ContractIdentifier.split('.'); + + const txOptions: ReadOnlyFunctionOptions = { + senderAddress: address, + contractAddress: bnsV2ContractAddress, + contractName: bnsV2ContractName, + functionName: 'get-primary-name', + functionArgs: [standardPrincipalCV(owner)], + network: GetStacksNetwork(fastify.chainId), + }; + + try { + const result = await callReadOnlyFunction(txOptions); + + if (result.type === ClarityType.OptionalNone) { + await reply.send({ name: null }); + } else if ( + result.type === ClarityType.OptionalSome && + result.value.type === ClarityType.UInt + ) { + const nameId = result.value.value.toString(); + await reply.send({ name: nameId }); + } else { + throw new Error('Unexpected response from contract'); + } + } catch (error) { + logger.error(error, `Error calling get-primary-name for ${owner}`); + await reply.status(500).send({ error: 'Internal server error' }); + } + } + ); +}; diff --git a/src/datastore/common.ts b/src/datastore/common.ts index e0f3cdfdd0..02c625ab6c 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -660,7 +660,9 @@ export interface DataStoreTxEventData { contractLogEvents: DbSmartContractEvent[]; smartContracts: DbSmartContract[]; names: DbBnsName[]; + namesV2: DbBnsNameV2[]; namespaces: DbBnsNamespace[]; + namespacesV2: DbBnsNamespaceV2[]; pox2Events: DbPoxSyntheticEvent[]; pox3Events: DbPoxSyntheticEvent[]; pox4Events: DbPoxSyntheticEvent[]; @@ -760,6 +762,29 @@ export interface DbBnsNamespace { canonical: boolean; } +export interface DbBnsNamespaceV2 { + id?: number; + namespace_id: string; + namespace_manager?: string; + manager_transferable: boolean; + manager_frozen: boolean; + namespace_import: string; + reveal_block: number; + launched_at?: number; + launch_block: number; + lifetime: number; + can_update_price_function: boolean; + buckets: string; + base: bigint; + coeff: bigint; + nonalpha_discount: bigint; + no_vowel_discount: bigint; + status?: string; + tx_id: string; + tx_index: number; + canonical: boolean; +} + export interface DbBnsName { id?: number; name: string; @@ -779,6 +804,25 @@ export interface DbBnsName { canonical: boolean; } +export interface DbBnsNameV2 { + id?: number; + fullName: string; + name: string; + namespace_id: string; + registered_at?: number; + imported_at?: number; + hashed_salted_fqn_preorder?: string; + preordered_by?: string; + renewal_height: number; + stx_burn: number; + owner: string; + tx_id: string; + tx_index: number; + event_index?: number; + status?: string; + canonical: boolean; +} + export interface DbBnsSubdomain { id?: number; name: string; @@ -1114,6 +1158,8 @@ interface ReOrgEntities { smartContracts: number; names: number; namespaces: number; + namesV2: number; + namespacesV2: number; subdomains: number; poxSigners: number; poxCycles: number; @@ -1585,6 +1631,29 @@ export interface BnsNameInsertValues { microblock_canonical: boolean; } +export interface BnsNameV2InsertValues { + fullName: string; + name: string; + namespace_id: string; + registered_at: number | null; + imported_at: number | null; + hashed_salted_fqn_preorder: string | null; + preordered_by: string | null; + renewal_height: number; + stx_burn: number; + owner: string; + tx_id: PgBytea; + tx_index: number; + event_index: number | null; + status: string | null; + canonical: boolean; + index_block_hash: PgBytea; + parent_index_block_hash: PgBytea; + microblock_hash: PgBytea; + microblock_sequence: number; + microblock_canonical: boolean; +} + export interface BnsSubdomainInsertValues { name: string; namespace_id: string; @@ -1629,6 +1698,33 @@ export interface BnsNamespaceInsertValues { microblock_canonical: boolean; } +export interface BnsNamespaceV2InsertValues { + namespace_id: string; + namespace_manager: string | null; + manager_transferable: boolean; + manager_frozen: boolean; + namespace_import: string; + reveal_block: number; + launched_at: number | null; + launch_block: number; + lifetime: number; + can_update_price_function: boolean; + buckets: string; + base: string; + coeff: string; + nonalpha_discount: string; + no_vowel_discount: string; + status: string | null; + tx_index: number; + tx_id: PgBytea; + canonical: boolean; + index_block_hash: PgBytea; + parent_index_block_hash: PgBytea; + microblock_hash: PgBytea; + microblock_sequence: number; + microblock_canonical: boolean; +} + export interface BnsZonefileInsertValues { name: string; zonefile: string; diff --git a/src/datastore/helpers.ts b/src/datastore/helpers.ts index 0780a3b0b8..786eca6d22 100644 --- a/src/datastore/helpers.ts +++ b/src/datastore/helpers.ts @@ -218,6 +218,8 @@ export const TX_METADATA_TABLES = [ 'smart_contracts', 'names', 'namespaces', + 'names_v2', + 'namespaces_v2', 'subdomains', // TODO: add pox_set table here ] as const; @@ -1331,7 +1333,9 @@ export function markBlockUpdateDataAsNonCanonical(data: DataStoreBlockUpdateData contractLogEvents: tx.contractLogEvents.map(e => ({ ...e, canonical: false })), smartContracts: tx.smartContracts.map(e => ({ ...e, canonical: false })), names: tx.names.map(e => ({ ...e, canonical: false })), + namesV2: tx.namesV2.map(e => ({ ...e, canonical: false })), namespaces: tx.namespaces.map(e => ({ ...e, canonical: false })), + namespacesV2: tx.namespacesV2.map(e => ({ ...e, canonical: false })), pox2Events: tx.pox2Events.map(e => ({ ...e, canonical: false })), pox3Events: tx.pox3Events.map(e => ({ ...e, canonical: false })), pox4Events: tx.pox4Events.map(e => ({ ...e, canonical: false })), @@ -1357,6 +1361,8 @@ export function newReOrgUpdatedEntities(): ReOrgUpdatedEntities { smartContracts: 0, names: 0, namespaces: 0, + namesV2: 0, + namespacesV2: 0, subdomains: 0, poxSigners: 0, poxCycles: 0, @@ -1377,6 +1383,8 @@ export function newReOrgUpdatedEntities(): ReOrgUpdatedEntities { smartContracts: 0, names: 0, namespaces: 0, + namesV2: 0, + namespacesV2: 0, subdomains: 0, poxSigners: 0, poxCycles: 0, diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 9cc7b62c0b..9af08c4daa 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -64,6 +64,8 @@ import { PoxSyntheticEventTable, DbPoxStacker, DbPoxSyntheticEvent, + DbBnsNameV2, + DbBnsNamespaceV2, } from './common'; import { abiColumn, @@ -3629,6 +3631,88 @@ export class PgStore extends BasePgStore { return { found: false } as const; } + async getNamespacesV2List({ includeUnanchored }: { includeUnanchored: boolean }) { + const queryResult = await this.sqlTransaction(async sql => { + const maxBlockHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + return await sql<{ namespace_id: string }[]>` + SELECT DISTINCT ON (namespace_id) namespace_id + FROM namespaces_v2 + WHERE canonical = true + AND (${includeUnanchored} OR microblock_canonical = true) + AND launch_block <= ${maxBlockHeight} + ORDER BY namespace_id, launch_block DESC, microblock_sequence DESC, tx_index DESC + `; + }); + const results = queryResult.map(r => r.namespace_id); + return { results }; + } + + async getNamespacesV2NamesList({ + namespace, + page, + includeUnanchored, + }: { + namespace: string; + page: number; + includeUnanchored: boolean; + }): Promise<{ + results: string[]; + }> { + const offset = page * 100; + const queryResult = await this.sqlTransaction(async sql => { + const maxBlockHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + return await sql<{ name: string }[]>` + SELECT name FROM ( + SELECT DISTINCT ON (fullName) name, status + FROM names_v2 + WHERE namespace_id = ${namespace} + AND (registered_at IS NULL OR registered_at <= ${maxBlockHeight}) + AND canonical = true + AND (${includeUnanchored} OR microblock_canonical = true) + ORDER BY fullName, COALESCE(registered_at, imported_at) DESC, microblock_sequence DESC, tx_index DESC, event_index DESC + LIMIT 100 + OFFSET ${offset} + ) AS name_status + WHERE status IS NULL OR status <> 'name-revoke' + `; + }); + const results = queryResult.map(r => r.name); + return { results }; + } + + async getNamespaceV2({ + namespace, + includeUnanchored, + }: { + namespace: string; + includeUnanchored: boolean; + }): Promise> { + const queryResult = await this.sqlTransaction(async sql => { + const maxBlockHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + return await sql<(DbBnsNamespaceV2 & { tx_id: string; index_block_hash: string })[]>` + SELECT DISTINCT ON (namespace_id) * + FROM namespaces_v2 + WHERE namespace_id = ${namespace} + AND launch_block <= ${maxBlockHeight} + AND canonical = true + AND (${includeUnanchored} OR microblock_canonical = true) + ORDER BY namespace_id, launch_block DESC, microblock_sequence DESC, tx_index DESC + LIMIT 1 + `; + }); + if (queryResult.length > 0) { + return { + found: true, + result: { + ...queryResult[0], + tx_id: queryResult[0].tx_id, + index_block_hash: queryResult[0].index_block_hash, + }, + }; + } + return { found: false } as const; + } + async getName({ name, includeUnanchored, @@ -3666,6 +3750,69 @@ export class PgStore extends BasePgStore { `; } + async getNameV2({ + name, + includeUnanchored, + }: { + name: string; + includeUnanchored: boolean; + }): Promise> { + return await this.sqlTransaction(async sql => { + const blockHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + const result = await this.getNameV2AtBlockHeight({ name, blockHeight, includeUnanchored }); + return result ? { found: true, result } : { found: false }; + }); + } + + async getNameV2AtBlockHeight(args: { + name: string; + blockHeight: number; + includeUnanchored: boolean; + }): Promise { + const query = await this.sql` + WITH name_results AS ( + SELECT DISTINCT ON (n.fullName) n.* + FROM names_v2 AS n + WHERE n.fullName = ${args.name} + AND (n.registered_at IS NULL OR n.registered_at <= ${args.blockHeight}) + AND n.canonical = true + AND (${args.includeUnanchored} OR n.microblock_canonical = true) + ORDER BY n.fullName, + COALESCE(n.registered_at, n.imported_at) DESC, + n.microblock_sequence DESC, + n.tx_index DESC, + n.event_index DESC + ) + SELECT * FROM name_results + WHERE status IS NULL OR status <> 'name-revoke' + `; + return query.length > 0 ? query[0] : null; + } + + async getNamesV2AtBlockHeight(args: { + names: string[]; + blockHeight: number; + includeUnanchored: boolean; + }): Promise { + return await this.sql` + WITH name_results AS ( + SELECT DISTINCT ON (n.fullName) n.* + FROM names_v2 AS n + WHERE n.fullName IN ${this.sql(args.names)} + AND (n.registered_at IS NULL OR n.registered_at <= ${args.blockHeight}) + AND n.canonical = true + AND (${args.includeUnanchored} OR n.microblock_canonical = true) + ORDER BY n.fullName, + COALESCE(n.registered_at, n.imported_at) DESC, + n.microblock_sequence DESC, + n.tx_index DESC, + n.event_index DESC + ) + SELECT * FROM name_results + WHERE status IS NULL OR status <> 'name-revoke' + `; + } + async getHistoricalZoneFile(args: { name: string; zoneFileHash: string; @@ -3894,6 +4041,45 @@ export class PgStore extends BasePgStore { return { found: false } as const; } + async getNamesV2ByAddressList({ + address, + includeUnanchored, + chainId, + }: { + address: string; + includeUnanchored: boolean; + chainId: ChainID; + }): Promise> { + const queryResult = await this.sqlTransaction(async sql => { + const maxBlockHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + + // Get names owned by this address from the names_v2 table + const namesQuery = await sql<{ fullName: string }[]>` + SELECT DISTINCT ON (fullName) + fullName + FROM + names_v2 + WHERE + owner = ${address} + AND renewal_height <= ${maxBlockHeight} + AND canonical = TRUE + ${includeUnanchored ? sql`` : sql`AND microblock_canonical = TRUE`} + ORDER BY + fullName, renewal_height DESC + `; + + return namesQuery.map(item => item.fullName).sort(); + }); + + if (queryResult.length > 0) { + return { + found: true, + result: queryResult, + }; + } + return { found: false } as const; + } + /** * This function returns the subdomains for a specific name * @param name - The name for which subdomains are required @@ -3979,6 +4165,31 @@ export class PgStore extends BasePgStore { return { results }; } + async getNamesV2List({ page, includeUnanchored }: { page: number; includeUnanchored: boolean }) { + const offset = page * 100; + const queryResult = await this.sqlTransaction(async sql => { + const maxBlockHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + return await sql<{ fullName: string }[]>` + WITH name_results AS ( + SELECT DISTINCT ON (fullName) fullName, status, renewal_height + FROM names_v2 + WHERE canonical = true + ${includeUnanchored ? sql`` : sql`AND microblock_canonical = true`} + AND renewal_height <= ${maxBlockHeight} + ORDER BY fullName, renewal_height DESC, microblock_sequence DESC, tx_index DESC + ) + SELECT fullName + FROM name_results + WHERE status <> 'revoked' + ORDER BY fullName ASC + LIMIT 100 + OFFSET ${offset} + `; + }); + const results = queryResult.map(r => r.fullName); + return { results }; + } + async getSubdomain({ subdomain, includeUnanchored, diff --git a/src/datastore/pg-write-store.ts b/src/datastore/pg-write-store.ts index 32badbeb73..75b54f5788 100644 --- a/src/datastore/pg-write-store.ts +++ b/src/datastore/pg-write-store.ts @@ -69,6 +69,10 @@ import { DbPoxSetSigners, PoxSetSignerValues, PoxCycleInsertValues, + DbBnsNameV2, + DbBnsNamespaceV2, + BnsNameV2InsertValues, + BnsNamespaceV2InsertValues, } from './common'; import { BLOCK_COLUMNS, @@ -301,6 +305,8 @@ export class PgWriteStore extends PgStore { q.enqueue(() => this.updateSmartContracts(sql, entry.tx, entry.smartContracts)); q.enqueue(() => this.updateNamespaces(sql, entry.tx, entry.namespaces)); q.enqueue(() => this.updateNames(sql, entry.tx, entry.names)); + q.enqueue(() => this.updateNamespacesV2(sql, entry.tx, entry.namespacesV2)); + q.enqueue(() => this.updateNamesV2(sql, entry.tx, entry.namesV2)); } } q.enqueue(async () => { @@ -646,6 +652,11 @@ export class PgWriteStore extends PgStore { smartContracts: entry.smartContracts.map(e => ({ ...e, block_height: blockHeight })), names: entry.names.map(e => ({ ...e, registered_at: blockHeight })), namespaces: entry.namespaces.map(e => ({ ...e, ready_block: blockHeight })), + namesV2: entry.namesV2.map(e => ({ + ...e, + registered_at: e.registered_at ?? blockHeight, + })), + namespacesV2: entry.namespacesV2.map(e => ({ ...e, launch_block: blockHeight })), pox2Events: entry.pox2Events.map(e => ({ ...e, block_height: blockHeight })), pox3Events: entry.pox3Events.map(e => ({ ...e, block_height: blockHeight })), pox4Events: entry.pox4Events.map(e => ({ ...e, block_height: blockHeight })), @@ -2216,6 +2227,94 @@ export class PgWriteStore extends PgStore { } } + async updateNamesV2(sql: PgSqlClient, tx: DataStoreBnsBlockTxData, namesV2: DbBnsNameV2[]) { + for (const bnsNameV2 of namesV2) { + const { + fullName, + name, + namespace_id, + registered_at, + imported_at, + hashed_salted_fqn_preorder, + preordered_by, + renewal_height, + stx_burn, + owner, + tx_id, + tx_index, + event_index, + status, + canonical, + } = bnsNameV2; + + const nameV2Values: BnsNameV2InsertValues = { + fullName, + name, + namespace_id, + registered_at: registered_at ?? null, + imported_at: imported_at ?? null, + hashed_salted_fqn_preorder: hashed_salted_fqn_preorder ?? null, + preordered_by: preordered_by ?? null, + renewal_height, + stx_burn, + owner, + tx_id, + tx_index, + event_index: event_index ?? null, + status: status ?? null, + canonical, + index_block_hash: tx.index_block_hash, + parent_index_block_hash: tx.parent_index_block_hash, + microblock_hash: tx.microblock_hash, + microblock_sequence: tx.microblock_sequence, + microblock_canonical: tx.microblock_canonical, + }; + + await sql` + INSERT INTO names_v2 ${sql(nameV2Values)} + ON CONFLICT (fullName, tx_id, index_block_hash, microblock_hash, event_index) + DO UPDATE SET + name = EXCLUDED.name, + namespace_id = EXCLUDED.namespace_id, + registered_at = EXCLUDED.registered_at, + imported_at = EXCLUDED.imported_at, + hashed_salted_fqn_preorder = EXCLUDED.hashed_salted_fqn_preorder, + preordered_by = EXCLUDED.preordered_by, + renewal_height = EXCLUDED.renewal_height, + stx_burn = EXCLUDED.stx_burn, + owner = EXCLUDED.owner, + status = EXCLUDED.status, + canonical = EXCLUDED.canonical, + parent_index_block_hash = EXCLUDED.parent_index_block_hash, + microblock_sequence = EXCLUDED.microblock_sequence, + microblock_canonical = EXCLUDED.microblock_canonical + WHERE names_v2.canonical = false OR EXCLUDED.canonical = true + ON CONFLICT (fullName) + DO UPDATE SET + name = EXCLUDED.name, + namespace_id = EXCLUDED.namespace_id, + registered_at = EXCLUDED.registered_at, + imported_at = EXCLUDED.imported_at, + hashed_salted_fqn_preorder = EXCLUDED.hashed_salted_fqn_preorder, + preordered_by = EXCLUDED.preordered_by, + renewal_height = EXCLUDED.renewal_height, + stx_burn = EXCLUDED.stx_burn, + owner = EXCLUDED.owner, + tx_id = EXCLUDED.tx_id, + tx_index = EXCLUDED.tx_index, + event_index = EXCLUDED.event_index, + status = EXCLUDED.status, + canonical = EXCLUDED.canonical, + index_block_hash = EXCLUDED.index_block_hash, + parent_index_block_hash = EXCLUDED.parent_index_block_hash, + microblock_hash = EXCLUDED.microblock_hash, + microblock_sequence = EXCLUDED.microblock_sequence, + microblock_canonical = EXCLUDED.microblock_canonical + WHERE names_v2.canonical = false OR EXCLUDED.canonical = true + `; + } + } + async updateNamespaces( sql: PgSqlClient, tx: DataStoreBnsBlockTxData, @@ -2268,6 +2367,92 @@ export class PgWriteStore extends PgStore { } } + async updateNamespacesV2( + sql: PgSqlClient, + tx: DataStoreBnsBlockTxData, + namespacesV2: DbBnsNamespaceV2[] + ) { + for (const batch of batchIterate(namespacesV2, INSERT_BATCH_SIZE)) { + const values: BnsNamespaceV2InsertValues[] = batch.map(namespace => ({ + namespace_id: namespace.namespace_id, + namespace_manager: namespace.namespace_manager ?? null, + manager_transferable: namespace.manager_transferable, + manager_frozen: namespace.manager_frozen, + namespace_import: namespace.namespace_import, + reveal_block: namespace.reveal_block, + launched_at: namespace.launched_at ?? null, + launch_block: namespace.launch_block, + lifetime: namespace.lifetime, + can_update_price_function: namespace.can_update_price_function, + buckets: namespace.buckets, + base: namespace.base.toString(), + coeff: namespace.coeff.toString(), + nonalpha_discount: namespace.nonalpha_discount.toString(), + no_vowel_discount: namespace.no_vowel_discount.toString(), + status: namespace.status ?? null, + tx_index: namespace.tx_index, + tx_id: namespace.tx_id, + canonical: namespace.canonical, + index_block_hash: tx.index_block_hash, + parent_index_block_hash: tx.parent_index_block_hash, + microblock_hash: tx.microblock_hash, + microblock_sequence: tx.microblock_sequence, + microblock_canonical: tx.microblock_canonical, + })); + await sql` + INSERT INTO namespaces_v2 ${sql(values)} + ON CONFLICT (namespace_id, tx_id, index_block_hash, microblock_hash) + DO UPDATE SET + namespace_manager = EXCLUDED.namespace_manager, + manager_transferable = EXCLUDED.manager_transferable, + manager_frozen = EXCLUDED.manager_frozen, + namespace_import = EXCLUDED.namespace_import, + reveal_block = EXCLUDED.reveal_block, + launched_at = EXCLUDED.launched_at, + launch_block = EXCLUDED.launch_block, + lifetime = EXCLUDED.lifetime, + can_update_price_function = EXCLUDED.can_update_price_function, + buckets = EXCLUDED.buckets, + base = EXCLUDED.base, + coeff = EXCLUDED.coeff, + nonalpha_discount = EXCLUDED.nonalpha_discount, + no_vowel_discount = EXCLUDED.no_vowel_discount, + status = EXCLUDED.status, + canonical = EXCLUDED.canonical, + parent_index_block_hash = EXCLUDED.parent_index_block_hash, + microblock_sequence = EXCLUDED.microblock_sequence, + microblock_canonical = EXCLUDED.microblock_canonical + WHERE namespaces_v2.canonical = false OR EXCLUDED.canonical = true + ON CONFLICT (namespace_id) + DO UPDATE SET + namespace_manager = EXCLUDED.namespace_manager, + manager_transferable = EXCLUDED.manager_transferable, + manager_frozen = EXCLUDED.manager_frozen, + namespace_import = EXCLUDED.namespace_import, + reveal_block = EXCLUDED.reveal_block, + launched_at = EXCLUDED.launched_at, + launch_block = EXCLUDED.launch_block, + lifetime = EXCLUDED.lifetime, + can_update_price_function = EXCLUDED.can_update_price_function, + buckets = EXCLUDED.buckets, + base = EXCLUDED.base, + coeff = EXCLUDED.coeff, + nonalpha_discount = EXCLUDED.nonalpha_discount, + no_vowel_discount = EXCLUDED.no_vowel_discount, + status = EXCLUDED.status, + tx_id = EXCLUDED.tx_id, + tx_index = EXCLUDED.tx_index, + canonical = EXCLUDED.canonical, + index_block_hash = EXCLUDED.index_block_hash, + parent_index_block_hash = EXCLUDED.parent_index_block_hash, + microblock_hash = EXCLUDED.microblock_hash, + microblock_sequence = EXCLUDED.microblock_sequence, + microblock_canonical = EXCLUDED.microblock_canonical + WHERE namespaces_v2.canonical = false OR EXCLUDED.canonical = true + `; + } + } + async updateBatchTokenOfferingLocked(sql: PgSqlClient, lockedInfos: DbTokenOfferingLocked[]) { try { const res = await sql` @@ -3452,6 +3637,12 @@ export class PgWriteStore extends PgStore { `; } + async insertNameV2Batch(sql: PgSqlClient, values: BnsNameV2InsertValues[]) { + await sql` + INSERT INTO names_v2 ${sql(values)} + `; + } + async insertNamespace( sql: PgSqlClient, blockData: { @@ -3490,6 +3681,48 @@ export class PgWriteStore extends PgStore { `; } + async insertNamespaceV2( + sql: PgSqlClient, + blockData: { + index_block_hash: string; + parent_index_block_hash: string; + microblock_hash: string; + microblock_sequence: number; + microblock_canonical: boolean; + }, + bnsNamespace: DbBnsNamespaceV2 + ) { + const values: BnsNamespaceV2InsertValues = { + namespace_id: bnsNamespace.namespace_id, + namespace_manager: (bnsNamespace as DbBnsNamespaceV2).namespace_manager ?? null, + manager_transferable: (bnsNamespace as DbBnsNamespaceV2).manager_transferable, + manager_frozen: (bnsNamespace as DbBnsNamespaceV2).manager_frozen, + namespace_import: (bnsNamespace as DbBnsNamespaceV2).namespace_import, + reveal_block: bnsNamespace.reveal_block, + launched_at: bnsNamespace.launched_at ?? null, + launch_block: (bnsNamespace as DbBnsNamespaceV2).launch_block, + lifetime: bnsNamespace.lifetime, + can_update_price_function: (bnsNamespace as DbBnsNamespaceV2).can_update_price_function, + buckets: bnsNamespace.buckets, + base: bnsNamespace.base.toString(), + coeff: bnsNamespace.coeff.toString(), + nonalpha_discount: bnsNamespace.nonalpha_discount.toString(), + no_vowel_discount: bnsNamespace.no_vowel_discount.toString(), + status: bnsNamespace.status ?? null, + tx_index: bnsNamespace.tx_index, + tx_id: bnsNamespace.tx_id, + canonical: bnsNamespace.canonical, + index_block_hash: blockData.index_block_hash, + parent_index_block_hash: blockData.parent_index_block_hash, + microblock_hash: blockData.microblock_hash, + microblock_sequence: blockData.microblock_sequence, + microblock_canonical: blockData.microblock_canonical, + }; + await sql` + INSERT INTO namespaces_v2 ${sql(values)} + `; + } + async insertZonefileBatch(sql: PgSqlClient, values: BnsZonefileInsertValues[]) { await sql` INSERT INTO zonefiles ${sql(values)} diff --git a/src/event-replay/parquet-based/importers/new-block-importer.ts b/src/event-replay/parquet-based/importers/new-block-importer.ts index d34f0c8458..2867477c7b 100644 --- a/src/event-replay/parquet-based/importers/new-block-importer.ts +++ b/src/event-replay/parquet-based/importers/new-block-importer.ts @@ -14,6 +14,8 @@ import { BnsNameInsertValues, BnsZonefileInsertValues, DataStoreBlockUpdateData, + BnsNameV2InsertValues, + DbBnsNameV2, } from '../../../datastore/common'; import { validateZonefileHash } from '../../../datastore/helpers'; import { logger } from '../../../logger'; @@ -146,6 +148,15 @@ const populateBatchInserters = (db: PgWriteStore) => { }); batchInserters.push(dbNameBatchInserter); + const dbNameV2BatchInserter = createBatchInserter({ + batchSize: 500, + insertFn: entries => { + logger.debug({ component: 'event-replay' }, 'Inserting into names_v2 table...'); + return db.insertNameV2Batch(db.sql, entries); + }, + }); + batchInserters.push(dbNameV2BatchInserter); + const dbZonefileBatchInserter = createBatchInserter({ batchSize: 500, insertFn: entries => { @@ -346,6 +357,35 @@ const populateBatchInserters = (db: PgWriteStore) => { } }; + const insertNamesV2 = async (dbData: DataStoreBlockUpdateData) => { + for (const entry of dbData.txs) { + await dbNameV2BatchInserter.push( + entry.namesV2.map((bnsNameV2: DbBnsNameV2) => ({ + fullName: bnsNameV2.fullName, + name: bnsNameV2.name, + namespace_id: bnsNameV2.namespace_id, + registered_at: bnsNameV2.registered_at ?? null, + imported_at: bnsNameV2.imported_at ?? null, + hashed_salted_fqn_preorder: bnsNameV2.hashed_salted_fqn_preorder ?? null, + preordered_by: bnsNameV2.preordered_by ?? null, + renewal_height: bnsNameV2.renewal_height, + stx_burn: bnsNameV2.stx_burn, + owner: bnsNameV2.owner, + tx_id: bnsNameV2.tx_id, + tx_index: bnsNameV2.tx_index, + event_index: bnsNameV2.event_index ?? null, + status: bnsNameV2.status ?? null, + canonical: bnsNameV2.canonical, + index_block_hash: entry.tx.index_block_hash, + parent_index_block_hash: entry.tx.parent_index_block_hash, + microblock_hash: entry.tx.microblock_hash, + microblock_sequence: entry.tx.microblock_sequence, + microblock_canonical: entry.tx.microblock_canonical, + })) + ); + } + }; + const insertZoneFiles = async (dbData: DataStoreBlockUpdateData) => { for (const entry of dbData.txs) { await dbZonefileBatchInserter.push( @@ -374,6 +414,14 @@ const populateBatchInserters = (db: PgWriteStore) => { } }; + const insertNamespacesV2 = async (dbData: DataStoreBlockUpdateData) => { + for (const entry of dbData.txs) { + for (const namespace of entry.namespacesV2) { + await db.insertNamespaceV2(db.sql, entry.tx, namespace); + } + } + }; + const insertStxLockEvents = async (dbData: DataStoreBlockUpdateData) => { for (const entry of dbData.txs) { await db.updateStxLockEvents(db.sql, [entry]); @@ -421,12 +469,14 @@ const populateBatchInserters = (db: PgWriteStore) => { insertNFTEvents(dbData), // Insert names insertNames(dbData), + insertNamesV2(dbData), // Insert zonefiles insertZoneFiles(dbData), // Insert smart_contracts insertSmartContracts(dbData), // Insert namespaces insertNamespaces(dbData), + insertNamespacesV2(dbData), // Insert stx_lock_events insertStxLockEvents(dbData), // Insert miner_rewards diff --git a/src/event-stream/bnsV2/bnsV2-constants.ts b/src/event-stream/bnsV2/bnsV2-constants.ts new file mode 100644 index 0000000000..e9467efc38 --- /dev/null +++ b/src/event-stream/bnsV2/bnsV2-constants.ts @@ -0,0 +1,27 @@ +export const BnsErrors = { + NoSuchNamespace: { + error: 'No such namespace', + }, + InvalidPageNumber: { + error: 'Invalid page', + }, + NoSuchName: { + error: 'No such name', + }, + InvalidNameOrSubdomain: { + error: 'Invalid name or subdomain', + }, +}; + +export const printTopic = 'print'; +export const enum BnsV2ContractIdentifier { + mainnet = 'SP2JMM3PH9AGMASBD11SHG4HWDS6CTY9MGN6CW48G.BNS-V2', + testnet = 'ST000000000000000000002AMW42H.BNS-V2', +} + +export const enum BnsV2ZonefileContractIdentifier { + mainnet = 'SP2JMM3PH9AGMASBD11SHG4HWDS6CTY9MGN6CW48G.BNS-V2-ZONEFILE', + testnet = 'ST000000000000000000002AMW42H.BNS-V2-ZONEFILE', +} + +export const bnsBlockchain = 'stacks'; diff --git a/src/event-stream/bnsV2/bnsV2-helpers.ts b/src/event-stream/bnsV2/bnsV2-helpers.ts new file mode 100644 index 0000000000..44b9d31559 --- /dev/null +++ b/src/event-stream/bnsV2/bnsV2-helpers.ts @@ -0,0 +1,431 @@ +import { BufferCV, ClarityType, hexToCV } from '@stacks/transactions'; +import { bnsNameCV, ChainID, getChainIDNetwork } from '../../helpers'; +import { CoreNodeEvent, CoreNodeEventType, CoreNodeParsedTxMessage } from '../core-node-message'; +import { getCoreNodeEndpoint } from '../../core-rpc/client'; +import { StacksMainnet, StacksTestnet } from '@stacks/network'; +import { URIType } from 'zone-file/dist/zoneFile'; +import { BnsV2ContractIdentifier, printTopic } from './bnsV2-constants'; +import * as crypto from 'crypto'; +import { + ClarityTypeID, + decodeClarityValue, + ClarityValueBuffer, + ClarityValueList, + ClarityValueOptionalUInt, + ClarityValuePrincipalStandard, + ClarityValueStringAscii, + ClarityValueTuple, + ClarityValueUInt, + TxPayloadTypeID, + ClarityValueOptional, + ClarityValueBool, + ClarityValuePrincipalContract, +} from 'stacks-encoding-native-js'; +import { SmartContractEvent } from '../core-node-message'; +import { DbBnsNamespaceV2, DbBnsNameV2 } from '../../datastore/common'; +import { hexToBuffer, hexToUtf8String } from '@hirosystems/api-toolkit'; + +export function GetStacksNetwork(chainId: ChainID) { + const url = `http://${getCoreNodeEndpoint()}`; + const network = + getChainIDNetwork(chainId) === 'mainnet' + ? new StacksMainnet({ url }) + : new StacksTestnet({ url }); + return network; +} + +export function getBnsV2ContractID(chainId: ChainID) { + const contractId = + getChainIDNetwork(chainId) === 'mainnet' + ? BnsV2ContractIdentifier.mainnet + : BnsV2ContractIdentifier.testnet; + return contractId; +} + +function isEventFromBnsV2Contract(event: SmartContractEvent): boolean { + return ( + event.committed === true && + event.contract_event.topic === printTopic && + (event.contract_event.contract_identifier === BnsV2ContractIdentifier.mainnet || + event.contract_event.contract_identifier === BnsV2ContractIdentifier.testnet) + ); +} + +export function parseNameV2RawValue( + rawValue: string, + block: number, + txid: string, + txIndex: number +): DbBnsNameV2 { + const cl_val = decodeClarityValue< + ClarityValueTuple<{ + topic: ClarityValueStringAscii; + owner: ClarityValuePrincipalStandard | ClarityValuePrincipalContract; + name: ClarityValueTuple<{ name: ClarityValueBuffer; namespace: ClarityValueBuffer }>; + id: ClarityValueUInt; + properties: ClarityValueTuple<{ + 'registered-at': ClarityValueOptionalUInt; + 'imported-at': ClarityValueOptionalUInt; + 'hashed-salted-fqn-preorder': ClarityValueOptional; + 'preordered-by': ClarityValueOptional< + ClarityValuePrincipalStandard | ClarityValuePrincipalContract + >; + 'renewal-height': ClarityValueUInt; + 'stx-burn': ClarityValueUInt; + owner: ClarityValuePrincipalStandard | ClarityValuePrincipalContract; + }>; + }> + >(rawValue); + if (cl_val.type_id !== ClarityTypeID.Tuple) { + throw Error('Invalid clarity type'); + } + const properties = cl_val.data.properties; + const nameCV = cl_val.data.name; + + const nameBuffer = nameCV.data.name.buffer; + const namespaceBuffer = nameCV.data.namespace.buffer; + + const name = hexToUtf8String(nameBuffer); + const namespace = hexToUtf8String(namespaceBuffer); + + const fullName = `${name}.${namespace}`; + + const registeredAtCV = properties.data['registered-at']; + const registered_at = + registeredAtCV.type_id === ClarityTypeID.OptionalSome + ? Number(registeredAtCV.value.value) + : undefined; + const importedAtCV = properties.data['imported-at']; + const imported_at = + importedAtCV.type_id === ClarityTypeID.OptionalSome + ? Number(importedAtCV.value.value) + : undefined; + const hashedSaltedFqnPreorderCV = properties.data['hashed-salted-fqn-preorder']; + const hashed_salted_fqn_preorder = + hashedSaltedFqnPreorderCV.type_id === ClarityTypeID.OptionalSome + ? hashedSaltedFqnPreorderCV.value.buffer + : undefined; + const preorderedByCV = properties.data['preordered-by']; + const preordered_by = + preorderedByCV.type_id === ClarityTypeID.OptionalSome + ? preorderedByCV.value.address + : undefined; + const renewalHeightCV = properties.data['renewal-height']; + const renewal_height = Number(renewalHeightCV.value); + const stxBurnCV = properties.data['stx-burn']; + const stx_burn = Number(stxBurnCV.value); + const ownerCV = properties.data.owner; + const owner = ownerCV.address; + + const result: DbBnsNameV2 = { + fullName: fullName, + name: name, + namespace_id: namespace, + registered_at: registered_at, + imported_at: imported_at, + hashed_salted_fqn_preorder: hashed_salted_fqn_preorder, + preordered_by: preordered_by, + renewal_height: renewal_height, + stx_burn: stx_burn, + owner: owner, + tx_id: txid, + tx_index: txIndex, + canonical: true, + }; + + return result; +} + +export function parseNameV2FromContractEvent( + event: SmartContractEvent, + tx: CoreNodeParsedTxMessage, + blockHeight: number +): DbBnsNameV2 | undefined { + if (tx.core_tx.status !== 'success' || !isEventFromBnsV2Contract(event)) { + return; + } + + // Decode the raw Clarity value from the contract event. + const decodedEvent = hexToCV(event.contract_event.raw_value); + + // Check if the decoded event is a tuple containing a 'topic' field. + if ( + decodedEvent.type === ClarityType.Tuple && + decodedEvent.data.topic && + decodedEvent.data.topic.type === ClarityType.StringASCII + ) { + // Extract the topic value from the event. + const topic = decodedEvent.data.topic.data; + + // Define the list of topics that we want to handle. + const topicsToHandle = ['transfer-name', 'burn-name', 'new-name', 'renew-name']; + + // Check if the event's topic is one of the statuses we care about. + if (topicsToHandle.includes(topic)) { + // Parse the namespace data from the event. + const name = parseNameV2RawValue( + event.contract_event.raw_value, + blockHeight, + event.txid, + tx.core_tx.tx_index + ); + return name; + } + } +} + +export function parseNamespaceV2RawValue( + rawValue: string, + launchBlock: number, + txid: string, + txIndex: number +): DbBnsNamespaceV2 | undefined { + const cl_val = decodeClarityValue< + ClarityValueTuple<{ + namespace: ClarityValueBuffer; + status: ClarityValueStringAscii; + properties: ClarityValueTuple<{ + 'namespace-manager': ClarityValueOptional< + ClarityValuePrincipalStandard | ClarityValuePrincipalContract + >; + 'manager-transferable': ClarityValueBool; + 'manager-frozen': ClarityValueBool; + 'namespace-import': ClarityValuePrincipalStandard | ClarityValuePrincipalContract; + 'revealed-at': ClarityValueUInt; + 'launched-at': ClarityValueOptionalUInt; + lifetime: ClarityValueUInt; + 'can-update-price-function': ClarityValueBool; + 'price-function': ClarityValueTuple<{ + base: ClarityValueUInt; + coeff: ClarityValueUInt; + 'no-vowel-discount': ClarityValueUInt; + 'nonalpha-discount': ClarityValueUInt; + buckets: ClarityValueList; + }>; + }>; + }> + >(rawValue); + if (cl_val.type_id !== ClarityTypeID.Tuple) { + throw new Error('Invalid clarity type'); + } + + const namespaceCV = cl_val.data.namespace; + const namespace = hexToUtf8String(namespaceCV.buffer); + + const statusCV = cl_val.data.status; + const status = statusCV.data; + + const properties = cl_val.data.properties; + + const namespaceManagerCV = properties.data['namespace-manager']; + const namespace_manager = + namespaceManagerCV.type_id === ClarityTypeID.OptionalSome + ? namespaceManagerCV.value.address + : undefined; + + const managerTransferableCV = properties.data['manager-transferable']; + const manager_transferable = managerTransferableCV.value; + + const managerFrozenCV = properties.data['manager-frozen']; + const manager_frozen = managerFrozenCV.value; + + const namespaceImportCV = properties.data['namespace-import']; + const namespace_import = namespaceImportCV.address; + + const revealed_atCV = properties.data['revealed-at']; + const revealed_at = Number(revealed_atCV.value); + + const launched_atCV = properties.data['launched-at']; + const launched_at = + launched_atCV.type_id === ClarityTypeID.OptionalSome + ? Number(launched_atCV.value.value) + : undefined; + + const lifetimeCV = properties.data.lifetime; + const lifetime = Number(lifetimeCV.value); + + const canUpdatePriceFunctionCV = properties.data['can-update-price-function']; + const can_update_price_function = canUpdatePriceFunctionCV.value; + + const price_function = properties.data['price-function']; + + const baseCV = price_function.data.base; + const base = BigInt(baseCV.value); + const coeffCV = price_function.data.coeff; + const coeff = BigInt(coeffCV.value); + const no_vowel_discountCV = price_function.data['no-vowel-discount']; + const no_vowel_discount = BigInt(no_vowel_discountCV.value); + const nonalpha_discountCV = price_function.data['nonalpha-discount']; + const nonalpha_discount = BigInt(nonalpha_discountCV.value); + const bucketsCV = price_function.data.buckets; + const buckets: bigint[] = []; + const listCV = bucketsCV.list; + for (let i = 0; i < listCV.length; i++) { + const cv = listCV[i]; + if (cv.type_id === ClarityTypeID.UInt) { + buckets.push(BigInt(cv.value)); + } + } + + const namespaceBnsV2: DbBnsNamespaceV2 = { + namespace_id: namespace, + namespace_manager: namespace_manager, + manager_transferable: manager_transferable, + manager_frozen: manager_frozen, + namespace_import: namespace_import, + reveal_block: revealed_at, + launched_at: launched_at, + launch_block: launchBlock, + lifetime: lifetime, + can_update_price_function: can_update_price_function, + buckets: buckets.toString(), + base: base, + coeff: coeff, + nonalpha_discount: nonalpha_discount, + no_vowel_discount: no_vowel_discount, + status: status, + tx_id: txid, + tx_index: txIndex, + canonical: true, + }; + return namespaceBnsV2; +} + +export function parseNamespaceFromV2ContractEvent( + event: SmartContractEvent, + tx: CoreNodeParsedTxMessage, + blockHeight: number +): DbBnsNamespaceV2 | undefined { + // Ensure the transaction was successful and the event is from the BNS-V2 contract. + if (tx.core_tx.status !== 'success' || !isEventFromBnsV2Contract(event)) { + return; + } + + // Decode the raw Clarity value from the contract event. + const decodedEvent = hexToCV(event.contract_event.raw_value); + + // Check if the decoded event is a tuple containing a 'status' field. + if ( + decodedEvent.type === ClarityType.Tuple && + decodedEvent.data.status && + decodedEvent.data.status.type === ClarityType.StringASCII + ) { + // Extract the status value from the event. + const status = decodedEvent.data.status.data; + + // Define the list of statuses that we want to handle. + const statusesToHandle = [ + 'launch', + 'transfer-manager', + 'freeze-manager', + 'turn-off-manager-transfers', + 'update-price-manager', + 'freeze-price-manager', + ]; + + // Check if the event's status is one of the statuses we care about. + if (statusesToHandle.includes(status)) { + // Parse the namespace data from the event. + const namespace = parseNamespaceV2RawValue( + event.contract_event.raw_value, + blockHeight, + event.txid, + tx.core_tx.tx_index + ); + return namespace; + } + } +} + +// export function parseNameRenewalWithNoZonefileHashFromContractCall( +// tx: CoreNodeParsedTxMessage, +// chainId: ChainID +// ): DbBnsName | undefined { +// const payload = tx.parsed_tx.payload; +// if ( +// tx.core_tx.status === 'success' && +// payload.type_id === TxPayloadTypeID.ContractCall && +// payload.function_name === 'name-renewal' && +// getBnsV2ContractID(chainId) === `${payload.address}.${payload.contract_name}` && +// payload.function_args.length === 5 && +// hexToCV(payload.function_args[4].hex).type === ClarityType.OptionalNone +// ) { +// const namespace = Buffer.from( +// (hexToCV(payload.function_args[0].hex) as BufferCV).buffer +// ).toString('utf8'); +// const name = Buffer.from((hexToCV(payload.function_args[1].hex) as BufferCV).buffer).toString( +// 'utf8' +// ); +// return { +// name: `${name}.${namespace}`, +// namespace_id: namespace, +// // NOTE: We're not using the `new_owner` argument here because there's a bug in the BNS +// // contract that doesn't actually transfer the name to the given principal: +// // https://github.com/stacks-network/stacks-blockchain/issues/2680, maybe this will be fixed +// // in Stacks 2.1 +// address: tx.sender_address, +// // expire_block will be calculated upon DB insert based on the namespace's lifetime. +// expire_block: 0, +// registered_at: tx.block_height, +// // Since we received no zonefile_hash, the previous one will be reused when writing to DB. +// zonefile_hash: '', +// zonefile: '', +// tx_id: tx.parsed_tx.tx_id, +// tx_index: tx.core_tx.tx_index, +// event_index: undefined, +// status: 'name-renewal', +// canonical: true, +// }; +// } +// } + +// export function parseResolver(uri: URIType[]) { +// let resolver = ''; +// uri.forEach(item => { +// if (item.name?.includes('resolver')) { +// resolver = item.target; +// } +// }); +// return resolver; +// } + +// interface ZoneFileTXT { +// owner: string; +// seqn: string; +// parts: string; +// zoneFile: string; +// zoneFileHash: string; +// } + +// export function parseZoneFileTxt(txtEntries: string | string[]) { +// const txt = Array.isArray(txtEntries) ? txtEntries : [txtEntries]; +// const parsed: ZoneFileTXT = { +// owner: '', +// seqn: '', +// parts: '', +// zoneFile: '', +// zoneFileHash: '', +// }; + +// let zoneFile = ''; +// for (let i = 0; i < txt.length; i++) { +// const [key, value] = txt[i].split('='); +// if (key == 'owner') { +// parsed.owner = value; +// } else if (key == 'seqn') { +// parsed.seqn = value; +// } else if (key == 'parts') { +// parsed.parts = value; +// } else if (key.startsWith('zf')) { +// zoneFile += value; +// } +// } +// parsed.zoneFile = Buffer.from(zoneFile, 'base64').toString('ascii'); +// parsed.zoneFileHash = crypto +// .createHash('sha256') +// .update(Buffer.from(zoneFile, 'base64')) +// .digest() +// .slice(16) +// .toString('hex'); +// return parsed; +// } diff --git a/src/event-stream/event-server.ts b/src/event-stream/event-server.ts index e31de16c9f..5707ce04ef 100644 --- a/src/event-stream/event-server.ts +++ b/src/event-stream/event-server.ts @@ -80,6 +80,10 @@ import { logger } from '../logger'; import * as zoneFileParser from 'zone-file'; import { hexToBuffer, isProdEnv, PINO_LOGGER_CONFIG, stopwatch } from '@hirosystems/api-toolkit'; import { POX_2_CONTRACT_NAME, POX_3_CONTRACT_NAME, POX_4_CONTRACT_NAME } from '../pox-helpers'; +import { + parseNamespaceFromV2ContractEvent, + parseNameV2FromContractEvent, +} from './bnsV2/bnsV2-helpers'; const IBD_PRUNABLE_ROUTES = ['/new_mempool_tx', '/drop_mempool_tx', '/new_microblocks']; @@ -439,7 +443,9 @@ function parseDataStoreTxEventData( contractLogEvents: [], smartContracts: [], names: [], + namesV2: [], namespaces: [], + namespacesV2: [], pox2Events: [], pox3Events: [], pox4Events: [], @@ -581,10 +587,22 @@ function parseDataStoreTxEventData( if (name) { dbTx.names.push(name); } + const nameV2 = parseNameV2FromContractEvent(event, parsedTx, blockData.block_height); + if (nameV2) { + dbTx.namesV2.push(nameV2); + } const namespace = parseNamespaceFromContractEvent(event, parsedTx, blockData.block_height); if (namespace) { dbTx.namespaces.push(namespace); } + const namespaceV2 = parseNamespaceFromV2ContractEvent( + event, + parsedTx, + blockData.block_height + ); + if (namespaceV2) { + dbTx.namespacesV2.push(namespaceV2); + } break; } case CoreNodeEventType.StxLockEvent: {