Skip to content

Commit

Permalink
feat: tx ordering options (#2005)
Browse files Browse the repository at this point in the history
* feat: ordering options for `/extendex/v1/tx` endpoint

* test: add tx ordering tests

* docs: openapi docs for tx ordering options

* ci: disable subnets test (not working)
  • Loading branch information
zone117x authored Jun 18, 2024
1 parent 03a91ed commit ae78773
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 2 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ jobs:
parallel: true

test-subnets:
if: false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
Expand Down
18 changes: 18 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions migrations/1718632097776_tx-sort-indexes.js
Original file line number Diff line number Diff line change
@@ -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');
};
31 changes: 31 additions & 0 deletions src/api/routes/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
27 changes: 25 additions & 2 deletions src/datastore/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand All @@ -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}
`;
Expand All @@ -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}
`;
Expand Down
152 changes: 152 additions & 0 deletions src/tests/tx-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit ae78773

Please sign in to comment.