Skip to content

F2+F3: transferSale awaitable, block timestamps written as UTC - #62

Merged
lucca65 merged 3 commits into
masterfrom
fix/transfersale-async-utc
Aug 8, 2026
Merged

F2+F3: transferSale awaitable, block timestamps written as UTC#62
lucca65 merged 3 commits into
masterfrom
fix/transfersale-async-utc

Conversation

@lucca65

@lucca65 lucca65 commented Aug 5, 2026

Copy link
Copy Markdown
Member

F2 + F3 — transferSale fire-and-forget, and host-local timestamps

Two packets from the follow-up handoff, both in the updater layer. Two commits: 912efef (F2) and 32a32d4 (F3).

F2 — transferSale was fire-and-forget (deployed to prod)

Observed in the Phase 1 e2e run: RELEASE SAVEPOINT can only be used in transaction blocks (SQLSTATE 25P01). transferSale was not async and called db.withTransaction(...).catch(...) without returning it, so ledgered() could not await it: the block transaction committed and _processed_actions claimed the action's global_seq while the inner work was still running. Any failure there would then be skipped by every future reindex — the "claimed ledger row without applied writes" mode the reindex runbook warns about (same class as #55's verifyClaim fix). Pre-existing since 68ef3b6.

Fix: async function transferSale + return db.withTransaction(...), matching createCommunity's shape.

Audit of every withTransaction in src/:

Location Shape Verdict
community.js:90 createCommunity return db.withTransaction(...) already correct
community.js:138 updateCommunity await db.withTransaction(...) already correct
community.js:280 transferSale bare call the bug — fixed
community.js:445 upsertAction return db.withTransaction(...) already correct
token.js, escrow.js none n/a

