package timelock_guardian import ( "chain" "chain/runtime/unsafe" "strconv" "strings" "time" ) // TargetConfig binds a named target to the only address allowed to // schedule actions against it, an enforced minimum delay, and an // optional guardian who can veto pending actions. Without this binding // a timelock attests nothing: anyone could schedule their own // short-delay action against any name and "execute" it. type TargetConfig struct { Name string Owner address Guardian address // empty = no guardian MinDelay int64 // seconds; every action for this target waits at least this // PendingOwner is the offered-but-not-accepted new owner (fix Y5: // ownership moves in two steps, so a stranger can never have a // target — and its quota slot and pending obligations — dumped on // them without consenting). PendingOwner address } // Action represents a scheduled operation that can only execute after // its delay has elapsed, and only within its grace window. // // State model (re-audit 2026-09-02): only PENDING and EXECUTED actions // are stored. Executed records are permanent attestations consumers // check via IsExecuted. Cancelled, vetoed, and expired actions are // REAPED from state — their history lives in emitted events — so the // live-action cap bounds live exposure and can never be consumed // permanently by schedule/cancel cycling. type Action struct { ID string Target string Creator address Data string // encoded call data or description of the action ScheduledAt time.Time ExecuteAfter time.Time ExpiresAt time.Time // ExecuteAfter + GracePeriod; not executable after Executed bool } const ( // MinDelayFloor is the smallest minimum delay a target may register. // A timelock with a 1-second delay protects nothing. MinDelayFloor = int64(60) // MaxDelay bounds both registered minimum delays and per-action // delays. Also the overflow guard: MaxDelay seconds in nanoseconds // is far below int64 range, so ExecuteAfter arithmetic cannot wrap // into the past. MaxDelay = int64(10 * 365 * 24 * 3600) // 10 years // GracePeriod is how long an action stays executable once ready. // After it, the action expires: something scheduled and forgotten // cannot be sprung on a target years later. GracePeriod = int64(30 * 24 * 3600) // 30 days // Quotas are PER-OWNER only (re-audit round 3): any global cap is a // shared resource a few sybil accounts can exhaust forever (10y // delays defeat expiry sweeping), bricking every other tenant. With // per-owner quotas an attacker only ever consumes their own budget; // state growth is priced in gas and funded accounts. MaxTargetsPerOwner = 10 MaxPendingPerTarget = 20 MaxTargetNameLen = 64 MaxDataLen = 2000 MaxRenderActions = 100 // RenderIndexCap bounds the render-ordering index (fix Y4): the // index self-trims to this size, so reaping an entry from it is a // bounded scan no matter how many actions the realm has ever seen. // Records older than the window stay in state (GetAction/IsExecuted // are map reads and permanent); they only leave the front page. RenderIndexCap = 2 * MaxRenderActions ) var ( targets map[string]*TargetConfig targetNames []string // insertion-ordered for deterministic iteration ownerTargets map[address]int actions map[string]*Action actionIDs []string // insertion-ordered render window, self-trimmed to RenderIndexCap // pending holds each target's LIVE pending action IDs in insertion // order (fix Y4): every list is bounded by MaxPendingPerTarget, so // scans, sweeps, and removals are bounded per call regardless of how // much state OTHER tenants have grown — queue position in a global // list is no longer a shared resource. pending map[string][]string // pendingTargets lists the targets holding >= 1 live pending action, // maintained with O(1) swap-remove via ptIndex (round-2 fix Y-1): no // write path ever scans a list another tenant can grow, and // GetPending/Render iterate only targets that actually have work. pendingTargets []string ptIndex map[string]int nextID int ) func init() { targets = make(map[string]*TargetConfig) targetNames = []string{} ownerTargets = make(map[address]int) actions = make(map[string]*Action) actionIDs = []string{} pending = make(map[string][]string) pendingTargets = []string{} ptIndex = make(map[string]int) nextID = 1 } func now() time.Time { return time.Now() } // ---------- helpers ---------- // rejectStraySend aborts when coins are attached to a call (fix Y6): // this realm handles no funds and holds no banker, so an attached send // would strand on the realm address forever. Aborting reverts the // transfer back to the sender. Fails open for realm-routed calls, whose // attached send lands on the intermediary realm, never here. func rejectStraySend(cur realm) { if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 { panic("this realm does not accept coins") } } func mustGetTarget(name string) *TargetConfig { t, ok := targets[name] if !ok { panic("target not found: " + name) } return t } func mustGet(id string) *Action { a, ok := actions[id] if !ok { panic("action not found: " + id) } return a } func isValidName(name string) bool { if name == "" || len(name) > MaxTargetNameLen { return false } for _, c := range name { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { return false } } return true } func expired(a *Action) bool { return !a.Executed && now().After(a.ExpiresAt) } func removeID(list []string, id string) []string { for i, x := range list { if x == id { return append(list[:i], list[i+1:]...) } } return list } func addPendingTarget(target string) { if _, ok := ptIndex[target]; ok { return } ptIndex[target] = len(pendingTargets) pendingTargets = append(pendingTargets, target) } // removePendingTarget drops a target from the with-pendings list in // O(1) by swapping the last entry into its slot. Order afterwards is // deterministic (a pure function of the operation history), which is // all iteration needs. func removePendingTarget(target string) { i, ok := ptIndex[target] if !ok { return } last := len(pendingTargets) - 1 moved := pendingTargets[last] pendingTargets[i] = moved ptIndex[moved] = i pendingTargets = pendingTargets[:last] delete(ptIndex, target) } // dropPending removes id from its target's pending list. Bounded: the // list never exceeds MaxPendingPerTarget. func dropPending(target, id string) { l := removeID(pending[target], id) if len(l) == 0 { delete(pending, target) removePendingTarget(target) } else { pending[target] = l } } // reap removes a non-executed action from state entirely; its history // is the emitted event. Executed records are permanent attestations: // reap refuses them outright (round-2 hardening), so no future caller // can erase one by mistake. func reap(id string) { a := actions[id] if a != nil && a.Executed { return } if a != nil { dropPending(a.Target, id) } delete(actions, id) actionIDs = removeID(actionIDs, id) } // sweepExpired reaps the expired pending actions of ONE target (fix // Y4/Y2: the old global budget-windowed sweep let long-delay entries at // the front of a shared queue starve everything behind them). A // target's list is bounded by MaxPendingPerTarget, so the sweep is a // bounded scan. Expiry history is the emitted event. func sweepExpired(target string) { var live []string for _, id := range pending[target] { a := actions[id] if a == nil { continue } if expired(a) { chain.Emit("timelock_expired", "id", id, "target", a.Target) delete(actions, id) actionIDs = removeID(actionIDs, id) continue } live = append(live, id) } if len(live) == 0 { delete(pending, target) removePendingTarget(target) } else { pending[target] = live } } // pendingCount is the length of the target's live list. The counter it // replaces (fix Y4) could desync from the lists it mirrored; a length // cannot. It may briefly include not-yet-swept expired actions; those // are reaped by the next Schedule's sweep or by anyone's Expire, and in // the worst case an owner briefly under-uses their own quota — never // another tenant's. func pendingCount(target string) int { return len(pending[target]) } // sanitize makes attacker-controlled text safe to embed in markdown and // single-line summaries: backticks, pipes, newlines, link syntax, and // raw HTML brackets (fix Y3: gnoweb has no HTML sanitization layer, so // a literal