Skip to content

Commit 379f278

Browse files
committed
feat: tx type filter
1 parent 0ddd6a6 commit 379f278

File tree

6 files changed

+207
-1
lines changed

6 files changed

+207
-1
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/* eslint-disable camelcase */
2+
3+
exports.shorthands = undefined;
4+
5+
exports.up = pgm => {
6+
pgm.createIndex('txs', 'status', {
7+
where: 'canonical = TRUE AND microblock_canonical = TRUE',
8+
name: 'idx_txs_status_optimized'
9+
});
10+
};
11+
12+
exports.down = pgm => {
13+
pgm.dropIndex('txs', 'status', {
14+
name: 'idx_txs_status_optimized'
15+
});
16+
};

src/api/controllers/db-controller.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,32 @@ export function parseTxTypeStrings(values: string[]): TransactionType[] {
111111
});
112112
}
113113

114+
export function parseStatusStrings(values: string[]): string[] {
115+
return values.map(v => {
116+
switch (v) {
117+
case 'success':
118+
case 'abort_by_response':
119+
case 'abort_by_post_condition':
120+
return v;
121+
default:
122+
throw new Error(`Unexpected tx status: ${JSON.stringify(v)}`);
123+
}
124+
});
125+
}
126+
127+
export function getStatusId(statusString: string): DbTxStatus[] {
128+
switch (statusString) {
129+
case 'success':
130+
return [DbTxStatus.Success];
131+
case 'abort_by_response':
132+
return [DbTxStatus.AbortByResponse];
133+
case 'abort_by_post_condition':
134+
return [DbTxStatus.AbortByPostCondition];
135+
default:
136+
throw new Error(`Unexpected tx status string: ${statusString}`);
137+
}
138+
}
139+
114140
export function getTxTypeString(typeId: DbTxTypeId): Transaction['tx_type'] {
115141
switch (typeId) {
116142
case DbTxTypeId.TokenTransfer:

src/api/routes/tx.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
parseTxTypeStrings,
3+
parseStatusStrings,
34
parseDbMempoolTx,
45
searchTx,
56
searchTxs,
@@ -42,6 +43,7 @@ import {
4243
Transaction,
4344
TransactionSchema,
4445
TransactionSearchResponseSchema,
46+
TransactionStatusSchema,
4547
TransactionTypeSchema,
4648
} from '../schemas/entities/transactions';
4749
import { PaginatedResponse } from '../schemas/util';
@@ -68,6 +70,9 @@ export const TxRoutes: FastifyPluginAsync<
6870
if (typeof req.query.type === 'string') {
6971
req.query.type = (req.query.type as string).split(',') as typeof req.query.type;
7072
}
73+
if (typeof req.query.status === 'string') {
74+
req.query.status = (req.query.status as string).split(',') as typeof req.query.status;
75+
}
7176
done();
7277
},
7378
schema: {
@@ -79,6 +84,7 @@ export const TxRoutes: FastifyPluginAsync<
7984
offset: OffsetParam(),
8085
limit: LimitParam(ResourceType.Tx),
8186
type: Type.Optional(Type.Array(TransactionTypeSchema)),
87+
status: Type.Optional(Type.Array(TransactionStatusSchema)),
8288
unanchored: UnanchoredParamSchema,
8389
order: Type.Optional(Type.Enum({ asc: 'asc', desc: 'desc' })),
8490
sort_by: Type.Optional(
@@ -147,6 +153,7 @@ export const TxRoutes: FastifyPluginAsync<
147153
const excludeFunctionArgs = req.query.exclude_function_args ?? false;
148154

149155
const txTypeFilter = parseTxTypeStrings(req.query.type ?? []);
156+
const statusFilter = parseStatusStrings(req.query.status ?? []);
150157

151158
let fromAddress: string | undefined;
152159
if (typeof req.query.from_address === 'string') {
@@ -185,6 +192,7 @@ export const TxRoutes: FastifyPluginAsync<
185192
offset,
186193
limit,
187194
txTypeFilter,
195+
statusFilter,
188196
includeUnanchored: req.query.unanchored ?? false,
189197
fromAddress,
190198
toAddress,

src/api/schemas/entities/transactions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ const TransactionType = {
1313
} as const;
1414
export const TransactionTypeSchema = Type.Enum(TransactionType);
1515

16+
const TransactionStatus = {
17+
success: 'success',
18+
abort_by_response: 'abort_by_response',
19+
abort_by_post_condition: 'abort_by_post_condition',
20+
} as const;
21+
export const TransactionStatusSchema = Type.Enum(TransactionStatus);
22+
1623
export const BaseTransactionSchemaProperties = {
1724
tx_id: Type.String({
1825
description: 'Transaction ID',

src/datastore/pg-store.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ClarityAbi } from '@stacks/transactions';
2-
import { getTxTypeId, getTxTypeString, TransactionType } from '../api/controllers/db-controller';
2+
import { getTxTypeId, getTxTypeString, getStatusId, TransactionType } from '../api/controllers/db-controller';
33
import {
44
unwrapNotNullish,
55
FoundOrNot,
@@ -1410,6 +1410,7 @@ export class PgStore extends BasePgStore {
14101410
limit,
14111411
offset,
14121412
txTypeFilter,
1413+
statusFilter,
14131414
includeUnanchored,
14141415
fromAddress,
14151416
toAddress,
@@ -1424,6 +1425,7 @@ export class PgStore extends BasePgStore {
14241425
limit: number;
14251426
offset: number;
14261427
txTypeFilter: TransactionType[];
1428+
statusFilter: string[];
14271429
includeUnanchored: boolean;
14281430
fromAddress?: string;
14291431
toAddress?: string;
@@ -1459,6 +1461,12 @@ export class PgStore extends BasePgStore {
14591461
txTypeFilter.length > 0
14601462
? sql`AND type_id IN ${sql(txTypeFilter.flatMap<number>(t => getTxTypeId(t)))}`
14611463
: sql``;
1464+
1465+
const statusFilterSql =
1466+
statusFilter.length > 0
1467+
? sql`AND status IN ${sql(statusFilter.flatMap<number>(s => getStatusId(s)))}`
1468+
: sql``;
1469+
14621470
const fromAddressFilterSql = fromAddress ? sql`AND sender_address = ${fromAddress}` : sql``;
14631471
const toAddressFilterSql = toAddress
14641472
? sql`AND token_transfer_recipient_address = ${toAddress}`
@@ -1474,6 +1482,7 @@ export class PgStore extends BasePgStore {
14741482
const nonceFilterSql = nonce ? sql`AND nonce = ${nonce}` : sql``;
14751483
const noFilters =
14761484
txTypeFilter.length === 0 &&
1485+
statusFilter.length === 0 &&
14771486
!fromAddress &&
14781487
!toAddress &&
14791488
!startTime &&
@@ -1492,6 +1501,7 @@ export class PgStore extends BasePgStore {
14921501
FROM txs
14931502
WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight}
14941503
${txTypeFilterSql}
1504+
${statusFilterSql}
14951505
${fromAddressFilterSql}
14961506
${toAddressFilterSql}
14971507
${startTimeFilterSql}
@@ -1506,6 +1516,7 @@ export class PgStore extends BasePgStore {
15061516
FROM txs
15071517
WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight}
15081518
${txTypeFilterSql}
1519+
${statusFilterSql}
15091520
${fromAddressFilterSql}
15101521
${toAddressFilterSql}
15111522
${startTimeFilterSql}

tests/api/tx.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2720,6 +2720,144 @@ describe('tx tests', () => {
27202720
);
27212721
});
27222722

2723+
test('tx list - filter by status', async () => {
2724+
const testSenderAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y';
2725+
const block1 = new TestBlockBuilder({
2726+
block_height: 1,
2727+
index_block_hash: '0x01',
2728+
burn_block_time: 1710000000,
2729+
})
2730+
.addTx({
2731+
tx_id: '0x1234',
2732+
fee_rate: 1n,
2733+
sender_address: testSenderAddr,
2734+
status: DbTxStatus.Success,
2735+
type_id: DbTxTypeId.TokenTransfer,
2736+
token_transfer_amount: 123456n,
2737+
token_transfer_memo: '0x1234',
2738+
token_transfer_recipient_address: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y',
2739+
})
2740+
.build();
2741+
2742+
await db.update(block1);
2743+
2744+
const block2 = new TestBlockBuilder({
2745+
block_height: 2,
2746+
index_block_hash: '0x02',
2747+
parent_block_hash: block1.block.block_hash,
2748+
parent_index_block_hash: block1.block.index_block_hash,
2749+
burn_block_time: 1720000000,
2750+
})
2751+
.addTx({
2752+
tx_id: '0x2234',
2753+
fee_rate: 3n,
2754+
sender_address: testSenderAddr,
2755+
status: DbTxStatus.AbortByResponse,
2756+
type_id: DbTxTypeId.ContractCall,
2757+
contract_call_contract_id: 'SP000000000000000000002Q6VF78.pox-4',
2758+
contract_call_function_name: 'delegate-stx',
2759+
contract_call_function_args: bufferToHex(
2760+
createClarityValueArray(uintCV(123456), stringAsciiCV('hello'))
2761+
),
2762+
})
2763+
.build();
2764+
await db.update(block2);
2765+
2766+
const block3 = new TestBlockBuilder({
2767+
block_height: 3,
2768+
index_block_hash: '0x03',
2769+
parent_block_hash: block2.block.block_hash,
2770+
parent_index_block_hash: block2.block.index_block_hash,
2771+
burn_block_time: 1730000000,
2772+
})
2773+
.addTx({
2774+
tx_id: '0x3234',
2775+
fee_rate: 2n,
2776+
sender_address: testSenderAddr,
2777+
status: DbTxStatus.AbortByPostCondition,
2778+
type_id: DbTxTypeId.TokenTransfer,
2779+
token_transfer_amount: 123456n,
2780+
token_transfer_memo: '0x1234',
2781+
token_transfer_recipient_address: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y',
2782+
})
2783+
.build();
2784+
await db.update(block3);
2785+
2786+
const txsReq1 = await supertest(api.server).get(`/extended/v1/tx?status=success`);
2787+
expect(txsReq1.status).toBe(200);
2788+
expect(txsReq1.body).toEqual(
2789+
expect.objectContaining({
2790+
results: [
2791+
expect.objectContaining({
2792+
tx_id: block1.txs[0].tx.tx_id,
2793+
tx_status: 'success',
2794+
}),
2795+
],
2796+
})
2797+
);
2798+
2799+
const txsReq2 = await supertest(api.server).get(`/extended/v1/tx?status=abort_by_response`);
2800+
expect(txsReq2.status).toBe(200);
2801+
expect(txsReq2.body).toEqual(
2802+
expect.objectContaining({
2803+
results: [
2804+
expect.objectContaining({
2805+
tx_id: block2.txs[0].tx.tx_id,
2806+
tx_status: 'abort_by_response',
2807+
}),
2808+
],
2809+
})
2810+
);
2811+
2812+
const txsReq3 = await supertest(api.server).get(
2813+
`/extended/v1/tx?status=abort_by_response,abort_by_post_condition`
2814+
);
2815+
expect(txsReq3.status).toBe(200);
2816+
expect(txsReq3.body).toEqual(
2817+
expect.objectContaining({
2818+
results: [
2819+
expect.objectContaining({
2820+
tx_id: block3.txs[0].tx.tx_id,
2821+
tx_status: 'abort_by_post_condition',
2822+
}),
2823+
expect.objectContaining({
2824+
tx_id: block2.txs[0].tx.tx_id,
2825+
tx_status: 'abort_by_response',
2826+
}),
2827+
],
2828+
})
2829+
);
2830+
2831+
const txsReq4 = await supertest(api.server).get(
2832+
`/extended/v1/tx?status=success&type=token_transfer`
2833+
);
2834+
expect(txsReq4.status).toBe(200);
2835+
expect(txsReq4.body).toEqual(
2836+
expect.objectContaining({
2837+
results: [
2838+
expect.objectContaining({
2839+
tx_id: block1.txs[0].tx.tx_id,
2840+
tx_status: 'success',
2841+
tx_type: 'token_transfer',
2842+
}),
2843+
],
2844+
})
2845+
);
2846+
2847+
const txsReq5 = await supertest(api.server).get(`/extended/v1/tx?status=pending`);
2848+
expect(txsReq5.status).toBe(200);
2849+
expect(txsReq5.body).toEqual(
2850+
expect.objectContaining({
2851+
results: [],
2852+
})
2853+
);
2854+
});
2855+
2856+
test('tx list - invalid status filter', async () => {
2857+
const txsReq = await supertest(api.server).get(`/extended/v1/tx?status=invalid_status`);
2858+
expect(txsReq.status).toBe(400);
2859+
});
2860+
27232861
test('fetch raw tx', async () => {
27242862
const block: DbBlock = {
27252863
block_hash: '0x1234',

0 commit comments

Comments
 (0)