Related fire-and-forget shapes found but not changed (out of F2's scope, flagged for a future packet): updateCommunity's tx callback fires tx.communities.update(...).catch(...) unawaited (community.js:130 — harmless today via node-pg per-connection serialization, but an update error is swallowed instead of rolling back); reward (community.js:529,547) and createToken/updateToken/setExpiry (token.js) fire DB writes the returned promise doesn't await.

F2 acceptance — replay procedure

Local chain (NODE_ENV=localcambiatus_local): pushed transfersale [1, "alice", "bob", "5 TST", 1] (block 28092), indexed (order 1 + order_item 1, stock 10→9). Then the handoff procedure: truncate _index_state / _block_number_txid / _processed_actions, full reindex from block 1. Cross-check the replay was complete: registered action types on chain total 196 = _processed_actions count after reindex.

BEFORE: orders=1, order_items=1, units=9,  _processed_actions=197
AFTER:  orders=1, order_items=1, units=9   (order 1 row identical: completed, chain_legacy, created_tx=9334ef92…)

Zero errors in the replay log; stock not double-decremented.

F3 — block timestamps were written in host-local time

Mechanism (verified against the installed libs, not assumed): GetActionsReader.js:150 builds blockInfo.timestamp correctly as a UTC instant, but node-pg's dateToString serializes a Date in the process's local zone and appends an offset; Postgres ignores the offset for timestamp without time zone input (SELECT '2026-08-04T21:04:09.000+02:00'::timestamp2026-08-04 21:04:09), so the local wall clock lands in the column. pg-promise's raw-SQL path reuses the same serializer. Rejected the one-line pg.defaults.parseInputDatesAsUTC = true global — it silently changes third-party paths and can't be audited per write.

Fix: new src/dates.js exporting toUTC(date) => date.toISOString(), applied to every affected write — block timestamps AND new Date() writes alike (fixing only block timestamps would have left created_at UTC but inserted_at host-local):

  • community.js — 14 blockInfo.timestamp writes (createCommunity, netlink ×2, transferSale ×5, upsertObjective, upsertAction ×2, claimAction, verifyClaim), 16 new Date() writes (subdomains, roles, network_roles, rewards, upsertRole, assignRole), plus upsertAction's deadline.
  • token.jstransfer.created_at, issue.created_at.
  • escrow.jsregDeposit $9, closeDeposit $4.

F3 acceptance — TZ experiment

Same action indexed twice, fresh rows each run:

chain block 29331 (UTC):                 2026-08-05T11:44:22.500
Run A (TZ=America/Sao_Paulo): created_at = inserted_at = updated_at = 2026-08-05 11:44:23
Run B (TZ=UTC), same action:   created_at = inserted_at = updated_at = 2026-08-05 11:44:23   ← identical
bonus: block 28092 replayed under TZ=UTC stored 11:34:03; chain block 28092 = 11:34:03.000Z

(11:44:22.50011:44:23 is timestamp(0) rounding.) Pre-fix code on this same machine (CEST) stored block 28092 as 13:34:03 — bug reproduced before, correct after.

Caveats surfaced during acceptance (not this PR's scope)

  • Pre-existing value bug: upsertAction's deadline is built with new Date(parseInt(payload.data.deadline)) (ms), but the contract compares now() < deadline in seconds — stores a 1970 instant whenever deadline > 0. Worth its own packet.
  • _index_state truncate footgun: demux's handler does findOne({id: 1}) || {} then save() — truncating the table inserts a new row per block and restarts reindex from genesis forever. The runbook's UPDATE _index_state SET block_number = … form is the safe one; the handoff's acceptance text should be amended.
  • Local chain's cambiatus.tk history is non-monotonic (seqs 100–106 at blocks 137–162, seqs 107–108 at blocks 10404–10405) — cosmetic, the reader handles it.

lucca65 and others added 3 commits August 5, 2026 11:53
transferSale was not async and called db.withTransaction(...) without
returning it, so the block transaction committed (claiming the action's
global_seq in _processed_actions) while the order/order_item writes were
still in flight. A failure in the inner transaction — seen live as
'RELEASE SAVEPOINT can only be used in transaction blocks' — was then
skipped by every future reindex: a claimed ledger row without applied
writes, the same class PR #55 fixed in verifyClaim.

Make transferSale async and return the withTransaction promise, matching
createCommunity. Audit of every withTransaction in src/: createCommunity
(returned), updateCommunity (awaited) and upsertAction (returned) were
already correct; transferSale was the only fire-and-forget hit.
Every timestamp column is 'timestamp without time zone', matching the
Elixir backend (which stores UTC via DateTime.utc_now()). node-pg (and
pg-promise, which reuses pg's serializer) formats a JS Date in the
process's LOCAL zone and appends an offset that Postgres ignores for
tz-less columns, so a raw Date landed as host-local wall clock — seen
live: block 2026-08-04T21:04:09Z stored as 23:04:09 on a CEST host.
Indexer rows and app rows disagreed in one database, and
current_month_quantity's date_trunc('month') window lands wrong at
month boundaries.

Add src/dates.js (toUTC -> Date.toISOString) and route every Date we
write through it: blockInfo.timestamp in community/token/escrow
updaters, plus the inserted_at/updated_at 'new Date()' writes, which
had the same host-local defect.
Two fixes on top of the merge with master (F0's watermark claim resolver and
F0b's escrow `closedBy`, both kept alongside the toUTC conversion).

transferSale returned the inner transaction so ledgered() could await it, but
kept `.catch(e => logError(...))` on the end. That catch turns a rejection back
into a resolved promise, so ledgered saw the updater succeed and kept the
_processed_actions row it had just claimed — the order stayed recorded as
applied with none of its writes landed, which is the exact failure the return
was added to prevent. Log and rethrow instead: the error propagates through
ledgered's non-ResolveError branch, the block rolls back (taking the ledger row
with it), and the action is left unprocessed for a restart or reindex.

Note on the UTC half: prod runs Etc/UTC (verified on the box today, node
reports offset 0), and prod rows match their block times — the last indexed
transfer sits at 00:50:51 for a block stamped 00:50:50.5Z. So this is a latent
correctness fix for non-UTC hosts, not a live production defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lucca65
lucca65 merged commit 28029e9 into master Aug 8, 2026
2 checks passed
@lucca65
lucca65 deleted the fix/transfersale-async-utc branch August 8, 2026 12:13
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