Skip to content

Commit 75ab43d

Browse files
authored
Merge pull request #174 from eosrio/fix/get-actions-mixed-bounds
fix(api): allow mixed date/block bounds on get_actions sort=asc
2 parents c61c60a + 2247ffc commit 75ab43d

6 files changed

Lines changed: 184 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
### Fixes
66

7+
* **`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 — a bare positive integer filters on `block_num`, anything else on `@timestamp` — 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. Specifically:
8+
* Classification uses a strict integer test (`Number.isInteger(Number(v)) && Number(v) > 0`) rather than `parseInt`, so a date without a `T` (e.g. `2026-01-01`) is no longer misread as block number `2026`.
9+
* The `sort=asc` recency window now applies to **any** date `after` bound, not only strings containing `T` — closing a hole where `after=2020-01-01` (or `after=0`, which parses to year 2000) bypassed the guard entirely.
10+
* The **v1** route schema no longer pins `after`/`before` to `format: date-time`, so block-number bounds reach the handler (previously rejected by schema validation before `getActions` ran). v1 also gains real `block_num` filtering — previously a block-number `after`/`before` was silently dropped into the `@timestamp` range.
11+
712
* **`/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.**
813

914
## 4.0.7 (2026-05-16)

src/api/routes/v1-history/get_actions/get_actions.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {mergeActionMeta, timedQuery} from "../../../helpers/functions.js";
33
import {hLog} from "../../../../indexer/helpers/common_functions.js";
44
import {Abieos} from "@eosrio/node-abieos";
55
import {terms} from "../../v2-history/get_actions/definitions.js";
6+
import {isBlockNumber} from "../../v2-history/get_actions/functions.js";
67
import {Client, estypes} from "@elastic/elasticsearch";
78
import {ABI, Serializer} from "@wharfkit/antelope";
89
import {SavedAbi} from "../../../../interfaces/hyperion-abi.js";
@@ -206,13 +207,15 @@ async function getActions(fastify: FastifyInstance, request: FastifyRequest) {
206207
if (!isValidBound(after) && !isValidBound(before)) {
207208
return {error: 'sort=asc requires a valid "after" or "before" (ISO date or block number) to bound the search'};
208209
}
209-
// validate the time window is not too wide (only for ISO date strings, not block numbers)
210-
if (typeof after === 'string' && after.includes('T')) {
210+
// Apply the recency window to a *date* "after" bound. Block-number bounds are
211+
// exempt. Classified the same way as the range filter below so a date without
212+
// a 'T' (e.g. "2026-01-01", or "0" which parses to year 2000) cannot slip past.
213+
if (after && !isBlockNumber(after)) {
211214
const afterDate = new Date(after);
212215
if (!isNaN(afterDate.getTime())) {
213216
const maxAge = Date.now() - (maxAscWindowDays * 86400000);
214217
if (afterDate.getTime() < maxAge) {
215-
return {error: `sort=asc "after" date must be within the last ${maxAscWindowDays} days`};
218+
return {error: `sort=asc "after" date must be within the last ${maxAscWindowDays} days — use block numbers for "after"/"before" to query older ranges`};
216219
}
217220
}
218221
}
@@ -263,14 +266,34 @@ async function getActions(fastify: FastifyInstance, request: FastifyRequest) {
263266
}
264267

265268
if (reqBody['after'] || reqBody['before']) {
266-
let _lte = "now";
267-
let _gte = 0;
268-
if (reqBody['before']) _lte = reqBody['before'];
269-
if (reqBody['after']) _gte = reqBody['after'];
269+
// Classify each bound independently: bare positive integers filter on block_num,
270+
// anything else is treated as a date/timestamp filter on @timestamp, so the two
271+
// bound types can be mixed (e.g. a block-number "after" with an ISO-date "before").
272+
const tsRange: any = {};
273+
const blockRange: any = {};
274+
const after = reqBody['after'];
275+
const before = reqBody['before'];
276+
if (after) {
277+
if (isBlockNumber(after)) {
278+
blockRange['gte'] = after;
279+
} else {
280+
tsRange['gte'] = after;
281+
}
282+
}
283+
if (before) {
284+
if (isBlockNumber(before)) {
285+
blockRange['lte'] = before;
286+
} else {
287+
tsRange['lte'] = before;
288+
}
289+
}
270290
if (!queryStruct.bool['filter']) queryStruct.bool['filter'] = [];
271-
queryStruct.bool['filter'].push({
272-
range: {"@timestamp": {"gte": _gte, "lte": _lte}}
273-
});
291+
if (Object.keys(tsRange).length > 0) {
292+
queryStruct.bool['filter'].push({range: {"@timestamp": tsRange}});
293+
}
294+
if (Object.keys(blockRange).length > 0) {
295+
queryStruct.bool['filter'].push({range: {block_num: blockRange}});
296+
}
274297
}
275298
if (reqBody.filter) {
276299
queryStruct.bool['should'] = filterObj;

src/api/routes/v1-history/get_actions/index.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,12 @@ export default function (fastify: FastifyInstance, opts: any, next) {
3737
type: 'string'
3838
},
3939
"after": {
40-
description: 'filter after specified date (ISO8601)',
41-
type: 'string',
42-
format: 'date-time'
40+
description: 'filter after specified date (ISO8601) or block number',
41+
type: 'string'
4342
},
4443
"before": {
45-
description: 'filter before specified date (ISO8601)',
46-
type: 'string',
47-
format: 'date-time'
44+
description: 'filter before specified date (ISO8601) or block number',
45+
type: 'string'
4846
},
4947
"parent": {
5048
description: 'filter by parent global sequence',

src/api/routes/v2-history/get_actions/functions.ts

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ function addRangeQuery(queryStruct, prop, pkey, query) {
6868
queryStruct.bool.must.push({range: _termQuery});
6969
}
7070

71+
// A bound is a block number when it is a bare positive integer; any other value
72+
// (ISO date string, etc.) is treated as a date/timestamp. Number() is strict where
73+
// parseInt is not — Number("2026-01-01") is NaN — so a date without a 'T' is correctly
74+
// classified as a date rather than as block 2026.
75+
export function isBlockNumber(v: any): boolean {
76+
return Number.isInteger(Number(v)) && Number(v) > 0;
77+
}
78+
7179
export function applyTimeFilter(query, queryStruct) {
7280
if (query['after'] || query['before']) {
7381

@@ -79,52 +87,48 @@ export function applyTimeFilter(query, queryStruct) {
7987
query['before'] = query['before'].replace(' ', 'T');
8088
}
8189

82-
if (query['after']?.includes('T') || query['before']?.includes('T')) {
83-
let _lte = "now";
84-
let _gte = "0";
85-
if (query['before']) {
90+
// Each bound is classified independently: bare positive integers filter on
91+
// block_num, anything else is treated as a date/timestamp filter on @timestamp.
92+
// Handling them separately lets the two bound types be mixed — e.g. a
93+
// block-number "after" together with an ISO-date "before" (previously a single
94+
// branch was chosen for both bounds, so a block number passed alongside a date
95+
// was fed to new Date(...) and threw "Invalid time value").
96+
const tsRange: any = {};
97+
const blockRange: any = {};
98+
99+
if (query['after']) {
100+
if (isBlockNumber(query['after'])) {
101+
blockRange['gte'] = query['after'];
102+
} else {
86103
try {
87-
_lte = new Date(query['before']).toISOString();
104+
tsRange['gte'] = new Date(query['after']).toISOString();
88105
} catch (e: any) {
89-
badRequest(e.message + ' [before]');
106+
badRequest(e.message + ' [after]');
90107
}
91108
}
92-
if (query['after']) {
109+
}
110+
111+
if (query['before']) {
112+
if (isBlockNumber(query['before'])) {
113+
blockRange['lte'] = query['before'];
114+
} else {
93115
try {
94-
_gte = new Date(query['after']).toISOString();
116+
tsRange['lte'] = new Date(query['before']).toISOString();
95117
} catch (e: any) {
96-
badRequest(e.message + ' [after]');
118+
badRequest(e.message + ' [before]');
97119
}
98120
}
121+
}
122+
123+
if (Object.keys(tsRange).length > 0 || Object.keys(blockRange).length > 0) {
99124
if (!queryStruct.bool['filter']) {
100125
queryStruct.bool['filter'] = [];
101126
}
102-
queryStruct.bool['filter'].push({
103-
range: {
104-
"@timestamp": {
105-
"gte": _gte,
106-
"lte": _lte
107-
}
108-
}
109-
});
110-
} else {
111-
// search by block number
112-
const rangeObj = {
113-
range: {
114-
block_num: {}
115-
}
116-
};
117-
if (parseInt(query['after']) > 0) {
118-
rangeObj.range.block_num['gte'] = query['after'];
119-
}
120-
if (parseInt(query['before']) > 0) {
121-
rangeObj.range.block_num['lte'] = query['before'];
127+
if (Object.keys(tsRange).length > 0) {
128+
queryStruct.bool['filter'].push({range: {"@timestamp": tsRange}});
122129
}
123-
if (Object.keys(rangeObj.range.block_num).length > 0) {
124-
if (!queryStruct.bool['filter']) {
125-
queryStruct.bool['filter'] = [];
126-
}
127-
queryStruct.bool['filter'].push(rangeObj);
130+
if (Object.keys(blockRange).length > 0) {
131+
queryStruct.bool['filter'].push({range: {block_num: blockRange}});
128132
}
129133
}
130134
}
@@ -277,13 +281,16 @@ export function getSortDir(query, maxAscWindowDays = 90) {
277281
if (!isValidBound(after) && !isValidBound(before)) {
278282
badRequest('sort=asc requires a valid "after" or "before" (ISO date or block number) to bound the search');
279283
}
280-
// validate the time window is not too wide (only for ISO date strings, not block numbers)
281-
if (typeof after === 'string' && after.includes('T')) {
284+
// Apply the recency window to a *date* "after" bound. Block-number bounds are
285+
// exempt — they bound the reverse scan just as well. Classified the same way
286+
// as applyTimeFilter so a date without a 'T' (e.g. "2026-01-01", or "0" which
287+
// parses to year 2000) cannot slip past the window check.
288+
if (after && !isBlockNumber(after)) {
282289
const afterDate = new Date(after);
283290
if (!isNaN(afterDate.getTime())) {
284291
const maxAge = Date.now() - (maxAscWindowDays * 86400000);
285292
if (afterDate.getTime() < maxAge) {
286-
badRequest(`sort=asc "after" date must be within the last ${maxAscWindowDays} days`);
293+
badRequest(`sort=asc "after" date must be within the last ${maxAscWindowDays} days — use block numbers for "after"/"before" to query older ranges`);
287294
}
288295
}
289296
}

src/api/routes/v2-history/get_actions/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,11 @@ export default function (fastify: FastifyInstance, opts: any, next) {
9696
type: 'string'
9797
},
9898
"after": {
99-
description: 'filter after specified date (ISO8601)',
99+
description: 'filter after specified date (ISO8601) or block number',
100100
type: 'string'
101101
},
102102
"before": {
103-
description: 'filter before specified date (ISO8601)',
103+
description: 'filter before specified date (ISO8601) or block number',
104104
type: 'string'
105105
},
106106
"simple": {

tests/unit/query-guards.test.ts

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'bun:test';
2-
import { getSortDir } from '../../src/api/routes/v2-history/get_actions/functions.js';
2+
import { getSortDir, applyTimeFilter, isBlockNumber } from '../../src/api/routes/v2-history/get_actions/functions.js';
33

44
describe('getSortDir', () => {
55

@@ -50,12 +50,11 @@ describe('getSortDir', () => {
5050
});
5151

5252
// sort=asc with invalid bounds
53-
it('should throw for sort=asc with after="0" (neither valid date with T nor positive int)', () => {
54-
// "0" is truthy, new Date("0") parses to 2000-01-01 (valid), Number("0") = 0 (not > 0)
55-
// isValidBound: Date parse succeeds → passes bounds check
56-
// But "0" doesn't contain 'T', so max window check is skipped → returns asc
57-
// This is acceptable: "0" as a date will produce @timestamp range that ES handles
58-
expect(getSortDir({ sort: 'asc', after: '0' })).toBe('asc');
53+
it('should reject sort=asc with after="0" (not a positive int → treated as a date, year 2000, outside the window)', () => {
54+
// "0" is not a positive integer, so it is classified as a date. new Date("0")
55+
// resolves to year 2000 — far outside the recency window — so it is now rejected
56+
// instead of silently bypassing the guard.
57+
expect(() => getSortDir({ sort: 'asc', after: '0' })).toThrow('within the last');
5958
});
6059

6160
it('should throw for sort=asc with after=0 (falsy)', () => {
@@ -88,4 +87,94 @@ describe('getSortDir', () => {
8887
it('should not apply max window check on block numbers', () => {
8988
expect(getSortDir({ sort: 'asc', after: '100' })).toBe('asc');
9089
});
90+
91+
// Date strings WITHOUT a 'T' separator must still be window-checked (previously they
92+
// slipped past because the check only fired on strings containing 'T').
93+
it('should reject sort=asc with an old date that has no T separator', () => {
94+
expect(() => getSortDir({ sort: 'asc', after: '2020-01-01' })).toThrow('within the last');
95+
});
96+
97+
it('should allow sort=asc with a recent date that has no T separator', () => {
98+
const todayDateOnly = new Date(Date.now() - 3600000).toISOString().split('T')[0];
99+
expect(getSortDir({ sort: 'asc', after: todayDateOnly })).toBe('asc');
100+
});
101+
});
102+
103+
describe('applyTimeFilter (mixed date / block-number bounds)', () => {
104+
105+
const newStruct = () => ({ bool: { must: [], boost: 1.0 } as any });
106+
107+
// Regression: this exact combination used to return 400 "Invalid time value [after]"
108+
it('block-number "after" + ISO-date "before" produces both ranges', () => {
109+
const qs = newStruct();
110+
applyTimeFilter({ after: '437506277', before: '2026-06-01T08:06:13' }, qs);
111+
const blockR = qs.bool.filter.find((f: any) => f.range.block_num);
112+
const tsR = qs.bool.filter.find((f: any) => f.range['@timestamp']);
113+
expect(blockR.range.block_num.gte).toBe('437506277');
114+
expect(blockR.range.block_num.lte).toBeUndefined();
115+
expect(tsR.range['@timestamp'].lte).toBe(new Date('2026-06-01T08:06:13').toISOString());
116+
expect(tsR.range['@timestamp'].gte).toBeUndefined();
117+
});
118+
119+
it('ISO-date "after" + block-number "before" produces both ranges', () => {
120+
const qs = newStruct();
121+
applyTimeFilter({ after: '2025-01-01T00:00:00Z', before: '500000000' }, qs);
122+
const blockR = qs.bool.filter.find((f: any) => f.range.block_num);
123+
const tsR = qs.bool.filter.find((f: any) => f.range['@timestamp']);
124+
expect(tsR.range['@timestamp'].gte).toBe(new Date('2025-01-01T00:00:00Z').toISOString());
125+
expect(blockR.range.block_num.lte).toBe('500000000');
126+
});
127+
128+
it('two block numbers collapse into a single block_num range', () => {
129+
const qs = newStruct();
130+
applyTimeFilter({ after: '100', before: '200' }, qs);
131+
expect(qs.bool.filter.length).toBe(1);
132+
expect(qs.bool.filter[0].range.block_num).toEqual({ gte: '100', lte: '200' });
133+
});
134+
135+
it('two ISO dates collapse into a single @timestamp range', () => {
136+
const qs = newStruct();
137+
applyTimeFilter({ after: '2026-01-01T00:00:00Z', before: '2026-02-01T00:00:00Z' }, qs);
138+
expect(qs.bool.filter.length).toBe(1);
139+
expect(qs.bool.filter[0].range['@timestamp']).toEqual({
140+
gte: new Date('2026-01-01T00:00:00Z').toISOString(),
141+
lte: new Date('2026-02-01T00:00:00Z').toISOString()
142+
});
143+
});
144+
145+
it('space-separated datetime is normalized to ISO and filters on @timestamp', () => {
146+
const qs = newStruct();
147+
applyTimeFilter({ after: '2026-01-01 00:00:00' }, qs);
148+
expect(qs.bool.filter[0].range['@timestamp'].gte).toBe(new Date('2026-01-01T00:00:00').toISOString());
149+
});
150+
151+
it('no bounds → no filter added', () => {
152+
const qs = newStruct();
153+
applyTimeFilter({}, qs);
154+
expect(qs.bool.filter).toBeUndefined();
155+
});
156+
157+
// Regression: a date string without 'T' must be a @timestamp bound, not block 2026
158+
it('classifies a date-only string (no T) as a @timestamp bound, not a block number', () => {
159+
const qs = newStruct();
160+
applyTimeFilter({ after: '2026-01-01' }, qs);
161+
expect(qs.bool.filter.find((f: any) => f.range.block_num)).toBeUndefined();
162+
expect(qs.bool.filter[0].range['@timestamp'].gte).toBe(new Date('2026-01-01').toISOString());
163+
});
164+
});
165+
166+
describe('isBlockNumber', () => {
167+
it('treats bare positive integers (string or number) as block numbers', () => {
168+
expect(isBlockNumber('437506277')).toBe(true);
169+
expect(isBlockNumber(437506277)).toBe(true);
170+
expect(isBlockNumber('1')).toBe(true);
171+
});
172+
173+
it('treats dates, zero, and non-integers as NOT block numbers', () => {
174+
expect(isBlockNumber('2026-01-01')).toBe(false);
175+
expect(isBlockNumber('2026-06-01T08:06:13')).toBe(false);
176+
expect(isBlockNumber('0')).toBe(false);
177+
expect(isBlockNumber('')).toBe(false);
178+
expect(isBlockNumber('garbage')).toBe(false);
179+
});
91180
});

0 commit comments

Comments
 (0)