Skip to content

F0b: index cambiatus.es::expire closes as status 'expired' (land before contract deploy) - #64

Merged
lucca65 merged 1 commit into
masterfrom
fix/index-expire
Aug 7, 2026
Merged

F0b: index cambiatus.es::expire closes as status 'expired' (land before contract deploy)#64
lucca65 merged 1 commit into
masterfrom
fix/index-expire

Conversation

@lucca65

@lucca65 lucca65 commented Aug 5, 2026

Copy link
Copy Markdown
Member

F0b (1/3) — Index cambiatus.es::expire closes as status expired

🔴 Land this before the hardened escrow contract deploys. contracts #47 added expire (closes a deposit, erases the row, pays the buyer) and the indexer doesn't know it exists. Unindexed, the first expire leaves the replica row open forever and the hourly reconciler reports permanent only_in_db drift — the exact divergence the reconciliation packet exists to detect, created by construction.

Companion PRs: backend fix/escrow-expired-status (status validation + payout reconciliation) and contracts fix/escrow-docs-offchain (DEPLOYMENT.md).

Changes

  • src/updaters.js — registers cambiatus.es::expire between refund and sweep, with a comment recording that setminimum is deliberately not indexed (configuration, not money movement; the reconciler reads nothing from it; the P3.2/P3.3 buy flow reads the mindeposit row from chain when validating amounts).
  • src/updaters/escrow.js — new expire() updater routes through the existing closeDeposit with status 'expired', so the replay guards (status = 'open' scope, created_block <= action block bound) apply unchanged — same UPDATE, verified. expired is distinct from refunded on purpose: the P3.5 escalation work needs to tell "seller refunded" from "counterparty went silent and the backstop fired".
  • closed_by records the buyer (from the row being closed, via COALESCE($5, buyer_id) with NULL passed for expired), not the action's actor. expire is permissionless — payload.authorization can be empty from a programmatic client, so authorization[0].actor would crash; and the actor says nothing about where the money went — the contract always pays the buyer, which is what reconciliation needs.

Acceptance (local chain, make node-fresh && make build && make bootstrap on contracts master — deploys the hardened escrow)

Deposit order_ref=777001 (carol→dave, arbiter alice, 10 TST); short-expiry wasm (ESCROW_EXPIRY_SECONDS=3, the test-suite shape) deployed; expire fired by third party eve; production wasm restored (code hash matches checked-in escrow.wasm before and after):

order_ref | status  | closed_tx          | closed_block | closed_by
----------+---------+--------------------+--------------+----------
 777001   | expired | 0514d195c62a74c5…  | 1997         | carol      <- buyer, not eve (caller)

Chain confirms: deposit erased, carol refunded 10 TST, inline transfer cambiatus.es → carol 10 TST.

Replay (safe rewind: UPDATE _index_state SET block_number=1996 … WHERE id=1 + DELETE FROM _processed_actions WHERE global_seq=2064, then reindex):

row_fingerprint before: 52c9dd6edaa96753b8bc4dc6cbf8af5e
row_fingerprint after:  52c9dd6edaa96753b8bc4dc6cbf8af5e   (identical, no duplicate)
log: Escrow expired: no open deposit for order_ref 777001 (tx 0514d195…) — skipping
_processed_actions: global_seq 2064 re-claimed, count 1

The deleted ledger row forced the replay through closeDeposit itself, and the status='open' + created_block bound made it a no-op — the two-layer guard holds for the new status path.

yarn format (StandardJS) clean.

contracts #47 added expire(order_ref) — the permissionless backstop that
refunds the buyer once a deposit outlives ESCROW_EXPIRY_SECONDS — and the
indexer never heard of it, leaving the replica row 'open' forever after the
money moved (permanent only_in_db drift, order never settles).

expire routes through the same closeDeposit path as release/refund, so the
replay guards (status = 'open' scoped, created_block bound) apply unchanged.
It closes as its own status, 'expired', not 'refunded': the counterparty
going silent and the backstop firing must stay distinguishable from the
seller refunding, for the P3.5 escalation work.

closed_by records the buyer for an expired close, taken from the row being
closed rather than the payload: expire is permissionless (no require_auth,
so the authorization array can even be empty) and whoever fires it gains
nothing — the contract pays the buyer regardless, so the buyer is the one
account every expire provably pays.

setminimum is deliberately NOT indexed: it writes the mindeposit config
table (configuration, not money movement) — noted at the updater
registration for whoever builds the P3.2/P3.3 buy flow.
@lucca65
lucca65 merged commit 103824b into master Aug 7, 2026
2 checks passed
@lucca65
lucca65 deleted the fix/index-expire branch August 7, 2026 16:45
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