diff --git a/migrations/1753214286445_txs-status-index.js b/migrations/1753214286445_txs-status-index.js new file mode 100644 index 000000000..5ecc966f2 --- /dev/null +++ b/migrations/1753214286445_txs-status-index.js @@ -0,0 +1,16 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = pgm => { + pgm.createIndex('txs', 'status', { + where: 'canonical = TRUE AND microblock_canonical = TRUE', + name: 'idx_txs_status_optimized' + }); +}; + +exports.down = pgm => { + pgm.dropIndex('txs', 'status', { + name: 'idx_txs_status_optimized' + }); +}; diff --git a/src/api/controllers/db-controller.ts b/src/api/controllers/db-controller.ts index c613f9818..fff034f58 100644 --- a/src/api/controllers/db-controller.ts +++ b/src/api/controllers/db-controller.ts @@ -111,6 +111,32 @@ export function parseTxTypeStrings(values: string[]): TransactionType[] { }); } +export function parseStatusStrings(values: string[]): string[] { + return values.map(v => { + switch (v) { + case 'success': + case 'abort_by_response': + case 'abort_by_post_condition': + return v; + default: + throw new Error(`Unexpected tx status: ${JSON.stringify(v)}`); + } + }); +} + +export function getStatusId(statusString: string): DbTxStatus[] { + switch (statusString) { + case 'success': + return [DbTxStatus.Success]; + case 'abort_by_response': + return [DbTxStatus.AbortByResponse]; + case 'abort_by_post_condition': + return [DbTxStatus.AbortByPostCondition]; + default: + throw new Error(`Unexpected tx status string: ${statusString}`); + } +} + export function getTxTypeString(typeId: DbTxTypeId): Transaction['tx_type'] { switch (typeId) { case DbTxTypeId.TokenTransfer: diff --git a/src/api/routes/tx.ts b/src/api/routes/tx.ts index 0c33d9104..75f7cbb3a 100644 --- a/src/api/routes/tx.ts +++ b/src/api/routes/tx.ts @@ -1,5 +1,6 @@ import { parseTxTypeStrings, + parseStatusStrings, parseDbMempoolTx, searchTx, searchTxs, @@ -42,6 +43,7 @@ import { Transaction, TransactionSchema, TransactionSearchResponseSchema, + TransactionStatusSchema, TransactionTypeSchema, } from '../schemas/entities/transactions'; import { PaginatedResponse } from '../schemas/util'; @@ -68,6 +70,9 @@ export const TxRoutes: FastifyPluginAsync< if (typeof req.query.type === 'string') { req.query.type = (req.query.type as string).split(',') as typeof req.query.type; } + if (typeof req.query.status === 'string') { + req.query.status = (req.query.status as string).split(',') as typeof req.query.status; + } done(); }, schema: { @@ -79,6 +84,7 @@ export const TxRoutes: FastifyPluginAsync< offset: OffsetParam(), limit: LimitParam(ResourceType.Tx), type: Type.Optional(Type.Array(TransactionTypeSchema)), + status: Type.Optional(Type.Array(TransactionStatusSchema)), unanchored: UnanchoredParamSchema, order: Type.Optional(Type.Enum({ asc: 'asc', desc: 'desc' })), sort_by: Type.Optional( @@ -147,6 +153,7 @@ export const TxRoutes: FastifyPluginAsync< const excludeFunctionArgs = req.query.exclude_function_args ?? false; const txTypeFilter = parseTxTypeStrings(req.query.type ?? []); + const statusFilter = parseStatusStrings(req.query.status ?? []); let fromAddress: string | undefined; if (typeof req.query.from_address === 'string') { @@ -185,6 +192,7 @@ export const TxRoutes: FastifyPluginAsync< offset, limit, txTypeFilter, + statusFilter, includeUnanchored: req.query.unanchored ?? false, fromAddress, toAddress, diff --git a/src/api/schemas/entities/transactions.ts b/src/api/schemas/entities/transactions.ts index 2491e82c7..b3a6c6fd0 100644 --- a/src/api/schemas/entities/transactions.ts +++ b/src/api/schemas/entities/transactions.ts @@ -13,6 +13,13 @@ const TransactionType = { } as const; export const TransactionTypeSchema = Type.Enum(TransactionType); +const TransactionStatus = { + success: 'success', + abort_by_response: 'abort_by_response', + abort_by_post_condition: 'abort_by_post_condition', +} as const; +export const TransactionStatusSchema = Type.Enum(TransactionStatus); + export const BaseTransactionSchemaProperties = { tx_id: Type.String({ description: 'Transaction ID', diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 26a29c04a..797854470 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -1,5 +1,10 @@ import { ClarityAbi } from '@stacks/transactions'; -import { getTxTypeId, getTxTypeString, TransactionType } from '../api/controllers/db-controller'; +import { + getTxTypeId, + getTxTypeString, + getStatusId, + TransactionType, +} from '../api/controllers/db-controller'; import { unwrapNotNullish, FoundOrNot, @@ -1410,6 +1415,7 @@ export class PgStore extends BasePgStore { limit, offset, txTypeFilter, + statusFilter, includeUnanchored, fromAddress, toAddress, @@ -1424,6 +1430,7 @@ export class PgStore extends BasePgStore { limit: number; offset: number; txTypeFilter: TransactionType[]; + statusFilter: string[]; includeUnanchored: boolean; fromAddress?: string; toAddress?: string; @@ -1459,6 +1466,12 @@ export class PgStore extends BasePgStore { txTypeFilter.length > 0 ? sql`AND type_id IN ${sql(txTypeFilter.flatMap(t => getTxTypeId(t)))}` : sql``; + + const statusFilterSql = + statusFilter.length > 0 + ? sql`AND status IN ${sql(statusFilter.flatMap(s => getStatusId(s)))}` + : sql``; + const fromAddressFilterSql = fromAddress ? sql`AND sender_address = ${fromAddress}` : sql``; const toAddressFilterSql = toAddress ? sql`AND token_transfer_recipient_address = ${toAddress}` @@ -1474,6 +1487,7 @@ export class PgStore extends BasePgStore { const nonceFilterSql = nonce ? sql`AND nonce = ${nonce}` : sql``; const noFilters = txTypeFilter.length === 0 && + statusFilter.length === 0 && !fromAddress && !toAddress && !startTime && @@ -1492,6 +1506,7 @@ export class PgStore extends BasePgStore { FROM txs WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight} ${txTypeFilterSql} + ${statusFilterSql} ${fromAddressFilterSql} ${toAddressFilterSql} ${startTimeFilterSql} @@ -1506,6 +1521,7 @@ export class PgStore extends BasePgStore { FROM txs WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight} ${txTypeFilterSql} + ${statusFilterSql} ${fromAddressFilterSql} ${toAddressFilterSql} ${startTimeFilterSql} diff --git a/tests/api/tx.test.ts b/tests/api/tx.test.ts index 5871de087..3fb506b13 100644 --- a/tests/api/tx.test.ts +++ b/tests/api/tx.test.ts @@ -2720,6 +2720,149 @@ describe('tx tests', () => { ); }); + test('tx list - filter by status', async () => { + const testSenderAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y'; + const block1 = new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x01', + burn_block_time: 1710000000, + }) + .addTx({ + tx_id: '0x1234', + fee_rate: 1n, + sender_address: testSenderAddr, + status: DbTxStatus.Success, + type_id: DbTxTypeId.TokenTransfer, + token_transfer_amount: 123456n, + token_transfer_memo: '0x1234', + token_transfer_recipient_address: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y', + }) + .build(); + + await db.update(block1); + + const block2 = new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02', + parent_block_hash: block1.block.block_hash, + parent_index_block_hash: block1.block.index_block_hash, + burn_block_time: 1720000000, + }) + .addTx({ + tx_id: '0x2234', + fee_rate: 3n, + sender_address: testSenderAddr, + status: DbTxStatus.AbortByResponse, + type_id: DbTxTypeId.ContractCall, + contract_call_contract_id: 'SP000000000000000000002Q6VF78.pox-4', + contract_call_function_name: 'delegate-stx', + contract_call_function_args: bufferToHex( + createClarityValueArray(uintCV(123456), stringAsciiCV('hello')) + ), + }) + .build(); + await db.update(block2); + + const block3 = new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x03', + parent_block_hash: block2.block.block_hash, + parent_index_block_hash: block2.block.index_block_hash, + burn_block_time: 1730000000, + }) + .addTx({ + tx_id: '0x3234', + fee_rate: 2n, + sender_address: testSenderAddr, + status: DbTxStatus.AbortByPostCondition, + type_id: DbTxTypeId.TokenTransfer, + token_transfer_amount: 123456n, + token_transfer_memo: '0x1234', + token_transfer_recipient_address: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y', + }) + .build(); + await db.update(block3); + + const txsReq1 = await supertest(api.server).get(`/extended/v1/tx?status=success`); + expect(txsReq1.status).toBe(200); + expect(txsReq1.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + tx_status: 'success', + }), + ], + }) + ); + + const txsReq2 = await supertest(api.server).get(`/extended/v1/tx?status=abort_by_response`); + expect(txsReq2.status).toBe(200); + expect(txsReq2.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + tx_status: 'abort_by_response', + }), + ], + }) + ); + + const txsReq3 = await supertest(api.server).get( + `/extended/v1/tx?status=abort_by_response,abort_by_post_condition` + ); + expect(txsReq3.status).toBe(200); + expect(txsReq3.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + tx_status: 'abort_by_post_condition', + }), + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + tx_status: 'abort_by_response', + }), + ], + }) + ); + + const txsReq4 = await supertest(api.server).get( + `/extended/v1/tx?status=success&type=token_transfer` + ); + expect(txsReq4.status).toBe(200); + expect(txsReq4.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + tx_status: 'success', + tx_type: 'token_transfer', + }), + ], + }) + ); + + const txsReq5 = await supertest(api.server).get( + `/extended/v1/tx?status=abort_by_post_condition&type=coinbase` + ); + expect(txsReq5.status).toBe(200); + expect(txsReq5.body).toEqual( + expect.objectContaining({ + results: [], + }) + ); + }); + + test('tx list - invalid status filter', async () => { + const txsReq1 = await supertest(api.server).get(`/extended/v1/tx?status=invalid_status`); + expect(txsReq1.status).toBe(400); + + const txsReq2 = await supertest(api.server).get(`/extended/v1/tx?status=pending`); + expect(txsReq2.status).toBe(400); + }); + test('fetch raw tx', async () => { const block: DbBlock = { block_hash: '0x1234',