diff --git a/src/datastore/postgres-store.ts b/src/datastore/postgres-store.ts index 9dd524ca..b93ae8e9 100644 --- a/src/datastore/postgres-store.ts +++ b/src/datastore/postgres-store.ts @@ -3451,20 +3451,11 @@ export class PgDataStore const hexTxIds = args.txIds.map(txId => hexToBuffer(txId)); const result = await client.query( ` - SELECT ${MEMPOOL_TX_COLUMNS}, - CASE - WHEN mempool_txs.type_id = $2 THEN ( - SELECT abi - FROM smart_contracts - WHERE smart_contracts.contract_id = mempool_txs.contract_call_contract_id - ORDER BY abi != 'null' DESC, canonical DESC, microblock_canonical DESC, block_height DESC - LIMIT 1 - ) - END as abi + SELECT ${MEMPOOL_TX_COLUMNS}, ${abiColumn('mempool_txs')} FROM mempool_txs WHERE tx_id = ANY($1) `, - [hexTxIds, DbTxTypeId.ContractCall] + [hexTxIds] ); return await this.parseMempoolTransactions(result, client, args.includeUnanchored); }); @@ -3482,20 +3473,11 @@ export class PgDataStore return this.queryTx(async client => { const result = await client.query( ` - SELECT ${MEMPOOL_TX_COLUMNS}, - CASE - WHEN mempool_txs.type_id = $2 THEN ( - SELECT abi - FROM smart_contracts - WHERE smart_contracts.contract_id = mempool_txs.contract_call_contract_id - ORDER BY abi != 'null' DESC, canonical DESC, microblock_canonical DESC, block_height DESC - LIMIT 1 - ) - END as abi + SELECT ${MEMPOOL_TX_COLUMNS}, ${abiColumn('mempool_txs')} FROM mempool_txs WHERE tx_id = $1 `, - [hexToBuffer(txId), DbTxTypeId.ContractCall] + [hexToBuffer(txId)] ); // Treat the tx as "not pruned" if it's in an unconfirmed microblock and the caller is has not opted-in to unanchored data. if (result.rows[0]?.pruned && !includeUnanchored) { @@ -3548,7 +3530,7 @@ export class PgDataStore const selectCols = MEMPOOL_TX_COLUMNS.replace('tx_id', 'mempool.tx_id'); const resultQuery = await client.query( ` - SELECT ${selectCols}, COUNT(*) OVER() AS count + SELECT ${selectCols}, ${abiColumn('mempool')}, COUNT(*) OVER() AS count FROM ( SELECT * FROM mempool_txs @@ -3633,7 +3615,7 @@ export class PgDataStore ); const resultQuery = await client.query( ` - SELECT ${MEMPOOL_TX_COLUMNS} + SELECT ${MEMPOOL_TX_COLUMNS}, ${abiColumn('mempool_txs')} FROM mempool_txs WHERE ${whereCondition} ORDER BY receipt_time DESC @@ -5689,7 +5671,10 @@ export class PgDataStore } const txMempoolQuery = await client.query( - `SELECT ${MEMPOOL_TX_COLUMNS} FROM mempool_txs WHERE pruned = false AND tx_id = $1 LIMIT 1`, + ` + SELECT ${MEMPOOL_TX_COLUMNS}, ${abiColumn('mempool_txs')} + FROM mempool_txs WHERE pruned = false AND tx_id = $1 LIMIT 1 + `, [hexToBuffer(hash)] ); if (txMempoolQuery.rowCount > 0) { @@ -5736,7 +5721,10 @@ export class PgDataStore return await this.query(async client => { if (isContract) { const contractMempoolTxResult = await client.query( - `SELECT ${MEMPOOL_TX_COLUMNS} from mempool_txs WHERE pruned = false AND smart_contract_contract_id = $1 LIMIT 1`, + ` + SELECT ${MEMPOOL_TX_COLUMNS}, ${abiColumn('mempool_txs')} + FROM mempool_txs WHERE pruned = false AND smart_contract_contract_id = $1 LIMIT 1 + `, [principal] ); if (contractMempoolTxResult.rowCount > 0) { diff --git a/src/tests/api-tests.ts b/src/tests/api-tests.ts index 8587f3fd..20ab9f86 100644 --- a/src/tests/api-tests.ts +++ b/src/tests/api-tests.ts @@ -50,6 +50,7 @@ import { PoolClient } from 'pg'; import { bufferToHexPrefixString, I32_MAX, microStxToStx, STACKS_DECIMAL_PLACES } from '../helpers'; import { FEE_RATE } from './../api/routes/fee-rate'; import { Block, FeeRateRequest } from 'docs/generated'; +import { TestBlockBuilder, TestMempoolTxBuilder } from './test-helpers'; describe('api tests', () => { let db: PgDataStore; @@ -1707,6 +1708,75 @@ describe('api tests', () => { expect(JSON.parse(searchResult7.text)).toEqual(expectedResp7); }); + test('mempool - contract_call tx abi details are retrieved', async () => { + const block1 = new TestBlockBuilder() + .addTx() + .addTxSmartContract() + .addTxContractLogEvent() + .build(); + await db.update(block1); + + const mempoolTx1 = new TestMempoolTxBuilder({ + type_id: DbTxTypeId.ContractCall, + tx_id: '0x1232000000000000000000000000000000000000000000000000000000000000', + }).build(); + await db.updateMempoolTxs({ mempoolTxs: [mempoolTx1] }); + + const expectedContractDetails = { + contract_id: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world', + function_args: [ + { + hex: '0x010000000000000000000000000001e240', + name: 'amount', + repr: 'u123456', + type: 'uint', + }, + ], + function_name: 'test-contract-fn', + function_signature: '(define-public (test-contract-fn (amount uint)))', + }; + + // Mempool txs + const mempoolResults = await supertest(api.server).get(`/extended/v1/tx/mempool`); + expect(mempoolResults.status).toBe(200); + expect(mempoolResults.type).toBe('application/json'); + expect(JSON.parse(mempoolResults.text).results[0].contract_call).toEqual( + expectedContractDetails + ); + + // Search mempool tx metadata + const searchResults = await supertest(api.server).get( + `/extended/v1/search/${mempoolTx1.tx_id}?include_metadata=true` + ); + expect(searchResults.status).toBe(200); + expect(searchResults.type).toBe('application/json'); + expect(JSON.parse(searchResults.text).result.metadata.contract_call).toEqual( + expectedContractDetails + ); + + // Search principal metadata + const searchPrincipalResults = await supertest(api.server).get( + `/extended/v1/search/${expectedContractDetails.contract_id}?include_metadata=true` + ); + expect(searchPrincipalResults.status).toBe(200); + expect(searchPrincipalResults.type).toBe('application/json'); + expect(JSON.parse(searchPrincipalResults.text).result.metadata.contract_call).toEqual( + expectedContractDetails + ); + + // Dropped mempool tx + await db.dropMempoolTxs({ + status: DbTxStatus.DroppedReplaceAcrossFork, + txIds: [mempoolTx1.tx_id], + }); + const mempoolDropResults = await supertest(api.server).get(`/extended/v1/tx/mempool/dropped`); + expect(mempoolDropResults.status).toBe(200); + expect(mempoolDropResults.type).toBe('application/json'); + expect(JSON.parse(mempoolDropResults.text).results[0].contract_call).toEqual( + expectedContractDetails + ); + }); + test('search term - hash', async () => { const block: DbBlock = { block_hash: '0x1234000000000000000000000000000000000000000000000000000000000000', diff --git a/src/tests/test-helpers.ts b/src/tests/test-helpers.ts index be13084f..f0e1d193 100644 --- a/src/tests/test-helpers.ts +++ b/src/tests/test-helpers.ts @@ -1,3 +1,13 @@ +import { I32_MAX } from '../helpers'; +import { + DataStoreBlockUpdateData, + DbEventTypeId, + DbMempoolTx, + DbTxTypeId, +} from '../datastore/common'; +import { bufferCVFromString, serializeCV, uintCV } from '@stacks/transactions'; +import { createClarityValueArray } from '../p2p/tx'; + // Hack to avoid jest outputting 'Your test suite must contain at least one test.' // https://stackoverflow.com/a/59864054/794962 test.skip('test-ignore-kludge', () => 1); @@ -66,3 +76,203 @@ export function withEnvVars(...envVars: TestEnvVar[]) { }); }; } + +/** + * Builder that creates a test block with any number of transactions and events so populating + * the DB for testing becomes easier. + * + * The output of `build()` can be used in a `db.update()` call to process the block just as + * if it came from the Event Server. + */ +export class TestBlockBuilder { + // Default values when none given. Useful when they are irrelevant for a particular test. + public static readonly SENDER_ADDRESS = 'SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27'; + public static readonly CONTRACT_ID = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y.hello-world'; + public static readonly CONTRACT_ABI = { + maps: [], + functions: [ + { + args: [{ type: 'uint128', name: 'amount' }], + name: 'test-contract-fn', + access: 'public', + outputs: { + type: { + response: { + ok: 'uint128', + error: 'none', + }, + }, + }, + }, + ], + variables: [], + fungible_tokens: [], + non_fungible_tokens: [], + }; + public static readonly CONTRACT_SOURCE = '(some-contract-src)'; + public static readonly CONTRACT_CALL_FUNCTION_NAME = 'test-contract-fn'; + + private data: DataStoreBlockUpdateData; + private txIndex = 0; + + constructor(args?: { block_height?: number; block_hash?: string }) { + this.data = { + block: { + block_hash: args?.block_hash ?? '0x1234', + index_block_hash: '0xdeadbeef', + parent_index_block_hash: '0x00', + parent_block_hash: '0xff0011', + parent_microblock_hash: '', + block_height: args?.block_height ?? 1, + burn_block_time: 94869286, + burn_block_hash: '0x1234', + burn_block_height: 123, + miner_txid: '0x4321', + canonical: true, + parent_microblock_sequence: 0, + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }, + microblocks: [], + minerRewards: [], + txs: [], + }; + } + + addTx(args?: { + sender_address?: string; + type_id?: DbTxTypeId; + tx_id?: string; + }): TestBlockBuilder { + this.data.txs.push({ + tx: { + tx_id: args?.tx_id ?? '0x01', + tx_index: 0, + anchor_mode: 3, + nonce: 0, + raw_tx: Buffer.alloc(0), + index_block_hash: this.data.block.index_block_hash, + block_hash: this.data.block.block_hash, + block_height: this.data.block.block_height, + burn_block_time: this.data.block.burn_block_time, + parent_burn_block_time: 1626122935, + type_id: args?.type_id ?? DbTxTypeId.Coinbase, + status: 1, + raw_result: '0x0100000000000000000000000000000001', // u1 + canonical: true, + post_conditions: Buffer.from([0x01, 0xf5]), + fee_rate: 1234n, + sponsored: false, + sponsor_address: undefined, + sender_address: args?.sender_address ?? TestBlockBuilder.SENDER_ADDRESS, + origin_hash_mode: 1, + coinbase_payload: Buffer.from('hi'), + event_count: 1, + parent_index_block_hash: '', + parent_block_hash: '', + microblock_canonical: true, + microblock_sequence: I32_MAX, + microblock_hash: '', + execution_cost_read_count: 0, + execution_cost_read_length: 0, + execution_cost_runtime: 0, + execution_cost_write_count: 0, + execution_cost_write_length: 0, + }, + stxLockEvents: [], + stxEvents: [], + ftEvents: [], + nftEvents: [], + contractLogEvents: [], + smartContracts: [], + names: [], + namespaces: [], + }); + this.txIndex = this.data.txs.length - 1; + return this; + } + + addTxContractLogEvent(args?: { contract_identifier?: string }): TestBlockBuilder { + this.data.txs[this.txIndex].contractLogEvents.push({ + event_index: 4, + tx_id: this.data.txs[this.txIndex].tx.tx_id, + tx_index: 0, + block_height: this.data.block.block_height, + canonical: true, + event_type: DbEventTypeId.SmartContractLog, + contract_identifier: args?.contract_identifier ?? TestBlockBuilder.CONTRACT_ID, + topic: 'some-topic', + value: serializeCV(bufferCVFromString('some val')), + }); + return this; + } + + addTxSmartContract(args?: { contract_id?: string; abi?: string }): TestBlockBuilder { + this.data.txs[this.txIndex].smartContracts.push({ + tx_id: this.data.txs[this.txIndex].tx.tx_id, + canonical: true, + block_height: this.data.block.block_height, + contract_id: args?.contract_id ?? TestBlockBuilder.CONTRACT_ID, + source_code: TestBlockBuilder.CONTRACT_SOURCE, + abi: args?.abi ?? JSON.stringify(TestBlockBuilder.CONTRACT_ABI), + }); + return this; + } + + build(): DataStoreBlockUpdateData { + return this.data; + } +} + +/** + * Builder that creates a test mempool transaction so populating the DB for testing becomes easier. + * + * The output of `build()` can be used in a `db.updateMempoolTxs()` call to process the tx just as + * if it came from the Event Server. + */ +export class TestMempoolTxBuilder { + data: DbMempoolTx; + + constructor(args?: { + type_id?: DbTxTypeId; + sender_address?: string; + tx_id?: string; + smart_contract_contract_id?: string; + contract_call_contract_id?: string; + contract_call_function_name?: string; + contract_call_function_args?: Buffer; + }) { + // If not given, default values are taken from `TestBlockBuilder` for consistency. + this.data = { + pruned: false, + tx_id: args?.tx_id ?? `0x1234`, + anchor_mode: 3, + nonce: 0, + raw_tx: Buffer.from('test-raw-tx'), + type_id: args?.type_id ?? DbTxTypeId.TokenTransfer, + receipt_time: (new Date().getTime() / 1000) | 0, + status: 1, + post_conditions: Buffer.from([0x01, 0xf5]), + fee_rate: 1234n, + sponsored: false, + sponsor_address: undefined, + origin_hash_mode: 1, + sender_address: args?.sender_address ?? TestBlockBuilder.SENDER_ADDRESS, + token_transfer_amount: 1234n, + token_transfer_memo: Buffer.alloc(0), + smart_contract_contract_id: args?.smart_contract_contract_id ?? TestBlockBuilder.CONTRACT_ID, + contract_call_contract_id: args?.contract_call_contract_id ?? TestBlockBuilder.CONTRACT_ID, + contract_call_function_name: + args?.contract_call_function_name ?? TestBlockBuilder.CONTRACT_CALL_FUNCTION_NAME, + contract_call_function_args: + args?.contract_call_function_args ?? createClarityValueArray(uintCV(123456)), + }; + } + + build(): DbMempoolTx { + return this.data; + } +}