Skip to content

fix(hive): accept the ISO form of limit_order_create expiration#375

Merged
BitHighlander merged 2 commits into
developfrom
fix/hive-expiration-iso
Jul 20, 2026
Merged

fix(hive): accept the ISO form of limit_order_create expiration#375
BitHighlander merged 2 commits into
developfrom
fix/hive-expiration-iso

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

The failure

A live hivehub.dev/market/swap order:

Vault Hive sign-operations failed (400):
{"error":"limit_order_create expiration out of uint32 range: NaN"}
{"orderid":1784583895,"owner":"bithighlander22",
 "amount_to_sell":"1.000 HIVE","min_to_receive":"0.048 HBD",
 "fill_or_kill":true,"expiration":"2026-08-16T21:44:55"}

Graphene's time_point_sec is a uint32 on the wire, but hived's JSON representation — and therefore what every Hive library and dApp sends — is a zone-less ISO 8601 string. hive-ops.ts:198 did Number(p.expiration), which is NaN for that form.

No real dApp could place an order. Every existing test passed a unix int (1700003600), so the fixtures encoded the wrong assumption and the entire phase-3 suite went green against a shape nothing actually sends.

The subtle half

timePointSec() accepts the ISO form, an explicit-offset ISO form, a numeric string, and a number. The 'Z' append is the part worth reviewing carefully:

const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(s)
const ms = Date.parse(hasZone ? s : `${s}Z`)

Graphene timestamps are always UTC, but JS parses the zone-less date-time form as local time (ES2015+). A naive Date.parse would therefore have signed an expiration off by the host's UTC offset:

TZ=America/Denver
  local-parsed: 1786938295
  UTC-parsed:   1786916695   ← correct
  differ by:    6 hours

That fails silently — the order signs, and expires six hours off what the user was shown. A wrong-but-plausible expiration is worse than a rejected one. An explicit offset (…Z, …+02:00) is passed through untouched so it isn't double-shifted.

Unparseable input now names what's wanted instead of surfacing NaN, and a pre-1970 timestamp is rejected by the existing u32 bound rather than wrapping to a huge uint32.

Tests

The exact field payload, ISO/unix byte equality, explicit-offset, numeric-string, rejection of undefined/null/''/'tomorrow'/{}/NaN, and a byte-level assertion that a zone-less stamp encodes as UTC.

Green under four timezones, including a half-hour offset:

TZ=UTC                 48 pass  0 fail
TZ=America/Denver      48 pass  0 fail
TZ=Asia/Kolkata        48 pass  0 fail
TZ=Pacific/Auckland    48 pass  0 fail

Differential-verified. Restoring Number() fails 5 tests. Dropping only the 'Z' append — the silent-drift mutation, the one that still produces a valid signature — still fails 3, including the byte-level UTC assertion.

Zero failures across the deterministic suites (txbuilder, shared, rest-sign-gating).

Notes

  • Branched from origin/develop; does not include the unpushed local commits on that branch.
  • Independent of feat(emu): expand the confirm dialog to cover the Hive operations #374 (emulator confirm details) — no file overlap.
  • The client passes operations through verbatim, which is correct: the serializer owns the wire format, so the fix belongs here rather than normalizing in the extension.
  • limit_order_create.expiration is the only time_point_sec field in the 14-op clear-sign table; the tx header's expirationUnix comes from Pioneer as an int and is unaffected.

🤖 Generated with Claude Code

A live hivehub.dev market order failed with

    limit_order_create expiration out of uint32 range: NaN

on the payload

    {"orderid":1784583895,"owner":"bithighlander22",
     "amount_to_sell":"1.000 HIVE","min_to_receive":"0.048 HBD",
     "fill_or_kill":true,"expiration":"2026-08-16T21:44:55"}

Graphene's time_point_sec is a uint32 on the wire, but hived's JSON
representation — and therefore what every Hive library and dApp sends — is a
zone-less ISO 8601 string. The serializer did Number(p.expiration), which is
NaN for that form, so no real dApp could place an order. Every existing test
passed a unix int (1700003600), so the fixtures encoded the wrong assumption
and the whole phase-3 suite went green against a shape nothing sends.

timePointSec() accepts the ISO form, an explicit-offset ISO form, a numeric
string, and a number.

The 'Z' append is the subtle half. Graphene timestamps are always UTC, but JS
parses the zone-less date-time form as LOCAL time (ES2015+), so a naive
Date.parse would have signed an expiration off by the host's UTC offset — six
hours in America/Denver — while showing the user the value they expected. A
wrong-but-plausible expiration is worse than a rejected one. An explicit
offset (…Z, …+02:00) is passed through untouched so it is not double-shifted.

Unparseable input now says what is wanted instead of surfacing NaN, and a
pre-1970 timestamp is rejected by the u32 bound rather than wrapping to a huge
uint32.

Tests: the exact field payload, ISO/unix byte equality, an explicit-offset
case, the numeric-string case, rejection of undefined/null/''/'tomorrow'/{}/NaN,
and a byte-level assertion that a zone-less stamp encodes as UTC. Run green
under TZ=UTC, America/Denver, Asia/Kolkata (half-hour offset) and
Pacific/Auckland.

Differential-verified. Restoring Number() fails 5 of them; dropping only the
'Z' append — the silent-drift mutation — still fails 3, including the
byte-level UTC assertion.

48 pass in hive-ops.test.ts; zero failures across the deterministic suites
(txbuilder, shared, rest-sign-gating).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Date.parse

Review finding: Date.parse is permissive in ways that silently CHANGE the
value being signed.

  "2026-02-30T12:00:00"  -> rolled forward to 2026-03-02
  "August 16, 2026"      -> accepted, despite the promised ISO form
  "08/16/2026"           -> accepted, and ambiguous between locales
  "26-08-16T21:44:55"    -> a 2-digit year maps to 1926 via Date.UTC

An expiration the user never wrote must be rejected, not normalised — a
rolled-forward date is a different order resting on the book for a different
window.

Now matches an explicit ISO 8601 grammar, then validates the calendar
components (month 1-12, day within month with the full Gregorian leap rule,
hour/minute/second ranges) before converting. Zone handling is unchanged:
zone-less is UTC, an explicit offset is honoured once. Uses setUTCFullYear
rather than Date.UTC so years 0-99 are not remapped into the 1900s.

Seconds stay optional and fractional seconds are tolerated (hived emits
neither, and neither changes the instant). NaN/Infinity keep falling through
to the generic "unix seconds or ISO 8601" message rather than the new
whole-number one.

+7 tests: impossible dates, leap-year boundaries (2028/2000 valid,
2026/2100 not), out-of-range times, six non-ISO formats, 2-digit years,
optional/fractional seconds, non-integer numbers.
Suite 55/55 under UTC, Denver, Kolkata and Auckland.
@BitHighlander
BitHighlander merged commit 42fa1aa into develop Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant