Skip to content

Add OGN buyback automation scripts and contracts - #2988

Open
shahthepro wants to merge 4 commits into
masterfrom
shah/buyback-automation
Open

Add OGN buyback automation scripts and contracts#2988
shahthepro wants to merge 4 commits into
masterfrom
shah/buyback-automation

Conversation

@shahthepro

@shahthepro shahthepro commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Automates the OGN buyback and xOGN reward-rate workflow without upgrading the Vault or OToken implementations.

OUSD/OETH protocol fees
        ↓
   FeeSplitter
   ↙         ↘
20% ops   80% OGN buyback
                 ↓
          xOGN rewards source

Economics change: protocol fees previously funded OGN buybacks in full. This configuration sends 20% to operations and 80% to buybacks, reducing the OGN purchased for xOGN stakers—and the resulting reward rate—by approximately 20%.

Changes

  • Add a non-upgradeable FeeSplitter as the OUSD and OETH vault fee recipient.

    • Supports OUSD and OETH, with configurable dust thresholds.
    • Sends 20% to the operations wallet and 80% to the OGN CoW harvester.
    • Caps the operations share at 50%.
    • Keeps supported OTokens rebasing while awaiting distribution.
    • Restricts changes to fund destinations and the split to governance.
    • Allows Talos, the Strategist, or governance to trigger distributions.
  • Add SetXOGNRewardRateModule for bounded reward-rate automation.

    • Settles accrued xOGN rewards at the old rate before changing it.
    • Enforces minimum and maximum rates, a maximum 25% step, and minimum runway.
    • Calculates runway from the reward source balance net of already-owed rewards.
    • Limits Talos to the bounds configured by the Guardian Safe.
  • Add Talos actions:

    • feeSplitterDistribute: distributes accumulated fees daily at 12:10 UTC.
    • setXOGNRewardRate: updates the rate weekly on Tuesday at 01:20 UTC.
    • The rate is based on OGN delivered by CoW over the trailing 30 days, constrained to a 1–4 day operating runway and a 5% deadband.
    • Cached fill data is deduplicated and re-scanned across a 50-block reorg buffer.
    • Both actions support --dryrun.
  • Add a shared, validated configuration for the deployment and Talos actions.

  • Add the corresponding schedule entries and action documentation.

Deployment

Contracts deployed:

  • FeeSplitter

    • Constructor: no arguments
    • Recorded as FeeSplitter
  • SetXOGNRewardRateModule

    • Safe: Multichain Strategist/Guardian Safe
    • Operator: Talos relayer
    • Rewards source: OGN Rewards Source
    • Token: OGN
    • Rewards target: xOGN
    • Recorded as SetXOGNRewardRateModule

The governance proposal:

  1. Claims governance of the FeeSplitter for the mainnet Timelock.
  2. Sets the OUSD Vault trustee address to the FeeSplitter.
  3. Sets the OETH Vault trustee address to the FeeSplitter.

No Vault or OToken implementation upgrade or storage change is required.

Post-deployment

  • Enable SetXOGNRewardRateModule on the Guardian Safe.
  • Configure its bounds:
    • Minimum rate: 0.25 OGN/second
    • Maximum rate: 5 OGN/second
    • Maximum step: 25%
    • On-chain minimum runway: 12 hours
  • Enable the new Talos schedules after deployment and configuration.
  • Replace the placeholder operations wallet with the NAV wallet through governance when ready.

Testing

  • Unit coverage for FeeSplitter distribution, rounding, dust thresholds, access control, asset management, rebasing, and rescue behavior.
  • Fuzz coverage verifies that distributions are lossless, favor the buyback on rounding, and respect the operations cap.
  • Unit coverage for reward-rate bounds, step limits, runway checks, Safe execution, and settle-before-repricing behavior.
  • Mainnet-fork coverage verifies behavior against the real OGN reward source, xOGN contract, and Guardian Safe.
  • Deployment fork checks verify governance, configuration, trustee changes, rebase opt-in, and end-to-end fee distribution.
  • Build, formatting/lint, Slither, Snyk, JS tests, unit tests, fork tests, and smoke tests pass in CI.

Code Change Checklist

To be completed before internal review begins:

  • The contract code is complete
  • Executable deployment file
  • Fork tests that test after the deployment file runs
  • Unit tests *if needed
  • The owner has done a full checklist review of the code + tests

Internal review:

  • Two approvals by internal reviewers

Deploy checklist

Two reviewers complete the following checklist:

- [ ] All deployed contracts are listed in the deploy PR's description
- [ ] Deployed contract's verified code (and all dependencies) match the code in master
- [ ] Contract constructors have correct arguments
- [ ] The transactions that interacted with the newly deployed contract match the deploy script.
- [ ] Governance proposal matches the deploy script
- [ ] Smoke tests pass after fork test execution of the governance proposal

@shahthepro shahthepro changed the title [WIP] Add OGN buyback automation scripts and contracts Add OGN buyback automation scripts and contracts Sep 1, 2026
@shahthepro
shahthepro marked this pull request as ready for review September 1, 2026 14:22
Comment thread contracts/contracts/harvest/FeeSplitter.sol Outdated
Comment thread contracts/contracts/harvest/FeeSplitter.sol
Comment thread contracts/contracts/automation/SetXOGNRewardRateModule.sol
Comment thread contracts/contracts/automation/SetXOGNRewardRateModule.sol
clement-ux
clement-ux previously approved these changes Sep 3, 2026

@clement-ux clement-ux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM now 👍

@sparrowDom

Copy link
Copy Markdown
Member

Code review

Reviewed at 2e7a06165 (2026-09-02), re-reviewed at e7b393fac "Address CR comments" (2026-09-03). File/line anchors under each finding point at e7b393fac. This write-up wasn't posted while e7b393fac was authored, so that commit responds to @clement-ux's comments only — the re-review section at the bottom maps it against these findings.

Verdict

The Solidity is clean, minimal, and genuinely well-tested; the deploy script is correct and simulates green end-to-end (I ran it). The wiring assumptions all check out against live mainnet state. The problem is the closed-loop behavior of the weekly rate controller: with today's measured on-chain numbers it burns the entire ~2.26M OGN reward reserve in ~3 weeks and then wedges itself permanently (every subsequent run reverts) until a human intervenes via the Safe. The config docs justify the parameters with a factual claim about the current reserve that is off by ~10×, and the governance proposal text describes the opposite economic effect (rate −20%) of what the automation will actually do first (rate +95% for 3 weeks, then an erratic trickle). This needs a parameter/design pass before deploy; the contracts themselves largely don't need to change.

What I verified

Live mainnet state (via RPC, 2026-09-02):

  • OGNRewardsSource (0x7609c88E…): strategistAddr = Multichain Guardian Safe 0x4FF1b9D9… ✓ (the module's Safe can call setRewardsPerSecond, which is onlyGovernorOrStrategist); rewardsTarget = xOGN ✓; rewardConfig = (lastCollect, 1.7 OGN/s); balance 2,292,282 OGN, previewRewards 31,436 → available ≈ 2.26M OGN ≈ 15.4 days runway. (Re-measured 2026-09-03: balance had grown to 2,530,960 OGN ≈ 17.1 days — the reserve is currently growing, which strengthens F-1.)
  • Verified source of the deployed FixedRateRewardsSource impl (0x16890bdd…): rewardConfig() tuple order matches the module's decode ✓; setRewardsPerSecond does not settle and reprices the open window retroactively (the module's settle-first is necessary and correct) ✓; previewRewards() caps at balance and collectRewards() advances lastCollect unconditionally, forfeiting any shortfall ✓ (both faithfully mirrored in MockFixedRateRewardsSource).
  • Verified source of xOGN/ExponentialStaking impl (0x97711c7a…): collectRewards() is permissionless ✓ and wraps rewardsSource.collectRewards() in try/catch ✓; it also skips the source call entirely when totalSupply()==0.
  • OGN CoW harvester (0x637C5093…, legacy deploy 189): owner = Guardian Safe, allowedBuyToken(OGN) ✓, allowedReceiver(OGNRewardsSource) ✓, tokenConfigs minSellAmounts exactly match the FeeSplitter minDistribute values (OUSD 20e18, OETH 1e16) ✓. But it has zero OUSD/OETH balance and zero outgoing OUSD/OETH transfers in the last 90 days — see F-4.
  • OUSD & OETH vaults: trusteeAddress = 0xBB077E71… (1-of-3 fee Safe) and trusteeFeeBps = 2000 on both ✓.
  • Measured trailing 30d CoW inflow to the reward source: 70 fills, 4,782,118 OGN = 1.845 OGN/s (≈159k/day). Fills are lumpy (~2.3/day average).

