Skip to content

feat(oft): consolidated per-chain deploy scripts for listing infra + oracle peers#141

Open
jtfirek wants to merge 29 commits into
feat/oft-listing-primitive-boilerplatefrom
feat/oft-listing-deploy-scripts
Open

feat(oft): consolidated per-chain deploy scripts for listing infra + oracle peers#141
jtfirek wants to merge 29 commits into
feat/oft-listing-primitive-boilerplatefrom
feat/oft-listing-deploy-scripts

Conversation

@jtfirek

@jtfirek jtfirek commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the fragmented infra deploy scripts with two single-shot, one-per-chain deploys that stand up the OFT listing stack and the oracle bridge, wiring the cross-chain OApp peers in a single pass. These are primarily the dev bring-up path, but are ENV-parametrized so the same scripts work for mainnet.

  • DeployOFTListingEthereum (chainId 1): OFTConfigRegistry + EtherFiOFTAdapter beacon impl + OFTAdapterFactory, plus a dedicated PriceProvider source and the PriceRelay LZ sender.
  • DeployOFTListingOptimism (chainId 10): OFTConfigRegistry + EtherFiShadowOFT beacon impl + ShadowOFTFactory, plus the OracleSink LZ receiver.

Key decisions

  • Reuse the cash RoleRegistry (read from deployments.json) instead of deploying a dedicated OFTRoleRegistry.
  • Dedicated fresh PriceProvider as the relay's price source (persisted as RelayPriceProvider so it doesn't clobber the cash one).
  • Deterministic peers + single-pass wiring: PriceRelay/OracleSink deploy via the repo's CREATE3-through-Nick's-factory pattern; each side predicts the counterpart's address from a shared, env-scoped salt and calls setPeer in the same broadcast, so the two chains can be deployed in any order.
  • EOA bring-up → handoff: the deployer owns the OApp during deploy to wire the peer, then setDelegate + transferOwnership to OFT_OWNER_<CHAIN> when set (left unset on dev so the EOA keeps ownership).
  • LZ endpoint 0x1a44…728c; EIDs ETH 30101 / OP 30111.

Housekeeping

  • Deletes DeployOFTAdapterSide.s.sol and DeployOFTShadowSide.s.sol (replaced).
  • Repoints kept per-asset scripts (ConfigureAndListOFTMainnet/Optimism, UpgradeOFTRateLimiter) from .addresses.OFTRoleRegistry.addresses.RoleRegistry.

Out of scope (intentionally kept as separate per-asset steps)

Per-asset listing / pathway / DVN config and rate limits stay in ConfigureAndListOFT* / SetOFTRateLimits. Oracle role grants and token subscriptions are performed by the RoleRegistry owner post-deploy.

Test plan

  • forge build passes.
  • Dry-run DeployOFTListingEthereum / DeployOFTListingOptimism on dev (ENV=dev) and confirm the predicted peer addresses match across both runs.
  • Confirm PriceRelay.peers(OP_EID) and OracleSink.peers(ETH_EID) resolve to the deployed counterparts.
  • Verify implementations on the respective explorers.

Made with Cursor

jtfirek and others added 26 commits June 5, 2026 14:13
…itive

Skeletons for the generic mainnet -> OP listing path so listing a new
ERC-20 becomes a single Foundry script + 3CP multisend instead of a
per-asset bridge + per-asset oracle integration.

Wrapping (mirrors the EtherFiSafeFactory beacon pattern, one shared
UpgradeableBeacon impl + cheap per-token CREATE3 beacon proxies):
- src/oft/OFTAdapterFactory.sol     - mainnet factory
- src/oft/ShadowOFTFactory.sol      - destination-chain mirror factory
- src/oft/EtherFiOFTAdapter.sol     - lock-on-deposit beacon impl
- src/oft/EtherFiShadowOFT.sol      - mintable iTOKEN beacon impl

Oracle relay (LZ OApp send/receive, slots into the existing
PriceProvider via a Chainlink-shaped latestRoundData accessor;
no PriceProvider changes required):
- src/oracle/PriceRelay.sol         - mainnet OApp sender, reads PriceProvider
- src/oracle/OracleSink.sol         - destination OApp receiver

