Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Fixes

* **`get_actions` `sort=asc` rejected mixed date/block bounds**: combining a block-number bound with an ISO-date bound (e.g. `after=<block>&before=<ISO date>`) returned `400 Invalid time value [after]`, because `after` and `before` were forced into a single date-*or*-block branch and the block number was then passed to `new Date(...)`. Each bound is now classified independently (ISO date strings → `@timestamp`, positive integers → `block_num`) and the two can be mixed, in both v1 and v2. This makes the documented escape hatch for querying ranges older than `max_asc_window_days` actually usable: pass block numbers for the bound(s), which legitimately bypass the recency window. The `sort=asc … must be within the last N days` error message now points operators at that workaround. (v1 also gains real `block_num` filtering — previously a block-number `after`/`before` was silently dropped into the `@timestamp` range.)

* **`/v2/state/get_tokens` missing balances — token-contract detection in `sync accounts`**: `./hyp-control sync accounts` (and `sync all`) resolved a contract's transfer parameter struct by the hard-coded struct name `"transfer"`. Per the ABI spec the struct backing an action is named by the action's `type` field, which is frequently *not* the action name — e.g. several contracts declare a fully standard transfer (`from:name, to:name, quantity:asset, memo:string`) under the struct name `transfer_token`. Those contracts were silently skipped, so their balances were never backfilled into the MongoDB `accounts` collection and `get_tokens` returned only the symbols the live indexer happened to capture — producing a "same contract, some symbols present and some missing" result on upgraded nodes. The struct is now resolved through the transfer action's declared `type`. **After upgrading, re-run `./hyp-control sync accounts <chain>` (or `sync all`) to backfill the previously skipped contracts.**

## 4.0.7 (2026-05-16)
Expand Down
36 changes: 28 additions & 8 deletions src/api/routes/v1-history/get_actions/get_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ async function getActions(fastify: FastifyInstance, request: FastifyRequest) {
if (!isNaN(afterDate.getTime())) {
const maxAge = Date.now() - (maxAscWindowDays * 86400000);
if (afterDate.getTime() < maxAge) {
return {error: `sort=asc "after" date must be within the last ${maxAscWindowDays} days`};
return {error: `sort=asc "after" date must be within the last ${maxAscWindowDays} days — use block numbers for "after"/"before" to query older ranges`};
}
Comment on lines 215 to 219
}
}
Expand Down Expand Up @@ -263,14 +263,34 @@ async function getActions(fastify: FastifyInstance, request: FastifyRequest) {
}