Tests I ran (both heads): FeeSplitter 55/55 unit+fuzz, module 30/30 unit (36/36 at e7b393fac), 8/8 fork tests against live mainnet, and make simulate — deploys, governance proposal (claimGovernance + 2× setTrusteeAddress), and _fork() verification all pass.

Code-level checks that came back clean:

  • OUSD/OETH post-resolution credit math: _adjustAccount computes newBalance = current − value and re-derives credits (ceil), so transfers change balances exactly_distribute's two-transfer sequence from one snapshot cannot underflow/revert.
  • FeeSplitter: rounding floors the ops share (remainder to buyback); duplicate assets in distribute(address[]) are harmless; optIntoRebase is an arbitrary-target call but fixed selector, no value — harmless.
  • Module: bounds start zeroed and setRewardRate is safely inert pre-setBounds; execTransactionFromModule return checked; available = balanceOf − previewRewards cannot underflow (preview caps at balance); calling xOGN.collectRewards() with the module as user is side-effect-free for the module.
  • Deploy: no storage-layout gate needed (new non-upgradeable contracts); the ordering is fail-safe — if the governance handover were missing, claimGovernance() reverts the whole proposal before the trustees are repointed.
  • Talos wiring: both actions resolve contracts via descriptors that make deploy-mainnet regenerates; FeeSplitter and the module are not proxied, so descriptor ABIs are the right source; schedules land enabled=false; ACTIONS.md updated; setXOGNRewardRate (Tue 01:20) deliberately runs 30 min after ognClaimAndForwardRewards (Tue 00:50) so forwarded OGN is in available — nice touch.

F-1 — MAJOR (design/parameters): the weekly controller burns the 2.26M OGN reserve in ~3 weeks, then wedges permanently

Where: contracts/tasks/actions/setXOGNRewardRate.ts:99-102 (the runway band — floorRate/ceilRate clamp; primary anchor). Also: contracts/scripts/config/ogn-buyback.json:27-28 (the 1–4d band values), contracts/utils/ogn-buyback-config.js:69 (the "~1.6 days" claim), contracts/contracts/automation/SetXOGNRewardRateModule.sol:152 (the runway require the wedge collides with).

The action computes rate = clamp(baseRate, available/maxRunway, available/minRunway) with a 1–4 day band. The config doc justifies the "don't hoard" floor with:

maxRunwaySeconds … Near-inert while the reward source holds ~1.6 days; becomes meaningful if it is ever funded to a real reserve.

Measured reality: the source holds ~15.4 days (2.26M available at 1.7/s; ~17 days as of 2026-09-03). The claim is off by ~10×, so floorRate = available/4d ≈ 6.54 OGN/s is the binding term from the first run, not an inert backstop. baseRate (the headline "rate from measured buybacks", 1.845 OGN/s) is irrelevant for the first month, and the "Sustained clamping…" warning will fire every week.

Simulated week by week with measured values (inflow 1.845 × 0.8 post-split = 1.476 OGN/s; the action's exact clamp order; module bounds 0.25–5, step 25%, minRunway 12h):

wk available floor/ceil (OGN/s) proposed outcome
0 2.26M 6.54 / 26.2 2.125 SET (1.7 → 2.125)
1 1.87M 5.41 / 21.6 2.656 SET
2 1.16M 3.34 / 13.4 3.320 SET
3 ~40k 0.11 / 0.45 2.490 REVERT — 2.49 × 43200 = 107.6k > available
4+ ~0 0 / 0 2.490 REVERT forever

The wedge mechanism: once the source is drained, the step limit (25%, enforced in both the action and the module) forbids proposing anything below 3.32 × 0.75 = 2.49 OGN/s, while the module's runway check requires 2.49 × 43200 ≈ 107.6k OGN available — which never accumulates, because pending accrual at the stuck nominal rate (3.32/s) outruns inflow (1.476/s) and xOGN sweeps the balance on every stake/unstake. Even minRate = 0.25 (needs only 10.8k) is unreachable through the step limit. Every weekly run reverts; the only exit is the Guardian manually calling setRewardsPerSecond (or re-setBounds).

Consequences, in order:

  1. Weeks 0–3: emissions ramp 1.7 → 3.32 OGN/s (+95%) and the entire ~2.26M OGN reserve is distributed. Maybe that's desired ("distribute the backlog") — but nothing in the PR or proposal says so, and the proposal text says the opposite: "reduces OGN bought for stakers by roughly a fifth, and lowers the xOGN reward rate proportionally." Voters would approve a fee cut and get a 2× emission ramp.
  2. Week 3 onward: the source runs dry mid-week. previewRewards caps at balance and collectRewards advances lastCollect regardless, so staker accrual silently truncates — actual payouts become "whatever CoW delivered since the last sweep" (~1.48/s average, lumpy) while the nominal on-chain rate reads 3.32. Dashboards and stakers see a rate that isn't being paid.
  3. The automation is dead (weekly reverting txs / failed Talos runs) until manual Safe action.

Note the module's runway check is working as designed here — it correctly refuses rates the source can't sustain. The controller drives into it and the step limits prevent backing out.

Recommendations (combine 1 with 2 and 3):

  1. Decide the reserve policy explicitly. If the backlog should be paid out, say so in the PR + proposal text and pick a deliberate spend-down horizon (e.g. maxRunwaySeconds = 30–90d gives a gentle glide instead of a 3-week burn). If not, set maxRunwaySeconds well above the actual current runway so floorRate is truly inert at go-live (the doc's stated intent).
  2. Make the operating band respect the control period. With weekly runs, a 1–4 day target buffer is unstable by construction: one week at any floor-driven rate can outspend the entire buffer. minRunwaySeconds (script) should be ≥ cadence + margin (≈ 10–14 days with weekly runs), or the action should run daily.
  3. Wedge-proof the module cheaply: exempt rate decreases from _checkStep (the risk both limits exist to bound — a compromised operator inflating emissions — is upward; unlimited decrease is bounded by minRate anyway), and/or apply the runway require only when newRate > currentRate (lowering the rate strictly improves runway; refusing a decrease for lack of runway is what creates the deadlock). Either one alone unwedges the system.

F-2 — MEDIUM (extends @clement-ux's 🟠): verify the settle instead of tolerating its silent failure

Where: contracts/contracts/automation/SetXOGNRewardRateModule.sol:139resolved in e7b393fac; that line is now the implemented require(lastCollect == block.timestamp, "Rewards not settled").

Two things to add to the original thread: in practice the silent-failure path was nearly unreachable (previewRewards caps at balance, so the inner OGN transfer can't fail for insufficient funds; realistic catch triggers are exotic — plus totalSupply()==0, where xOGN skips the source call entirely). Which is exactly why it was cheap to close completely: collectRewards sets lastCollect = block.timestamp iff it actually ran, so the equality check is exact. The implemented fix matches this shape, covers both skip paths, and keeps the previewRewards() subtraction as belt-and-braces. Note it adds no new failure mode to the F-1 wedge: in the drained-source state the payout truncates to 0 but lastCollect still advances, so the check passes.

F-3 — MEDIUM (endorses @clement-ux): per-call step limit ratchets

Where: contracts/contracts/automation/SetXOGNRewardRateModule.sol:214-243 (_checkStep, now checkpoint-based) and :176 (setBounds with the new stepPeriod param) — resolved in e7b393fac.

Confirmed the original issue: nothing stopped consecutive calls walking 1.7 → 5.0 in 5 txs in one block. The checkpoint design fixes it soundly: within-period calls share one baseline (reachable band [chk×0.75, chk×1.25]), refresh-then-require is atomic, and compounding is 25% per 7 days — a compromised key now needs ~5 weeks instead of one block. See N-1/N-2 below for two small sharp edges the new design introduces.

F-4 — MEDIUM (ops/go-live): nothing on-chain shows the CoW bot will actually sell from the OGN harvester

Where: contracts/scripts/deploy/mainnet/006_OGNBuyback.s.sol:65 (feeSplitter.setHarvester(Mainnet.OGNCoWHarvester)); the checklist ask belongs in the PR description's Post-deployment section.

The 80% leg ends at the EIP-1271 harvester, which has held zero OUSD/OETH and produced zero outgoing OUSD/OETH transfers in ≥90 days — historically the fee Safe signed CoW orders itself, so this harvester path has plausibly never run end-to-end for OUSD/OETH. Token configs exist (deploy 189), but "wired but never fed" and "not wired" are indistinguishable from chain data. If the bot (0x7aD5C91…) isn't watching this harvester's OUSD/OETH balances, fees pile up there unsold: no OGN reaches the reward source, measured inflow decays toward zero, and the rate controller winds down (or hits F-1's wedge sooner).

Suggested post-deployment checklist item: confirm the CoW bot creates OUSD→OGN and OETH→OGN orders from 0x637C5093… (e.g. seed a small OUSD amount through distribute() and watch a fill) before enabling the schedules. Also worth knowing: the harvester can't rebaseOptIn (no such function in its ABI), so the buyback share earns nothing while parked there — fine at daily cadence.

F-5 — LOW: reorged-away fills are never pruned from the scan store

Where: contracts/tasks/actions/setXOGNRewardRate.ts:269 (the [...storedFills, ...fresh] merge loop in measureInflow).

The merge dedupes by txHash:logIndex, but a stored fill the reorg removed (or whose logIndex changed) survives until it ages out of the 30d window — a small, permanent over-count. Since the rescan range is re-read from the canonical chain anyway, treat it as authoritative:

for (const fill of [...storedFills.filter((f) => f.block < fromBlock), ...fresh]) 

F-6 — NIT: stale path in config docs

Where: contracts/utils/ogn-buyback-config.js:8 — says the deploy script is scripts/deploy/mainnet/005_OGNBuyback.s.sol; it's 006_OGNBuyback.s.sol.

F-7 — OBSERVATION: moduleBounds in the JSON are enforced nowhere

Where: contracts/scripts/config/ogn-buyback.json:19-24; the still-unset assertions it contrasts with are contracts/scripts/deploy/mainnet/006_OGNBuyback.s.sol:180-181.

They document the Guardian's intended manual setBounds (now 5 parameters), and the deploy _fork() asserts bounds are still unset — good — but nothing later checks the Guardian's actual values match the reviewed file. Consider printing the exact setBounds calldata in the deploy output (copy-paste for the Safe UI) and/or a smoke test that, once bounds are non-zero, asserts they equal the JSON.


What's good (worth keeping as-is)

  • FeeSplitter permission split by blast radius (money-routing = governor; bookkeeping = strategist; timing = operator) is exactly right, and "routing fixed in code, caller only picks when" is the correct answer to replacing a 1-of-3 Safe.
  • Non-upgradeable FeeSplitter with the "replacing it costs the same governance as an upgrade" argument — right call; no Vault/OToken change anywhere.
  • Settle-before-reprice in the module is necessary (verified against the deployed source) and done in the same tx.
  • Mock fidelity: MockFixedRateRewardsSource reproduces the two production sharp edges with comments telling future editors not to "fix" them, and the fork suite exists specifically to catch the mock being wrong — the right instinct.
  • Deploy script: config-file-driven with _fork() re-asserting every value, an end-to-end vault-minted distribution check, and honest reviewer notes about the economics change.
  • Ops details: dust floors matched to harvester minSellAmounts, daily distribute cadence justified by bot-key exposure, schedule ordering after ognClaimAndForwardRewards, disabled-by-default schedule rows.

Re-review of e7b393fac "Address CR comments"

Everything re-verified at the new head: rebuilt, 36 module unit tests (6 new), 55 FeeSplitter unit tests, 8 fork tests against live mainnet, and make simulate all pass.

Finding Status after e7b393fac
F-1 controller burns reserve then wedges OPEN — unchanged. Script band still 1–4 days, cadence weekly, module minRunway still 12h, "~1.6 days" claim still in the docs. The checkpoint mechanics don't alter the trajectory: normal weekly runs open a fresh period each time (baseline = live rate → same ramp), and a reverted run doesn't advance the checkpoint, so wedge-state retries still re-baseline to the stuck rate and re-fail. Same table, same permanent wedge at week ~3.
F-2 verify the settle RESOLVED.
F-3 step-limit ratcheting RESOLVED. Compromise walk 1.7→5 now takes ~5 weeks instead of one block.
F-4 CoW bot wiring unproven OPEN.
F-5 reorged fills never pruned OPEN.
F-6 stale 005 path OPEN.
F-7 JSON bounds enforced nowhere OPEN — now with a 5th setBounds parameter to get right.

Two new (low) observations introduced by the checkpoint design:

  • N-1 — stepPeriodSeconds exactly equals the cron cadence (both 604800s). Where: contracts/scripts/config/ogn-buyback.json:22, claim at contracts/utils/ogn-buyback-config.js:57. "A normal run always opens a fresh period" requires this week's tx block-timestamp to land ≥ exactly 7d after last week's — with the cron firing at the same wall time weekly, inclusion jitter makes that roughly a coin flip. A "short" week measures against the stale checkpoint, clamps to last week's rate, and the deadband skips the update entirely; the following week (≈14d elapsed) refreshes and proceeds. Only a one-week delay during convergence phases, and it self-corrects, but the effective cadence becomes erratic. Fix: set stepPeriodSeconds a few hours under the cadence (e.g. ~6.9 days / 596400) — the same "backstop sits below the target" pattern the config already uses for minRunwaySeconds.
  • N-2 — a within-period Safe override can be walked back by the next operator call. Where: contracts/contracts/automation/SetXOGNRewardRateModule.sol:214-243 (_checkStep — within-period calls measure against checkpointRate, ignoring a fresher Safe-set currentRate). If the Safe directly sets an emergency rate mid-period (say 1.7 → 0.5 after an incident), the checkpoint still says 1.7 until the period expires, so an operator call inside the window may set anything in [1.275, 2.125] — a +155% jump from the Safe's 0.5, where the old per-call code allowed at most +25%. The window is narrow (a failed-run retry, or an N-1 "short" week), and refresh-to-live at the boundary is the deliberate mitigation, but two cheap hardenings exist: re-baseline immediately whenever the live rate is outside the checkpoint's reachable band (an out-of-band rate proves a higher authority moved it — adopt it), or document the emergency playbook as "when manually overriding the rate, also tighten setBounds or disable the schedule/module".

Bottom line: e7b393fac cleanly resolves both of clement-ux's substantive findings with faithful tests. The major finding — the runway-band controller draining the reserve and then wedging — is fully intact, since it lives in the parameters and the governance/doc text, none of which changed. F-1 needs a decision before this deploys.

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.

3 participants