Interfaces: IOFTAdapterFactory, IShadowOFTFactory, IPriceRelay,
IOracleSink (all in src/interfaces/).

Deps: adds the lz-address-book submodule (pinned to 747e078, matching
the sibling oft-factory repo) and the LayerZero v2 / OApp / OFT
upgradeable remappings.

Conventions: SPDX MIT, pragma ^0.8.28, ERC-7201 storage with
cast-computed slot constants, UpgradeableProxy + RoleRegistry access
control, solady CREATE3, NatSpec, custom errors.

Core cross-chain logic (OFT _debit/_credit lock/unlock, PriceRelay
payload encoding + _lzSend, OracleSink _lzReceive decode) is left as
explicit TODO stubs in this skeleton pass.

forge build is green; all six new contracts are well under EIP-170.

Co-authored-by: Cursor <cursoragent@cursor.com>
- OFTConfigRegistry: canonical per-chain DVN/library config, version counter, bridge enumeration, pushTo/pushToAll/pushToRange (EnumerableSetLib for dstEid + bridge sets)
- ConfigurableOFTBase: pull-based syncConfig; self-authorized endpoint.setConfig (no delegate)
- EtherFiOFTAdapter / EtherFiShadowOFT: per-proxy ERC-7201 storage for token/decimals/rate so one beacon serves all decimals; adapter fee-on-transfer guard; shadow decimals mirror + placeholder-before-init
- Factories: auto-register + syncConfig at deploy (fail-hard); decimals threaded into shadow factory; paginated getters return empty past end

Tests deferred (pending review). Refs COR-699.
Fill in the LayerZero send/receive logic in the PriceRelay (mainnet sender)
and OracleSink (OP receiver) skeletons.

- PriceRelay pays the LZ fee from its own balance (receive/withdraw), so poke
  is permissionless and non-payable. poke only relays a token once its
  heartbeat elapsed or its price deviated past the configured threshold,
  preventing griefers from draining the relay's ETH and implementing the
  heartbeat + deviation oracle update model on-chain.
- OracleSink._lzReceive decodes (tokens, prices, timestamp) and stores each
  price; peer auth is enforced by OAppReceiver's OnlyPeer check.
- IPriceRelay: add heartbeat to TokenSubscription, drop options from
  poke/quote, add setLzOptions/withdraw/lastRelayed/lzOptions + events/errors.
- Add Foundry tests (mock LZ endpoint) covering round-trip, heartbeat,
  deviation, peer rejection, funding, and OP PriceProvider consumption.

Co-authored-by: Cursor <cursoragent@cursor.com>
47 tests (local mock endpoint, no fork): decimals math (6/8/18), fee-on-transfer guard, init guards, OFTConfigRegistry (config/version/push/pagination), and factory auto-sync (register + syncConfig, fail-hard).

Also re-sorts OFTAdapterFactory imports (forge fmt). Refs COR-699.
Add adversarial, config-registry robustness/sync, conservation invariant, cross-chain setup, decimals fuzz, factory mechanics, round-trip, and weird-token suites plus the conservation handler. Vendor the LZ v1 ULN interface and add matching remappings.
Replace @inheritdoc with full inline NatSpec on the OFT impls (registry, factories, ConfigurableOFTBase) to match the repo's prevailing convention, and document the decimal-conversion internal overrides on the adapter/shadow impls.
…ss guard

- add a RegistryNotSet revert test via a no-guard ConfigurableOFT subclass
  (both prod impls reject a zero registry at construction, so the guard is
  otherwise unreachable); closes the one uncovered branch in ConfigurableOFTBase
- broaden the conservation invariant from a single 8-decimal pair to 6/8/18-decimal
  pairs; expose the handler's pair so each pair is asserted independently
- fuzz the NonLosslessTransfer lossless guard over amount (and fee bps) instead of
  a single fixed example
…leness

Allow the OP PriceProvider to read the multi-token OracleSink directly via
its calldata branch (no per-token Chainlink adapter / no new contract per
listed asset).

