Skip to content

feat(healthcheck): couple facets to their companion periphery contracts - #2125

Open
0xDEnYO wants to merge 26 commits into
mainfrom
claude/busy-solomon-53e1c2
Open

feat(healthcheck): couple facets to their companion periphery contracts#2125
0xDEnYO wants to merge 26 commits into
mainfrom
claude/busy-solomon-53e1c2

Conversation

@0xDEnYO

@0xDEnYO 0xDEnYO commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Which Linear task belongs to this PR?

https://linear.app/lifi-linear/issue/EXSC-684/couple-facets-to-their-companion-periphery-contracts-so-a-receiver

Motivating incident: EXSC-682 (missing ReceiverAcrossV4 on Robinhood, #2124).

Why did I implement it this way?

A bridge facet only covers the source side; destination calls need its companion Receiver on
the same chain. Nothing tied the two together, so a facet could be rolled out to a new chain
while its Receiver was silently forgotten — which is what disabled Across destination calls
on Robinhood, and Daniel reports it has happened several times.

Why nothing caught it. Receivers had no "must exist" coverage at any tier:

  • non-core-facets-deployed filters target-state entries on k.includes('Facet'), so a
    Receiver listed there is never checked for deployment.
  • periphery-registered only checks corePeriphery ∪ whitelistPeripheryFunctions; no
    Receiver is in either list.
  • receiver-executor-binding and receiver-owner both skip a Receiver whose address is
    absent, so a missing one is silently exempt from the only checks that mention it.

Why the requirement is derived from the facet, not declared per chain. Robinhood's
_targetState.json never listed ReceiverAcrossV4 either — a check that only compared
target state against chain state would have stayed green. So the coupling lives once, next to
coreFacets / corePeriphery in config/global.json, and the requirement is derived from
whichever facets are actually live on a chain. Adding a chain cannot silently opt out.

Why config/global.json and not deployRequirements.json. deployRequirements.json is
consumed by checkDeployRequirements() (script/helperFunctions.sh) and both its sections
mean one thing: this is a constructor argument, resolve it or refuse to deploy. A companion
Receiver is not a constructor arg, and the ordering is inverted — the facet is legitimately
deployed first — so a hard pre-deploy block would be wrong by construction. It is also
bash-only, so the TypeScript health check could not consume it without a second reader.
global.json already holds the comparable contract-topology maps and is already loaded by
healthCheck.ts.

Why the registry is keyed by facet name. The match has always been on the facet name, so
that is the key: facetPeripheryCouplings["AcrossFacetV4"] is a direct lookup, and it mirrors
how the sibling registries (deployRequirements.json, whitelistPeripheryFunctions) are keyed.
Variants of one family each get their own entry pointing at the same companion, and the
evaluator merges them back into a single requirement so reporting stays per-integration rather
than per-facet.

Why requiresAnyOf (a list) even though every entry currently has one element. It exists
for genuine interchangeability. Across kept the handleV3AcrossMessage signature in V4, so
ReceiverAcrossV3 could service a V4 destination call — but V3 is deprecated, and accepting
it would let a deprecated contract mask a missing current one, so V4 now requires
ReceiverAcrossV4 specifically. Checked against every active chain first: no chain runs V4
facets with only the V3 receiver, so tightening this created zero new failures.

Why two enforcement tiers.

  • facet-required-periphery health-check invariant (severity error, production scope) is
    the gate. It triggers on facets registered on chain and asserts the on-chain
    PeripheryRegistry returns a non-zero address for one of the companions. It reads the
    registry rather than ctx.deployedContracts because the deploy log can be incomplete —
    ReceiverOIF is live on mainnet and base but has no entry in either deployments/*.json.
    It runs on the existing daily sweep (healthCheckAllNetworks.yml) and on new-network PRs
    (healthCheckForNewNetworkDeployment.yml), so no new workflow is needed.
  • facetCompanionReminder.ts is a non-fatal deploy-time nudge, following the existing
    facetRefundReminder.ts pattern in deploySingleContract.sh. Non-fatal because
    facet-before-Receiver is the normal order.

Triggering on on-chain facets rather than target state also means chains mid-rollout (plume
has the Across V4 facets in target state but nothing deployed) are not flagged here — that
gap is a target-state concern, not this invariant's.

Per-network carve-outs require a reason and print it when they fire. notRequiredOn maps a
network key to why the destination side genuinely does not apply there; the skip is logged with
that reason so it is never invisible. It is currently empty — no chain has a justified exemption.

Live gaps found by this check — in scope for this ticket

Every active production network was audited on chain with this invariant's logic (reading
PeripheryRegistry.getPeripheryContract, not the deploy logs).

ReceiverOIF is missing on 7 production chains. Policy is to ship it wherever either
LiFiIntentEscrow facet is live. It is registered only on mainnet, base and arbitrum;
the escrow facets are live with no receiver on jovay, katana, megaeth, optimism,
pharos, polygon and robinhood
, plus four testnets (arbitrumsepolia, arctestnet,
basesepolia, optimismsepolia). arbitrum, arc and bsc could not be read from this
session and are likely in the same state.
Correction (review round 2): arbitrum was
re-read and is fine — ReceiverOIF is registered and has code there; its deploy-log entry was
missing and is now backfilled. arc and bsc still could not be read; the new
periphery-registry-log-sync invariant will surface their state on the daily sweep.

⚠️ Merging this turns the daily health check red on those chains, and the sweep posts to
Slack on failure. That is the intended behaviour — they are real gaps of exactly the class this
PR exists to surface — but it is a deliberate, visible consequence, not a surprise. Deploying
the missing receivers is the remediation.

tempoReceiverAcrossV4 deployed but never registered. AcrossFacetV4 and AcrossV4SwapFacet are registered on chain, and
ReceiverAcrossV4 is deployed and correctly wired (0xac6ab3D8026Bfd31eDeA055deFedF61956439d0f,
has code, EXECUTOR()0x4556099dde35755d00fEc81100481C582A5EE63c, tempo's Executor) — but
getPeripheryContract("ReceiverAcrossV4") returns the zero address. Deployed, never registered,
so Across destination calls are disabled on tempo. ReceiverStargateV2 on the same diamond is
registered, so nothing structural stopped it; the step was simply skipped. It is also already in
_targetState.json, so this is purely a missing on-chain registration, not a config gap.

Remediating it needs diamondUpdatePeriphery on the tempo diamond, which is owned by the
LiFiTimelockController (0xa7A28FB774a742e82dc237a08d515745f96A46b1) — so Safe proposal →
timelock → execute, which needs the lifi-connect tunnel and hardware-wallet signing. That cannot
be done from an agent session (docs/Setup-agents.md), so the resulting
deployments/tempo.diamond.json Periphery entry is not in this PR yet; it lands here as a
follow-up commit once the registration executes. The log is deliberately left untouched rather than
pre-filled, so it never claims a registration that has not happened.

Known limitation RESOLVED in round 3: skipHealthcheck is gone — every chain runs every check

skipHealthcheck: true used to blanket-skip arc, robinhood and tempo — including robinhood
and tempo, precisely the two chains where this bug class actually manifested. Round 3 removes
the flag entirely
(the INetwork field, the early return in healthCheck.ts, and all three
network entries). Genuine specialties are now carved out narrowly with mandatory, printed reasons
in config/healthCheckExclusions.json, which has two tiers:

  • invariantExclusions — skip one whole invariant on one network (currently empty).
  • corePeripheryExemptions — exempt one core periphery contract on one network, where a
    whole-invariant skip would hide unrelated coverage. Currently: TokenWrapper on arc and
    tempo (no native/wrap path on either — reasons lifted from their devNotes).

What running the previously-skipped chains surfaced (all verified live):

  • robinhood now passes its first-ever full health check (exit 0). The new
    periphery-registry-log-sync invariant immediately caught OutputValidator and ReceiverOIF
    registered on chain but missing from the deploy log, plus OutputValidator absent from
    whitelist.json. All fixed in this PR (addresses verified on chain; robinhood's ReceiverOIF
    at 0xdD54…C576 binds the correct Executor — so the "missing on robinhood" entry in the gap
    list above is outdated: it was deployed and registered after the incident, just never logged).
  • tempo: 3 genuine gaps stay red by designReceiverAcrossV4 deployed-but-unregistered
    (the timelock-gated remediation described above) and SquidFacet in target state but not
    deployed (deploy it or de-scope it from _targetState.json — team decision).
  • arc: RPC unreachable from the agent session; CI uses Mongo-fetched RPCs and may still reach
    it. Either way the sweep now shows arc's true state instead of a silent green skip.

Review round 2 — deploy-log blind spots closed, immutables gated (Daniela's finding + follow-ups)

Daniela's review found the facet side of facet-required-periphery resolving identity through
the same incomplete deploy log the PR rejects for receivers. Confirmed and fixed, plus the
generalizations agreed with Daniel:

  1. Facet identity is now deploy log ∪ on-chain selectors. A facet live on chain but missing
    from the log is identified by matching its full compiled selector set against the diamond's
    facets() output (resolveLiveFacets in shared/facetPeripheryCouplings.ts). When neither
    source can identify an on-chain facet, a warning fires instead of a silent pass.
  2. receiver-executor-binding / receiver-owner resolve receivers registry-first (deploy
    log as fallback) — previously both silently skipped any receiver missing from the log, which
    exempted ReceiverOIF on mainnet/base/arbitrum from binding and ownership coverage entirely.
    receiver-owner now also covers the bridge-specific receivers (they had no owner check).
  3. New periphery-registry-log-sync invariant (error): every known periphery name registered
    on chain must appear in the deploy log with the same address — an incomplete log silently
    shrinks the coverage of every log-resolved check, so it is now a first-class failure.
    deployments/{mainnet,base,arbitrum}.json backfilled with the on-chain-verified ReceiverOIF
    (0x761B0e8f6e80BBd23F3886663Cc071a554be37A3); verified live green on mainnet.
  4. New immutable-bindings-match-config invariant (error): contracts binding external
    counterparties immutably (ReceiverAcrossV4.SPOKEPOOL, ReceiverStargateV2.tokenMessaging /
    endpointV2, ReceiverChainflip.chainflipVault) are compared against the config files, driven
    by getter annotations on the existing deployRequirements.json entries (extra key is ignored
    by the bash consumer). Catches the "integration migrated, config moved on, immutable still
    points at the dead counterparty" class that presence + executor-binding checks cannot see.
    Verified live: all annotated bindings match config on mainnet.
  5. HEALTH_CHECK_EXCLUSIONS moved to config/healthCheckExclusions.json so per-network
    carve-outs are ops-editable config, not TS edits; integrity tests validate every entry against
    real invariant names and networks.
  6. Registry-drift test: every Receiver* companion in facetPeripheryCouplings must appear
    in RECEIVER_EXECUTOR_GETTERS (deprecated ReceiverAcrossV3 exempt) — a new coupling can no
    longer ship presence-checked but binding-unchecked.

Known remaining gaps (deliberately not in this PR): ReceiverOIF has no deployRequirements.json
entry (so its OUTPUT_SETTLER binding is not yet annotatable — needs the OIF config key added
first), the log-sync and binding invariants are EVM-only (Tron receivers unchecked), and the
reverse-dependency deploy-time cascade advisor ("redeploying Executor → these N contracts bind it
immutably") is a follow-up ticket.

Review round 3 — Daniel's follow-ups: full deployRequirements coverage, Tron, cascade advisor, no more blanket skips

  1. deployRequirements audit (fbf485f67): every deploy script consuming config/deploy-log
    addresses was compared against deployRequirements.json. Five contracts had no pre-deploy
    gate: ReceiverOIF, DeBridgeDlnFacet, EcoFacet, LidoWrapper, MayanFacet — all five
    added, each address arg annotated with its getter so immutable-bindings-match-config covers
    them. Live-verified on mainnet: DLN_SOURCE, PORTAL, MAYAN, OUTPUT_SETTLER all match
    config (8/8 annotated bindings green).
  2. Cascade advisor (912f1b45e): contractDependencyReminder.ts walks
    deployRequirements.jsoncontractAddresses in reverse, transitively — deploying the
    Executor now warns "ReceiverAcrossV4, ReceiverChainflip, ReceiverOIF, ReceiverStargateV2 bind
    it at construction"; deploying ERC20Proxy shows the full chain (ReceiverAcrossV4 (via Executor)). Non-fatal nudge in deploySingleContract.sh; the binding invariants stay the gate.
  3. Tron (131f70df7): periphery-registry-log-sync and immutable-bindings-match-config
    gain Tron branches (base58 comparison, both zero-encodings guarded).
    receiver-executor-binding stays evm-only with the reason documented inline: no coupled
    receiver exists on Tron yet.
  4. No more blanket skips (82ba279ee): see the resolved section above.

Checklist before requesting a review

Checklist for reviewer (DO NOT DEPLOY and contracts BEFORE CHECKING THIS!!!)

A bridge facet only covers the source side; destination calls need its
companion Receiver on the same chain. Nothing tied the two together, so a
facet could be rolled out to a new chain while its Receiver was silently
forgotten - which disabled Across destination calls on Robinhood.

Receivers had no "must exist" coverage at any tier: non-core-facets-deployed
filters on names containing "Facet", periphery-registered only checks
corePeriphery/whitelistPeripheryFunctions, and receiver-executor-binding
skips a Receiver whose address is absent.

Declare the couplings in config/global.json -> facetPeripheryCouplings and
enforce them in two places:

- facet-required-periphery health-check invariant (daily sweep + new-network
  CI): for every facet REGISTERED ON CHAIN, assert one of its requiresAnyOf
  contracts is registered in the PeripheryRegistry. Triggering on on-chain
  facets rather than target state matters - target state was itself missing
  the Receiver in the Robinhood incident.
- facetCompanionReminder.ts: non-fatal deploy-time nudge when the companion
  is absent from the network's deploy log. Non-fatal because deploying the
  facet before its Receiver is the normal order.

requiresAnyOf (not a single name) because Across kept the
handleV3AcrossMessage callback in V4, so either Receiver can service a V4
destination call. notRequiredYet records a coupling that is not active yet
(OIF destination execution is unsupported today) so nobody has to
rediscover it; notRequiredOn carves out individual chains, both with a
mandatory reason that is printed when the check is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lifi-action-bot
lifi-action-bot marked this pull request as draft July 27, 2026 11:13
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The pull request adds facet-periphery coupling configuration, live-facet selector resolution, expanded health-check invariants, immutable-binding validation, non-fatal deployment reminders, deploy-log helpers, and ReceiverOIF deployment metadata.

Changes

Facet coupling and live-facet resolution

Layer / File(s) Summary
Coupling registry and selector resolution
.agents/rules/*, config/global.json, script/deploy/shared/facetPeripheryCouplings.ts, script/deploy/shared/facetPeripheryCouplings.test.ts
Facet couplings now define required companion receivers. Registered selectors exclude update-script selectors. Live-facet detection combines deploy-log and on-chain selector identity, with unresolved and version-drift reporting.

Health-check enforcement

Layer / File(s) Summary
Invariant execution and resilience
config/healthCheckExclusions.json, script/deploy/healthCheck.ts, script/deploy/healthCheckInvariants.ts, script/common/types.ts, config/networks.json, script/deploy/healthCheckInvariants.test.ts
Health checks no longer support network-wide bypasses. The framework adds per-invariant exemptions, Tron validation, registry caching, registry/log synchronization, companion checks, and resilient receiver and immutable-binding checks.

Immutable binding configuration

Layer / File(s) Summary
Getter metadata and expected-address checks
script/deploy/shared/immutableBindings.ts, script/deploy/shared/immutableBindings.test.ts, script/deploy/resources/deployRequirements.json
Getter-annotated constructor bindings resolve expected addresses from validated config paths. Deployment requirements include new getter metadata and contract configuration entries.

Deployment reminders and metadata

Layer / File(s) Summary
Deployment warnings and registry data
script/deploy/deploySingleContract.sh, script/deploy/resources/*Reminder*, script/deploy/shared/deployLog.ts, deployments/*, docs/TronFork.md, script/deploy/safe/delete-pending-proposals.ts
Deployments now emit best-effort companion and dependency reminders. Deploy-log parsing is forgiving and path-safe. ReceiverOIF mappings and related deployment documentation are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: QA AI Reviewing

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: coupling facets to their required companion periphery contracts.
Description check ✅ Passed The description is detailed and directly explains the coupling registry, health checks, deployment reminders, and related safeguards.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/busy-solomon-53e1c2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…-log path

readDeployLog built a path from a CLI argument without validating it, so a
name like `../../.env` would traverse out of deployments/ (Aikido: potential
file inclusion via ReadFile). The sibling facetRefundReminder.ts already
guards the same shape with isValidContractName; mirror that with
isValidNetworkName, matching the alphanumeric/-/_ network keys used in
config/networks.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@script/deploy/healthCheckInvariants.ts`:
- Around line 1183-1225: Replace the silent `else return` in the periphery
registration check with a warning through `ctx.logWarn`, indicating that
registration could not be checked because neither a Tron RPC URL nor an EVM
public client is available; preserve the existing Tron and EVM branches
unchanged.
- Around line 1183-1204: In the Tron periphery-check loop around
callTronContract, replace the catch block’s immediate return with logic that
records the failed lookup and continues processing the remaining wanted
peripheries. Preserve the existing error logging, and ensure later required
couplings are still evaluated despite an individual RPC failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ca6d62f-c2c4-4f0c-842f-ebc69e41a881

📥 Commits

Reviewing files that changed from the base of the PR and between b221ae0 and 253e052.

📒 Files selected for processing (9)
  • .agents/rules/601-healthcheck-invariants.md
  • config/global.json
  • script/deploy/deploySingleContract.sh
  • script/deploy/healthCheckInvariants.test.ts
  • script/deploy/healthCheckInvariants.ts
  • script/deploy/resources/facetCompanionReminder.test.ts
  • script/deploy/resources/facetCompanionReminder.ts
  • script/deploy/shared/facetPeripheryCouplings.test.ts
  • script/deploy/shared/facetPeripheryCouplings.ts

Comment thread script/deploy/healthCheckInvariants.ts
Comment thread script/deploy/healthCheckInvariants.ts Outdated
…path

Bot review follow-ups on facet-required-periphery.

A failed registry read is not evidence of absence, but the invariant treated
it as fatal or as a violation:

- Tron: a single callTronContract throw returned out of the whole invariant,
  abandoning every companion and coupling not yet checked. One flaky read on
  a severity=error production invariant silently skipped unrelated couplings.
- EVM: Promise.all rejected the whole batch on one failed read.
- Neither branch available: returned silently, so reduced coverage was
  invisible in the sweep report.

Now every companion is looked up independently (continue / allSettled), a
failed lookup is warned and recorded as unresolved, and a coupling is only
reported as a violation when at least one companion actually resolved to
"not registered". A coupling whose companions all failed to resolve warns as
undetermined; a missing chain client warns too.

readDeployLog additionally checks the resolved path stays inside
deployments/, so containment no longer rests on the name regex alone, and
the CLI now says nothing for a name that is not a plain network key instead
of printing a reminder naming an impossible network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@0xDEnYO
0xDEnYO marked this pull request as ready for review July 28, 2026 01:12
Review feedback on the registry shape and contents.

- Key by facet name instead of an invented coupling label. The label was
  never matched against anything - it only appeared in log lines - while the
  actual match has always been on the facet name. Keying by facet makes the
  lookup a direct key hit and matches how the sibling registries
  (deployRequirements.json, whitelistPeripheryFunctions) are keyed. Facets
  needing the same companion are merged back into one requirement at
  evaluation time, so the reporting is unchanged.
- Drop the acrossV3 coupling: AcrossFacet is deprecated and listed in no
  network's target state.
- Drop ReceiverAcrossV3 as an alternative for the V4 facets. It was allowed
  because Across kept the handleV3AcrossMessage signature, but accepting a
  deprecated contract would let it mask a missing current one. Verified
  against every active chain: no chain runs V4 facets with only the V3
  receiver, so this tightens the check without creating a single new failure.
- Activate the OIF coupling. Policy is to ship ReceiverOIF on every chain
  where either LiFiIntentEscrow facet is live, so the notRequiredYet marker
  is gone and the missing receivers are now reported as the gaps they are.

Also adds a test asserting every facet sharing a coupled family's prefix is
itself coupled, so a future AcrossFacetV5 cannot land unchecked - the
allowlist's main failure mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0xDEnYO and others added 5 commits July 28, 2026 21:12
…the deploy log

The facet-required-periphery gate resolved facet identity through
deployments/<network>.json (ctx.deployedContracts) — the same deploy log this
check deliberately bypasses on the periphery side (it reads getPeripheryContract
on chain because the log can be incomplete). A coupled facet registered on chain
but missing from the log resolved to no name, was filtered out, and its coupling
was never evaluated: a silent miss on an error-severity gate. The
no-unexpected-facets warning already proves on-chain facets do go missing from
the log, so this was a real blind spot, not a hypothetical one.

Resolve live facets from two independent sources, unioned so coverage only
grows: the deploy log (address -> name) and on-chain selectors matched against
compiled artifacts. A diamond maps each selector to exactly one facet, so a
coupled facet is present iff some on-chain facet registers its full selector
set — identity that does not depend on the log. When neither source can identify
an on-chain facet absent from the log (e.g. out/ not built), surface a warning
so the gate never passes silently.

Addresses review feedback from Daniela on PR #2125 (EXSC-684).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ctor loader

loadFacetSelectorsFromArtifact composes a facet name into a file path. Names come
from config/global.json (repo-controlled, not attacker input), but harden the read
regardless: validate the name as a Solidity identifier and assert the resolved path
stays inside out/. Mirrors readDeployLog / isValidNetworkName in the sibling
facetCompanionReminder.ts. Clears the Aikido path-traversal finding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…enforce registry/log sync

Review follow-ups (a, b, d) on the coupling PR:

- receiver-executor-binding and receiver-owner now resolve receiver addresses
  from the on-chain PeripheryRegistry first, deploy log as fallback - the same
  union principle as facet identity. Previously both skipped any receiver
  missing from deployments/<network>.json, which silently exempted ReceiverOIF
  on mainnet, base and arbitrum from binding and ownership coverage.
  receiver-owner additionally now covers the bridge-specific receivers, which
  had no owner check anywhere.

- New periphery-registry-log-sync invariant (error, production): every known
  periphery name (corePeriphery, whitelistPeripheryFunctions, coupling
  companions, receiver getter list) registered on chain must appear in the
  deploy log with the same address. An incomplete log is not cosmetic: it
  silently exempts contracts from every log-resolved check.

- Deploy logs backfilled from on-chain truth: ReceiverOIF
  (0x761B0e8f6e80BBd23F3886663Cc071a554be37A3) verified registered with code
  on mainnet, base AND arbitrum (arbitrum was previously assumed missing) and
  added to all three logs. Verified live: all new checks green on mainnet.

- Drift test ties facetPeripheryCouplings to RECEIVER_EXECUTOR_GETTERS: a new
  Receiver coupling without a binding check entry now fails CI instead of
  shipping presence-checked but binding-unchecked.

- HEALTH_CHECK_EXCLUSIONS moved from a TS literal to
  config/healthCheckExclusions.json so per-network carve-outs are ops-editable
  config; existing integrity tests validate entries against real invariant
  names and networks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ig (EXSC-684)

Review follow-up (c): contracts like ReceiverAcrossV4 bind their counterparty
(SPOKEPOOL) immutably at construction. When the integration migrates and the
config file moves on, presence and executor-binding checks stay green while
destination calls fail against a dead counterparty - nothing compared the live
binding to config.

deployRequirements.json already maps each constructor arg to a config file +
per-network key; entries annotated with a "getter" (the public getter exposing
the bound value) are now checkable. The new immutable-bindings-match-config
invariant (error, production) resolves the contract registry-first, reads the
getter on chain, and compares against the config-resolved expected address.
Coverage grows by annotating entries; the extra JSON key is ignored by the
bash consumer (checkDeployRequirements reads named keys only).

Annotated: ReceiverAcrossV4.SPOKEPOOL, ReceiverStargateV2.endpointV2 /
tokenMessaging, ReceiverChainflip.chainflipVault - each getter validated
against the compiled artifact in tests. Verified live on mainnet: all
annotated bindings match config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
script/deploy/healthCheckInvariants.ts (1)

1609-1643: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Mixed confirmed-absent + unresolved companions are misreported as a hard error, not a warning.

undetermined.length === requirement.requiresAnyOf.length only downgrades to a warning when every companion lookup failed. If requiresAnyOf has ≥2 entries and one resolves to confirmed-false while a sibling lookup merely fails (RPC blip), the requirement falls through to ctx.logError even though the failed sibling's real status is unknown — a false positive on a production, severity="error" invariant. The one-time re-verify in executeInvariant reduces but doesn't eliminate this (needs the same lookup to fail twice).

🔧 Proposed fix: downgrade to warning whenever any companion lookup is unresolved
-        const undetermined = requirement.requiresAnyOf.filter((periphery) =>
-          unresolved.has(periphery)
-        )
-        if (undetermined.length === requirement.requiresAnyOf.length) {
-          ctx.logWarn(
-            `${requirement.triggeredBy.join(
-              ', '
-            )}: could not determine whether a companion is registered (all lookups failed: ${undetermined.join(
-              ', '
-            )})`
-          )
-          continue
-        }
+        const undetermined = requirement.requiresAnyOf.filter((periphery) =>
+          unresolved.has(periphery)
+        )
+        if (undetermined.length > 0) {
+          ctx.logWarn(
+            `${requirement.triggeredBy.join(
+              ', '
+            )}: could not fully determine companion registration (lookup failed for: ${undetermined.join(
+              ', '
+            )})`
+          )
+          continue
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/healthCheckInvariants.ts` around lines 1609 - 1643, Update the
companion-status handling in the required-requirements loop so any unresolved
companion lookup causes a warning instead of a hard error, including when other
companions are confirmed absent. Change the condition around undetermined to
check for at least one unresolved entry, preserve the existing warning message
and continue behavior, and leave the registered-success path unchanged.
🧹 Nitpick comments (1)
script/deploy/healthCheckInvariants.test.ts (1)

707-953: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated mock-address constants and context-builder pattern across four describe blocks.

periphery-registry-log-sync, receiver-executor-binding registry-first resolution, receiver-owner covers bridge-specific receivers, and immutable-bindings-match-config each redefine RECEIVER/OTHER/DIAMOND/ZERO and a near-identical makeXCtx that stubs publicClient.readContract. Extracting shared constants and a generic mock-context builder (parameterized by the readContract behavior) would reduce this duplication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/healthCheckInvariants.test.ts` around lines 707 - 953, The four
invariant test suites duplicate mock address constants and context builders.
Extract shared RECEIVER, OTHER, DIAMOND, and ZERO constants plus a generic mock
context builder parameterized by readContract behavior, then update makeSyncCtx,
makeBindingCtx, makeOwnerCtx, and makeBindingsCtx to reuse them while preserving
each suite’s specific context fields and responses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deployments/arbitrum.json`:
- Around line 70-71: Rename the ReceiverOIF configuration key to the agreed
snake_case spelling consistently in deployments/arbitrum.json:70-71,
deployments/base.json:62-63, and deployments/mainnet.json:88-89, and update
every reader atomically; alternatively, document an explicit exception for
contract-name keys.

---

Outside diff comments:
In `@script/deploy/healthCheckInvariants.ts`:
- Around line 1609-1643: Update the companion-status handling in the
required-requirements loop so any unresolved companion lookup causes a warning
instead of a hard error, including when other companions are confirmed absent.
Change the condition around undetermined to check for at least one unresolved
entry, preserve the existing warning message and continue behavior, and leave
the registered-success path unchanged.

---

Nitpick comments:
In `@script/deploy/healthCheckInvariants.test.ts`:
- Around line 707-953: The four invariant test suites duplicate mock address
constants and context builders. Extract shared RECEIVER, OTHER, DIAMOND, and
ZERO constants plus a generic mock context builder parameterized by readContract
behavior, then update makeSyncCtx, makeBindingCtx, makeOwnerCtx, and
makeBindingsCtx to reuse them while preserving each suite’s specific context
fields and responses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 86c3c6a9-2b4b-4488-b8cc-8e2aea956b28

📥 Commits

Reviewing files that changed from the base of the PR and between 6f1f4f0 and b64f388.

📒 Files selected for processing (12)
  • config/global.json
  • config/healthCheckExclusions.json
  • deployments/arbitrum.json
  • deployments/base.json
  • deployments/mainnet.json
  • script/deploy/healthCheckInvariants.test.ts
  • script/deploy/healthCheckInvariants.ts
  • script/deploy/resources/deployRequirements.json
  • script/deploy/shared/facetPeripheryCouplings.test.ts
  • script/deploy/shared/facetPeripheryCouplings.ts
  • script/deploy/shared/immutableBindings.test.ts
  • script/deploy/shared/immutableBindings.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • config/global.json
  • script/deploy/shared/facetPeripheryCouplings.test.ts
  • script/deploy/shared/facetPeripheryCouplings.ts

Comment thread deployments/arbitrum.json Outdated
0xDEnYO and others added 4 commits July 29, 2026 10:46
…ments (EXSC-684)

Audit: every deploy script consuming config/deploy-log addresses was compared
against deployRequirements.json. Five contracts deployed with address args but
no pre-deploy validation gate: ReceiverOIF, DeBridgeDlnFacet, EcoFacet,
LidoWrapper, MayanFacet. All five get entries mirroring their deploy scripts'
actual sources, each address arg annotated with its public getter so the
immutable-bindings-match-config invariant covers them too.

Verified live on mainnet: DLN_SOURCE, PORTAL, MAYAN and OUTPUT_SETTLER all
match their config values on chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…a dependency (EXSC-684)

deployRequirements.json contractAddresses records the forward edge (Receiver
needs Executor); nothing surfaced the reverse: redeploying the Executor
invalidates every deployed Receiver's immutable binding, and redeploying the
ERC20Proxy cascades through the Executor to all of them.

contractDependencyReminder.ts walks the reverse graph transitively and, on
deploy, lists every dependent present in the network's deploy log with its
path (e.g. "ReceiverAcrossV4 (via Executor)"). Non-fatal nudge in
deploySingleContract.sh, same tier as facetCompanionReminder; the binding
health-check invariants remain the enforcing gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cks to Tron (EXSC-684)

periphery-registry-log-sync and immutable-bindings-match-config were scoped
evm-only. Both gain a Tron branch (callTronContract + base58 comparison via
parseTronAddressOutput/ensureTronAddress, zero guarded against both Tron
encodings; candidates use getTronCorePeriphery). receiver-executor-binding
stays evm-only with the reason documented inline: none of the coupled
receivers exist on Tron - grow the branch with the first one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…argeted exemptions (EXSC-684)

The blanket skipHealthcheck flag (arc, robinhood, tempo) is removed entirely -
flag, INetwork field, and the early return in healthCheck.ts. Every chain now
runs every applicable invariant; genuine per-network specialties are carved out
per-invariant or per-contract with a mandatory reason:

- config/healthCheckExclusions.json becomes { invariantExclusions,
  corePeripheryExemptions }. The new per-contract tier exempts a single core
  periphery contract on a single network where a whole-invariant skip would
  hide unrelated coverage. TokenWrapper is exempted on arc and tempo (no
  native/wrap path - reasons from the networks' devNotes); the skip prints its
  reason whenever it applies.

Inventory results from running the previously-skipped chains:
- robinhood: the new periphery-registry-log-sync invariant immediately found
  OutputValidator and ReceiverOIF registered on chain but missing from the
  deploy log, plus OutputValidator absent from whitelist.json. All three fixed
  (addresses verified on chain; ReceiverOIF binds the correct Executor).
  robinhood now passes its first-ever full health check (exit 0).
- tempo: 3 genuine gaps remain red BY DESIGN - ReceiverAcrossV4 deployed but
  never registered (needs the timelock proposal, see PR body) and SquidFacet
  in target state but not deployed. Exactly the visibility this PR exists for.
- arc: RPC unreachable from this session; CI (Mongo-fetched RPCs) may still
  reach it - the sweep will show its true state instead of a silent skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread script/deploy/resources/facetCompanionReminder.ts Outdated
…is being deprecated

Per review: V1 is superseded by LiFiIntentEscrowFacetV2 and will be deprecated,
so its coupling entry goes. Zero production coverage lost: every production
chain running V1 also runs V2 (verified across all deploy logs), and the only
V1-without-V2 chains are four testnets, out of scope for the production-scoped
invariant. V1 joins AcrossFacet as a documented exemption in the family drift
test so its absence from the registry stays deliberate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@melianessa melianessa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff against a local main checkout. Design is sound and the read-failure-is-not-absence handling is the right call for an error-severity gate. Four things I'd want changed before merge.

1. The on-chain selector identity source never fires for the Across V4 family — the exact family this PR exists for

identifyCoupledFacetsOnChain requires a facet's entire compiled selector set to be registered on one on-chain facet:

onChainSelectorSets.some((set) => wanted.every((selector) => set.has(selector)))

But facets are registered with explicit selector exclusions:

  • script/deploy/facets/UpdateAcrossFacetV4.s.sol:10-14 excludes SPOKEPOOL() and WRAPPED_NATIVE()
  • UpdateAcrossFacetPackedV4.s.sol:20-30 excludes 9 selectors
  • UpdateAcrossV4SwapFacet.s.sol:11-19 excludes 7

Meanwhile out/AcrossFacetV4.sol/AcrossFacetV4.json has 6 methodIdentifiers, two of which are exactly SPOKEPOOL() / WRAPPED_NATIVE(). So the subset test can never succeed for those three facets, and resolveLiveFacets silently degrades to deploy-log-only identity for them — reintroducing the blind spot round 2 was added to close, for the Across family specifically.

It is silent because blindSpotWarning only fires when every candidate is unresolved (i.e. out/ absent entirely). With out/ built, unresolved is empty and nothing is logged.

Suggested fix: match on a non-empty, unambiguous selector intersection, or subtract the update script's excludes, or require a quorum of facet-unique selectors. Whichever you pick, please add a test using real artifacts plus a realistic post-exclusion on-chain set — every current test injects synthetic selector maps (facetPeripheryCouplings.test.ts, the SELECTORS fixtures), which is why this slipped through.

2. receiver-owner's new loop has no per-receiver error handling

const owner = await getOwnableContract(address, ctx.publicClient).read.owner()

An uncaught throw here is converted into a hard error by executeInvariant (healthCheckInvariants.ts:1805-1808) and aborts the loop, so the remaining receivers go unchecked. Every sibling addition in this PR carefully warns-and-continues on read failure; the re-verify pass covers transient flake but not a permanent read failure. Suggest wrapping the read in try/catch → logWarn, matching immutable-bindings-match-config.

3. immutable-bindings-match-config resolves facets through resolvePeripheryAddress

Four of the eight annotated entries are facets (DeBridgeDlnFacet, EcoFacet, MayanFacet; LidoWrapper is genuinely periphery). For a facet, getPeripheryContract("MayanFacet") is always zero, so resolution falls through to ctx.deployedContracts — which holds the latest deployed facet, not necessarily the one registered in the diamond. Between a facet deploy and its diamondUpdate the invariant reads a contract that is not live. It also spends a pointless RPC call per facet per network on the daily sweep.

The invariant already has the machinery for this: consider readsOnChainFacets: true and resolving facet-typed entries from ctx.onChainFacets.

4. Redundant registry reads (small individually, daily × 40 networks)

periphery-registry-log-sync reads getPeripheryContract for all 15 candidates, then receiver-executor-binding, receiver-owner and immutable-bindings-match-config each independently re-read it for overlapping names via resolvePeripheryAddress — 3–4× duplicate reads per receiver. A memoized cache on ctx (same pattern as onChainFacets) would cut it and reduce rate-limit exposure, which matters because a rate-limited read in facet-required-periphery degrades the gate from error to warning.


Verified as correct while reviewing, for the record: all five new deployRequirements.json entries (getters exist on the contracts, every config key path resolves, and every network where each contract is currently deployed has a config value — so the new hard pre-deploy gates break no existing deploy path); across.json / stargateV2.json / chainflip.json cover 100% of the networks where the corresponding receiver is logged (23 / 40 / 2), so no "cannot verify" warning noise; the robinhood OutputValidator whitelist entry matches deployments/robinhood.json and sits in the right slot; and no dangling skipHealthcheck references remain in code or config.

@lifi-qa-agent

lifi-qa-agent Bot commented Jul 29, 2026

Copy link
Copy Markdown

QA Review — EXSC-684 — PR #2125

Review type: Post-approval re-review (Run #28)
Reviewer: lifi-qa-agent[bot] | Date: 2026-07-30

New commit pushed after Run #27 approval (SHA a0c60d686afb) — plus a core dev review from melianessa posted after that approval. Analysing post-approval changes and all 7 melianessa findings.


Post-Approval Commit Scope (301f5db — the new HEAD)

The post-approval commit chain (b3660262 → merge 3885f5ef301f5db7) splits cleanly:

  • EXSC-684 fixes (healthCheckInvariants.ts +64/-28, healthCheckInvariants.test.ts +122/-4, facetPeripheryCouplings.ts +13/-7, facetPeripheryCouplings.test.ts +153/-2): hardens loadFacetRegisteredSelectors to handle the zksync exclude path, adds artifact-path resolution from cwd only (drops injectable root), expands test coverage for version-drift scenarios.
  • Merge from main (82 other files): AllBridgeFacet v2.2.0, network deprecations, bun.lock CVE patch, robinhood.diamond.json +1/-1 (OutputValidator address filled in — already reviewed upstream). None of these touch EXSC-684 scope.

The merge-from-main changes are treated as already-reviewed upstream. No regression relevant to EXSC-684 was introduced.


Melianessa's 7 Findings — Status

Finding 1 — robinhood.diamond.json ReceiverOIF inconsistency [BLOCKING — NOT FIXED]

Verified on PR HEAD (SHA 301f5db):

  • deployments/robinhood.json"ReceiverOIF": "0xdD54bEa53F94554d632d0D844D88a4fd51b2C576" (populated)
  • deployments/robinhood.diamond.json"ReceiverOIF": "" (empty string)

The +1/-1 on robinhood.diamond.json in the post-approval commit scope was for OutputValidator, not ReceiverOIF. Finding 1 is unresolved.

Impact is real: helperFunctions.sh:810-821 reads .diamond.jsonPeriphery to resolve a periphery contract's version. An empty value means tooling cannot resolve robinhood's ReceiverOIF version during deploys/updates. This is a one-line fix that was not applied.

Verdict: Blocking. Must fix before merge.


Finding 2 — periphery-registry-log-sync scope gap [Advisory — new ticket acceptable]

Confirmed: the candidate set at healthCheckInvariants.ts:1397–1407 is the union of corePeriphery, whitelistPeripheryFunctions keys, coupling requiresAnyOf names, and RECEIVER_EXECUTOR_GETTERS. Names like Receiver, FeeCollector, LiFiDEXAggregator, ReceiverAcrossV3, Composer are absent from this set. The invariant also only compares against the flat deploy log, not .diamond.json's Periphery map.

Melianessa's point that feeding .diamond.json's Periphery keys as extra candidates would close both gaps and would have caught Finding 1 on its own is well-taken.

However, the invariant still catches the primary class of bugs it was designed to prevent (registered-but-unlogged periphery contracts), and the gap is a coverage improvement rather than a blocking defect. Acceptable as a follow-up ticket.

Verdict: Advisory. New ticket recommended.


Finding 3 — receiver-owner fleet scope [BLOCKING — NOT FIXED]

Verified: receiver-owner invariant at line 2105 has scope: {} (all chains, all environments). The invariant now iterates all four RECEIVER_EXECUTOR_GETTERS entries and asserts owner() == refundWallet across production. Approximately four receiver types multiplied against ~40 production networks are newly under the error gate.

Developer's comment states "Live diamond measurements: ChainflipFacet, MayanFacet, DeBridgeDlnFacet show selector drift" — these are in the facet-required-periphery path, but the receiver-owner scope issue is separate. There is no fleet enumeration provided anywhere in the PR for receiver ownership state, and no indication that the full sweep has been run and all receivers confirmed to be owned by refundWallet.

Melianessa's framing is precise: "The whole case for merging something that turns the sweep red is that every red was enumerated in advance." Without that enumeration, merging an error-severity gate that covers ~160 network-receiver combinations that have never been swept is a meaningful operational risk.

Acceptable resolution options (either would unblock):

  1. Provide fleet enumeration in the PR showing the sweep is green, or
  2. Temporarily downgrade receiver-owner to severity: 'warning' and promote in a follow-up after the fleet is validated.

Verdict: Blocking. Requires fleet enumeration or temporary severity downgrade before merge.


Finding 4 — periphery-registry-log-sync warning-first [Advisory]

Valid operational concern: an error-severity gate on hand-maintained log completeness verified against only four of ~forty production networks risks false-positives that erode alert value. Melianessa recommends shipping as warning, clearing the fleet, then promoting.

This is a judgment call on risk tolerance. The invariant's intent matches the ticket AC, and downgrading is a one-word change that can be made in a follow-up. Acceptable to defer.

Verdict: Advisory. Defer to follow-up.


Finding 5 — docs/TronFork.md stale escape hatch [BLOCKING — NOT FIXED]

Verified: TronFork.md line 94 still lists somnia.skipHealthcheck = true as a tracked config diff, and line 181 prescribes skipHealthcheck as the fix for the sync-PR failure class. This PR replaces skipHealthcheck with invariantExclusions — those two doc references now prescribe a no-op mechanism. The TronFork doc is the primary place developers in the fork repo look for guidance on handling sync-PR healthcheck failures.

Current state in config/networks.json: no network uses skipHealthcheck at all (the field is absent from all entries). The doc instructs developers to use a field that no longer exists in the schema, silently failing.

Verdict: Blocking. Both TronFork.md references must be updated to invariantExclusions before merge.


Finding 6 — dead comparison in isNonZeroTronAddress [BLOCKING — NOT FIXED]

Verified at PR HEAD:

export function isNonZeroTronAddress(value: string): boolean {
  return (
    value.startsWith('T') &&
    value.length === 34 &&
    value !== TRON_ZERO_ADDRESS_BASE58 &&
    value !== TRON_ZERO_ADDRESS   // <-- unreachable
  )
}

TRON_ZERO_ADDRESS from @lifi/tron-devkit is the hex form (410000…0000, 42 chars), so value !== TRON_ZERO_ADDRESS can never be false when the preceding guards (startsWith('T') && length === 34) passed. The import on line 18 exists only for this dead comparison.

Code defect: a hex-encoded zero address passed to isNonZeroTronAddress would return true (treated as non-zero), which is the wrong answer. The practical impact is bounded because callTronContract returns base58, so the realistic input to parseTronAddressOutputisNonZeroTronAddress should always be base58 — but the function's contract is broken: it claims to check both encodings but silently doesn't. Either drop both the import and the dead check (accepting only base58), or drop the shape guards if the intent was to accept both encodings.

Verdict: Blocking. The dead check is a code defect with a misleading comment. Must fix.


Finding 7 — devNotes wording mismatch [Advisory — partially addressed]

arc devNotes says: "TokenWrapper-related invariants are excluded per-invariant in config/healthCheckExclusions.json"
Actual mechanism used: corePeripheryExemptions array in healthCheckExclusions.json (not invariantExclusions).

tempo devNotes does not mention the TokenWrapper exclusion mechanism at all.

Both are documentation-only. arc names the wrong mechanism (the two-tier distinction is the point of the architecture). tempo omits any reference. This was already noted as A1 (Low advisory) in the prior QA review; still unfixed.

Melianessa's framing is correct — since the per-contract vs per-invariant split is deliberate, the one place people read about it should be accurate.

Verdict: Advisory (Low). Recommended fix, not blocking in isolation.


Developer's 2 Deferred Items

Item 1 — Selector identity version-locked to HEAD artifacts (architectural deferral)

loadFacetRegisteredSelectors computes from the current build. Where a chain runs an older deployed build, selectors differ. The PR now tracks this as versionDriftNotes (info-level, not error) and the facet is still covered via the deploy log. Developer's offer to open a ticket is the right approach — matching against prior released artifact versions is a broader platform problem.

The current behavior is safe: version-drift facets log an info note and continue to be covered by the deploy log source. The gate does not pass silently (deploy-log coverage persists; blindSpotWarning fires only when the deploy log AND selectors both fail to identify a facet).

Assessment: Acceptable deferral. New ticket recommended.

Item 2 — getExpectedPairs unguarded getAddress (pre-existing)

Reviewed at healthCheckInvariants.ts:562–660. The getAddress call in getExpectedPairs operates on PERIPHERY entries from whitelist.json (config-sourced), not on hand-edited deploy-log entries. The entire function is wrapped in a try/catch that calls logError and returns [] on any exception, so a single malformed entry causes the whole expected-pair set to collapse to empty (reduced coverage, not a crash), with an error logged.

Developer is correct that this pre-dates this PR. The try/catch wrapper means the failure is loud but could still degrade the whitelist check to an empty comparison. Low practical risk since whitelist.json is machine-generated. Note also that tryGetAddress (a null-returning wrapper added in this PR at line 973) is not used here — a straightforward improvement but pre-existing.

Assessment: Acceptable deferral. Noted as pre-existing. New ticket for tryGetAddress adoption in getExpectedPairs recommended.


Summary

# Finding Severity Status
F1 robinhood.diamond.json ReceiverOIF "" vs flat log address Medium BLOCKING — Not Fixed
F2 periphery-registry-log-sync missing candidates + single-log comparison Medium Advisory — new ticket
F3 receiver-owner scope: ~160 network-receiver combos under error gate, no fleet sweep Medium BLOCKING — Not Fixed
F4 periphery-registry-log-sync warning-first Low Advisory — defer
F5 TronFork.md prescribes skipHealthcheck (now a no-op) Low BLOCKING — Not Fixed
F6 isNonZeroTronAddress dead comparison, misleading comment Medium BLOCKING — Not Fixed
F7 arc/tempo devNotes wrong/missing exclusion mechanism name Low Advisory
D1 Selector version-lock to HEAD Architectural Acceptable deferral
D2 getExpectedPairs unguarded getAddress Low pre-existing Acceptable deferral

4 blocking items remain (F1, F3, F5, F6). Verdict: Needs Work.

Required before re-approval:

  1. deployments/robinhood.diamond.json → set "ReceiverOIF": "0xdD54bEa53F94554d632d0D844D88a4fd51b2C576" (one-line fix)
  2. receiver-owner invariant: provide fleet enumeration showing all ~40 production networks are green, or downgrade severity to 'warning' for this release
  3. docs/TronFork.md lines 94 and 181: replace skipHealthcheck references with invariantExclusions
  4. isNonZeroTronAddress: fix the dead comparison (drop the hex-form check and import, or drop the shape guards)

lifi-qa-agent[bot]
lifi-qa-agent Bot previously approved these changes Jul 29, 2026

@lifi-qa-agent lifi-qa-agent 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.

✅ QA approved. Facet-periphery coupling health-check infrastructure is correctly implemented: facetPeripheryCouplings registry, facet-required-periphery invariant (severity error, production scope), facetCompanionReminder deploy-time hook, skipHealthcheck flag removal, and comprehensive test coverage. One pending action before merge: SC core dev formal APPROVED review required (melianessa has only commented, not approved). (lifi-qa-agent Run #26)

0xDEnYO and others added 2 commits July 30, 2026 08:51
…artifact set

Facets are cut into the diamond with getExcludes() exclusions (immutable
getters, ownership functions), so a facet's full artifact selector set never
appears on chain for such facets - the selector identity source could not fire
for the entire Across V4 family it was built for. Match against the registered
set (artifact minus the update script's excludes) instead, parsed from the real
Update<Facet>.s.sol with a declared-size validity check so an unparseable
exclude shape degrades to "unresolved" rather than a set that never matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…from the diamond, cache registry reads

- receiver-owner / receiver-executor-binding: wrap the per-receiver read in
  try/catch -> logWarn + continue, so one flaky RPC read no longer aborts the
  remaining receivers (matches the warn-and-continue convention of the sibling
  checks in this PR)
- immutable-bindings-match-config: facet-typed entries (DeBridgeDlnFacet,
  EcoFacet, MayanFacet) now resolve from the diamond's selector map
  (readsOnChainFacets) instead of a pointless getPeripheryContract read that
  falls back to the deploy log's latest-deployed address - which between a
  facet deploy and its diamondUpdate is not the live facet
- cache PeripheryRegistry reads per run on ctx: four invariants probe
  overlapping name sets, previously 3-4x duplicate RPC reads per name per
  network; failed reads are evicted so re-verify still hits the RPC fresh

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…try robustness

- immutable-bindings-match-config: skip with a warning when the on-chain facet
  list is unavailable - the deploy-log fallback could otherwise verify a stale
  (deployed-but-not-cut) facet as a false pass; success lines now name the
  resolution path (live diamond facet vs registry/deploy-log address)
- blindSpotWarning fires per unresolved candidate, not only when ALL candidates
  are unresolved - unresolvability became a per-facet condition with excludes
  parsing, and one unparseable update script must not hide behind the others
- resolveLiveFacets reports version drift: a log-identified facet whose current
  artifact selectors match nothing on chain is logged (selector identity
  inactive for it until the deployed build catches up)
- loadFacetRegisteredSelectors consults the zksync update script variant and
  degrades to unresolved when its excludes diverge from the canonical ones
- periphery-registered: Promise.allSettled + per-name warn; a failing read is
  skipped, never misreported as "not registered"
- resolvePeripheryAddress guards getAddress on deploy-log entries so a
  malformed address cannot abort a receiver loop
- executeInvariant re-verify clears the registry read cache so a
  successful-but-stale read is not replayed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
script/deploy/shared/facetPeripheryCouplings.ts (1)

216-259: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Brace matching is literal-unaware.

The depth scan counts {/} anywhere after the marker, including inside string literals (e.g. revert("{")). No current update script hits this, but a future one would silently yield bodyEnd at the wrong place and turn into null ("identity unknown"), which downgrades selector identity for that facet. A cheap guard is to skip characters inside "/' runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/shared/facetPeripheryCouplings.ts` around lines 216 - 259,
Update the brace-depth scan in the getExcludes parsing logic to ignore `{` and
`}` characters inside single- or double-quoted string literals, including
escaped characters, while preserving normal brace matching outside strings. Keep
the existing null fallback for unmatched braces and the returned selector
extraction behavior unchanged.
script/deploy/shared/facetPeripheryCouplings.test.ts (1)

425-432: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Exact-equality snapshot will break on any new AcrossFacetV4 function.

This asserts the full registered set equals a hand-maintained list, so adding any external function to AcrossFacetV4 fails a parser/loader test for an unrelated reason. The assertions at Lines 452-461 (excludes absent, length < artifact) already capture the behavior under test without the maintenance tripwire.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/shared/facetPeripheryCouplings.test.ts` around lines 425 - 432,
Remove the exact-equality assertion comparing registered selectors with
ACROSS_V4_REGISTERED from the AcrossFacetV4 test, while retaining the existing
assertions that verify excluded selectors are absent and the registered count is
below the artifact count. Keep the test focused on post-exclusion behavior
without requiring updates when new AcrossFacetV4 functions are added.
script/deploy/healthCheckInvariants.ts (1)

2476-2483: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Cache clear during the re-verify pass races with concurrently running invariants.

runHealthCheckInvariants executes each phase with Promise.all, so when one error-severity invariant clears baseCtx.peripheryRegistryCache, other invariants may have in-flight reads whose entries were just dropped, and a later failure handler (peripheryRegistryCache.delete(name) at Line 998) can remove an entry repopulated after the clear. Both outcomes are only extra RPC reads, so no correctness impact — worth a note in the comment so the interaction is not mistaken for a stale-read guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/healthCheckInvariants.ts` around lines 2476 - 2483, The
cache-clear comment near the error-severity re-verification in
runHealthCheckInvariants should explicitly note that phases run concurrently and
in-flight reads or later peripheryRegistryCache.delete calls may race with the
clear, causing extra RPC reads without affecting correctness. Update only the
comment to document this interaction while preserving the existing stale-cache
invalidation explanation.
config/networks.json (1)

1032-1036: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the stale skipHealthcheck references.

config/networks.json no longer carries the flag for robinhood/tempo and script/deploy/healthCheck.ts documents no network-level bypass, but docs/TronFork.md still describes somnia.skipHealthcheck = true and the earlier fix as a temporary config change. Update the docs to say the flag is retired and link to the new per-invariant exclusion model instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/networks.json` around lines 1032 - 1036, The stale documentation in
TronFork.md still instructs users to set somnia.skipHealthcheck and describes
the old temporary workaround. Update the relevant section to state that
skipHealthcheck is retired and reference the current per-invariant exclusion
model, removing the obsolete network-level configuration guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@config/networks.json`:
- Line 173: Update the arc network devNotes entry to state that TokenWrapper is
intentionally not deployed and exempted through corePeripheryExemptions, rather
than claiming TokenWrapper-related invariants are excluded per-invariant;
preserve the existing native USDC and dummy wrappedNativeAddress details.

In `@script/deploy/healthCheckInvariants.test.ts`:
- Around line 1053-1058: In the test setup containing the onChainFacets entry
for FACET, capture the result of loadFacetRegisteredSelectors('MayanFacet') and
assert it is non-null before constructing the facet configuration. Pass the
validated selectors to identifyCoupledFacetsOnChain so exclude-parse failures
report the loader assertion instead of failing during selectors.map.

---

Nitpick comments:
In `@config/networks.json`:
- Around line 1032-1036: The stale documentation in TronFork.md still instructs
users to set somnia.skipHealthcheck and describes the old temporary workaround.
Update the relevant section to state that skipHealthcheck is retired and
reference the current per-invariant exclusion model, removing the obsolete
network-level configuration guidance.

In `@script/deploy/healthCheckInvariants.ts`:
- Around line 2476-2483: The cache-clear comment near the error-severity
re-verification in runHealthCheckInvariants should explicitly note that phases
run concurrently and in-flight reads or later peripheryRegistryCache.delete
calls may race with the clear, causing extra RPC reads without affecting
correctness. Update only the comment to document this interaction while
preserving the existing stale-cache invalidation explanation.

In `@script/deploy/shared/facetPeripheryCouplings.test.ts`:
- Around line 425-432: Remove the exact-equality assertion comparing registered
selectors with ACROSS_V4_REGISTERED from the AcrossFacetV4 test, while retaining
the existing assertions that verify excluded selectors are absent and the
registered count is below the artifact count. Keep the test focused on
post-exclusion behavior without requiring updates when new AcrossFacetV4
functions are added.

In `@script/deploy/shared/facetPeripheryCouplings.ts`:
- Around line 216-259: Update the brace-depth scan in the getExcludes parsing
logic to ignore `{` and `}` characters inside single- or double-quoted string
literals, including escaped characters, while preserving normal brace matching
outside strings. Keep the existing null fallback for unmatched braces and the
returned selector extraction behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 47985cc1-bbfc-4629-9c16-cc65d65a6007

📥 Commits

Reviewing files that changed from the base of the PR and between b64f388 and a0c60d6.

📒 Files selected for processing (15)
  • config/global.json
  • config/healthCheckExclusions.json
  • config/networks.json
  • config/whitelist.json
  • deployments/robinhood.json
  • script/common/types.ts
  • script/deploy/deploySingleContract.sh
  • script/deploy/healthCheck.ts
  • script/deploy/healthCheckInvariants.test.ts
  • script/deploy/healthCheckInvariants.ts
  • script/deploy/resources/contractDependencyReminder.test.ts
  • script/deploy/resources/contractDependencyReminder.ts
  • script/deploy/resources/deployRequirements.json
  • script/deploy/shared/facetPeripheryCouplings.test.ts
  • script/deploy/shared/facetPeripheryCouplings.ts
💤 Files with no reviewable changes (2)
  • script/common/types.ts
  • config/global.json

Comment thread config/networks.json Outdated
Comment thread script/deploy/healthCheckInvariants.test.ts
lifi-qa-agent[bot]
lifi-qa-agent Bot previously approved these changes Jul 30, 2026

@lifi-qa-agent lifi-qa-agent 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.

✅ QA approved (Run #27, post-approval re-review). 3 new commits improve facet-selector identity, per-receiver RPC resilience, and registry robustness. Advisory: devNotes wording + test null-coalesce. (lifi-qa-agent[bot])

…path

- re-verify gets a private read cache instead of clearing the shared one, so a
  retrying invariant cannot evict entries sibling invariants are mid-flight on;
  cache eviction is identity-guarded so a stale rejection cannot tear down a
  newer healthy entry
- periphery-registered judges deploy-log presence BEFORE skipping unresolved
  names, so a flaky read no longer downgrades a real "not deployed" error to a
  warning
- guard the two remaining getAddress call sites on deploy-log entries
  (periphery-registered, periphery-registry-log-sync) via a shared helper - one
  malformed entry no longer aborts a whole check loop
- loadFacetRegisteredSelectors takes an injectable repo root; the zksync
  divergence/unparseable/zksync-only/no-script paths are now covered
- skip the immutable-binding warning when there are no annotated checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDEnYO and others added 2 commits July 30, 2026 14:17
…3e1c2

# Conflicts:
#	deployments/robinhood.json
…ctable root

The injectable repo root added for test isolation turned a constant path base
into a caller-supplied one, which Aikido flags as a path-traversal sink (2 new
HIGH). Production code resolves from process.cwd() again; the zksync exclude
tests enter a synthetic checkout via process.chdir instead, restoring cwd and
removing the temp dir in a finally block.

Also pin the other half of the re-verify cache contract: a stale cached success
must not survive into the retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@0xDEnYO

0xDEnYO commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Review-gate residual notes (not fixed in this PR)

Follow-up on the last review round. All four findings are addressed in b7ec3c10, 4d4c4db9, a0c60d68, 301f5db7. Two items were deliberately not fixed here:

1. Selector identity is version-locked to the HEAD artifacts (architectural, needs a decision)

loadFacetRegisteredSelectors computes the registered set from the current build. Where a chain runs an older deployed build, the selector sets differ and identity cannot match, so coverage for that facet falls back to the deploy log alone. Measured against live diamonds:

Facet mainnet arbitrum
AcrossFacetV4 / AcrossFacetPackedV4 / AcrossV4SwapFacet identified identified
LiFiIntentEscrowFacetV2, StargateFacetV2, EcoFacet identified identified
ChainflipFacet drifted (chainflipVault() vs CHAINFLIP_VAULT()) drifted
MayanFacet drifted (pre-v2.0.0 struct sigs) drifted
DeBridgeDlnFacet identified drifted (dlnSource() vs DLN_SOURCE())

Rather than leave that invisible, resolveLiveFacets now returns versionDriftNotes and the invariant logs one line per drifted facet. Closing it properly means matching against prior released artifact versions — bigger than this PR should carry. Happy to open a ticket if you agree.

2. getExpectedPairs unguarded getAddress (pre-existing, out of scope)

script/deploy/healthCheckInvariants.ts:650 applies getAddress to a possibly deploy-log-sourced address inside a per-item loop; one malformed entry collapses the whole expected-pair set. It fails loud (Failed to get expected pairs on an error-severity invariant), so no silent green. Pre-dates this PR — flagging for a follow-up rather than widening the diff.

Verification note: the registered-selector sets were checked against the live mainnet and arbitrum diamonds (exact set match), not only against unit tests; the parser was run over all 63 Update*.s.sol scripts with zero mis-parses.

@melianessa melianessa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1. This PR adds a new production log inconsistency on robinhood. deployments/robinhood.json gains ReceiverOIF: 0xdD54bEa53F94554d632d0D844D88a4fd51b2C576, but robinhood.diamond.jsonLiFiDiamond.Periphery.ReceiverOIF is still "" on this branch. OutputValidator in that same file is populated, so the empty string reads as "not registered here", not as a placeholder convention.

That's not cosmetic: script/helperFunctions.sh:810-821 reads .diamond.jsonPeriphery to resolve a periphery contract's version during deploys and updates, so an empty value means the tooling can't resolve robinhood's ReceiverOIF. I checked the rest of the fleet, and the only other networks where a .diamond.json Periphery entry is "" while the flat log has an address are dead testnets (goerli, mumbai, lineatest, bsc-testnet). So this would be the one production case, on the chain the PR is remediating. One-line fix.

2. periphery-registry-log-sync only checks one of the two logs. The description calls it "every known periphery name", but the candidate set at healthCheckInvariants.ts:1392 is the union of corePeriphery, whitelistPeripheryFunctions, the coupling companions and RECEIVER_EXECUTOR_GETTERS, which comes to about 15 names. Receiver, FeeCollector, LiFiDEXAggregator, ReceiverAcrossV3 and Composer aren't probed. And it only ever compares against the flat log.

.diamond.json already enumerates the full registered set per network. Feeding its Periphery keys in as extra candidates and its values in as a second comparison target closes both gaps cheaply, and would have caught #1 on its own. Left as is, that second log is exactly the sort of unchecked identity source this PR exists to kill, which feels worth either fixing or naming as a follow-up in the description.

3. Has anyone audited receiver-owner across the fleet? It has scope {}, so all chains and all environments, and it now asserts owner() == refundWallet for all four RECEIVER_EXECUTOR_GETTERS entries (healthCheckInvariants.ts:2128) rather than just the generic Receiver. That's roughly four receivers times forty production networks newly under an error gate, and the description only reports mainnet verified. Any receiver deployed before the refundWallet-owner convention, or owned by a Safe, goes red.

The description is careful to enumerate the expected new reds for ReceiverOIF presence but doesn't mention ownership at all. The whole case for merging something that turns the sweep red is that every red was enumerated in advance, so I'd want this one run fleet-wide and folded into that section.

4. Consider landing periphery-registry-log-sync as a warning first. Same worry from a different angle: it's an error-severity gate on hand-maintained deploy-log completeness, verified on four of about forty production networks. The sweep posts to Slack on failure. If it goes red on a dozen chains for log-hygiene reasons, people stop reading the alerts, which is the mechanism that let EXSC-682 sit there in the first place. Ship it as a warning, clear the fleet, promote it in a one-liner. facet-required-periphery is the check that actually gates functionality and should stay an error.

5. docs/TronFork.md goes stale, and the fork loses its escape hatch. Line 94 lists somnia.skipHealthcheck = true as one of the fork's tracked config diffs, and line 181 prescribes it as the fix for a documented sync-PR failure class. Once this merges and syncs down, that field is a silent no-op, since there's no schema validation on networks.json to complain about an unknown key. The doc needs to point at invariantExclusions instead, and the fork needs the equivalent entry.

6. Dead comparison in isNonZeroTronAddress. TRON_ZERO_ADDRESS from the devkit is '410000…0000', a 42-char hex string (dist/constants.d.ts:20), so the value !== TRON_ZERO_ADDRESS check at line 413 can't fire behind startsWith('T') && length === 34. The import on line 18 exists only to feed it. Either drop both, or drop the shape checks if the intent was to accept both encodings. Tests pass either way, which is presumably how it survived.

7. The arc / tempo devNotes name the wrong mechanism. They now say the TokenWrapper invariants are "excluded per-invariant in config/healthCheckExclusions.json", but what's actually used is corePeripheryExemptions, which is per-contract; invariantExclusions is empty. Since the two-tier split is the point, it'd be good not to blur it in the one place people will read.

Smaller stuff, none of it blocking:

  • deploySingleContract.sh now spawns two more bunx tsx processes per contract, roughly a second or two each, which is a minute-plus on a full diamond rollout. Worth folding both reminders and facetRefundReminder into one invocation. Also, 2>/dev/null || true means a typo in either script silently disables the nudge forever; a smoke test asserting each CLI exits 0 and prints something for a known input would be cheap.
  • isValidNetworkName and readDeployLog are exported from facetCompanionReminder.ts and imported by contractDependencyReminder.ts, which drags in a module with a top-level if (isDirectRun()) runCli(). The guard is correct, but these are generic deploy-log helpers and belong in script/deploy/shared/.
  • resolveLiveFacets lowercases addresses, and base58 is case-sensitive. It's applied to both sides so Tron matching still works; it's only a trap if that map is ever reused for display or compared against a non-lowercased source. A comment would cover it.
  • identifyCoupledFacetsOnChain matches on subset, so if facet A's registered set were a strict subset of on-chain facet B's, A resolves to B's address. The one-selector-one-facet rule makes that close to impossible and you have a test for the sibling case, but in immutable-bindings-match-config the consequence is reading a getter on the wrong contract, so requiring that the candidate isn't already claimed would tighten it for almost nothing.
  • facet-required-periphery only asserts non-zero, so a registration pointing at a wrong or codeless address passes. receiver-owner would then fail its read and logWarn rather than logError, so a bogus registration is warning-only. The tiers compose reasonably; just noting where the ceiling is.
  • resolveConfigValue can't handle jq-quoted paths. All eight current annotations use plain dot paths, but annotating something like .networks."my-chain".x yields expectedAddress: null and a warning, i.e. coverage shrinks quietly, which is the failure mode this PR is about. A test asserting every annotated path resolves on at least one network would guard it; right now there's only the across-specific precondition.
  • The delete-pending-proposals.ts change is pure Prettier reformatting and unrelated.

@lifi-qa-agent lifi-qa-agent 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.

Post-approval re-review (Run #28): 4 blocking items require fixes before merge.

  1. robinhood.diamond.json ReceiverOIF still emptydeployments/robinhood.diamond.json has "ReceiverOIF": "" while deployments/robinhood.json has the address. helperFunctions.sh reads .diamond.json for version resolution; one-line fix.

  2. receiver-owner fleet scope unvalidatedscope: {} puts ~160 network-receiver combinations under the error gate with no prior sweep. Either provide fleet enumeration (all green) or downgrade to severity: 'warning' for this release.

  3. TronFork.md prescribes a no-op field — Lines 94 and 181 still prescribe skipHealthcheck (now absent from the schema). Both references must point to invariantExclusions.

  4. isNonZeroTronAddress dead comparisonvalue !== TRON_ZERO_ADDRESS (hex form) can never be false after startsWith('T') && length === 34 pass. Drop the dead check and import, or drop the shape guards if both encodings were intended.

Full analysis: see QA review comment on this PR.

# Conflicts:
#	config/networks.json
#	script/common/types.ts
0xDEnYO and others added 3 commits July 31, 2026 14:43
- robinhood.diamond.json: fill the empty ReceiverOIF entry (was breaking
  helperFunctions.sh version resolution)
- periphery-registry-log-sync: widen candidates to both deploy logs' keys and
  compare the diamond log as a second target; ship warning-first (fleet dry-run
  2026-07-31: 114 inconsistencies across 38 production networks would have gone
  red on an error gate)
- receiver-owner: keep error severity - fleet dry-run verified owner() ==
  refundWallet on all 65 active EVM production networks; survive a flaky
  generic-Receiver read like the per-receiver loop already does
- isNonZeroTronAddress: drop the dead hex-form comparison and unused import
- docs/TronFork.md: point the fork's healthcheck escape hatch at
  invariantExclusions (skipHealthcheck no longer exists)
- networks.json arc devNotes: name corePeripheryExemptions, not per-invariant
  exclusion
- identifyCoupledFacetsOnChain: exact matches claim facets before subset
  matches, so a strict-subset candidate cannot resolve to another facet
- move isValidNetworkName/readDeployLog to shared/deployLog.ts (generic deploy
  log helpers, out of the CLI-entry module)
- CLI smoke tests for both reminder scripts (they run behind '|| true' in
  deploySingleContract.sh - a crash would silently disable them)
- guard test: every getter-annotated deployRequirements path must resolve on
  at least one network
- revert unrelated prettier churn in delete-pending-proposals.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e use

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… pharos, polygon

The widened periphery-registry-log-sync fleet dry-run found ReceiverOIF
registered in these five diamonds' PeripheryRegistry while absent from both
deploy logs (the same EXSC-682 gap this PR fixes on mainnet/base/arbitrum/
robinhood). Addresses read from each network's on-chain registry and
re-verified via cast (registry entry + non-empty code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@0xDEnYO

0xDEnYO commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@melianessa Thanks for the round — all seven items are addressed in 97d2fb42c / f8126dd4a (plus the merge of latest main). Point-by-point:

1. robinhood diamond log — Fixed: robinhood.diamond.jsonPeriphery.ReceiverOIF now carries 0xdD54…C576 (hand-edited in the file's existing key order).

2. periphery-registry-log-sync only checks one log — Widened as suggested: the candidate set now unions both logs' keys (flat log ∪ diamond.json Periphery) on top of the hand-maintained lists, and the diamond log is compared as a second target (empty/missing entry for a registered contract, and value mismatches, are both flagged; a populated diamond entry with no on-chain registration warns as stale/pending). You were right that it would have caught #1 on its own — see #4 for what else it caught.

3. receiver-owner fleet audit — Ran it: a dry-run of exactly this invariant against all 65 active EVM production networks (networks.json RPCs; the handful of public-RPC rate-limit gaps re-run via our own endpoints until every network was fully read). Result: owner() == refundWallet for every receiver on every network — zero mismatches. Tron's generic Receiver path was already live-verified. So the error gate stays, now with the fleet enumeration behind it. One robustness fix fell out: the generic checkOwnership('Receiver', …) call is now wrapped like the per-receiver loop, so a transient RPC failure warns instead of redding the network.

4. Warning-first — You were right, and the data is unambiguous: the widened check finds 114 log/registry inconsistencies across 38 production networks. Top classes: Receiver/LiFuelFeeCollector/ServiceFeeCollector registered on chain but absent from the diamond log (~77, mostly legacy registrations), genuine flat-log↔registry mismatches (ServiceFeeCollector ×9, RelayerCelerIM ×4, AxelarExecutor ×3, Receiver ×1), and 5 more chains with the ReceiverOIF gap (see below). Shipped as severity: 'warning' with a code comment stating the promote-to-error condition; facet-required-periphery stays the error-severity functional gate.

Bonus: the dry-run found ReceiverOIF live-but-unlogged on katana, megaeth, optimism, pharos, polygon — same EXSC-682 class this PR fixes on mainnet/base/arbitrum/robinhood. Synced both logs for all five in f8126dd4a; every address read from the on-chain registry and re-verified via cast (registry entry + non-empty code).

5. TronFork.md — Both spots updated to prescribe an invariantExclusions entry in config/healthCheckExclusions.json, with an explicit note that the fork must carry the per-invariant entry since skipHealthcheck no longer exists.

6. Dead Tron comparison — Dropped the hex-form compare and the now-unused import; fixed the TRON_ZERO_ADDRESS_BASE58 doc comment that claimed both spellings must be checked.

7. arc devNotes — Now says "exempted per-contract via corePeripheryExemptions". (Only arc carried the wording; tempo's devNotes was replaced wholesale by main's gasEstimateMultiplier rewrite in the merge.)

Smaller bullets:

  • Silent-disable 2>/dev/null || true: added CLI smoke tests for both reminder scripts (spawn the real entry point, assert exit 0 + expected output for a known input, and exit 0 + silence without args). Folding both reminders + facetRefundReminder into one invocation is a real perf win but touches the deploy script's structure — left as is for this PR to keep the diff reviewable; happy to do it as a follow-up.
  • Helpers in the CLI-entry module: isValidNetworkName/readDeployLog moved to script/deploy/shared/deployLog.ts; both reminders import from there.
  • base58 lowercasing: comment added at the nameByAddress map explaining why it's safe only there.
  • Subset-match ambiguity: tightened — exact selector-set matches claim their facet first, and subset matches (kept for version-drift tolerance) only bind to unclaimed facets. Test added for the strict-subset-vs-owner case.
  • resolveConfigValue quoted paths: added the guard test you suggested — every getter-annotated deployRequirements.json path must resolve on at least one production network, so a jq-quoted path can't silently shrink coverage.
  • Non-zero-only ceiling of facet-required-periphery: agreed with your read — the tiers compose (bogus registration → receiver-owner read fails → warning), and the new diamond-log cross-check tightens it a bit further since a wrong address must now also match two logs. Left as designed.
  • delete-pending-proposals.ts: reverted, the reformat was unrelated churn.

Leaving all threads open for you to resolve.

Comment thread script/deploy/healthCheckInvariants.ts Outdated
Comment on lines +994 to +1001
const diamondLogPath = path.join(
process.cwd(),
'deployments',
`${networkLower}.diamond.json`
)
if (!existsSync(diamondLogPath)) return null
try {
const parsed = JSON.parse(readFileSync(diamondLogPath, 'utf8')) as {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Potential file inclusion attack via reading file - high severity
If an attacker can control the input leading into the ReadFile function, they might be able to read sensitive files and launch further attacks with that information.

Show fix
Suggested change
const diamondLogPath = path.join(
process.cwd(),
'deployments',
`${networkLower}.diamond.json`
)
if (!existsSync(diamondLogPath)) return null
try {
const parsed = JSON.parse(readFileSync(diamondLogPath, 'utf8')) as {
const base = path.resolve(process.cwd(), 'deployments')
const target = path.resolve(base, `${networkLower}.diamond.json`)
const relative = path.relative(base, target)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return null
}
if (!existsSync(target)) return null
try {
const parsed = JSON.parse(readFileSync(target, 'utf8')) as {

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ee2cebf: loadDiamondLogPeriphery now applies the same containment as shared/deployLog.ts readDeployLog — network-name regex gate plus a resolved-path check that the target stays inside deployments/.

@0xDEnYO

0xDEnYO commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@lifi-qa-agent All four blocking items from Run #28 are addressed (details in the reply to Daniela's review above):

  1. robinhood.diamond.json ReceiverOIF — filled in 97d2fb42c.
  2. receiver-owner fleet scope — fleet enumeration done: dry-run of the invariant across all 65 active EVM production networks, owner() == refundWallet everywhere, zero mismatches (all green). Error severity retained on that evidence.
  3. TronFork.md — both references (fork-diff list and the sync-pain remediation) now prescribe invariantExclusions in config/healthCheckExclusions.json.
  4. isNonZeroTronAddress — dead hex-form compare and its import dropped; the base58 shape checks were the intended behavior (callTronContract only ever returns base58) and stay.

Additionally, periphery-registry-log-sync ships warning-first (114 pre-existing inconsistencies across 38 networks in the fleet dry-run) and was widened to cross-check <network>.diamond.json as a second log; the five further ReceiverOIF log gaps it uncovered (katana, megaeth, optimism, pharos, polygon) are synced in f8126dd4a.

@0xDEnYO

0xDEnYO commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Review-gate residual notes (not fixed in this PR)

The fleet dry-run of the widened periphery-registry-log-sync surfaced pre-existing production drift that needs human investigation rather than a log edit — now visible as warnings once this merges:

  1. Flat-log ↔ registry address mismatches (registry points at an address our deploy log does not have): ServiceFeeCollector on 9 networks, RelayerCelerIM on 4 (incl. mainnet), AxelarExecutor on 3 (incl. mainnet), Receiver on 1. Each needs a call on which address is canonical — stale registration to deregister, or stale log entry to correct.
  2. Legacy contracts still registered on chain but absent from the diamond log: Receiver (30 networks), LiFuelFeeCollector (27), ServiceFeeCollector (20), LiFiTimelockController (3) — mostly retired periphery whose registrations were never cleaned up. Deregistration proposals or a deliberate "leave registered" decision, per contract.

Raw per-network data is reproducible by running the invariant standalone; happy to park a ticket if wanted.

…ments/

Same name-regex + resolved-path containment as shared/deployLog.ts, per the
Aikido finding on the new read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
script/deploy/shared/facetPeripheryCouplings.test.ts (1)

280-285: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the nested ternary in subsetLoad.

The coding guidelines forbid nested ternary operators. A lookup record also gives a place for an explicit return type.

♻️ Proposed refactor
-    const subsetLoad = (name: string) =>
-      name === 'SuperFacet'
-        ? ['0xaaaa0001', '0xaaaa0002', '0xaaaa0003']
-        : name === 'SubsetFacet'
-        ? ['0xaaaa0001', '0xaaaa0002']
-        : null
+    const subsetSelectors: Record<string, string[]> = {
+      SuperFacet: ['0xaaaa0001', '0xaaaa0002', '0xaaaa0003'],
+      SubsetFacet: ['0xaaaa0001', '0xaaaa0002'],
+    }
+    const subsetLoad = (name: string): string[] | null =>
+      subsetSelectors[name] ?? null

As per coding guidelines: "Avoid nested ternary operators" and "Use explicit return types for functions in TypeScript".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/shared/facetPeripheryCouplings.test.ts` around lines 280 - 285,
Replace the nested conditional expression in subsetLoad with a lookup record
keyed by facet name and an explicit function return type. Preserve the existing
arrays for SuperFacet and SubsetFacet, and return null for names not present in
the lookup.

Source: Coding guidelines

script/deploy/healthCheckInvariants.ts (1)

1447-1458: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Restrict the candidate set to periphery names.

Object.keys(ctx.deployedContracts) covers the whole flat deploy log, which also lists facets and other non-periphery contracts. Each of those names becomes a getPeripheryContract read that always returns ZERO_ADDRESS. On EVM the reads are cached and batched, so the cost is bounded but still large. On Tron the loop at line 1496 awaits each read in sequence, so the added names multiply the wall-clock time of the invariant.

Consider filtering deploy-log keys to names that are periphery candidates (for example, keys also present in the diamond log periphery section, the core periphery lists, or the coupling/getter lists), or keep the flat-log keys only when they are absent from the facet artifacts.

Run the following script to size the effect on a production log:

#!/bin/bash
# Count flat deploy-log entries vs diamond-log periphery entries per network.
fd -e json . deployments --max-depth 1 | head -40 | while read -r f; do
  base=$(basename "$f" .json)
  case "$base" in *.diamond) continue;; esac
  total=$(jq 'keys | length' "$f" 2>/dev/null)
  periphery=$(jq '.LiFiDiamond.Periphery // {} | keys | length' "deployments/${base}.diamond.json" 2>/dev/null)
  echo "$base flat=$total diamondPeriphery=$periphery"
done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/healthCheckInvariants.ts` around lines 1447 - 1458, Restrict
the candidates assembled in the candidate-set construction to periphery names
instead of all keys from ctx.deployedContracts. Filter flat deploy-log entries
against known periphery sources such as diamondLogPeriphery,
getCorePeriphery/getTronCorePeriphery, coupling requirements, and receiver
getter names, while preserving candidates supplied by the existing
non-deploy-log sources.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@script/deploy/healthCheckInvariants.ts`:
- Around line 1447-1458: Restrict the candidates assembled in the candidate-set
construction to periphery names instead of all keys from ctx.deployedContracts.
Filter flat deploy-log entries against known periphery sources such as
diamondLogPeriphery, getCorePeriphery/getTronCorePeriphery, coupling
requirements, and receiver getter names, while preserving candidates supplied by
the existing non-deploy-log sources.

In `@script/deploy/shared/facetPeripheryCouplings.test.ts`:
- Around line 280-285: Replace the nested conditional expression in subsetLoad
with a lookup record keyed by facet name and an explicit function return type.
Preserve the existing arrays for SuperFacet and SubsetFacet, and return null for
names not present in the lookup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 41ce67c0-33cd-4063-be47-ce56fc85e667

📥 Commits

Reviewing files that changed from the base of the PR and between a0c60d6 and ee2cebf.

📒 Files selected for processing (29)
  • config/networks.json
  • deployments/arbitrum.json
  • deployments/base.json
  • deployments/katana.diamond.json
  • deployments/katana.json
  • deployments/mainnet.json
  • deployments/megaeth.diamond.json
  • deployments/megaeth.json
  • deployments/optimism.diamond.json
  • deployments/optimism.json
  • deployments/pharos.diamond.json
  • deployments/pharos.json
  • deployments/polygon.diamond.json
  • deployments/polygon.json
  • deployments/robinhood.diamond.json
  • docs/TronFork.md
  • script/common/types.ts
  • script/deploy/deploySingleContract.sh
  • script/deploy/healthCheckInvariants.test.ts
  • script/deploy/healthCheckInvariants.ts
  • script/deploy/resources/contractDependencyReminder.test.ts
  • script/deploy/resources/contractDependencyReminder.ts
  • script/deploy/resources/facetCompanionReminder.test.ts
  • script/deploy/resources/facetCompanionReminder.ts
  • script/deploy/safe/delete-pending-proposals.ts
  • script/deploy/shared/deployLog.ts
  • script/deploy/shared/facetPeripheryCouplings.test.ts
  • script/deploy/shared/facetPeripheryCouplings.ts
  • script/deploy/shared/immutableBindings.test.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • deployments/base.json
  • deployments/mainnet.json
  • config/networks.json
  • script/common/types.ts
  • script/deploy/shared/immutableBindings.test.ts
  • script/deploy/deploySingleContract.sh
  • script/deploy/resources/contractDependencyReminder.ts
  • deployments/arbitrum.json
  • script/deploy/shared/facetPeripheryCouplings.ts

@lifi-qa-agent

lifi-qa-agent Bot commented Jul 31, 2026

Copy link
Copy Markdown

🔍 QA Review — EXSC-684 — Health-Check Invariants + Deployment Registry Fixes

Field Value
Ticket EXSC-684
PR #2125
Reviewer lifi-qa-agent[bot] (Run #30 — 2026-07-31)
Verdict Pass — all previously blocking findings resolved

Summary

This is a re-review following CHANGES_REQUESTED in Run #28 (2026-07-30). All four blocking findings are now confirmed fixed. The PR is approved to merge.


Resolved Findings (previously blocking)

Finding Run #28 Status Run #30 Status Resolution
F1robinhood.diamond.json missing ReceiverOIF address ❌ Blocking ✅ Fixed "ReceiverOIF": "0xdD54bEa53F94554d632d0D844D88a4fd51b2C576" now populated
F3receiver-owner fleet invariant unvalidated against live fleet ❌ Blocking ✅ Fixed Full dry-run executed across 65 EVM networks — 0 mismatches; severity: 'error' retained
F5TronFork.md still prescribed deprecated skipHealthcheck ❌ Blocking ✅ Fixed Both occurrences updated to reference config/healthCheckExclusions.json: invariantExclusions
F6isNonZeroTronAddress dead hex comparison ❌ Blocking ✅ Fixed Dead TRON_ZERO_ADDRESS hex path removed; only TRON_ZERO_ADDRESS_BASE58 check remains

Acceptance Criteria Verification

AC Status Notes
facetPeripheryCouplings registry introduced Correctly maps facets to required companion periphery contracts
isNonZeroTronAddress correct after shape-guard refactor Dead branch removed; base58 shape guards make hex form unreachable
loadDiamondLogPeriphery path traversal fix Regex gate + path.relative containment check prevents traversal
Deployment log restoration unconditional Log refresh restored to run regardless of upstream result
Health-check exclusion migration documented TronFork.md updated with correct invariantExclusions guidance
ReceiverOIF populated for all active deployments robinhood.diamond.json Periphery entry confirmed present

Findings

None. All prior concerns resolved.


🤖 Automated QA re-review by lifi-qa-agent[bot]. Re-review Run #30 (2026-07-31) — resolves CHANGES_REQUESTED from Run #28 (2026-07-30).

lifi-qa-agent[bot]
lifi-qa-agent Bot previously approved these changes Jul 31, 2026

@lifi-qa-agent lifi-qa-agent 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.

✅ QA AI Approved (Run #30 — 2026-07-31). All 4 previously blocking findings from Run #28 CHANGES_REQUESTED are confirmed resolved: F1 robinhood.diamond.json ReceiverOIF populated, F3 receiver-owner fleet dry-run 65 networks 0 mismatches, F5 TronFork.md updated to invariantExclusions, F6 isNonZeroTronAddress dead hex branch removed.

0xDEnYO added a commit that referenced this pull request Aug 4, 2026
* chore(deploy): add ReceiverOIF v1.0.0 on 7 chains (EXSC-684)

Deploys ReceiverOIF v1.0.0 to arc, bsc, megaeth, optimism, pharos, polygon
and robinhood, so the facet-required-periphery invariant from #2125 has a
receiver to find wherever an intent-escrow facet is live.

Also adds the two pieces of framework config the rollout needed: a
ReceiverOIF entry in deployRequirements.json (constructor-arg validation)
and ReceiverOIF 1.0.0 in _targetState.json for the target chains plus
mainnet/base/arbitrum, which had it deployed but never declared.

jovay is excluded: neither OIF settler has code there, so the contract
cannot be deployed. katana is pending a deployer top-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deploy): add ReceiverOIF v1.0.0 on katana (EXSC-684)

katana's deploy failed forge's pre-flight balance check: the pinned 3 gwei
gasPrice combined with the repo-wide GAS_ESTIMATE_MULTIPLIER=500 made forge
reserve 5,023,370 gas x 3 gwei (0.01507 ETH) against a 0.01006 ETH balance,
for a deploy whose real cost is ~918k gas (~0.0000009 ETH at the 0.001 gwei
live base fee).

Sets katana's gasEstimateMultiplier to 200 rather than touching the gasPrice
pin, which is a deliberate documented workaround for the RPC over-reporting
eth_gasPrice. 200 keeps a 2x buffer on the gas limit and is fundable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(networks): correct katana gasEstimateMultiplier rationale (EXSC-684)

The note claimed 500 was the repo-wide multiplier; .env.example ships 130, so
500 was the local operator value. Restates the reserve as scaling with whatever
GAS_ESTIMATE_MULTIPLIER the operator's .env carries, which is the actual reason
the per-network pin makes the reserve deterministic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* revert(networks): drop temporary katana gasEstimateMultiplier override (EXSC-684)

The override existed only to get katana's ReceiverOIF deploy past forge's
pre-flight gas reserve; the deploy and its Safe proposal are complete, so the
override is no longer load-bearing. config/networks.json returns to main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deploy): sync ReceiverOIF diamond logs after cut execution on 8 chains (EXSC-684)

diamondUpdatePeriphery timelock ops executed on-chain for arc, bsc, katana,
megaeth, optimism, pharos, polygon, robinhood. Registry entries regenerated
and scoped to ReceiverOIF only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Goran Vladika <goran.vladika@gmail.com>
…3e1c2

# Conflicts:
#	deployments/arbitrum.json
#	deployments/base.json
#	deployments/katana.diamond.json
#	deployments/katana.json
#	deployments/mainnet.json
#	deployments/optimism.diamond.json
#	deployments/optimism.json
#	deployments/pharos.diamond.json
#	deployments/pharos.json
#	deployments/polygon.diamond.json
#	deployments/polygon.json
#	deployments/robinhood.diamond.json
#	deployments/robinhood.json
#	script/deploy/resources/deployRequirements.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants