diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a6db61a..39d80ecf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -413,6 +413,7 @@ jobs: parallel: true test-subnets: + if: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d1ef93fba..fb1f85474 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -219,6 +219,24 @@ paths: items: type: string enum: [coinbase, token_transfer, smart_contract, contract_call, poison_microblock, tenure_change] + - name: sort_by + in: query + description: Option to sort results by block height, timestamp, or fee + required: false + schema: + type: string + enum: [block_height, burn_block_time, fee] + example: burn_block_time + default: block_height + - name: order + in: query + description: Option to sort results in ascending or descending order + required: false + schema: + type: string + enum: [asc, desc] + example: desc + default: desc - name: unanchored in: query description: Include transaction data from unanchored (i.e. unconfirmed) microblocks diff --git a/migrations/1718632097776_tx-sort-indexes.js b/migrations/1718632097776_tx-sort-indexes.js new file mode 100644 index 000000000..d5e8adb9f --- /dev/null +++ b/migrations/1718632097776_tx-sort-indexes.js @@ -0,0 +1,11 @@ +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.up = pgm => { + pgm.createIndex('txs', 'burn_block_time'); + pgm.createIndex('txs', 'fee_rate'); +}; + +/** @param { import("node-pg-migrate").MigrationBuilder } pgm */ +exports.down = pgm => { + pgm.dropIndex('txs', 'burn_block_time'); + pgm.dropIndex('txs', 'fee_rate'); +}; diff --git a/src/api/routes/tx.ts b/src/api/routes/tx.ts index 73eae4315..b20b87d97 100644 --- a/src/api/routes/tx.ts +++ b/src/api/routes/tx.ts @@ -65,12 +65,43 @@ export function createTxRouter(db: PgStore): express.Router { txTypeFilter = []; } + let order: 'asc' | 'desc' | undefined; + if (req.query.order) { + if ( + typeof req.query.order === 'string' && + (req.query.order === 'asc' || req.query.order === 'desc') + ) { + order = req.query.order; + } else { + throw new InvalidRequestError( + `The "order" query parameter must be a 'desc' or 'asc'`, + InvalidRequestErrorType.invalid_param + ); + } + } + + let sortBy: 'block_height' | 'burn_block_time' | 'fee' | undefined; + if (req.query.sort_by) { + if ( + typeof req.query.sort_by === 'string' && + ['block_height', 'burn_block_time', 'fee'].includes(req.query.sort_by) + ) { + sortBy = req.query.sort_by as typeof sortBy; + } else { + throw new InvalidRequestError( + `The "sort_by" query parameter must be 'block_height', 'burn_block_time', or 'fee'`, + InvalidRequestErrorType.invalid_param + ); + } + } const includeUnanchored = isUnanchoredRequest(req, res, next); const { results: txResults, total } = await db.getTxList({ offset, limit, txTypeFilter, includeUnanchored, + order, + sortBy, }); const results = txResults.map(tx => parseDbTx(tx)); const response: TransactionResults = { limit, offset, total, results }; diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 3a95734b4..726ddbf35 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -102,6 +102,7 @@ import { import * as path from 'path'; import { PgStoreV2 } from './pg-store-v2'; import { MempoolOrderByParam, OrderParam } from '../api/query-helpers'; +import { Fragment } from 'postgres'; export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); @@ -1415,16 +1416,38 @@ export class PgStore extends BasePgStore { offset, txTypeFilter, includeUnanchored, + order, + sortBy, }: { limit: number; offset: number; txTypeFilter: TransactionType[]; includeUnanchored: boolean; + order?: 'desc' | 'asc'; + sortBy?: 'block_height' | 'burn_block_time' | 'fee'; }): Promise<{ results: DbTx[]; total: number }> { let totalQuery: { count: number }[]; let resultQuery: ContractTxQueryResult[]; return await this.sqlTransaction(async sql => { const maxHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); + const orderSql = order === 'asc' ? sql`ASC` : sql`DESC`; + + let orderBySql: Fragment; + switch (sortBy) { + case undefined: + case 'block_height': + orderBySql = sql`ORDER BY block_height ${orderSql}, microblock_sequence ${orderSql}, tx_index ${orderSql}`; + break; + case 'burn_block_time': + orderBySql = sql`ORDER BY burn_block_time ${orderSql}, block_height ${orderSql}, microblock_sequence ${orderSql}, tx_index ${orderSql}`; + break; + case 'fee': + orderBySql = sql`ORDER BY fee_rate ${orderSql}, block_height ${orderSql}, microblock_sequence ${orderSql}, tx_index ${orderSql}`; + break; + default: + throw new Error(`Invalid sortBy param: ${sortBy}`); + } + if (txTypeFilter.length === 0) { totalQuery = await sql<{ count: number }[]>` SELECT ${includeUnanchored ? sql('tx_count_unanchored') : sql('tx_count')} AS count @@ -1434,7 +1457,7 @@ export class PgStore extends BasePgStore { SELECT ${sql(TX_COLUMNS)}, ${abiColumn(sql)} FROM txs WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight} - ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC + ${orderBySql} LIMIT ${limit} OFFSET ${offset} `; @@ -1451,7 +1474,7 @@ export class PgStore extends BasePgStore { FROM txs WHERE canonical = true AND microblock_canonical = true AND type_id IN ${sql(txTypeIds)} AND block_height <= ${maxHeight} - ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC + ${orderBySql} LIMIT ${limit} OFFSET ${offset} `; diff --git a/src/tests/tx-tests.ts b/src/tests/tx-tests.ts index 4fcc7ef98..1d85176d5 100644 --- a/src/tests/tx-tests.ts +++ b/src/tests/tx-tests.ts @@ -1938,6 +1938,158 @@ describe('tx tests', () => { expect(JSON.parse(fetchTx.text)).toEqual(expectedResp); }); + test('tx list - order by', async () => { + const block1 = new TestBlockBuilder({ block_height: 1, index_block_hash: '0x01' }) + .addTx({ + tx_id: '0x1234', + fee_rate: 1n, + burn_block_time: 2, + }) + .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, + }) + .addTx({ + tx_id: '0x2234', + fee_rate: 3n, + burn_block_time: 1, + }) + .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, + }) + .addTx({ + tx_id: '0x3234', + fee_rate: 2n, + burn_block_time: 3, + }) + .build(); + await db.update(block3); + + const txsReqAsc = await supertest(api.server).get(`/extended/v1/tx?order=asc`); + expect(txsReqAsc.status).toBe(200); + expect(txsReqAsc.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + ], + }) + ); + + const txsReqDesc = await supertest(api.server).get(`/extended/v1/tx?order=desc`); + expect(txsReqDesc.status).toBe(200); + expect(txsReqDesc.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + ], + }) + ); + + const txsReqTimeDesc = await supertest(api.server).get( + `/extended/v1/tx?sort_by=burn_block_time&order=desc` + ); + expect(txsReqTimeDesc.status).toBe(200); + expect(txsReqTimeDesc.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + ], + }) + ); + + const txsReqTimeAsc = await supertest(api.server).get( + `/extended/v1/tx?sort_by=burn_block_time&order=asc` + ); + expect(txsReqTimeAsc.status).toBe(200); + expect(txsReqTimeAsc.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + ], + }) + ); + + const txsReqFeeDesc = await supertest(api.server).get(`/extended/v1/tx?sort_by=fee&order=desc`); + expect(txsReqFeeDesc.status).toBe(200); + expect(txsReqFeeDesc.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + ], + }) + ); + + const txsReqFeeAsc = await supertest(api.server).get(`/extended/v1/tx?sort_by=fee&order=asc`); + expect(txsReqFeeAsc.status).toBe(200); + expect(txsReqFeeAsc.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + ], + }) + ); + }); + test('fetch raw tx', async () => { const block: DbBlock = { block_hash: '0x1234',