- OracleSink.price(token): returns the relayed 6-decimal USD price and reverts
  PriceStale once the relay-delivery timestamp exceeds the per-token window
- setMaxStaleness(token, seconds) gated by ORACLE_SINK_ADMIN_ROLE (0 disables)
- staleness surfaces to the protocol as PriceProvider.price() reverting, same
  as any other feed
- tests: direct read, stale -> revert, disabled-window, admin-gating

Co-authored-by: Cursor <cursoragent@cursor.com>
The bridge no longer stores a per-proxy synced version. Sync history is
observable via the ConfigSynced event instead, so the ConfigurableOFT
storage struct and the syncedConfigVersion() getter are removed from
both the interface and the base. Tests updated to assert sync behavior
(call count + dstEids) rather than the removed per-proxy version.
The registry version is a global counter, so stamping it into a
per-bridge sync event is a leaky freshness signal: editing one
destination's pathway bumps the version an unrelated bridge's sync is
compared against, flagging that bridge as stale when its own pathway
never changed. Per-destination drift is better derived from the
dstEid-keyed PathwayConfigSet event, and configVersion() remains on the
registry as the source of truth. The event now just reports which
dstEids were synced.
Removes named return variables from the OFT implementation contracts in
favor of explicit return statements. Interfaces keep their named returns
as API documentation; only the implementations are changed.
registerBridge ignored the EnumerableSetLib.add return, so re-registering a known bridge emitted a phantom BridgeRegistered with no state change. Gate the event on the insertion result. Harden the idempotency test to assert no event fires on the duplicate call.
…cases

Addresses two Bugbot findings on _deviated, both of which could make poke()
relay more often than intended and waste the relay's prefunded LZ balance:

- deviationBps == 0 now disables the deviation trigger (heartbeat-only) instead
  of evaluating `diff * BPS >= 0` (always true) and relaying on every poke.
- a previously-relayed price of 0 no longer keeps the deviation branch
  permanently true; `last == current` short-circuits to no-deviation.
- 0 -> non-zero is still treated as an unbounded move and relays.

First-send detection moved to an explicit `lastSentAt == 0` check in poke so it
no longer relies on `lastSentPrice == 0` overloading.

Adds tests for zero-bps (disabled trigger + heartbeat still fires), relayed-zero
non-spam, and zero->non-zero recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ink-lz-impl

feat(oracle): implement PriceRelay + OracleSink LZ price relay
…P) sides

Deploys the per-chain OFT listing-primitive infrastructure with an EOA:
a dedicated RoleRegistry, OFTConfigRegistry, the beacon implementation
(EtherFiOFTAdapter on mainnet / EtherFiShadowOFT on OP), and the factory.

RoleRegistry ownership is set to the protocol Safe (OFT_OWNER_MAINNET /
OFT_OWNER_OPTIMISM) so the EOA retains no privileged role. Deployed
addresses are merged into the per-chain deployments.json, preserving
existing keys. Privileged wiring (role grants, pathway config, asset
listing, peer wiring) is done separately by the Safe.
feat(oft): mainnet-OP OFT listing primitive
- removePathway / deregisterBridge to retire destinations and bridges
- syncBridge: single-bridge re-pull (registrar path) for factory auto-sync
- validate DVN sets (sorted, unique, bounded) + optional-DVN threshold
- gate syncConfig to the registry (UnauthorizedSync); blocks third-party forced re-pulls
Port the bidirectional PairwiseRateLimiter from the weETH cross-chain work and
wire it into both OFT bridges, shipped as a beacon upgrade. Each bridge enforces
an independent outbound/inbound cap per peer EID with a linear-decay window.

