timelock_guardian — security fix series (2026-09-21)
Base: mechanical pearl port (port.py) of SillyZir/timelock_guardian @ 44a4f3a685b9f2c263d51f4362065aa26b880dc9. The user then authorized fixing every YELLOW audit finding, so the files in this directory are port + fix series — they intentionally diverge from the GitHub commit. port.py is kept as provenance for the original mechanical transform only; the committed .gno files here are the deployable source of truth until the fixes are pushed upstream.
Three fresh-context audit rounds: round 1 (ported code) 0 RED / 5+1 YELLOW; round 2 (after fixes 1-6) verified all six closed, found 3 new low YELLOWs; round 3 verified those closed too — 0 RED, 0 YELLOW remaining, cosmetic notes only.
Round 1 findings → fixes
| # | Finding | Fix | Regression test |
|---|---|---|---|
| Y1 | caller() helper derived identity via stack-walking unsafe.PreviousRealm() in a non-crossing helper — correct on all paths but enforced only by call-site discipline | All crossing entrypoints declare cur realm; identity is cur.Previous().Address() inline; helper deleted; unsafe retained for OriginSend only | whole suite (every auth test crosses the new shape) |
| Y2 | Sweep-queue starvation: long-delay sybil wall in the global FIFO window made victims' expired actions unreapable — wedging quota and SetGuardian | Permissionless Expire(cur, actionID) reaps any provably expired action (refuses live/executed); recovery no longer depends on sweep order | TestExpire_PermissionlessRecovery, TestExpire_RefusesLiveAndExecuted, TestVictimRecoversBehindSybilWall |
| Y3 | sanitize() missed < > — raw HTML (incl. <script>) reached the shared Render page | Replacer maps <→(, >→) | TestRender_SanitizesHTMLBrackets |
| Y4 | O(n) removeID scans over globally shared, attacker-growable pendingIDs/actionIDs; counter map could desync | Per-target pending map[string][]string (each ≤ MaxPendingPerTarget); per-target sweep (global SweepBudget removed); pendingCount = list length (no counter to desync); render index self-trims to RenderIndexCap | TestGetPending_GroupedByTarget, TestPendingCounterConsistency, TestRenderIndexSelfTrims, TestVictimRecoversBehindSybilWall |
| Y5 | One-step TransferTargetOwnership consumed the recipient's quota and dumped pending obligations without consent | Two-step: transfer sets PendingOwner (or clears with ""); new AcceptTargetOwnership moves ownership + quota at consent time | TestTransferTargetOwnership_TwoStep, _ClearOffer, _QuotaCheckedAtAccept, TestUnsolicitedOfferConsumesNothing |
| Y6 | No stray-send guard and no banker — coins attached to any call stranded on the realm address forever | rejectStraySend(cur) first statement of all 9 crossing functions | TestStraySendRejectedEverywhere (all 9 probed) |
Round 2 findings → fixes
| # | Finding | Fix | Regression test |
|---|---|---|---|
| Y-1 | GetPending iterated every target ever registered (append-only targetNames) — unbounded sybil-growable scan | pendingTargets list (targets with ≥1 live pending) maintained via O(1) swap-remove (ptIndex); GetPending/Render iterate only it | TestPendingTargetsBookkeeping |
| Y-2 | Render's Pending section read the trimmed index — executed churn could push a live pending action off the page for its whole delay window | Pending section built from the live per-target lists (flood-immune, capped with note); Executed section keeps the bounded index window | TestRenderShowsPendingDespiteChurn |
| Y-3 | float64 Duration.Seconds() fed a consensus-visible abort message | Integer remaining / time.Second at both sites; realm is float-free | TestExecute_TooEarlyBlocked (message path) |
| G2 | reap() would delete an executed record if ever called on one | reap early-returns on Executed — permanence enforced at the sink | — (defensive) |
| UX | Expired-but-unswept actions blocked SetGuardian | Owner-gated sweep before the pending gate | TestSetGuardian_SweepsExpiredFirst |
| G4 | Coverage gaps | Veto added to stray-send probe; already-owner offer branch tested; misleading gone locals renamed | in-suite |
Suite: 50 tests, all passing on the chain/pearl toolchain (c4c72fd); lint clean; full workspace green.
Provenance v2 (2026-09-21, post-push)
Remediation pushed upstream: SillyZir/timelock_guardian @ bb05e64898967125b900a9275ca84229bff5e007 (main), authored solely by SillyZir, no AI attribution (verified via fresh clone + GitHub API). GitHub is again the source of truth; the transformation to this directory is T1 ONLY: gnomod module-path namespace rewrite — both .gno files are byte-identical to the GitHub commit (verified against the fresh clone).