if (reqBody['after'] || reqBody['before']) {
let _lte = "now";
let _gte = 0;
if (reqBody['before']) _lte = reqBody['before'];
if (reqBody['after']) _gte = reqBody['after'];
// Classify each bound independently: ISO date strings (containing 'T') filter
// on @timestamp, bare positive integers filter on block_num, so the two bound
// types can be mixed (e.g. a block-number "after" with an ISO-date "before").
const tsRange: any = {};
const blockRange: any = {};
const after = reqBody['after'];
const before = reqBody['before'];
if (after) {
if (typeof after === 'string' && after.includes('T')) {
tsRange['gte'] = after;
} else if (parseInt(after) > 0) {
blockRange['gte'] = after;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow block bounds through the v1 schema

For the v1 endpoint, this new block-number branch is not reachable for normal requests because src/api/routes/v1-history/get_actions/index.ts still declares both after and before as type: 'string', format: 'date-time'. With Fastify schema validation enabled on this route, a POST body such as { "sort": "asc", "after": "437506277" } is rejected before getActions runs, so the documented block-number workaround and the new v1 block_num filtering do not actually work through the API. The v1 schema needs to accept block-number strings/numbers as well as date-times.

Useful? React with 👍 / 👎.

}
}
if (before) {
if (typeof before === 'string' && before.includes('T')) {
tsRange['lte'] = before;
} else if (parseInt(before) > 0) {
blockRange['lte'] = before;
}
}
Comment on lines +276 to +289

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current classification logic relies on after.includes('T') to identify dates and parseInt(after) > 0 to identify block numbers. This is fragile and incorrect. For example, a valid date string like "2026-06-01" (which does not contain 'T') will fail the first check, and parseInt("2026-06-01") will return 2026, incorrectly classifying it as a block number.

Instead, we should classify block numbers first by checking if the value is a valid positive integer using Number.isInteger(Number(value)) && Number(value) > 0 (consistent with isValidBound used elsewhere in the codebase), and treat any other value as a date/timestamp.

Suggested change
if (after) {
if (typeof after === 'string' && after.includes('T')) {
tsRange['gte'] = after;
} else if (parseInt(after) > 0) {
blockRange['gte'] = after;
}
}
if (before) {
if (typeof before === 'string' && before.includes('T')) {
tsRange['lte'] = before;
} else if (parseInt(before) > 0) {
blockRange['lte'] = before;
}
}
if (after) {
if (Number.isInteger(Number(after)) && Number(after) > 0) {
blockRange['gte'] = after;
} else {
tsRange['gte'] = after;
}
}
if (before) {
if (Number.isInteger(Number(before)) && Number(before) > 0) {
blockRange['lte'] = before;
} else {
tsRange['lte'] = before;
}
}

if (!queryStruct.bool['filter']) queryStruct.bool['filter'] = [];
queryStruct.bool['filter'].push({
range: {"@timestamp": {"gte": _gte, "lte": _lte}}
});
if (Object.keys(tsRange).length > 0) {
queryStruct.bool['filter'].push({range: {"@timestamp": tsRange}});
}
if (Object.keys(blockRange).length > 0) {
queryStruct.bool['filter'].push({range: {block_num: blockRange}});
}
}
if (reqBody.filter) {
queryStruct.bool['should'] = filterObj;
Expand Down
66 changes: 31 additions & 35 deletions src/api/routes/v2-history/get_actions/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,52 +79,48 @@ export function applyTimeFilter(query, queryStruct) {
query['before'] = query['before'].replace(' ', 'T');
}

if (query['after']?.includes('T') || query['before']?.includes('T')) {
let _lte = "now";
let _gte = "0";
if (query['before']) {
// Each bound is classified independently: ISO date strings (containing 'T')
// filter on @timestamp, bare positive integers filter on block_num. Handling
// them separately lets the two bound types be mixed — e.g. a block-number
// "after" together with an ISO-date "before". Previously a single branch was
// chosen for both bounds, so a block number passed alongside a date was fed to
// new Date(...) and threw "Invalid time value".
const tsRange: any = {};
const blockRange: any = {};

if (query['after']) {
if (typeof query['after'] === 'string' && query['after'].includes('T')) {
try {
_lte = new Date(query['before']).toISOString();
tsRange['gte'] = new Date(query['after']).toISOString();
} catch (e: any) {
badRequest(e.message + ' [before]');
badRequest(e.message + ' [after]');
}
} else if (parseInt(query['after']) > 0) {
blockRange['gte'] = query['after'];
}
if (query['after']) {
}

if (query['before']) {
if (typeof query['before'] === 'string' && query['before'].includes('T')) {
try {
_gte = new Date(query['after']).toISOString();
tsRange['lte'] = new Date(query['before']).toISOString();
} catch (e: any) {
badRequest(e.message + ' [after]');
badRequest(e.message + ' [before]');
}
} else if (parseInt(query['before']) > 0) {
blockRange['lte'] = query['before'];
}
}
Comment on lines +99 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current classification logic relies on query['after'].includes('T') to identify dates and parseInt(query['after']) > 0 to identify block numbers. This is fragile and incorrect. For example, a valid date string like "2026-06-01" (which does not contain 'T') will fail the first check, and parseInt("2026-06-01") will return 2026, incorrectly classifying it as a block number.

Instead, we should classify block numbers first by checking if the value is a valid positive integer using Number.isInteger(Number(value)) && Number(value) > 0 (consistent with isValidBound used elsewhere in the codebase), and treat any other value as a date/timestamp.

        if (query['after']) {
            if (Number.isInteger(Number(query['after'])) && Number(query['after']) > 0) {
                blockRange['gte'] = query['after'];
            } else {
                try {
                    tsRange['gte'] = new Date(query['after']).toISOString();
                } catch (e: any) {
                    badRequest(e.message + ' [after]');
                }
            }
        }

        if (query['before']) {
            if (Number.isInteger(Number(query['before'])) && Number(query['before']) > 0) {
                blockRange['lte'] = query['before'];
            } else {
                try {
                    tsRange['lte'] = new Date(query['before']).toISOString();
                } catch (e: any) {
                    badRequest(e.message + ' [before]');
                }
            }
        }


if (Object.keys(tsRange).length > 0 || Object.keys(blockRange).length > 0) {
if (!queryStruct.bool['filter']) {
queryStruct.bool['filter'] = [];
}
queryStruct.bool['filter'].push({
range: {
"@timestamp": {
"gte": _gte,
"lte": _lte
}
}
});
} else {
// search by block number
const rangeObj = {
range: {
block_num: {}
}
};
if (parseInt(query['after']) > 0) {
rangeObj.range.block_num['gte'] = query['after'];
}
if (parseInt(query['before']) > 0) {
rangeObj.range.block_num['lte'] = query['before'];
if (Object.keys(tsRange).length > 0) {
queryStruct.bool['filter'].push({range: {"@timestamp": tsRange}});
}
if (Object.keys(rangeObj.range.block_num).length > 0) {
if (!queryStruct.bool['filter']) {
queryStruct.bool['filter'] = [];
}
queryStruct.bool['filter'].push(rangeObj);
if (Object.keys(blockRange).length > 0) {
queryStruct.bool['filter'].push({range: {block_num: blockRange}});
}
}
}
Expand Down Expand Up @@ -283,7 +279,7 @@ export function getSortDir(query, maxAscWindowDays = 90) {
if (!isNaN(afterDate.getTime())) {
const maxAge = Date.now() - (maxAscWindowDays * 86400000);
if (afterDate.getTime() < maxAge) {
badRequest(`sort=asc "after" date must be within the last ${maxAscWindowDays} days`);
badRequest(`sort=asc "after" date must be within the last ${maxAscWindowDays} days — use block numbers for "after"/"before" to query older ranges`);
}
Comment on lines 290 to 294
}
}
Expand Down
57 changes: 56 additions & 1 deletion tests/unit/query-guards.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'bun:test';
import { getSortDir } from '../../src/api/routes/v2-history/get_actions/functions.js';
import { getSortDir, applyTimeFilter } from '../../src/api/routes/v2-history/get_actions/functions.js';

describe('getSortDir', () => {

Expand Down Expand Up @@ -89,3 +89,58 @@ describe('getSortDir', () => {
expect(getSortDir({ sort: 'asc', after: '100' })).toBe('asc');
});
});

describe('applyTimeFilter (mixed date / block-number bounds)', () => {

const newStruct = () => ({ bool: { must: [], boost: 1.0 } as any });

// Regression: this exact combination used to return 400 "Invalid time value [after]"
it('block-number "after" + ISO-date "before" produces both ranges', () => {
const qs = newStruct();
applyTimeFilter({ after: '437506277', before: '2026-06-01T08:06:13' }, qs);
const blockR = qs.bool.filter.find((f: any) => f.range.block_num);
const tsR = qs.bool.filter.find((f: any) => f.range['@timestamp']);
expect(blockR.range.block_num.gte).toBe('437506277');
expect(blockR.range.block_num.lte).toBeUndefined();
expect(tsR.range['@timestamp'].lte).toBe(new Date('2026-06-01T08:06:13').toISOString());
expect(tsR.range['@timestamp'].gte).toBeUndefined();
});

it('ISO-date "after" + block-number "before" produces both ranges', () => {
const qs = newStruct();
applyTimeFilter({ after: '2025-01-01T00:00:00Z', before: '500000000' }, qs);
const blockR = qs.bool.filter.find((f: any) => f.range.block_num);
const tsR = qs.bool.filter.find((f: any) => f.range['@timestamp']);
expect(tsR.range['@timestamp'].gte).toBe(new Date('2025-01-01T00:00:00Z').toISOString());
expect(blockR.range.block_num.lte).toBe('500000000');
});

it('two block numbers collapse into a single block_num range', () => {
const qs = newStruct();
applyTimeFilter({ after: '100', before: '200' }, qs);
expect(qs.bool.filter.length).toBe(1);
expect(qs.bool.filter[0].range.block_num).toEqual({ gte: '100', lte: '200' });
});

it('two ISO dates collapse into a single @timestamp range', () => {
const qs = newStruct();
applyTimeFilter({ after: '2026-01-01T00:00:00Z', before: '2026-02-01T00:00:00Z' }, qs);
expect(qs.bool.filter.length).toBe(1);
expect(qs.bool.filter[0].range['@timestamp']).toEqual({
gte: new Date('2026-01-01T00:00:00Z').toISOString(),
lte: new Date('2026-02-01T00:00:00Z').toISOString()
});
});

it('space-separated datetime is normalized to ISO and filters on @timestamp', () => {
const qs = newStruct();
applyTimeFilter({ after: '2026-01-01 00:00:00' }, qs);
expect(qs.bool.filter[0].range['@timestamp'].gte).toBe(new Date('2026-01-01T00:00:00').toISOString());
});

it('no bounds → no filter added', () => {
const qs = newStruct();
applyTimeFilter({}, qs);
expect(qs.bool.filter).toBeUndefined();
});
});
Loading