- PairwiseRateLimiter: ERC-7201 namespaced storage (collision-proof for the
  beacon upgrade), onlyOwner setters, fail-closed (unset pathway = 0 throughput,
  matching cash-v3's explicit-config convention)
- EtherFiOFTAdapter: meter dust-removed amountSentLD in _debit, _amountLD in _credit
- EtherFiShadowOFT: add _debit/_credit overrides that meter then call super
- scripts: UpgradeOFTRateLimiter (beacon rollout), SetOFTRateLimits (caps in whole
  tokens scaled to the asset's decimals); listing scripts set default caps inline
- tests: fail-closed, linear decay, mid-window limit changes, direction/eid
  independence, owner-gating, LD metering, and upgrade-preserves-state on both sides
ConfigureAndListOFT{Mainnet,Optimism} only synced an adapter's endpoint
DVN/library rows on first deploy (via the factory's syncBridge). On a
re-run after editing the pathway constants, setPathwayConfig updated the
registry but the skipped deploy meant the live bridge kept stale endpoint
rows. Now the already-listed branch pushes the freshly-set config to the
existing bridge via configRegistry.pushTo, so the script's idempotency
holds under config edits.

Add CheckOFTConfigSync: a read-only, per-chain staleness check that
compares each registered bridge x active destination's effective ULN
config on the LayerZero endpoint against the registry's canonical config,
with REVERT_IF_STALE for CI gating.
_amountCanBeSent computed decay as (_limit * timeSinceLastDeposit) / _window,
which overflows the intermediate product when _limit is near type(uint256).max
(e.g. an "unlimited" sentinel) and 0 < elapsed < window. Switch to OpenZeppelin
Math.mulDiv, which carries the product at full 512-bit precision. Unreachable
with realistic token-denominated caps, but removes the footgun and matches the
mulDiv convention used elsewhere in the codebase. Fast path is identical to the
plain divide, so no meaningful gas/storage cost.
…ating

poke() is now payable and relays every subscribed token in one message,
with the caller supplying the LayerZero fee (endpoint refunds surplus).
This removes the contract-funded balance, so there is nothing to drain
and no need for the heartbeat/deviation gating, lastSent tracking, or
fund/withdraw machinery the keeper now decides when to relay.

Also swaps the stored bytes lzOptions for a uint128 lzReceiveGasLimit
(options built inline) and strips poke() modifiers to keep the hot path
lean. Storage slot order is preserved (3rd slot bytes -> uint128) so the
change is upgrade-safe.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ler-pays

refactor(oracle): simplify PriceRelay to caller-pays
…ing-scripts

feat(oft): registry hardening, EOA scripts, and rate limiter
…oracle peers

Replaces the fragmented DeployOFTAdapterSide / DeployOFTShadowSide infra
scripts with two single-shot, one-per-chain deploys (primarily the dev
bring-up path; ENV-parametrized for dev/mainnet):

- DeployOFTListingEthereum: OFTConfigRegistry + EtherFiOFTAdapter beacon +
  OFTAdapterFactory, plus a dedicated PriceProvider source and the PriceRelay
  LZ sender.
- DeployOFTListingOptimism: OFTConfigRegistry + EtherFiShadowOFT beacon +
  ShadowOFTFactory, plus the OracleSink LZ receiver.

Both reuse the chain's existing cash RoleRegistry instead of a dedicated one.
The oracle OApp peers (PriceRelay/OracleSink) deploy via the repo's
CREATE3-through-Nick's-factory pattern and each side predicts the counterpart's
deterministic address to setPeer in the same broadcast, so the two chains can be
deployed in any order. Salts are env-scoped so dev/mainnet don't collide on the
same physical chain. The deployer EOA owns the OApp during bring-up to wire the
peer, then hands ownership + LZ delegate to OFT_OWNER_<CHAIN> when set.

Repoints the kept per-asset scripts (ConfigureAndListOFTMainnet/Optimism,
UpgradeOFTRateLimiter) from .addresses.OFTRoleRegistry to .addresses.RoleRegistry.

Co-authored-by: Cursor <cursoragent@cursor.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

Comment thread scripts/DeployOFTListingEthereum.s.sol
jtfirek and others added 2 commits June 11, 2026 15:35
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 42e1ec7. Configure here.

Comment thread scripts/DeployOFTListingEthereum.s.sol
…tate corruption)

Co-authored-by: Cursor <cursoragent@cursor.com>
@nikkaroraa
nikkaroraa force-pushed the feat/oft-listing-primitive-boilerplate branch from 535b698 to 0284670 Compare June 12, 2026 06:51
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.

2 participants