This document is the consolidated output of a multi-agent security review of the Orochi Network ON CCIP Bridge. It covers the custom contract, deployment scripts, CCIP integration, test suite, and operational surface.
After the initial review, each finding was re-validated against actual code and addressed. Status field added to every finding. Tag legend:
| Status | Meaning |
|---|---|
FIXED |
Code/config/doc change landed in this remediation pass. |
DOC ADDED |
Finding resolved via NatSpec / inline comment / runbook clarification (no behaviour change). |
FALSE POSITIVE |
Re-validation showed the issue does not exist as described, or is already addressed in-tree. |
ALREADY ADDRESSED |
Closed before this review pass began (e.g. by the SECURITY.md restoration commit itself). |
DESIGN ACK |
Pre-existing INFO entry retained for record; no action taken because the design choice is sound. |
DEFERRED |
Valid but not implemented in this pass; tracked for pre-mainnet follow-up. |
Counts after this pass:
| Status | Count |
|---|---|
| FIXED | 76 |
| DOC ADDED | 7 |
| FALSE POSITIVE | 6 |
| ALREADY ADDRESSED | 1 |
| DESIGN ACK | 10 |
| DEFERRED | 4 |
| Total | 104 |
(11 of the entries are "investigated, no defect" — CCIP-5 plus 10 DESIGN ACK
items including the WON-10/15/18, DEP-14/21, OPS-20 acknowledgements — so the
effective resolution rate on actionable findings is 89 / 93. FIXED count includes
one FIXED-partial: DEP-3.)
Tests after the remediation pass: 130 passing, 0 failing (was 121 before the
third-pass review; this round added 9 net new tests covering WON-11 burn zero-amount
guards, WON-14 received-zero, TEST-16 reentrancy-mock hardening, TEST-17 BSC
RMN curse, TEST-20 four typed-revert paths through MockBadPool).
Four findings remain DEFERRED:
- TEST-7 (LOW) — requires live mainnet investigation of the BSC ON token's admin path before the fork test can be tightened.
- OPS-8 (LOW) — Slither CI gating left advisory until immediately before
mainnet broadcast; will be flipped to
--fail-on HIGHthen. - OPS-13 (LOW) — SARIF upload + Code-Scanning visibility bundled with the OPS-8 pre-mainnet workflow commit so both land together.
- OPS-27 (INFO) — README submodule SHA table bundled with the same pre-mainnet workflow commit.
All originally-HIGH findings (CCIP-1, DEP-1, TEST-1, TEST-2, OPS-1, OPS-2) and
DEP-8 (HIGH, added in the second-pass review) are FIXED.
- Scope:
src/,script/,test/,Makefile,foundry.toml,.gitmodules,README.md,RUNBOOK.md,CLAUDE.md,.github/workflows/,.env.example. - Out of scope: vendored code in
lib/chainlink-ccip,lib/chainlink-evm, andlib/openzeppelin-contracts(audited upstream; pinned atcontracts-ccip-v1.6.1/contracts-v1.4.0/ OZ 5.x). - Methodology: 5 reviewers worked independently in parallel — one per area — each producing findings with substantiated code references.
- Reviewers / ID prefixes:
WON-*—src/WrappedON.solcontract reviewDEP-*— Deployment scripts (script/01..08)CCIP-*— CCIP integration, pool wiring, rate limits, trust modelTEST-*— Test coverage and qualityOPS-*— Build, docs, env handling, CI, operator runbook
Every finding has a unique ID. Numbering may be non-contiguous where an investigation closed without a finding; those slots are preserved as INFO records so future references remain stable.
Report vulnerabilities privately to security@orochi.network before public
disclosure. Do not file public GitHub issues for unpatched security findings
on this repository.
| Severity | Meaning |
|---|---|
| CRITICAL | Direct funds-loss or trust-breaking path with a realistic attack precondition. |
| HIGH | Significant fund-loss, lockup, or trust-degrading path; or a substantial operator footgun. |
| MEDIUM | Meaningful correctness, recovery, or operability issue; partial mitigation exists. |
| LOW | Minor correctness or hygiene issue; primarily a polish or documentation gap. |
| INFO | Investigated and confirmed not a defect, or a design acknowledgement worth recording. |
| Area | CRITICAL | HIGH | MEDIUM | LOW | INFO | Total |
|---|---|---|---|---|---|---|
| WON | 0 | 0 | 3 | 10 | 7 | 20 |
| DEP | 0 | 3 | 5 | 12 | 4 | 24 |
| CCIP | 0 | 1 | 6 | 4 | 3 | 14 |
| TEST | 0 | 2 | 9 | 9 | 0 | 20 |
| OPS | 0 | 2 | 4 | 18 | 6 | 30 |
| Total | 0 | 8 | 27 | 53 | 20 | 108 |
Headline: no CRITICAL findings. The custom contract surface (WrappedON.sol)
is clean — the three MEDIUM WON findings are all resolved (WON-19 reversed by
product decision; WON-20 fixed by removing auto-unwrap), so the highest open
WON finding is LOW. The bulk of the actionable risk
sits in the operational surface (key handling, post-handoff workflows,
documentation gaps) and in tightening test rigor (fork pinning, invariant
config, typed revert expectations). The single most impactful invariant —
lockedON_BSC + reserveON_ETH >= totalSupply(wON) — is structurally upheld;
the MAX_CCIP_MINTED cap, however, is an approximation rather than a hard
lifetime bound (see CCIP-7).
All six findings originally tagged HIGH (CCIP-1, DEP-1, TEST-1, TEST-2, OPS-1,
OPS-2) are now FIXED. See the per-finding status entries below for the specific
remediation. The only remaining open items are TEST-7 (LOW, deferred until the BSC
ON token admin path is concluded — see CLAUDE.md "Known open items") and OPS-8
(LOW, Slither gating flipped to fail-on-HIGH immediately before mainnet broadcast).
- Severity: LOW
- Status: FIXED —
mintnow revertsZeroAmounton zero. Test:test_MintRevertsOnZeroAmount. - Location:
src/WrappedON.sol:221-235 - Description:
depositandwithdrawboth revert onamount == 0, butmint(the CCIP entrypoint) does not. A zero-amount call increments nothing and mints nothing, but it emits an ERC20Transfer(pool, account, 0)event and an OZ AccessControl check passes silently. Under normal CCIP operation the pool will never pass zero, but the asymmetry is worth eliminating for indexer hygiene and to match the pattern of every other state-mutating entry point in the contract. - Impact: No meaningful state harm; a misbehaving or test pool can spam zero-value Transfer events. No financial loss.
- Recommendation: Add
if (amount == 0) revert ZeroAmount();at the top ofmint, matching thedeposit/withdrawguards.
- Severity: INFO
- Status: DESIGN ACK — the recommended on-chain count check requires
AccessControlEnumerable, which expands inheritance for marginal benefit. The single-pool invariant is enforced operationally: script 03 (GrantRoles) only grants to the deployed pool, the multisig handoff transfers admin to a Safe, and the RUNBOOK monitoring table pages on everyRoleGranted(BURNER_ROLE, *). AddingAccessControlEnumerableis left as an option for a future redeploy if multi-grantee scenarios become realistic. - Location:
src/WrappedON.sol:254-259 - Description: The two-argument
burn(address account, uint256 amount)overload calls_burn(account, amount)with no_spendAllowancecheck, matching theIBurnMintERC20interface contract. The test attest/WrappedON.t.sol:264explicitly verifies this. Safety relies entirely onBURNER_ROLEexclusivity. IfBURNER_ROLEwere ever granted to more than one address, any role holder could burn arbitrary balances without delegation. - Impact: Single-pool deployment is safe; multi-grantee deployment is not.
- Recommendation: Add a
getRoleMemberCount(BURNER_ROLE) <= 1post-deploy assertion toscript/08_PostDeployVerify.s.sol, and document the constraint in the multisig handoff runbook.
- Severity: LOW
- Status: FIXED — RUNBOOK monitoring table now keys on
IERC20(ON).balanceOf(BSC_LockReleaseTokenPool)as the authoritative locked-balance read with an explicit note thatccipMintHeadroomUsedis a local indicator only. M1 / #23: the counter was additionally renamedccipMintedSupply→ccipMintHeadroomUsed(and_decrementCcipMinted→_decrementCcipMintHeadroom) so the name no longer implies it tracks BSC-side minted/locked supply — it is a CCIP mint-cap headroom counter. This is the chosen alignment for QuillAudits M1 (the "soft headroom counter" option): keep the saturating subtract, rename so no name/doc presents it as a BSC-liquidity proxy; authoritative exposure staysBSC_ON.balanceOf(BSC_pool). - Location:
src/WrappedON.sol(ccipMintHeadroomUsed,_decrementCcipMintHeadroom) - Description: The saturating-decrement in
_decrementCcipMintHeadroomis intentional: it handles deposit-backed wON being bridged outbound without underflowing. The consequence is thatccipMintHeadroomUsedcan read zero while a non-trivial amount of BSC-locked ON exists. Monitoring alerts keyed only toccipMintHeadroomUsedapproachingMAX_CCIP_MINTEDmay produce false negatives. (Closely related to CCIP-7.) - Impact:
ccipMintHeadroomUsedis not a reliable real-time BSC-exposure gauge. Operational/monitoring risk only. - Recommendation: RUNBOOK monitoring guidance should key on the BSC
LockReleaseTokenPoollocked balance (on-chain call or event index) alongsideccipMintHeadroomUsed.
- Severity: LOW
- Status: FIXED —
CCIPMinted(account, amount, ccipMintHeadroomUsed)emitted frommint;CCIPBurned(account, amount, ccipMintHeadroomUsed)emitted from all three burn entrypoints. Tests:test_MintEmitsCCIPMinted,test_BurnEmitsCCIPBurned_*. - Location:
src/WrappedON.sol:221-235(mint),242-268(burn entrypoints) - Description:
depositemitsWrapped,withdrawemitsUnwrapped, but the CCIPmintpath emits only the inherited ERC20Transfer(address(0), account, amount). Burns are similar. Indexers cannot distinguish CCIP-inbound mints from deposit wraps by event topic alone. - Impact: Cross-chain reconciliation tooling must correlate with pool-level CCIP events.
- Recommendation: Add
CCIPMinted(address indexed account, uint256 amount, uint256 ccipMintHeadroomUsed)(and optionallyCCIPBurned) for direct on-chain auditability.
- Severity: LOW
- Status: FIXED —
CCIPAdminProposalCancelled(address)emitted insetCCIPAdminwhen overwriting a different pending address. Identical re-proposal does NOT emit (verified bytest_SetCCIPAdminRePropose_DoesNotEmitCancellation). Test:test_SetCCIPAdminEmitsCancellationWhenOverwritten. - Location:
src/WrappedON.sol:287-313 - Description:
setCCIPAdminallows the current admin to overwrites_pendingCcipAdminwith a new address at any time. The prior proposed address receives no on-chain cancellation signal. If they had a multisig transaction queued foracceptCCIPAdmin, it will revert withOnlyPendingCCIPAdmin. - Impact: Operational confusion; no funds at risk.
- Recommendation: Emit
CCIPAdminProposalCancelled(address indexed cancelled)when overwriting a non-zero pending admin with a different address.
- Severity: INFO
- Status: DESIGN ACK — no code change. Recorded for completeness.
- Location:
src/WrappedON.sol:199-210 - Description: Current ordering: read
ON.balanceOf(this)→ revert if insufficient →_burn→safeTransfer. With ON as a plain non-hookable ERC20 andnonReentrantactive, no attack path exists. This is purely a defensive note: ifONwere ever replaced by an ERC-777-style token with receiver hooks, the ordering would still be correct because reentry would observe the post-burntotalSupply. - Impact: None under current ON token.
- Recommendation: No code change required. The
ONimmutable + non-upgradeable design closes this path.
- Severity: INFO
- Status: FIXED —
IERC20Metadata.interfaceIdadded tosupportsInterface. Test updated:test_SupportsInterfacePositiveAndNegativenow assertstrue. - Location:
src/WrappedON.sol:330-334 - Description: The contract satisfies
IERC20Metadata(via OZ ERC20) but does not returntruefor its interface ID. Integrations that ERC-165-check before readingdecimals()will get a false negative. - Impact: Minor integration friction; no security risk.
- Recommendation: Add
|| interfaceId == type(IERC20Metadata).interfaceId.
- Severity: MEDIUM
- Status: FALSE POSITIVE — the constructor is only deployable against a canonical ON token whose interface and value are fixed at the project level. ON on Ethereum (
0x33f6BE84becfF45ea6aA2952d7eF890B44bFB59d, 600M supply) and ON on BSC (0x0e4F6209eD984b21EDEA43acE6e09559eD051D48, 100M supply) are both immutable non-upgradeable ERC20Metadata implementations withdecimals() == 18.Helper.getConfig(block.chainid)hardcodes these mainnet addresses, and the01_DeployWrappedONscript readsonTokenfromHelper.getConfig()rather than from operator input. There is no realistic deploy path wheredecimals()returns a non-18 value or reverts — the equality checkonDecimals != decimals()already provides the only defence the architecture supports. Adding a try/catch + zero-check would be a defensive no-op that doesn't move the threat model. - Location:
src/WrappedON.sol:149-152 - Description:
IERC20Metadata(onToken).decimals()is called without try/catch in the constructor. A non-IERC20Metadatatoken with a fallback returning 18-like bytes would pass; a token returning0would pass only if wON'sdecimals()were changed from 18. - Impact: None under the canonical-token constraint.
- Recommendation: No code change. The deploy-time controls (hardcoded canonical addresses + non-mintable, non-upgradeable ON token) are the load-bearing guarantee.
- Severity: LOW
- Status: FIXED — event signature renamed to
Wrapped(address indexed account, uint256 received)so indexers using the parameter name (e.g. via the deployed ABI JSON) see the post-fee semantics directly. The wire format / topic is unchanged because event-parameter names are not part of the keccak signature, so existing decoders keyed offTransfer/Wrapped(address,uint256)continue to match. NatSpec on the event now spells out the received-amount semantics. The correspondingUnwrappedevent remainsamount-labelled becausewithdrawoperates against a non-hookable internal reserve and there is no fee-on-transfer asymmetry to disclose. - Location:
src/WrappedON.sol:101(Wrappedevent),164-192(deposit) - Description:
deposituses received-amount accounting (computesreceived = balanceAfter - balanceBefore) so the credited wON tracks the actual transfer, including any fee-on-transfer skim. The emitted event field was labelledamount, suggesting it matched the caller-supplied argument; under a fee-on-transfer token the two diverge. - Impact: Indexers correlating against the caller-supplied
amountargument would see a mismatch with the post-fee credit. No funds at risk. - Recommendation: Rename to
receivedor add a second parameter making the requested amount explicit.
- Severity: INFO
- Status: DESIGN ACK —
setCCIPAdminalready rejectsaddress(this)viaInvalidCCIPAdmin(round-6 R-56), so the path tos_pendingCcipAdmin == address(this)is structurally closed. Adding the same guard toacceptCCIPAdminwould be symmetric belt-and-suspenders only; no realistic attack path. Recorded for completeness — left out to keep the contract surface minimal. - Location:
src/WrappedON.sol:317-324 - Description:
acceptCCIPAdmindoes not redundantly checkmsg.sender != address(this). Even if the pending slot were somehow forced toaddress(this), no external caller can satisfymsg.sender == address(this)without a recursiveaddress(this).call(…)— which the contract never makes. - Impact: None under current logic.
- Recommendation: Optional. If the maintainer prefers symmetry, add
if (msg.sender == address(this)) revert InvalidCCIPAdmin();at the top ofacceptCCIPAdmin.
- Severity: LOW
- Status: FIXED — added
if (amount == 0) revert ZeroAmount();to all three burn overloads (burn(uint256),burn(address,uint256),burnFrom). Mirrors the WON-1 mint guard. New tests:test_BurnRevertsOnZeroAmount_SingleArg,test_BurnRevertsOnZeroAmount_AddressOverload,test_BurnFromRevertsOnZeroAmount. - Location:
src/WrappedON.sol:242-268 - Description: WON-1 closed
mint(0). The three burn paths emittedCCIPBurned(account, 0, supply)on zero-amount calls, polluting indexer accounting. - Impact: Indexer audit-trail noise. No funds at risk.
- Recommendation: Add the zero-amount guard symmetrically.
- Severity: LOW
- Status: FIXED —
s_pendingCcipAdmin = newAdminnow happens before either event emits. No reentrancy risk in either order (no external calls), but emitting after the state write matches themint/burn/acceptCCIPAdminorder and prevents an indexer subscribing toCCIPAdminProposalCancelledfrom reading the stalependingCCIPAdmin()in the same block. - Location:
src/WrappedON.sol:287-313 - Description: Effects-events inversion vs the rest of the contract. Consistency only.
- Impact: None for funds; potential indexer race only.
- Recommendation: Reorder to state-write-then-emit.
- Severity: LOW
- Status: FIXED —
_decrementCcipMintHeadroomnow returns the new supply; each burn path emits the local return value (saves one SLOAD per burn × 3 sites, matches themintpath'swouldBe-local pattern). - Location:
src/WrappedON.sol:242-268(burn entrypoints),345-359(_decrementCcipMintHeadroom+_ccipBurn) - Description: Asymmetric with the
mintpath which emits the localwouldBe. Identical pattern in three places was a refactor smell. - Impact: Gas only.
- Recommendation: Return-value refactor on
_decrementCcipMintHeadroom.
- Severity: LOW
- Status: FIXED —
depositnow rejectsreceived == 0post-transfer withZeroAmount. Mirrors the WON-1 mint guard for the deposit path. New test:test_DepositRevertsOnReceivedZerousing a mock whosetransferFromreturnstruewithout moving anything. - Location:
src/WrappedON.sol:164-192 - Description: A 100%-fee or buggy ERC20 whose
transferFromreturnedtruewithout state change would letdeposit(N)mint zero wON and emitWrapped(_, 0). Canonical ON cannot hit this; the received-amount accounting is itself the defensive accommodation for non-canonical variants — so a symmetric guard is consistent. - Impact: Indexer audit-trail noise; no funds at risk.
- Recommendation: Add the post-transfer zero-amount guard.
- Severity: INFO
- Status: DESIGN ACK — kept
Unwrapped(account, amount)becausewithdrawis internal-reserve-only:safeTransferfrom the contract's own balance to the user has no fee-on-transfer asymmetry on canonical ON. Added explicit NatSpec on the event documenting the asymmetry (vs WON-9'sWrapped(received)rename) so a future fee-on-transfer ON variant would not silently misreport. Symmetrizing to areceived-style accounting onwithdrawwould require received-amount tracking at the recipient — which the contract can't observe without trust assumptions on the recipient. - Location:
src/WrappedON.sol:101-108 - Description: Same field name in a sibling event masks the difference in semantics between the two paths.
- Impact: Documentation-only under canonical ON.
- Recommendation: Inline NatSpec on
Unwrapped.
- Severity: INFO
- Status: FIXED —
mint's docstring now cross-references the contract-level CAP REPLENISHMENT block (CCIP-7) and explicitly says the cap is a live BSC-balance approximation, not a lifetime CCIP-mint ceiling. The previous wording ("so deposit-backed wON does not consume it") suggested a clean separation between the deposit and CCIP-mint paths' impact on the counter, which is misleading once deposit-backed wON is bridged out. - Location:
src/WrappedON.sol:214-235 - Description: Per-function NatSpec contradicted the more detailed contract-level block. An auditor reading
mint's docstring first formed an incorrect mental model. - Impact: Documentation only.
- Recommendation: Rephrase to match the contract-level block.
- Severity: LOW
- Status: FIXED — added
nonReentranttomint,burn(uint256),burn(address,uint256),burnFrom. Defence-in-depth: OZ 5.x ERC20 has no hooks, so reentry via_mint/_burncannot happen against the current code — but a future OZ release or a subclass override adding an_updatehook would expose accipMintHeadroomUseddesync window. Thedeposit/withdrawpaths already carrynonReentrant; consistency was worth the ~2.5k gas/call. - Location:
src/WrappedON.sol:221, 242, 254, 262 - Description: The CCIP-side entrypoints didn't carry the same modifier as the wrap-side, so a hookable-token redeploy or a future OZ subclass change could open a same-tx reentry. Existing invariants pass under the new modifier.
- Impact: No active exploit path against current OZ ERC20; forward-compat hardening.
- Recommendation: Mirror
nonReentrantfromdeposit/withdraw.
- Severity: INFO
- Status: DESIGN ACK —
WON-17addsnonReentrantto all four CCIP entrypoints, which is the structural fix. The TEST-8 deposit-side reentry test (now hardened by TEST-16 to assert the specificReentrancyGuardReentrantCallselector) exercises the OZ guard's behaviour against a hook-bearing token; adding a parallel CCIP-side test would be belt-and-suspenders since the samenonReentrantmodifier is in play. Recorded so a future deploy against a hookable-ERC20 token has a single canonical follow-up: extend TEST-8's pattern to the CCIP path. - Location:
test/WrappedON.t.sol,src/WrappedON.sol:221, 242, 254, 262 - Description: No malicious-burner-pool mock exercises a same-tx reentry from
burn → mintor vice versa. - Impact: Forward-compat only.
- Recommendation: Defer to redeploy if the bridge is ever wired to a hookable ON variant.
- Severity: MEDIUM
- Status: REVERSED (2026-06-23) by product decision —
depositis permissionless again;LIQUIDITY_MANAGER_ROLEremoved entirely. Original FIXED status (below) preserved as history. See two residual-risk notes appended after the description. - Previous status (now superseded): FIXED —
depositwas gated toLIQUIDITY_MANAGER_ROLE(issue #25). The constructor seeded the role to the bootstrap admin, script 06 granted it to the multisig at handoff, andRenounceDeployerAdminrenounced the deployer's grant. Tests:test_DepositRevertsWithoutLiquidityManagerRole,test_DepositSucceedsWithLiquidityManagerRole,test_ConstructorGrantsLiquidityManagerRoleToAdmin,test_AdminCanRevokeLiquidityManagerRole(WrappedON.t.sol). - Location:
src/WrappedON.sol(deposit). - Description: QuillAudits Wrapped ON Finding M3.
deposit()was public and uncapped: any ETH-ON holder could mint wON 1:1. The team intends to seed a limited ETH-side reserve (≈10M, up to 100M). A public wrap lets anyone convert ETH ON → wON freely, which does not break the aggregate supply invariant but can grow wON supply — and therefore ETH→BSC redemption demand — beyond the BSC liquidity and rate limits the launch was sized for (compounding M2 / CCIP-2 stuck-message pressure). - Impact (original): A limited-liquidity launch would otherwise expose a public, uncapped conversion path; redemption demand toward BSC could exceed available BSC pool liquidity.
- Recommendation (original): (Was implemented) restrict
depositto a protocol-managedLIQUIDITY_MANAGER_ROLE. Per-window ETH→BSC redemption is then bounded by what the role-holder wraps plus the BSC inbound rate limits (see RUNBOOK §4.5). - Residual risk note A — permissionless deposit: With
depositpermissionless and uncapped, wON supply growth and ETH→BSC redemption demand are bounded only by the ETH-side ON circulating supply (600M) and the configured CCIP pool rate limits. The aggregate safety invariant (lockedON_BSC + reserveON_ETH >= totalSupply(wON)) continues to hold mechanically, but burst ETH→BSC redemption pressure is no longer capped by role access. Operators must size BSC pool liquidity and ETH→BSC rate limits conservatively (see RUNBOOK §4.5). - Residual risk note B — auto-unwrap reserve drain (RETIRED 2026-06-23, see WON-20): An earlier draft had
WrappedON.mintauto-unwrap — transferring native ON out of the reserve when it covered a BSC→ETH arrival — which let a compromisedMINTER_ROLEpool drain the reserve via fabricated inbound messages. Auto-unwrap was removed (issue #48) before redeploy:mintnow only mints wON and never touches the reserve, so this vector no longer exists. The reserve can only ever leave viawithdraw(caller burns their own wON). See WON-20.
- Severity: MEDIUM
- Status: FIXED (2026-06-23) — auto-unwrap removed from
WrappedON.mint. The CCIPreleaseOrMintpath now always mints wON (the registered token) to every receiver, EOA or contract; it never reads the reserve or delivers native ON. The asset a BSC→ETH receiver gets is deterministic. Tests:test_MintMintsWonEvenWhenReserveCovers,test_MintMintsWonAtExactReserve,test_MintToContractReceiverMintsWon,testFuzz_MintAlwaysMintsWonRegardlessOfReserve(WrappedON.t.sol);test_BscToEth_MintsWonEvenWhenReserveCovers(PoolRoundtrip.t.sol);test_Fork_ETH_BscToEth_MintsWonEvenWhenReserveCovers(Fork_ETH.t.sol). Retires residual-risk note B above. - Location:
src/WrappedON.sol(mint). - Description: Issue #48. The auto-unwrap branch added in #45 delivered native ON (and minted 0 wON) when
ON.balanceOf(wON) >= amount. For CCIP programmatic token transfers (token + data), a contract receiver coded to expectamountwON would instead observe 0 new wON and an unexpected native-ON balance — breaking its wON-based accounting or stranding value. The trigger was front-runnable:deposit/withdraware permissionless, so anyone could moveON.balanceOf(wON)across the>= amountboundary in the same block, flipping the delivered asset. A BSC→ETH sender could not predict which asset their receiver got. - Impact: Integration footgun for contract receivers on the BSC→ETH lane. No protocol-invariant violation —
lockedON_BSC + reserveON_ETH >= totalSupply(wON)held throughout. EOA receivers were unaffected (native ON was the intended outcome for them). - Recommendation: (Implemented) remove auto-unwrap; always mint wON on the CCIP path. Holders who want native ON call
withdraw. This eliminates the front-runnable asset switch at the source and shrinks the trust surface — themintpath can no longer move ON out of the reserve. Alternatives considered and rejected: EOA-gating the auto-unwrap (account.code.length == 0) and documentation-only; seedocs/superpowers/specs/2026-06-23-won-remove-autounwrap-design.md.
- Severity: MEDIUM
- Status: DESIGN ACK / DOC-ADDED (2026-06-23) — no code change;
withdrawstays pausable by design (product decision).PAUSER_ROLEis re-documented as a custody-affecting emergency authority (it CAN halt redemption), not merely "liveness-only", insrc/WrappedON.solNatSpec (PAUSER_ROLE,pause,withdraw),CLAUDE.md, and here. Paused-revert coverage for every value path already exists (test_PausedBlocksValuePaths,test_PausedBlocksWithdraw,test_PausedBlocksBurnSingleArg,test_PausedBlocksBurnAddressOverload,test_PausedBlocksBurnFrom), alongsidetest_PausedAllowsTransfer(pins "transfers stay live", which makes the finding material) andtest_UnpauseRestoresValuePaths/test_UnpauseRestoresWithdraw(WrappedONPause.t.sol). - Location:
src/WrappedON.sol(withdrawwhenNotPaused;pause;PAUSER_ROLE). - Description: Issue #56.
withdrawcarrieswhenNotPaused; plain ERC20transferdoes not. So while paused, deposit-backed holders cannot redeem their 1:1 native ON viawithdraw, yet wON keeps circulating viatransfer— wON can trade at a discount with its arbitrage backstop frozen. A singlePAUSER_ROLEholder (a weaker authority thanDEFAULT_ADMIN_ROLE, and whose role-admin is the multisig) can impose this unilaterally and indefinitely (no max-pause duration). This is a stronger capability than the original "halt-only / liveness-only" framing implied. - Impact: Redemption censorship while paused. Funds are not stolen, but 1:1 redemption of the native-ON reserve is frozen until
unpause. - Recommendation / disposition: KEEP
withdrawpausable (issue option (a)). The pause onwithdrawis a deliberate, custody-grade emergency control: in a bridge/protocol compromise where wON is minted improperly (e.g. a compromised pool over-mints, or a CCIP fault), an attacker holding illegitimate wON could otherwise callwithdrawand drain the native-ON reserve that backs honest depositors. Freezing redemption during an active incident protects the reserve; the censorship trade-off (honest holders also cannot redeem while paused) is accepted as the lesser harm and is bounded operationally —PAUSER_ROLEis held by the ops multisig after handoff, andunpauserestores redemption. Option (c) make-withdraw-unpausable was rejected because it would remove exactly this reserve-drain emergency stop (product decision 2026-06-23); option (b) bound-pause-duration was rejected as adding upgradeable storage + timekeeping for marginal benefit. The residual capability is documented here and in NatSpec/CLAUDE.md so it is not mistaken for "liveness-only."
- Severity: LOW
- Status: VERIFIED + DOC-ADDED + TEST-ADDED (2026-06-23) — issue #58. No code change required: the decoupling matches the CCIP two-step convention and the two-step guards are correct (
setCCIPAdminrejectsaddress(0)/self/address(this);acceptCCIPAdminrequiresmsg.sender == pendingCcipAdmin; an in-flight pending can't be clobbered by a third party). Confirmed that BOTH the renounce precondition (script/06_TransferOwnership.s.sol_assertReadyToRenounce) and post-deploy verify (script/08_PostDeployVerify.s.sol_checkDeployerRenounced) assert the acceptedgetCCIPAdmin() == multisig, NOT merelypendingCCIPAdmin(). Added explicit tests for the proposed-but-not-accepted state:test_RevertsWhenCcipAdminOnlyProposedNotAccepted(Script06Renounce.t.sol) andtest_CheckDeployerRenounced_RevertsWhenCcipAdminOnlyProposed(Script08Verify.t.sol). RUNBOOK §3.3 now documents the asymmetry explicitly. - Location:
src/WrappedON.sol(setCCIPAdmin/acceptCCIPAdmin, the$.ccipAdminslot;initializeseedsccipAdmin = admin). - Description: CCIP-admin rotation lives in a standalone
$.ccipAdminslot outside AccessControl. Afterinitialize,ccipAdminandDEFAULT_ADMIN_ROLErotate independently, and there is no on-chain path forDEFAULT_ADMIN_ROLEto reclaim or resetccipAdmin. A botched handoff (CCIP admin accepted by a wrong/lost address, or proposed but never accepted while the deployer renounces) could permanently strand the registry-facing CCIP admin — re-registration would then require Chainlink (theTokenAdminRegistryowner) out-of-band. - Impact: If the multisig accepts
DEFAULT_ADMIN_ROLEbut thesetCCIPAdmin → acceptCCIPAdminhandoff is botched, the registry-facing admin is stuck with no on-chain remediation. The renounce guard prevents the worst case (the deployer renouncing whileccipAdminis still unaccepted) by reverting. - Recommendation: (Implemented) Harden the procedure, not the contract: the handoff checklist + script 08 post-assert the accepted
getCCIPAdmin() == multisig; the renounce precondition (script 06) blocks the deployer renounce until then; RUNBOOK §3.3 documents the asymmetry so operators verify the two admin authorities coincide.
- Severity: HIGH
- Status: FIXED — script 04 now probes
TokenAdminRegistry.getTokenConfig(token)before broadcasting; skips the register / accept / setPool steps individually based on observed state. Re-running after a partial failure is now safe at every step. - Location:
script/04_RegisterAdminAndPool.s.sol:66-70 - Description: The NatSpec on line 47 claims "
acceptAdminRoleandsetPoolare idempotent," but the three calls inside the singlevm.startBroadcast()block are executed unconditionally. On re-run,_registerAdmin → registerAdminViaGetCCIPAdmin → proposeAdministratorrevertsAlreadyRegistered;acceptAdminRolerevertsOnlyPendingAdministrator. There is no pre-broadcast probe. - Impact: If
_registerAdmin+acceptAdminRoleland butsetPoolfails (nonce gap, gas exhaustion), the operator cannot re-run the script to finishsetPool— they have to call it manually with rawforge script, against misleading NatSpec. - Recommendation: Probe
TokenAdminRegistry.isAdministrator(token, msg.sender). If already the administrator, skip the register/accept block and call onlysetPool. Pattern is already used in scripts 05 and 06.
- Severity: MEDIUM
- Status: FIXED —
precheck-helperis now a prerequisite ofhandoffandrenounce(and via the existinghandoff-all → handoffchain, both legs). Misfilled Helper placeholders fail fast before any broadcast. - Location:
script/06_TransferOwnership.s.sol:73,152,158,Makefile:116-142 - Description:
_handoffonly calls_requireSeton the addresses it reads (cfg.tokenAdminRegistry). For other Helper fields, no pre-flight check exists. Unlikedeploy-eth/deploy-bsc, thehandoff,handoff-all, andrenounceMake targets do not invokeprecheck-helperfirst. - Impact: A misfilled Helper between deploy and handoff is not surfaced fast.
- Recommendation: Add
precheck-helperas a prerequisite tohandoff,handoff-all, andrenounce.
- Severity: MEDIUM
- Status: FIXED (partial) — automated cross-chain checks aren't possible from a single
forge scriptinstance, but the renounce now logs an explicit REMINDER to verify the BSC pool ownership before treating the deployer EOA as fully retired. RUNBOOK §3.4 already requiredmake verify-bscpost-handoff; the on-script reminder makes the dependency unmissable. - Location:
script/06_TransferOwnership.s.sol:231-258, comment at line 175 - Description:
_assertReadyToRenouncechecks the ETH-side pool owner and ETHTokenAdminRegistryadmin role. It does not verify that the BSCLockReleaseTokenPoolownership has been accepted by the multisig. BSC pool ownership is the custody-grade authority over locked ON (viasetRebalancer → withdrawLiquidity). - Impact: An operator running
make renounceafter completing only the ETH leg ofhandoff-allwill succeed. Deployer EOA loses ETH privilege but retains BSC pool ownership. The bridge is left in a permanently asymmetric authority state — including full custody of the BSC reserve still on the deployer. - Recommendation: Read the BSC pool
owner()(via a small view helper invoked over the BSC RPC) and block renounce until it matches the multisig. At minimum, expand the "Next steps" log to require explicit BSC-side verification.
- Severity: LOW
- Status: FIXED —
_requireSetonrouter,rmnProxy, andtokenAdminRegistryadded before_checkPoolWiring. Unfilled placeholders now surface asMissingAddress(name). - Location:
script/08_PostDeployVerify.s.sol:55,86-98 - Description:
_checkPoolWiring(localPool, local.router, local.rmnProxy)is called without first running_requireSet. If placeholders are unfilled, the script revertsRouterMismatch(expected=0x0, actual=…), which looks like a pool misconfiguration rather than an operator error. - Impact: Misleading diagnostic at the verify step.
- Recommendation: Call
_requireSet(local.router, "router")and_requireSet(local.rmnProxy, "rmnProxy")before_checkPoolWiring.
- Severity: LOW
- Status: FALSE POSITIVE — re-validated against the script source.
remote.router,remote.rmnProxy, andremote.tokenAdminRegistryare NEVER read by script 05; onlyremote.chainSelector(a non-address constant) andremote.onToken(validated indirectly via_remoteTokenAddress) are consumed. Adding_requireSeton never-read fields wouldn't catch any real misconfiguration. Themake deploy-eth/make deploy-bscflow already callsprecheck-helperas a prerequisite, which validates every placeholder on both chains. - Location:
script/05_ApplyChainUpdates.s.sol:24,63-75 - Description: Only
remotePool,remoteToken, andlocalPoolare guarded.remote.router,remote.rmnProxy,remote.tokenAdminRegistryfrom_remoteConfig(block.chainid)are not — they're never read in this script, but the absence of the guard means a misfilled remote config produces no early fail signal. - Impact: Diagnostic only —
precheck-helperalready covers this when used. - Recommendation: Add
_requireSet(remote.router, "remote router")defensively, or rely onprecheck-helperbeing a prerequisite to allmake deploy-*andapplytargets.
- Severity: INFO
- Status: FIXED —
hasRole(MINTER_ROLE, pool)andhasRole(BURNER_ROLE, pool)probes added; already-granted roles are skipped to produce cleaner logs and avoid wasted-gas no-op broadcasts. - Location:
script/03_GrantRoles.s.sol:23-25 - Description: Unlike 01/02/05/06, script 03 does not probe state. OZ 5.x
grantRoleis a no-op if the role is already held, so re-runs are silently safe but waste two broadcast tx. - Impact: Wasted gas on re-run, no correctness risk.
- Recommendation: Optionally probe
hasRolefor cleaner logs.
- Severity: LOW
- Status: FIXED —
tryReadAddressnow wrapskeyExistsJsonandparseJsonAddressin try/catch, returningaddress(0)on malformed JSON so corrupt files route through the calling script's_requireSetdiagnostic instead of a low-level Foundry panic. - Location:
script/Deployments.sol:63-70 - Description:
vm.writeJsonwrites in-place; a process killed mid-write leaves a corrupt file.tryReadAddressguards against a missing file viavm.existsbut not against parse errors —parseJsonAddresswill panic before any_requireSetguard runs. - Impact: Corrupt-file recovery produces a Foundry-internal panic instead of a friendly diagnostic.
- Recommendation: Wrap
parseJsonAddressin try/catch, returningaddress(0)with a console warning on failure (consistent with the missing-file path), or document the recovery step (delete + re-run) prominently in RUNBOOK.
- Severity: HIGH
- Status: FIXED —
_checkDeployerRenouncednow accepts an explicitdeployerparameter; the caller inrun()reads it fromDEPLOYERenv via the new_envAddressOrZerohelper and revertsDeployerEnvMissingwhen the var is absent andMULTISIGis set. Verification withMULTISIGset now requires the operator to supply the deployer address (or skip the renounce assertion by leavingMULTISIGunset). New tests:test_CheckDeployerRenounced_RevertsWhenDeployerStillHoldsRole(asserts the typedRoleNotRenouncedrevert when the deployer still holds the role) andtest_CheckDeployerRenounced_PassesAfterRenounce(asserts the happy path). - Location:
script/08_PostDeployVerify.s.sol:213-227,Makefile:108-114 - Description: The renounce assertion read
msg.senderdirectly.verify-eth/verify-bscinvokeforge scriptview-only with no--sender/--account/--private-key, somsg.senderis Foundry's default sender (0x1804c8AB…) — an address that has never heldDEFAULT_ADMIN_ROLE. The branchwon.hasRole(adminRole, msg.sender)therefore always evaluatesfalseand theRoleNotRenouncedrevert is unreachable; the only post-deploy programmatic check that the deployer EOA has actually renounced was silently a no-op. - Impact: A non-renounced deployer state would NOT be caught by
make verify-*despite the runbook treating it as load-bearing. - Recommendation: Thread the deployer address through as a parameter and require an env var (
DEPLOYER) whenMULTISIGis set so the renounce check actually validates what its name promises.
- Severity: LOW
- Status: FIXED — both
remotePoolBytesandremoteTokenBytesare length-checked (== 32) beforeabi.decode. A non-32-byte value now surfaces as a typedMalformedRemoteEncoding(selector, field, actualLength)instead of a low-level Foundry panic. Mirrors the encoding-assumption check already documented for the CCIP-6 stale-wiring path in script 05. - Location:
script/08_PostDeployVerify.s.sol:128-141 - Description:
TokenPool.setRemotePoolaccepts rawbyteswith no encoding constraint. A non-32-byte stored value would panic atabi.decode(remotePoolBytes, (address))with a generic ABI error rather than producing a typed diagnostic. - Impact: Diagnostic only; CCIP message validation would still revert at use-time. The friendlier error helps operators debug an unusual manual wiring.
- Recommendation: Assert
remotePoolBytes.length == 32(and likewise forremoteTokenBytes) before decoding, with a human-readable revert.
- Severity: LOW
- Status: FIXED —
UpdateRateLimits.run()now revertsRemoteChainNotWired(selector)before any broadcast if the local pool does not list the remote selector as supported. Mirrors the preflight posture of script 05 and script 08. - Location:
script/07_UpdateRateLimits.s.sol:25-35 - Description:
setChainRateLimiterConfigon an unwired remote selector would burn a broadcast tx with a generic deep-revert. Scripts 05 and 08 both preflightisSupportedChain; script 07 didn't. - Impact: Wasted gas + unclear diagnostic; no correctness risk.
- Recommendation:
require(TokenPool(localPool).isSupportedChain(remoteSelector), "remote chain not wired yet; run script 05 first");beforevm.startBroadcast().
- Severity: MEDIUM (sibling to DEP-8 HIGH)
- Status: FIXED — script 01 now writes the deployer EOA to
deployments/<chainId>.jsonunder adeployerkey; script 08's_checkDeployerRenouncedcross-validates the operator-suppliedDEPLOYERenv against the recorded value and revertsDeployerAddressMismatch(envSupplied, recorded)if they differ. Falls open when the JSON predates this fix (nodeployerkey recorded) so legacy deployments aren't blocked. - Location:
script/01_DeployWrappedON.s.sol:39-44,script/08_PostDeployVerify.s.sol:104-110 - Description: DEP-8 replaced
msg.senderwithvm.envOr("DEPLOYER", …), butwon.hasRole(adminRole, deployer)reverts only if the supplied address still holds the role. A typo or unrelated address trivially returns false → the renounce assertion passed for the wrong subject. - Impact: Without DEP-11 a typo'd verify would happily print
[ok] renouncedwhile the real deployer still held the role. - Recommendation: Record + cross-validate the deployer at script 01 / script 08.
- Severity: LOW
- Status: FIXED — script 08 now reads
MULTISIGviavm.envOr(…, address(0))and, when zero, distinguishes "literal0x000…0" (typedMultisigIsZeroAddressrevert) from "unset" (log + continue) via an explicittry vm.envAddress(MULTISIG)probe. - Location:
script/08_PostDeployVerify.s.sol:80-97 - Description: Before the fix, the try/catch around
vm.envAddresscaught only "unset"; an explicitMULTISIG=0x0succeeded, then theif (multisig != address(0))guard skipped both_checkOwnershipHandoffand_checkDeployerRenounced.All checks passed.printed regardless. - Impact: Silent-skip of the most important post-handoff assertions.
- Recommendation: Surface the typo with a typed revert; explicit non-zero check.
- Severity: LOW
- Status: FIXED —
Deployments.jsonIsValid(chainId)probes the file for syntactic JSON validity (true for missing file, true for valid JSON with absent key, false for malformed JSON). Scripts 01 / 02 refuse to broadcast on a corrupt-JSON state with a typedDeploymentsJsonCorrupt(chainId, key)revert. The "delete a single key to force redeploy" recovery path is preserved (the JSON stays syntactically valid; only the key is missing). - Location:
script/Deployments.sol:23-39,script/01_DeployWrappedON.s.sol:25-35,script/02_DeployPools.s.sol:29-40 - Description: DEP-7's
tryReadAddressreturnedaddress(0)for missing-file AND corrupt-file. Scripts 01 / 02 used that zero as "not deployed → broadcast" — so a partially-written JSON would silently re-deploy. - Impact: Wasted broadcast + a possibly-orphaned previous on-chain artefact.
- Recommendation:
jsonIsValidprobe before broadcasting.
- Severity: INFO
- Status: DESIGN ACK — the
regAdmin != broadcaster && regPool == poolbranch is an abnormal state (registry shows a non-broadcaster admin yet the pool is already wired). Skipping silently would mask the question "who registered this?" — the operator should investigate before treating the deployment as healthy. The existing_registerAdminrevert when re-running is the right surface for "this isn't what you think it is". - Location:
script/04_RegisterAdminAndPool.s.sol:82-106 - Description: A prior run on a different EOA leaves the registry wired correctly but
regAdmin != broadcaster. Re-running broadcasts_registerAdminwhich reverts. - Impact: Spurious failed broadcast under an abnormal state — but the abnormality is itself worth surfacing.
- Recommendation: Accept current behaviour; document the recovery path in RUNBOOK.
- Severity: LOW
- Status: FIXED —
_checkRateLimitsnow routes disabled-bucket logging through_logBucket(direction, bucket), which emits[warn] %s rate limit DISABLED (STRICT_RATE_LIMITS=false)for the disabled-bucket case under non-strict mode. Operator-facing output now matches the NatSpec promise. - Location:
script/08_PostDeployVerify.s.sol:151-168 - Description: The non-strict path early-returned from
_assertConfiguredOrWarnand then unconditionally logged[ok] cap=0 rate=0— contradicting the "downgrades to a warning" NatSpec. - Impact: Operator might read the
[ok]line and miss that rate limits are off. - Recommendation: Gate
[ok]onisEnabledand emit[warn]otherwise.
- Severity: INFO
- Status: DOC ADDED — Foundry's
vm.envOr(string, bool)accepts onlytrue/false(case-insensitive). Documented in.env.example(the same constraint applies toOUTBOUND_ENABLED/INBOUND_ENABLEDdriven by script 07). Wrapping in a string-parse normaliser was considered and rejected — it would mask "operator typedSTRICT_RATE_LIMITS=yesthinking it would work" rather than failing fast on the typo. - Location:
script/08_PostDeployVerify.s.sol:153,.env.example - Description: A
0/1/yes/novalue would revert deep inside the cheatcode with Foundry's native parse error. - Impact: Operator UX only.
- Recommendation: Document the constraint.
- Severity: LOW
- Status: FIXED — replaced the try/catch helper with
vm.envOr("DEPLOYER", address(0)), which returns zero only for unset. A truncated-hex value now bubbles up with Foundry's native parse error instead of being masked asDeployerEnvMissing. - Location:
script/08_PostDeployVerify.s.sol:101-107 - Description: A malformed
DEPLOYERvalue (e.g.0x123) returned zero through the catch arm, then surfaced the wrong diagnostic. - Impact: Operator diagnostic only.
- Recommendation: Use
envOrand let malformed values bubble up.
- Severity: LOW
- Status: FIXED —
getToken()(and the DEP-22 newgetRouter()/getRmnProxy()/typeAndVersion()checks) are each wrapped in try/catch so a non-pool contract atdeployments/<chainId>.json::poolsurfaces a typedPoolGetTokenCallFailed,PoolMisidentified, orPoolTypeMismatchrevert instead of an empty EVM revert. - Location:
script/03_GrantRoles.s.sol:36-86 - Description: A stale or hand-edited JSON pointing at a non-pool address would revert with no selector and no human-readable reason. Every other failure mode in the script surfaced as a custom error.
- Impact: Operator diagnostic only.
- Recommendation: Try/catch around the staticcalls.
- Severity: LOW
- Status: FIXED — replaced both
requirestrings with typedBscRebalancerReadFailed(pool)/PoolOwnerReadFailed(pool)errors. Brings these helpers in line with the rest of the script's error vocabulary and letsvm.expectRevert(selector)work in tests (TEST-20). - Location:
script/08_PostDeployVerify.s.sol:194-209, 220-227 - Description: Slither's
prefer-custom-errorsdetector would flag these. Tests had to rely on string-matching. - Impact: Style / test ergonomics.
- Recommendation: Typed errors.
- Severity: MEDIUM
- Status: FIXED —
_handoffnow staticcallsITokenPoolReadToken(pool).getToken()(try/catch) and revertsPoolTokenMismatch(pool, poolToken, expectedToken)if the pool isn't bound to the expected token. Mirrors the CCIP-4 check in script 03. Applied to both ETH and BSC sides; the BSC side is the higher-stakes leg because the pool owns the locked-ON reserve viasetRebalancer/withdrawLiquidity. - Location:
script/06_TransferOwnership.s.sol:96-104 - Description: A tampered
deployments/<chainId>.json::poolcould redirect ownership of a custody-grade BSC pool. The ETH-side CCIP-4 check protected role grants but not the ownership handoff. - Impact: Filesystem-tamper required for exploit; if it lands, full custody of the BSC reserve transfers to an attacker-controlled pool.
- Recommendation: Symmetric check on the handoff path.
- Severity: INFO
- Status: DESIGN ACK — script 05 is the wiring step (
applyChainUpdates); script 07 is the explicit rate-limit-changes step (setChainRateLimiterConfig). TheisSupportedChain == trueskip branch deliberately does NOT touch rate-limit state, with an explicit log line directing operators tomake update-limits. A revert on drift would conflate two distinct operator workflows. Documented behaviour matches script semantics. - Location:
script/05_ApplyChainUpdates.s.sol:53-72 - Description: An operator editing
DEFAULT_CAPACITY/DEFAULT_RATEbetween deploy runs would not see the new values applied by a re-run ofmake deploy-eth. - Impact: Operator-workflow surprise only.
- Recommendation: Accept; the explicit
make update-limitsstep is the right place for rate-limit changes.
- Severity: LOW (filesystem-tamper required; impact high if hit)
- Status: FIXED — script 03 now cross-checks the pool TYPE (
typeAndVersion()begins with"BurnMintTokenPool "— see DEP-27 for why this is a prefix match, not the exact"BurnMintTokenPool 1.6.1"),getRouter() == cfg.router, ANDgetRmnProxy() == cfg.rmnProxyin addition to the existinggetToken() == wonAddr. Each check is individually forgeable by a custom mock, but the combined surface raises the cost of a deployments JSON tamper from "write a 30-lineFakePool { getToken() returns wON; … }" to "match four pool-identity surfaces simultaneously, including the cfg-bound router and RMN addresses". - Location:
script/03_GrantRoles.s.sol:36-86 - Description: A filesystem-write-attack between script 02 broadcast and script 03 read could install a
FakePool { getToken() returns wON; drain() { wON.mint(attacker, 100M); } }and harvestMINTER_ROLE/BURNER_ROLE. The CCIP-4 check was a real defence but raised the forgery cost by a constant; DEP-22 raises it meaningfully. - Impact: Up to 100M wON mint authority granted to a forged pool if all four checks are passed.
- Recommendation: Multi-surface identity probe.
- Severity: MEDIUM
- Status: FIXED —
08_PostDeployVerifynow verifies the full UUPS/timelock model._checkUpgradeAuthority(ETH, always-on) assertsUPGRADER_ROLEis held by the deployedTimelockController, is self-administered (getRoleAdmin(UPGRADER_ROLE) == UPGRADER_ROLE— the UPG-1 mitigation #3 bypass-closure), thatPAUSER_ROLEkeeps itsDEFAULT_ADMIN_ROLEadmin, and that the timelockminDelaymatches the deploy-time value (TIMELOCK_DELAY, default 48h)._checkDeployerRenouncedis extended to assertPAUSER_ROLEmoved to the multisig + was renounced by the deployer, and that the deployer never holdsUPGRADER_ROLE._checkTimelockHandoffasserts the multisig holds PROPOSER/EXECUTOR/CANCELLER and the deployer renounced those plus the setupDEFAULT_ADMIN_ROLE(DEP-24). Tests:test_CheckUpgradeAuthority_*,test_CheckTimelockHandoff_*,test_CheckDeployerRenounced_*(Script08Verify.t.sol). - Location:
script/08_PostDeployVerify.s.sol. - Description: Script 08 was written for the pre-upgradeable model and only verified pool wiring,
MINTER/BURNER, and theDEFAULT_ADMIN/ccipAdminrenounce. After wON became UUPS-upgradeable, the entire upgrade-authority surface (timelock holdingUPGRADER_ROLE, the self-admin,PAUSER/timelock-role handoff,minDelay) went unverified —make verify-ethwould print "All checks passed" even if a misconfiguration left the 48h timelock bypassable. - Impact: A custody-grade misconfiguration (e.g.
UPGRADER_ROLEnot self-administered, orPAUSERnot handed off) could ship undetected. - Recommendation: (Implemented) verify the upgrade-authority wiring in script 08.
- Severity: HIGH (deployment-blocking)
- Status: FIXED — script 01 now deploys the
TimelockControllerwithadmin = deployer(the OZ setup-admin pattern) instead ofaddress(0), so the deployer holds the timelock'sDEFAULT_ADMIN_ROLEand can grant PROPOSER/EXECUTOR/CANCELLER to the multisig during the script 06_handoff.RenounceDeployerAdminthen renounces the deployer's SETUP-ONLY timelockDEFAULT_ADMIN_ROLE(after the operational-role handoff), leaving the timelock fully self-administered. Tests:Script06TimelockHandoffTest.test_HandoffAndRenounceSucceeds(fixed flow) +test_OldSelfAdministeredDeploy_HandoffReverts(locks the bug); the masking fixture inScript06Renounce.t.solis corrected to useadmin = deployer. - Location:
script/01_DeployWrappedON.s.sol,script/06_TransferOwnership.s.sol. - Description: Script 01 deployed the timelock with
admin = address(0), so only the timelock itself heldDEFAULT_ADMIN_ROLE. Script 06's_handoffcallstl.grantRole(PROPOSER_ROLE, multisig)as the deployer, which is admin'd byDEFAULT_ADMIN_ROLE— so the call revertsAccessControlUnauthorizedAccount(deployer, 0x00). The handoff (make handoff-all) would revert on the ETH side. The bug was masked byScript06Renounce.t.soldeploying the fixture timelock withadmin = address(this), and by no test exercising the actual_handoffgrant. - Impact: The multisig handoff could not complete — the bridge could be deployed but never handed off to the ops multisig (deployer EOA stuck as the sole proposer/executor).
- Recommendation: (Implemented) deploy the timelock with the deployer as setup-admin; renounce it after the operational-role handoff.
DEP-25: post-deploy verification (script 08) did not check the TokenAdminRegistry admin-role handoff
- Severity: LOW
- Status: FIXED —
08_PostDeployVerifynow calls_checkRegistryAdminHandoff(registry, token, multisig)in theMULTISIG-set branch on both chains. It readsgetTokenConfig(token)and asserts the activeadministrator == multisig(revertsRegistryAdminNotHandedOff) andpendingAdministrator == address(0)(revertsRegistryAdminTransferPending). Tests:test_CheckRegistryAdmin_*(Script08Verify.t.sol). - Location:
script/08_PostDeployVerify.s.sol. - Description: The always-on
_checkRegistryonly assertedTokenAdminRegistry.getPool(token) == pool— never the registryadministrator. Somake verify-eth/bsc MULTISIG=..printed "All checks passed" while the registry admin role was still pending-acceptance by the multisig (or, iftransferAdminRolewas never broadcast, still held by the deployer). On the ETH legRenounceDeployerAdmin._assertReadyToRenounceblocks the renounce on_registryAdministrator == multisig, partly compensating; but verify is run/relied on independently of renounce, and the BSC leg has no renounce step at all, so the gap was real there. - Impact: A false-green on a half-handed-off registry. The registry administrator can re-point the token's pool via
setPool— a custody-relevant authority. - Recommendation: (Implemented) assert the registry administrator and pending state in script 08, symmetric with the script-06 renounce precondition.
DEP-26: Script 06 BSC handoff did not assert rebalancer == address(0) before transferring pool ownership
- Severity: LOW
- Status: FIXED —
TransferOwnership._handoffnow calls_assertPoolHasNoRebalancer(pool)on the BSC leg (chainid ∉ {1, 11155111}) beforevm.startBroadcast(). It staticcallsgetRebalancer()and revertsUnexpectedRebalanceron a non-zero slot orRebalancerReadFailedon a malformed read. Tests:Script06RebalancerTest(Script06Rebalancer.t.sol). - Location:
script/06_TransferOwnership.s.sol. - Description: The BSC
_handoffdid the DEP-20getToken()cross-check but never readgetRebalancer()before transferring custody-gradeLockReleaseTokenPoolownership. If a rebalancer were set (accidentally or maliciously) before handoff, the multisig would inherit a pool whose locked-ON reserve is already drainable viawithdrawLiquidity. Compensated only by script 08's always-on_checkBscRebalancer— and only if the operator actually runsmake verify-bscafter handoff and before trusting it. Defense-in-depth at the handoff broadcast itself was absent. Sibling to CCIP-1 / DEP-3 (which cover the verify-side and the renounce reminder, not the handoff-time assertion). - Impact: Custody-grade if a non-zero rebalancer were present at handoff time and went unnoticed.
- Recommendation: (Implemented) assert
getRebalancer() == address(0)on the BSC leg of_handoff.
- Severity: LOW (fail-closed; maintenance/operational)
- Status: FIXED — script 03's pool-type check matches the
"BurnMintTokenPool "TYPE prefix (_isExpectedPoolType→Helper._startsWith) instead of the exactkeccak256("BurnMintTokenPool 1.6.1")._startsWithis now shared onHelper(also used byValidateConfig, whose local copy was removed). Tests:Script03GrantRolesTest(Script03GrantRoles.t.sol) — accepts1.6.1/1.6.2/2.0.0, rejectsLockReleaseTokenPool …, the empty string, a bare unversioned name, and aBurnMintTokenPoolEvilimpostor. - Location:
script/03_GrantRoles.s.sol,script/Helper.sol,script/ValidateConfig.s.sol. - Description: The exact-string keccak check would revert a perfectly legitimate pool on any CCIP patch bump (e.g.
1.6.2), blockingmake deploy-ethuntil the literal was hand-edited — silent breakage on a submodule update. The submodule pin already controls the deployed version, so this guard's job is TYPE identity (reject a LockReleaseTokenPool / non-pool), not version pinning. This matches the prefix-matching approachValidateConfigalready uses for the Router/Registry/RMN identity checks. The trailing space in the prefix ensures a version suffix must follow, so a bare-name or differently-named impostor is still rejected. The check remains defense-in-depth (the type string is forgeable, as DEP-22 notes); loosening from exact-version to type-prefix does not weaken the combined multi-surface probe. - Impact: Maintenance/operational only — fail-closed (a bumped pool reverts loudly rather than mis-granting).
- Recommendation: (Implemented) prefix-match the pool type; share
_startsWithonHelper.
CCIP-1: BSC pool withdrawLiquidity is rebalancer-gated, but setRebalancer is owner-set with no monitor
- Severity: HIGH
- Status: FIXED —
script/08_PostDeployVerify.s.solnow calls_checkBscRebalancer(pool)on every BSC verify run and revertsUnexpectedRebalancerif the slot is non-zero. The RUNBOOK monitoring table already pages onsetRebalancercalldata; the verify-time assertion catches an accidental set at deploy and on every operator re-verify. - Location:
lib/chainlink-ccip/chains/evm/contracts/pools/LockReleaseTokenPool.sol:53-102,script/02_DeployPools.s.sol:53 - Description: CCIP 1.6.1 has no
acceptLiquidityflag; with no rebalancer set,provideLiquidityandwithdrawLiquidityboth revertUnauthorized(they requiremsg.sender == s_rebalancer), andsetRebalancerisonlyOwner(no timelock, no cap, no in-flight-message guard). The pool owner can set the rebalancer to themselves and drain the reserve in a single multisig batch. Mid-flight BSC→ETH messages would commit on ETH (wON minted) but fail permanently on BSC release withInsufficientLiquidity. This is the documented CCT trust model and is correctly disclosed in CLAUDE.md/RUNBOOK — but no script-level or monitoring assertion exists ons_rebalancer. - Impact: The custodial risk is accepted by design. The gap is that an accidental or malicious
setRebalanceris not detected promptly. - Recommendation: Add
_checkRebalancer(localPool)toscript/08_PostDeployVerify.s.solassertingLockReleaseTokenPool(pool).getRebalancer() == address(0). Add the same assertion to the RUNBOOK monitoring checklist as a recurring check.
- Severity: MEDIUM
- Status: RESOLVED (2026-06-23) — issue #61. Script 05 (and the reconcile script 09) now ship directional defaults instead of symmetric ones: inbound 100,000 ON / 10 ON/sec, outbound 80,000 ON / 8 ON/sec on both pools. This follows Chainlink's published guidance — Token Pool Rate Limits are strongly recommended on all lanes, inbound/outbound are configured separately, and "outbound limits are often configured to be slightly lower than inbound limits to provide buffer room and reduce the risk of in-flight congestion" (how-rate-limits-work, overview). Chainlink prescribes no specific numbers; these are a deliberate, documented launch default. Still required before mainnet broadcast: finalize the BSC-inbound (ETH→BSC release) capacity/rate against the actual seeded BSC reserve liquidity (the binding M2 constraint) — script 05 NatSpec carries this
>>> BEFORE MAINNET BROADCASTreminder. - External audit: QuillAudits Wrapped ON Initial Audit Report — Finding M2 (Medium, Likelihood High; "ETH→BSC burns can proceed without local proof of BSC release liquidity"), tracked as issue #24, maps here. M2 has no on-chain fix on the Ethereum side — an ETH burn cannot synchronously read BSC liquidity (inherent to the CCT architecture). Remediation is operational and now documented: (1) size the ETH→BSC inbound rate-limit to BSC releasable liquidity minus a buffer (this finding); (2) a §3 monitoring alert on
BSC_ON.balanceOf(BSC pool)vs that capacity; (3) the stuck-message recovery procedure in RUNBOOK §4.5 (replenish via direct ON transfer sinceprovideLiquidityis disabled, then CCIP manual execution). The burned-on-source value is delayed, not lost —lockedON_BSC + reserveON_ETH >= totalSupply(wON)is preserved. - Location:
script/05_ApplyChainUpdates.s.sol(INBOUND_*/OUTBOUND_*constants),script/09_ReconcileRemotePool.s.sol. - Description: Previously both pools shared a single
DEFAULT_CAPACITY = 100_000 ether/DEFAULT_RATE = 10 ether/secin both directions. The bridge is asymmetric (ETH has the hardMAX_CCIP_MINTED = 100Mcap; BSC has no equivalent wON-side cap). Bucket-asymmetry between independent pool clocks means queued messages can arrive after a bucket has been redrained. - Impact: No direct exploit. Possible DoS / unexpected user-facing reverts during burst+queue scenarios.
- Recommendation: (Implemented) ship directional defaults (outbound < inbound per Chainlink) and finalize BSC-inbound against seeded liquidity before broadcast; per-direction tuning post-launch via
script/07_UpdateRateLimits.s.sol.
- Severity: LOW
- Status: DOC ADDED — script 05 NatSpec spells out the ~2.8h-from-zero refill window so operators decide consciously before mainnet rather than inheriting it implicitly. The #61 directional update keeps the same ~2.8h ratio in both directions (inbound 100k:10/s, outbound 80k:8/s) — the capacity:rate ratio is deliberately preserved; tighten it before broadcast if a shorter refill window is wanted.
- Location:
script/05_ApplyChainUpdates.s.sol(INBOUND_*/OUTBOUND_*constants),src/WrappedON.sol:78-80 - Description:
100_000 ethercapacity against10 ether/secrate = 10,000 s to refill (~2.78 h);80_000 : 8/secoutbound is the same ratio. A single bridging tx can saturate the bucket and block all other users until refill. - Impact: Temporary DoS, resolvable by waiting or by operator-adjusted rate.
- Recommendation: Consider sizing capacity to a smaller multiple of rate (e.g. 100-second refill window) and document the chosen ratio as an explicit decision in RUNBOOK. (#61 set directional defaults; the ratio decision remains open for broadcast-time tuning.)
- Severity: MEDIUM
- Status: FIXED —
script/03_GrantRoles.s.solnow staticcallsTokenPool(pool).getToken()and revertsPoolTokenMismatchif it doesn't equal the deployed wON before granting any roles. A tampered JSON pointing at a non-pool contract or a pool wired to a different token now fails fast instead of leaking mint authority. - Location:
script/04_RegisterAdminAndPool.s.sol:61-69,script/03_GrantRoles.s.sol - Description: Roles are granted to the address stored in
deployments/<chainId>.jsonwith no on-chain check that the address is a real CCIP pool wired to the right token. A hand-edited or supply-chain-tampered JSON could redirectMINTER_ROLEto an attacker contract, enabling minting up to 100M wON. - Impact: Up to 100M wON mint authority can be granted to a wrong address if the JSON file is compromised before
make deploy-ethruns. - Recommendation: In
script/03_GrantRoles.s.sol, assertTokenPool(pool).getToken() == address(wON)before granting roles. This is a single staticcall that cannot be forged without deploying a matching contract.
- Severity: INFO
- Status: FALSE POSITIVE — investigated; all four selectors match the canonical CCIP directory. Record retained so the slot is reserved.
- Location:
script/Helper.sol:29-32 - Description: Reviewed against the canonical Chainlink CCIP directory: ETH Mainnet
5009297550715157269, BSC Mainnet11344663589394136015, Sepolia16015286601757825753, BSC Testnet13264668187771770619— all correct. - Impact: None.
- Recommendation: None.
- Severity: LOW
- Status: FALSE POSITIVE — re-reading
script/05_ApplyChainUpdates.s.sol:47-50, the comment block immediately above thebytes32cast already states: "abi.encode(address)produces exactly 32 bytes (left-padded), and the pool'sgetRemotePoolsreturns the same shape. Compare directly as bytes32 rather than hashing both sides — same intent, cheaper, and clearer about the assumed shape." The encoding assumption is documented and the assertionwiredRemote.length == 32 && bytes32(wiredRemote) == bytes32(uint256(uint160(remotePool)))already explicitly checks the length, so a non-32-byte encoding would fail therequire, not silently pass it. - Location:
script/05_ApplyChainUpdates.s.sol:51-53 - Description:
wiredRemoteis cast tobytes32and compared. Correct forabi.encode(address)(32-byte left-padded).TokenPool.setRemotePooltakes rawbyteswith no encoding requirement, so a directly-set non-32-byte encoding bypasses the check. Real CCIP messages would still revert at validation time, so this is a diagnostic-only issue. - Impact: Silent skip of stale-detection under non-standard manual wiring.
- Recommendation: Add a code comment documenting the encoding assumption; assert
wiredRemote.length == 32explicitly.
- Severity: MEDIUM
- Status: DOC ADDED —
WrappedON.solNatSpec now contains a dedicated CAP REPLENISHMENT paragraph spelling out the cycling-refills-headroom behaviour, the preserved safety invariant, and the operational consequence (monitorccipMintHeadroomUsedrelative to current BSC locked balance, not relative toMAX_CCIP_MINTED). The monotone-counter alternative is left as a redeploy option if a true lifetime CCIP-mint bound becomes a requirement. M1 / #23: the counter was renamedccipMintedSupply→ccipMintHeadroomUsedto make the "headroom used, not BSC-minted-supply" semantics explicit at the identifier level (see WON-3). - External audit: Maps to QuillAudits Wrapped ON Initial Audit Report — Finding I4 (Informational, reviewed at
b9de6da), tracked as repo issue #26. Closed by a doc-consistency sweep ofREADME.md,RUNBOOK.md, anddocs/ARCHITECTURE.mdconfirming no wording presentsMAX_CCIP_MINTEDas atotalSupply()ceiling. Canonical phrasing: MAX_CCIP_MINTED caps the local CCIP mint counter, not aggregate wON supply. - Clarification (the exact invariant vs. the local counter): The genuine cross-chain invariant is enforced by CCIP, not by this contract: every CCIP message pairs one BSC
lock/releasewith one Ethereummint/burn, so the ON locked on BSC via CCIP equals the wON minted on Ethereum via CCIP, message-for-message. Ethereum cannot read the BSC pool's balance, so that equality rests on a Chainlink trust assumption (the DON + RMN delivering each message once and honouring the pairing) —mintfires when the trusted off-ramp callsreleaseOrMintand cannot verify the matching BSC lock itself.ccipMintHeadroomUsedis only a local proxy that exists because the real figure is off-chain: it saturates at 0 (hence WON-3) and reflects neither operator-seeded rebalancer liquidity nor any live cross-chain read. Do not conflate the exact protocol pairing with the approximate local counter. - Location:
src/WrappedON.sol:345-349(saturating decrement) - Description: wON is fungible. A user can
depositnative ETH-side ON (deposit-backed wON,ccipMintHeadroomUseduntouched), then bridge that wON to BSC (burnsaturating-decrementsccipMintHeadroomUsedtoward zero). After cycling, the 100M cap is fully replenished, even though no CCIP-minted supply was burned. In the extreme: 100M deposit → 100M bridge-out → counter resets to 0 → another 100M CCIP-mint available. The safety invariantlockedON_BSC + reserveON_ETH >= totalSupply(wON)still holds, but the cap's intent (bounding damage from a compromised pool) is weakened: compromised-pool damage is bounded by currentccipMintHeadroomUsedheadroom, which may exceed 100M cumulatively over time. - Impact: The cap is an approximation, not a hard CCIP-mint lifetime ceiling. The contract NatSpec already states this; the SECURITY-relevant point is that monitoring should not assume cap-fraction-used equals risk-fraction-used.
- Recommendation: Document the cycling scenario explicitly in
WrappedON.solNatSpec. If a true lifetime CCIP-mint bound is desired, use a monotone non-decrementing counter (with a parallel cap higher than 100M to permit honest cycling).
- Severity: LOW
- Status: FALSE POSITIVE —
RUNBOOK.md §4.2 "Responding to an RMN curse"already documents the operator-side response: confirm the curse via the CCIP Explorer, coordinate with Chainlink ops, transfers resume automatically once uncursed. The finding was missed by the reviewer's scan. - Location:
lib/chainlink-ccip/chains/evm/contracts/pools/TokenPool.sol:283,302 - Description: A curse on the BSC selector blocks both
lockOrBurnandreleaseOrMint. Users cannot exit in either direction. RUNBOOK has no documented response procedure. - Impact: During an RMN curse, all bridging stops. Funds are not at risk; user UX is.
- Recommendation: Add a "RMN Curse Response" section to RUNBOOK: detection (monitor
CursedByRMNreverts or queryIRMN.isCursed), authority (Chainlink), user communication.
- Severity: MEDIUM
- Status: FALSE POSITIVE — same architecture argument as WON-8. The BSC ON token at
0x0e4F6209eD984b21EDEA43acE6e09559eD051D48is a non-upgradeable ERC20 withdecimals() == 18already deployed and immutable;Helper.getConfig(56)hardcodes this address. A future redeploy against a token with differentdecimals()would also require updatingHelper.sol, which is a deliberate code change reviewed under normal change control rather than a runtime risk. The ETH-side equality check in the wON constructor remains the architectural enforcement point — a redeploy that ignored both would already have to bypass Helper's hardcoded mainnet addresses. - Location:
script/02_DeployPools.s.sol:53,script/08_PostDeployVerify.s.sol(BSC branch) - Description: WON-8's check guards the ETH side. No script asserts that the BSC ON token returns 18. A future redeploy against a token with different
decimals()would silently misalign cross-chain accounting (no revert — wrong nominal amount delivered). - Impact: None under canonical-token + hardcoded-Helper constraint.
- Recommendation: No code change required. Deploy-time controls (hardcoded canonical addresses in Helper + immutable ON tokens on both chains) are sufficient.
- Severity: LOW
- Status: FIXED —
_checkRateLimitsreadsSTRICT_RATE_LIMITSfrom env (defaulttrue, preserving the previous "rate limits must be on at launch" posture). WithSTRICT_RATE_LIMITS=falsea disabled bucket downgrades fromRateLimitDisabledrevert to a no-op (the silently-brickedenabled-but-rate==0state still revertsRateLimitMisconfiguredregardless — that case is never a deliberate launch choice). New tests:test_NonStrictPassesOnDisabledBucketandtest_NonStrictStillRejectsZeroRate. - Location:
script/08_PostDeployVerify.s.sol:141-181 - Description: Script 07's preflight accepts
OUTBOUND_ENABLED=false(mirroring CCIP's_validateTokenBucketConfigexactly); script 08's_assertEnabledAndConfiguredreverted on!isEnabled. An operator deliberately running with rate-limits off — a documented but unusual launch decision — could not passmake verify-*. - Impact: Operator UX only — the bridge is intended to launch with rate-limits engaged, but the escape hatch should be reachable through the released toolchain.
- Recommendation: Add a
STRICT_RATE_LIMITSenv gate (default true), or document the interaction.
- Severity: INFO
- Status: FIXED — replaced with
bytes32(0)inscript/04_RegisterAdminAndPool.s.sol:142. Functionally identical (Solidity literal0x00widens tobytes32(0)when typed asbytes32), butbytes32(0)matches the RUNBOOK §0.2cast callexample and is harder to mis-read as a 1-byte value. - Location:
script/04_RegisterAdminAndPool.s.sol:142 - Description:
hasRole(0x00, broadcaster)andhasRole(bytes32(0), broadcaster)produce identical bytecode; the second form is clearer. - Impact: None.
- Recommendation: Replace with
bytes32(0).
- Severity: INFO
- Status: DOC ADDED — NatSpec on the
burn(uint256)overload now states:accountismsg.sender(the burning pool), not the token holder. The concurrent ERC20Transfer(from, 0, amount)hasfrom= the token holder; indexers correlating both events need to match againstTransfer.from. No on-chain behaviour change. - Location:
src/WrappedON.sol:237-247 - Description: Subscribing only to
CCIPBurnedand assumingaccountis the holder misreads the single-arg path. - Impact: Indexer correctness — documentation gap.
- Recommendation: NatSpec disclosure.
- Severity: MEDIUM (custody-grade, silent post-handoff)
- Status: FIXED — added
RouterUpdated(oldRouter, newRouter)(both pools) as Critical in the §3 trust-model monitoring table, with explicit rationale in the new "Rationale for the post-handoff additions" subsection. ThesetRouter(addr)function isonlyOwner; a compromised multisig can swaps_routerand route bothlockOrBurn(drains BSC reserve) andreleaseOrMint(mints unbacked wON up to cap) through attacker-controlled_onlyOnRamp/_onlyOffRamplookups. - Location:
RUNBOOK.md§3 (Trust-model monitoring table) - Description: Direct analog of CCIP-1 (
setRebalancer) for routing; was not in the live alert surface. - Impact: Bypass of CCIP message-validation guard rails if missed.
- Recommendation: Critical-severity row in the monitoring table.
- Severity: MEDIUM
- Status: FIXED — added all four to the monitoring table.
RemotePoolSetis Critical (alters the source-pool keccak used to validate inbound mints — opens forged-source-mint door).ChainAdded/ChainRemoved/ChainConfiguredare High (rate-limit reset / selector wiring changes). - Location:
RUNBOOK.md§3 (Trust-model monitoring table) - Description: Compromised owner can redirect source-pool validation or re-wire rate limits without surfacing on the alert path.
- Impact: Allows forged inbound mints or attacker-favourable rate-limit reconfigurations.
- Recommendation: Add to monitoring table.
- Severity: LOW
- Status: DOC ADDED + TESTED (2026-06-23) — issue #57. The ETH-inbound stuck-message mode is now named explicitly as the sibling of the BSC-side M2/CCIP-2 stuck-release. Recovery (CCIP manual re-execution once headroom drops or the contract is unpaused) is documented in RUNBOOK §0.2 (the
CCIPMintCapExceededincident-response block) and §4.6 (the pause in-flight-inbound block); a precursor monitoring alert onccipMintHeadroomUsedapproachingMAX_CCIP_MINTEDwas added to the §3 table. Tests now drive the revert THROUGHethPool.releaseOrMintfor both paths (previouslyCCIPMintCapExceeded/ the pause guard were only hit via directWrappedON.mintunit calls):test_BscToEth_ReleaseOrMintRevertsOnCapExceededandtest_BscToEth_ReleaseOrMintRevertsWhenPaused(PoolRoundtrip.t.sol) — each asserts nothing is minted, headroom is unchanged, and the inbound rate-limit consumption rolls back (message retryable); the pause test further asserts a post-unpauseretry of the same message succeeds. - Location:
src/WrappedON.sol(mintwhenNotPaused+CCIPMintCapExceeded);lib/chainlink-ccip/.../pools/BurnMintTokenPoolAbstract.sol(unconditionali_token.mint);lib/chainlink-ccip/.../pools/TokenPool.sol(releaseOrMint). - Description:
BurnMintTokenPoolAbstract._releaseOrMintcallsIBurnMintERC20(i_token).mintunconditionally. wON'smintadds two revert conditions the stock pool does not anticipate —CCIPMintCapExceeded(whenccipMintHeadroomUsed + amount > MAX_CCIP_MINTED) andwhenNotPaused. When either fires, the inbound BSC→ETH message reverts after the BSCLockReleaseTokenPoolhas already locked the user's ON. Funds are safe and the message is retryable/manually-executable; the inbound rate-limit consumption rolls back with the revert. This is the mirror of the BSC-side M2/CCIP-2 stuck-release, previously treated only implicitly (CCIP-7 covers the cap semantics; UPG-4 covers pause) rather than as a named ETH-inbound stuck-message mode. - Impact: The user's funds remain locked on BSC until
ccipMintHeadroomUseddrops (an ETH→BSC bridge-out saturating-decrements the counter) or the contract is unpaused, after which the message is manually re-executed. The cap isMAX_CCIP_MINTED = 100M(= full BSC supply), so honest flow can only hit it near total-bridge saturation — hence LOW. - Recommendation: (Implemented) name the mode here + in RUNBOOK, add the
ccipMintHeadroomUsed-approaching-cap precursor alert, and test the revert through the pool path for both cap and pause. Operators treat a live cap-revert as a deliberate fail-safe trip (RUNBOOK §0.2 — reconcile againstIERC20(ON).balanceOf(BSC pool)before re-executing), not a delivery bug to force through.
- Severity: HIGH
- Status: FIXED — every
vm.createFork/vm.createSelectForkcall now passes a pinned block (defaulting toETH=22_000_000,BSC=50_000_000), overridable viaETH_FORK_BLOCKandBSC_FORK_BLOCKenv vars. Defaults should be refreshed deliberately on any CCIP-side state change. - Location:
test/fork/Fork_ETH.t.sol:60,test/fork/Fork_BSC.t.sol:63,test/fork/Fork_Bridge.t.sol:74-75 - Description: All three fork tests call
vm.createSelectFork(rpc)/vm.createFork(rpc)with no block number, so each run forks the live chain tip. On-ramp / off-ramp addresses are resolved dynamically viagetOffRamps/getOnRamp. A CCIP router upgrade can silently change ramp resolution and break tests with no code change here. - Impact: CI non-determinism; mainnet upgrades can mask or fabricate regressions.
- Recommendation: Pass a fixed
blockNumberto eachcreateFork/createSelectFork. Document the chosen block and update it deliberately.
- Severity: HIGH
- Status: FIXED —
foundry.tomlnow has an explicit[invariant]table:runs=256, depth=50, fail_on_revert=true, plus a[profile.ci.invariant]override atruns=500, depth=100. The handler's early-exit guards meanfail_on_revert=trueis safe. The full invariant suite still passes (4/4). - Location:
foundry.toml(general),test/WrappedONInvariant.t.sol - Description:
[profile.ci]overridesfuzz.runs = 1000but sets nothing for invariants. Foundry defaults toruns = 256, depth = 15, fail_on_revert = false. Depth 15 is far too shallow for the five-action handler to reach interestingadversarialPoolBurn → ccipMint → adversarialPoolBurninterleavings.fail_on_revert = falsesilently swallows handler reverts (broken handlers degrade coverage with no signal). - Impact: Saturating-decrement regressions, multi-step invariant violations, and broken handler logic are all under-detected.
- Recommendation: Add
[invariant](or[profile.ci.invariant]):runs = 500..1000, depth = 50..100, fail_on_revert = true. Existing handlers already early-exit on zero balance, sofail_on_revert = trueshould not introduce noise.
- Severity: MEDIUM
- Status: FIXED — typed selectors / payload assertions across all 7 call sites:
TokenPool.CallerIsNotARampOnRouter,RateLimiter.TokenRateLimitReached(viaexpectPartialRevert),TokenPool.Unauthorized(viaexpectPartialRevert), the CCIP ConfirmedOwnerbytes("Only callable by owner")literal,IERC20Errors.ERC20InsufficientBalance/ERC20InsufficientAllowance,RegisterAdminAndPool.CannotResolveCCIPAdmin.expectPartialRevertmatches the 4-byte selector when the args are time- or caller-dependent. - Location:
test/PoolRoundtrip.t.sol:246,:305;test/DeploymentE2E.t.sol:366,:423;test/WrappedON.t.sol:128,:231;test/Script04Paths.t.sol:176 - Description: Nine calls to bare
vm.expectRevert()accept any revert reason. The comments name the intended selector (TokenRateLimitReached,ERC20InsufficientBalance,CannotResolveCCIPAdmin,OwnableUnauthorizedAccount) but assertions don't check it. A refactor that reverts for a different reason still passes — most critically thetest_OnlyOnRampCanLockaccess-control check. - Impact: Access-control or gating regressions silently keep tests green.
- Recommendation: Use
vm.expectRevert(abi.encodeWithSelector(...))or at minimumvm.expectRevert(bytes4(...))everywhere. OZ 5.x typed errors expose selectors directly.
- Severity: MEDIUM
- Status: FIXED — new
testFuzz_RateLimitRefillMath(uint128 drainAmt, uint40 elapsedSeconds)drains by a fuzzed amount, warps a fuzzed time, then assertsbucket.tokensexactly equalscapacity - drainAmt + elapsed * rateclamped tocapacity. - Location:
test/PoolRoundtrip.t.sol:278-312 - Description: Drain-to-zero + warp-1-second + send-1-ether is a point check, not a fuzz. Partial-drain followed by various elapsed times — exactly the regime where token-bucket arithmetic bugs surface — is not exercised.
- Impact: Off-by-one / rounding bugs in
tokens += elapsed * ratecapped atcapacitywould slip through. - Recommendation: Add
testFuzz_RateLimitRefill(uint128 drainAmt, uint40 elapsedSeconds)boundingdrainAmt ∈ [1, capacity]andelapsedSeconds ∈ [0, 10_000], assertingavailable == min(drainAmt, elapsed * rate).
TEST-5: test_E2E_RenounceBeforeMultisigAcceptIsBlocked does not actually exercise the renounce script
- Severity: MEDIUM
- Status: DESIGN ACK — the script's actual revert path IS exercised by
test/Script06Renounce.t.sol:test_RevertsWhenCcipAdminNotAccepted(referenced intest_E2E_RenounceBeforeMultisigAcceptIsBlocked's NatSpec). This test covers the precondition logic inline; the script-level revert is covered separately. Renaming or adding avm.expectRevertcall here would be redundant. - Location:
test/DeploymentE2E.t.sol:440-476 - Description: The test only asserts the in-script precondition bool
ccipAdminReadyisfalse— it never callsRenounceDeployerAdmin.run()or_assertReadyToRenounce. The "block" is a pure boolean evaluation; novm.expectRevert. - Impact: A regression in
_assertReadyToRenouncewould not be caught by this test (it is independently covered inScript06Renounce.t.sol, but the claim in this file is overstated). - Recommendation: Either call the script and
expectRevert, or add a clear cross-reference toScript06Renounce.t.sol.
- Severity: MEDIUM
- Status: FIXED — added
ccipBurnFrom(approves pool, thenburnFrom) andccipBurnAddress(pool callsburn(address,uint256)) as handler actions; both included intargetSelector. Every burn overload's_decrementCcipMintHeadroombranch is now fuzzer-reachable. - Location:
test/WrappedONInvariant.t.sol:113-132,152-166 - Description: Both invariant burn actions call the single-argument overload. The other two overloads each independently call
_decrementCcipMintHeadroom. Multi-step sequences involving them (including allowance interactions) are invisible to the property-based engine. - Impact: Path-specific saturating-decrement bugs in
burnFromorburn(address,uint256)would be missed. - Recommendation: Add
ccipBurnFromandccipBurnAddresshandler actions and include them intargetSelector.
- Severity: LOW
- Status: DEFERRED — the test's looseness reflects the open
Known open itemin CLAUDE.md ("BSC ON token CCIP-admin hook"). Tightening the assertion to verify the resolved admin equals the deployer requires first concluding which path the live BSC token exposes (RUNBOOK §0.2). Once that is settled the test can pin the chosen path. - Live probe (issue #22):
script/ValidateBscAdmin.s.sol(make validate-bsc-admin) runs script 04's path resolution read-only against live BSC. Probed against0x0e4F…1D48on 2026-06-01, the canonical ON token resolves to path 4 for any deployer:getCCIPAdmin()absent,owner()returns the zero address (ownership renounced →registerAdminViaOwnerunusable),AccessControl.hasRoleabsent. Implication: script 04 will revertCannotResolveCCIPAdminon BSC mainnet; CCIP-admin registration must be arranged out-of-band with Chainlink (theTokenAdminRegistryowner) before the BSC deploy. This is now a confirmed pre-mainnet blocker rather than an unknown — but the resolution (Chainlink coordination) is operational, so the finding stays open until that registration is in place. - Location:
test/fork/Fork_BSC.t.sol:108-112 - Description: The test asserts
hasCCIPAdmin || hasOwnable; a non-deployer Ownable owner would still pass. The script-04proposeAdministratorfallback path (used when neither interface is exposed) is not exercised by the fork test at all — but is a known open item in CLAUDE.md. - Impact: The fork test does not reliably signal which script-04 path will succeed on mainnet.
- Recommendation: Assert that the resolved admin equals the expected deployer; on no-interface,
vm.skip(true)with aconsole.lograther than failing.
- Severity: LOW
- Status: FIXED — new
ReentrantMockONoverridestransferFromto re-enterWrappedON.deposit;test_DepositReentrancyGuardFiresconstructs a wON against it and asserts the outer deposit completes (i.e. the inner reentry is rejected bynonReentrant). - Location:
test/WrappedON.t.sol(general) - Description: The
nonReentrantguard's behaviour is asserted only by reading the modifier, never by a malicious-ON mock that reenters viatransferFrom. The current ON is non-hookable, but the constructor accepts any IERC20. - Impact: Future redeployments against a hookable token rely on review rather than test.
- Recommendation: Add
test_Deposit_ReentrancyGuardFiresusing a mock ON whosetransferFromre-entersdeposit/withdraw, asserting the OZReentrancyGuardReentrantCallselector.
- Severity: LOW
- Status: FIXED —
test_WithdrawRevertsOnInsufficientWonBalanceandtest_BurnFromRevertsOnInsufficientAllowancenow usevm.expectRevert(abi.encodeWithSelector(IERC20Errors.*Error.selector, …))with full payload. - Location:
test/WrappedON.t.sol:128,231 - Description:
test_BurnFromRevertsOnInsufficientAllowanceandtest_WithdrawRevertsOnInsufficientWonBalanceuse bareexpectRevert()forERC20InsufficientAllowance/ERC20InsufficientBalance. Subset of TEST-3 but called out for the user-facing ERC20 surface specifically. - Impact: Wrong-error regressions on the ERC20 path pass silently.
- Recommendation: Use
vm.expectRevert(abi.encodeWithSelector(IERC20Errors.*Error.selector, …)).
- Severity: MEDIUM
- Status: FIXED — new
test_AcceptAfterProposalOverwriteRevertsintest/WrappedON.t.sol: aftersetCCIPAdmin(A)thensetCCIPAdmin(B), A'sacceptCCIPAdminmust revertOnlyPendingCCIPAdminand B's must succeed. TheCCIPAdminProposalCancelled(A)event sibling (WON-5) pinned the writer-side overwrite signal; this pins the accept-side behaviour so a regression that failed to clear the stale pending wouldn't silently honour A's tx. - Location:
test/WrappedON.t.sol - Description: The
setCCIPAdmin(A) → setCCIPAdmin(B) → A.acceptCCIPAdmin()sequence was unexercised. - Impact: A regression failing to overwrite
s_pendingCcipAdminwould be invisible to tests. - Recommendation: Add
test_AcceptAfterProposalOverwriteReverts.
- Severity: MEDIUM
- Status: FIXED — new
test_AcceptCCIPAdminDoubleCallRevertsintest/WrappedON.t.sol: after a successful accept,pendingCCIPAdmin() == address(0)and a second call from the now-current admin revertsOnlyPendingCCIPAdmin. - Location:
test/WrappedON.t.sol - Description: After success,
s_pendingCcipAdmin = address(0). A second call from the new admin should revertOnlyPendingCCIPAdmin. Untested. - Impact: Slot-clear regression would be invisible.
- Recommendation: Add
test_AcceptCCIPAdminDoubleCallReverts.
- Severity: LOW
- Status: FIXED — added two tests in
test/WrappedON.t.sol:test_MintToZeroAddressRevertsAndDoesNotInflateassertsERC20InvalidReceiverANDccipMintHeadroomUsedunchanged;test_BurnAddressOverloadZeroAddressRevertsAndDoesNotDesyncassertsERC20InvalidSenderANDccipMintHeadroomUsedunchanged. The mint-path test pins the WriteCounter→Mint ordering (OZ rolls the counter write back on the_mint(0)revert); the burn-path test pins the WriteCounter→Burn ordering (OZ rolls the saturating-decrement back on the_burn(0)revert). Two complementary halves cover the symmetric class — a future refactor that decoupled counter and ERC20 path on either side would break one of them. - Location:
test/WrappedON.t.sol - Description:
ccipMintHeadroomUsedis incremented BEFORE_mint; on OZ 5.xERC20InvalidReceiverthe EVM rolls back, so it's safe today. No test pinned the ordering. - Impact: A refactor decoupling the increment from the mint could leave
ccipMintHeadroomUsedpermanently inflated. - Recommendation: Add
test_MintToZeroAddressRevertsAndDoesNotInflate(and symmetricburn(address(0))test).
- Severity: MEDIUM
- Status: FIXED — new
MockSilentRevertModuleintest/Script04Paths.t.soldoes an explicitassembly { revert(0, 0) }. Newtest_Dispatch_Path3_SilentRevertFallsThroughasserts the script falls through to path-4CannotResolveCCIPAdminwhen the module silently reverts (the same look-alike behaviour as a missing v1.5 selector). Together withtest_Dispatch_Path3_StructuredRevertPropagates, both sides of thereason.length != 0branch are now under test. - Location:
test/Script04Paths.t.sol - Description: Existing tests relied on the real v1.5 module's empty-revert behavior. A complementary mock that does an explicit
revert();was missing, so an inversion of thereason.length != 0branch could pass. - Impact: A regression dropping the
!= 0check would silently swallow real v1.6 structured reverts. - Recommendation: Add the silent-revert mock and a complementary structured-revert mock.
- Severity: LOW
- Status: FIXED — added
setCCIPAdminRaceandacceptCCIPAdminRacehandler selectors intest/WrappedONInvariant.t.sol, both included intargetSelector. The handler tracks the current ccipAdmin internally so rotations interleave correctly with mint/burn. All four invariants (BackingCoversSupply,CounterBoundedByBscLocked,CcipMintedSupplyWithinCap,ReserveMatchesNetDeposits) still pass across the rotation paths. - Location:
test/WrappedONInvariant.t.sol - Description: Two-step admin transitions were never interleaved with mint/burn under the fuzzer.
- Impact: Low likelihood of finding a bug, but a state-space gap.
- Recommendation: Add
setCCIPAdminRace/acceptCCIPAdminRacehandler functions.
- Severity: LOW
- Status: FIXED — new
test_ReleaseOrMintRevertsWhenRMNCursedintest/PoolRoundtrip.t.solcursed the BSC selector on the ETH RMN, then asserted the typedTokenPool.CursedByRMNselector whenethPool.releaseOrMintis called viaethOffRamp. Combined with the existing outbound test, both curse-check branches (_validateLockOrBurnand_validateReleaseOrMint) are now under test. - Location:
test/PoolRoundtrip.t.sol - Description:
test_LockOrBurnRevertsWhenRMNCursedcovered the outbound direction only. - Impact: A regression that only patched one direction's curse check would pass the outbound test while silently letting funds arrive under a curse.
- Recommendation: Add
test_ReleaseOrMintRevertsWhenRMNCursed.
- Severity: MEDIUM
- Status: FIXED —
ReentrantMockONnow pre-funds itself with 100 ON and approves the wON contract from insidetransferFrom, so the inner reentry's ERC20 accounting is satisfied and a removednonReentrantmodifier would no longer trivially revert viaERC20InsufficientBalance. The mock'srequire(!success)is paired with a typed-selector assertionbytes4(ret) == ReentrancyGuard.ReentrancyGuardReentrantCall.selectorso an inner revert from a different cause cannot masquerade as the guard firing. - Location:
test/WrappedON.t.sol:39-65 - Description: The inner reentry called
safeTransferFrom(rOn, rWon, 1)whileaddress(rOn)held zero balance, so the inner call reverted on insufficient balance regardless of whethernonReentrantfired. Removing the modifier still passed the test. - Impact: False sense of security on the deposit-reentry guard.
- Recommendation: Assert the typed selector + give the inner call enough balance to reach the modifier.
- Severity: LOW
- Status: FIXED — new
test_BscLockOrBurnRevertsWhenRMNCursedintest/PoolRoundtrip.t.sol. CursedbscRmnagainstETH_SELECTOR, assertedbscPool.lockOrBurn(...)revertsTokenPool.CursedByRMN. TEST-15 covered ETH-side outbound + inbound; TEST-17 closes the BSC-side outbound leg. - Location:
test/PoolRoundtrip.t.sol - Description: A regression patching only the ETH pool's curse wiring would pass TEST-15 but let BSC users lock through a curse.
- Impact: Direction-asymmetric curse-bypass risk if missed.
- Recommendation: Symmetric test.
- Severity: LOW
- Status: DESIGN ACK — the harness
exposeCheckDeployerRenouncedalready exercises the post-env-resolved code; therun()-levelvm.envOr(…)→DeployerEnvMissingrevert sequence is a tinyif (addr == 0) revertwhose regression would surface immediately in any operator-side verify run. Adding avm.setEnv("MULTISIG", …)test would also need to manageDEPLOYERunset across the test process which Foundry's env model makes brittle. Documented as a known minimal-coverage gap. - Location:
script/08_PostDeployVerify.s.sol:94-106,test/Script08Verify.t.sol - Description: A future refactor that swapped the condition could silently restore the DEP-8 vacuous-satisfaction bug.
- Impact: Minimal — the assertion is two lines.
- Recommendation: Accept; revisit if env-set tests become a project pattern.
- Severity: LOW
- Status: FIXED —
setCCIPAdminRacenow searches the actor pool for an actor distinct from the current admin (modulo-bounded indexing to avoid overflow under fuzz seeds nearuint256.max);acceptCCIPAdminRacebootstraps a proposal when none is pending so every call contributes observable state. No-op rate now ~0 across both selectors at depth=50. - Location:
test/WrappedONInvariant.t.sol:226-257 - Description: Original selectors silently returned ~2/9 of the time when seeds resolved to the current admin or pending was zero — dilutes effective state-space coverage.
- Impact: Lower invariant coverage at fixed depth.
- Recommendation: Handler-side gating.
- Severity: MEDIUM
- Status: FIXED — four new tests in
test/Script08Verify.t.sol:test_CheckBscRebalancer_RevertsOnUnexpectedRebalancer(CCIP-1 typed revert).test_CheckBscRebalancer_RevertsOnReadFailure(DEP-19 typed revert).test_CheckRemoteLink_RevertsOnMalformedRemotePool(DEP-9 typed revert).test_CheckRemoteLink_RevertsOnMalformedRemoteToken(DEP-9 typed revert). Each uses the newMockBadPoolfixture to drive a specific malformed-response case. A regression that inverted any of the four checks now fails an explicit test.
- Location:
test/Script08Verify.t.sol - Description:
UnexpectedRebalancerandMalformedRemoteEncodingwere defined but never exercised in tests; the script-06 idempotency branches and script-05 stale-wiringrequirewere only exercised end-to-end (E2E tests perform the operations inline rather than driving the script'srun()). - Impact: A regression could land silently on the existing suite.
- Recommendation: Direct negative tests via a harness.
- Severity: HIGH
- Status: FIXED — the raw-key path was removed entirely:
DEPLOY_FLAGSsigns via--account $(ACCOUNT)(keystore, defaultdeployer),DEPLOYER_PKis gone from.env.example, and the Makefile/README/RUNBOOK document keystore signing as the only path (cast wallet import deployer --interactive+--account deployer). No--private-keyremains anywhere in the deploy tooling, so there is no longer a default that leaks the key onps aux. - Location:
Makefile:6,README.md(§4, §8) - Description:
DEPLOY_FLAGShardcodes--private-key $(DEPLOYER_PK), putting the raw key in process arguments visible to any process on the host and recorded in shell history. RUNBOOK §0.3 recommendscast wallet import+--account deployer— README does not mention it. - Impact: An operator following README alone on mainnet exposes their key for the duration of the broadcast window.
- Recommendation: Add a security callout in README §4/§8 referencing the
cast wallet import deployer --interactive+--account deployerflow. Provide aDEPLOY_FLAGS_ACCOUNToverride in the Makefile so operators can switch without editing it.
- Severity: HIGH
- Status: FIXED —
make update-limitsacceptsCALLER_FLAGS(e.g.CALLER_FLAGS='--account ratelimit-admin') for post-handoff callers, and falls back to the deployer keystore account (--account $(ACCOUNT)) pre-handoff. The rawDEPLOYER_PKfallback was removed (keystore-only), so the pre-handoff path no longer puts a key in process args either. - Location:
Makefile:144-151,README.md:173-178,RUNBOOK.md §4.1 - Description:
update-limitsexpandsDEPLOY_FLAGS(hardcoded--private-key $(DEPLOYER_PK)). After handoff, the deployer is neither pool owner nor rate-limit admin; the call revertsonlyOwner. The Makefile guard only checks thatDEPLOYER_PKis set. - Impact: Operator broadcasts a revert-bound tx after handoff. The Makefile has no path for the multisig or a delegated
rateLimitAdminto make the call. - Recommendation: Introduce a
CALLER_PK(defaulting toDEPLOYER_PK) and/or an--accountoverride path. Document explicitly in RUNBOOK §4.1 and the Makefile that post-handoff the caller must be the multisig or a delegatedrateLimitAdmin.
- Severity: MEDIUM
- Status: FIXED —
.gitmodulesnow carries an explicit header comment warning againstgit submodule update --remoteand documenting the safe path (git -C lib/<name> checkout <hash>). - Location:
.gitmodules - Description: All five submodules are pinned to exact release tags —
lib/chainlink-ccip(contracts-ccip-v1.6.1),lib/chainlink-evm(contracts-v1.4.0),lib/chainlink-local(v0.2.8),lib/forge-std(v1.16.1), andlib/openzeppelin-contracts(v5.6.1); each gitlink commit resolves to its tag viagit describe --tags --exact-match, andfoundry.lockrecords the matching tag + rev. Nobranch =lock in any entry.git submodule update --remote(a common but wrong invocation) would advance all to upstream tip. - Impact: Low in practice (the Makefile uses
--init --recursive), but a misconfigured CI step that uses--remotecould change compiler and library behaviour silently. - Recommendation: Add a comment in
.gitmoduleswarning against--remote, and add a dependency table to README listing the exact intended commit hashes for auditor cross-reference.
- Severity: MEDIUM
- Status: FIXED — same change as TEST-2: explicit
[invariant]table withfail_on_revert=true. - Location:
foundry.toml - Description: Same root cause as TEST-2, called out separately as a config issue:
[profile.ci]setsfuzz.runs = 1000but invariants run at Foundry defaults (256 runs, depth 15,fail_on_revert = false). - Impact: Reserve-safety invariants are weaker than intended.
- Recommendation: Add
[invariant](or[profile.ci.invariant]) explicit settings withfail_on_revert = true.
- Severity: MEDIUM
- Status: FIXED — README §5 now carries a "Recovery after mid-sequence failure" callout describing the safe action (re-run the same
maketarget — all scripts are idempotent — and explicitly NOT manually re-running individual scripts). - Location:
Makefile:91-106,RUNBOOK.md §1,README.md §5 - Description: Five sequential
forge scriptcalls. All scripts are idempotent (per CLAUDE.md), but neither RUNBOOK nor README tells operators that the safe recovery action is simply re-running the samemaketarget from the start. - Impact: Operators in mid-failure may guess at manual recovery, miscalculate which scripts already executed, and skip a step (most likely
03_GrantRoles) → a pool that cannot mint. - Recommendation: Add a "Recovery" callout in RUNBOOK §1 and README §5: "If any script fails, re-run the same
maketarget. All scripts are idempotent. Do not manually re-run individual scripts."
- Severity: LOW
- Status: FIXED — CLAUDE.md "Build & test" block updated to reference
make testwith a note that fork tests self-skip when RPC vars are absent. - Location:
CLAUDE.md:99,Makefile:54-55 - Description: CLAUDE.md shows
forge test -vvv --no-match-path "test/fork/**". The actual target isforge test -vvv(fork tests self-skip when RPC vars are absent). Functionally equivalent, but a developer comparing the two may distrust both. - Impact: Minor confusion.
- Recommendation: Update CLAUDE.md to match, with a note that fork tests self-skip.
- Severity: LOW
- Status: ALREADY ADDRESSED — this very file restored the disclosure policy with
security@orochi.network. - Location: repository root
- Description: SECURITY.md was removed in commit
dea561dand is being restored by this review. Without a disclosure channel, security researchers have no guidance on how to report critical findings before public disclosure. - Impact: Increased risk of public disclosure before a patch.
- Recommendation: This file (as committed) addresses the gap. Maintain
security@orochi.networkas the disclosure address and consider enabling GitHub Security Advisories.
- Severity: LOW
- Status: DEFERRED — Slither gating is left advisory until immediately before mainnet broadcast. At that point the
continue-on-error: truewill be removed (or--fail-on HIGHadded) so a HIGH detector blocks merges. Currently advisory so the pre-mainnet rule cleanup can be done in a single audit-final commit rather than retroactively across PRs. - Location:
.github/workflows/ci.yml:48 - Description: Slither runs but is non-blocking. New HIGH/CRITICAL detectors on
src/WrappedON.solwould not block merges. - Impact: Pre-mainnet, static analysis is advisory rather than gating.
- Recommendation: Before mainnet, drop
continue-on-error: true(or use--fail-on HIGH). Suppress vendored-library noise via.slither.config.json.
- Severity: LOW
- Status: FIXED —
MULTISIG=0x0…0placeholder added to.env.examplewith a comment explaining when it's required. - Location:
.env.example,Makefile:117,129,138 - Description:
handoff,handoff-all,renounceall require$(MULTISIG)..env.exampledoes not list it. - Impact: Operator confusion at the handoff step.
- Recommendation: Add
MULTISIG=0x000…000 # Gnosis Safe address; required for handoff-all and renounceto.env.example.
- Severity: LOW
- Status: FIXED — RUNBOOK §3.2 now has a "Verify each transaction before signing" subsection with explicit instructions (Safe simulation, Tenderly, cross-check against
deployments/<chainId>.json, post-acceptancemake verify-*run). - Location:
RUNBOOK.md §3.2 - Description: The five multisig transactions are listed but no guidance is given on simulating them (Safe built-in, Tenderly, or
forge script --simulate) before signing. The BSC pool owner is the custody-grade authority over the entire locked-ON reserve — a typo'd calldata target here is high-consequence. - Impact: Signers may sign without verifying target addresses; an incorrect target would still revert ("not pending owner") but extends the deployer-retention window unnecessarily.
- Recommendation: Add: "Before signing each transaction, simulate via Safe / Tenderly. Cross-check target addresses against
deployments/<chainId>.json. Runmake verify-eth/make verify-bscafter acceptance."
- Severity: LOW
- Status: FIXED — deleted
remappings.txt.foundry.toml'sremappingstable is now the single source of truth, with a header comment warning future contributors not to recreate the sibling file. Foundry givesremappings.txtprecedence when both exist, so the duplication was a silent-divergence trap. - Location:
remappings.txt(deleted),foundry.toml:17-26 - Description: Both files listed the same four remappings. An edit to one but not the other would silently change resolution (most dangerous for
@chainlink/contracts-ccip/drifting to a different vendored version). - Impact: Latent — easy to introduce a divergence in a future PR.
- Recommendation: Delete
remappings.txt; keep onlyfoundry.toml's table.
- Severity: LOW
- Status: FIXED —
pip3 install slither-analyzer==0.11.0in.github/workflows/ci.yml. New detectors or behaviour changes upstream no longer silently alter CI output. Refresh deliberately on bumps. Combined with OPS-8 (gating flipped immediately before mainnet broadcast), the pre-mainnet sign-off uses the same Slither version as the lead-up. - Location:
.github/workflows/ci.yml:63 - Description: Pre-fix
pip3 install slither-analyzerfollowed upstream. New detector or behaviour change silently alters CI output. - Impact: Latent — CI signal could drift without a code change in the repo.
- Recommendation: Pin to a tested release.
- Severity: LOW
- Status: DEFERRED — pre-mainnet hardening. SARIF upload +
security-events: writeadds a meaningful "appears in the PR Security tab" surface, but it's strictly a visibility nicety on top of the OPS-8 gating change. Bundled with OPS-8 so both land together in the final pre-mainnet workflow commit. - Location:
.github/workflows/ci.yml:45-73 - Description: Findings live only in workflow logs; nothing surfaces in the PR Security tab.
- Impact: Operator visibility only.
- Recommendation: Add
--sarif slither.sarif,permissions: security-events: write, and agithub/codeql-action/upload-sarif@v3step.
- Severity: INFO
- Status: FIXED — pinned to
foundry-rs/foundry-toolchain@v1.4.0in bothtestandslitherjobs. Refresh deliberately. SHA-pinning is the next hygiene step before mainnet broadcast. - Location:
.github/workflows/ci.yml:24,58 - Description:
v1is a moving major-version pointer; a regression on the next CI run would silently alter build behaviour. - Impact: Latent; standard supply-chain hygiene.
- Recommendation: Pin to a specific Foundry release tag and SHA-pin the action reference.
- Severity: LOW
- Status: FIXED —
.env.examplenow lists commented-outDEPLOYER,OUTBOUND_ENABLED,INBOUND_ENABLED,STRICT_RATE_LIMITS(introduced by DEP-8 / DEP-16 / CCIP-10) with usage notes pointing back to the relevant finding IDs. - Location:
.env.example - Description:
DEPLOYERis load-bearing formake verify-*post-handoff; an operator hittingDeployerEnvMissingwith no template entry had nothing to copy. - Impact: Operator setup friction.
- Recommendation: Add to template.
- Severity: INFO
- Status: DOC ADDED —
.env.examplenow warns thatCALLER_FLAGSmust be strictly--account <name>or--keystore <path>and must NOT contain shell metacharacters. Threat model assumes a trusted local.env; the documented constraint matches what the Makefile target's textual expansion requires. - Location:
Makefile(update-limits target),.env.example - Description: A
.envvalue of--account x; rm -rf deployments/would produce two shell commands at broadcast time. - Impact: Mitigated by the documented constraint + trusted-
.envassumption. - Recommendation: Strict-allowlist validation in the Makefile is the next step; documented for now.
- Severity: LOW
- Status: FIXED — both updated to
130 non-fork tests (126 unit/integration + 4 stateful invariants). Same edit applied toCLAUDE.md,docs/ARCHITECTURE.md, and this file. - Location:
README.md:46,RUNBOOK.md:74,CLAUDE.md:89,docs/ARCHITECTURE.md:550 - Description: Stale "111 tests" referenced post-second-pass.
- Impact: Doc drift.
- Recommendation: Update; track via a CI grep gate going forward.
- Severity: INFO
- Status: FIXED —
foundry.toml's[invariant]block now has an inline comment explaining thatmake testuses the local defaults (256/50) while CI'sFOUNDRY_PROFILE=cioverrides at 500/100. A green localmake testdoes not imply a green CI run. - Location:
foundry.toml:33-46 - Description: Two configs in the same file, neither pointed at the other.
- Impact: Operator confusion only.
- Recommendation: Inline comment.
- Severity: LOW
- Status: FIXED — README "Trust-model / events" bullet now explicitly notes the
amount → receivedrename + that ABI consumers reading parameters by name need to update bindings; consumers reading by index are unaffected. - Location:
README.md(Trust-Model / events bullets) - Description: WON-9 was internal to the contract changelog; README didn't reflect it.
- Impact: Integration friction for indexers that use name-based parameter access.
- Recommendation: Surface in README.
- Severity: INFO
- Status: DESIGN ACK —
_assertEnabledAndConfiguredis retained as a strict-mode shim that delegates to_assertConfiguredOrWarn(strict=true). NatSpec calls it out as the "strict gate" entrypoint; tests use it via the harness without threading thestricttoggle. Deleting the shim and routing the harness throughexposeAssertConfiguredOrWarnis possible but increases test churn for no behaviour change. - Location:
script/08_PostDeployVerify.s.sol:215-227 - Description: After CCIP-10 split off
_assertConfiguredOrWarn, the original helper became a one-line delegator only invoked from the harness. - Impact: Test-only dead code in the production script.
- Recommendation: NatSpec.
- Severity: LOW
- Status: FIXED —
docs/ARCHITECTURE.md:539updated to4 stateful invariants over 9 handler actions(TEST-6 added two burn-overload handlers, TEST-14 added two admin-rotation handlers). - Location:
docs/ARCHITECTURE.md - Description: Inherited from the pre-second-pass count.
- Impact: Doc drift.
- Recommendation: Update to match
targetSelectorlength.
- Severity: LOW
- Status: FIXED — references in
RUNBOOK.mdandCLAUDE.md(and the newdocs/ARCHITECTURE.md) updated inline to point at the current WON-/DEP-/CCIP-/TEST-/OPS- IDs with the legacy tag in parentheses (e.g. "TEST-7— legacy audit tag H-4").H-5had no current ledger entry (the half-handoff footgun is closed operationally bymake handoff-allrather than at the script level) and is annotated as such. - Location:
RUNBOOK.md:30, 156, 163, 165, 221,CLAUDE.md:120,docs/ARCHITECTURE.md:580 - Description: The original audit's
H-/C-IDs were replaced by the per-domain WON-/DEP-/CCIP-/TEST-/OPS- scheme in commit3253efe; cross-refs in the operator docs hadn't been retrofitted. - Impact: An auditor clicking through couldn't resolve the references.
- Recommendation: Inline retrofit + legacy-tag note.
- Severity: MEDIUM
- Status: DOC ADDED — new RUNBOOK §1.0 "Deploy a mock ON token (testnet only)" tells operators to deploy a
MockERC20("Orochi Network (Testnet)", "ON", 18)and patchHelper.solwith the resulting address before running scripts 01/02 on Sepolia / BSC testnet. Ascript/00_DeployMockON.s.solautomating this is the next-step recommendation (tracked here for pre-mainnet follow-up). - Location:
RUNBOOK.md§1.0 (new),README.md§1,docs/ARCHITECTURE.md§10 - Description:
Helper.solintentionally leavesonToken: address(0)for chainids11_155_111/97; scripts 01 / 02_requireSetit. The documentedmake deploy-eth RPC=sepolia/make deploy-bsc RPC=bsc_testnetreverted immediately withMissingAddress. - Impact: Testnet rehearsal — the headline pre-mainnet ritual — blocked.
- Recommendation: Document the manual mock deploy now; script-based path before mainnet.
- Severity: LOW
- Status: FIXED —
Makefile,CLAUDE.md, and the README test list updated to read "everything except WrappedON unit tests and forks". The broader sweep (Deployments, Script04..08, WrappedONInvariant) is the actually-useful CI loop. - Location:
Makefile:9-14,CLAUDE.md:91,README.md:53 - Description: The recipe runs
--no-match-path 'test/{WrappedON.t.sol,fork/**}'which sweeps in 9 files / 70+ tests, but the description claimed "PoolRoundtrip + DeploymentE2E" only. - Impact: Operator confusion.
- Recommendation: Match descriptions to recipe.
- Severity: LOW
- Status: FIXED —
make helpnow listsprecheck-helper,handoff-all,fmt, andpatch-pragmas. README §9 / RUNBOOK §3.1 already recommendhandoff-allas the preferred two-chain handoff;make helpnow matches. - Location:
Makefile:8-30 - Description: Operators discovering targets via
make helpsaw only the per-chainhandofftarget. - Impact: Discoverability.
- Recommendation: Add to help block.
- Severity: LOW
- Status: FIXED — CLAUDE.md bullet rewritten to point at
TEST-1..TEST-20per-finding entries with status callouts. The original "8 tracked gaps" phrasing was correct in spirit but cited a section title that disappeared in the SECURITY.md restoration commit. - Location:
CLAUDE.md:122 - Description: Dangling ledger reference.
- Impact: Doc drift.
- Recommendation: Update to current scheme.
- Severity: INFO
- Status: DEFERRED — bundled with the pre-mainnet workflow commit alongside OPS-8 (Slither gating) and OPS-13 (SARIF upload). The
lib/chainlink-ccip/lib/chainlink-evmpin tags are documented in prose at README:15 and ARCHITECTURE.md §3.2; an enumerated SHA table is the cross-reference aid an auditor readsgit submodule statusfor today, and is worth adding once. - Location:
README.md(top-of-file),docs/ARCHITECTURE.md§3.2 - Description: OPS-3's original recommendation was two-part; only
.gitmoduleswarning landed. - Impact: Auditor cross-reference friction.
- Recommendation: SHA table in README before mainnet broadcast.
- Severity: LOW
- Status: FIXED — monitoring table now includes
setRateLimitAdmin(addr)calldata trace (both pools, High —onlyOwner, no event) andAdministratorTransferRequested/AdministratorTransferredonTokenAdminRegistry(High). The wONCCIPAdminTransferProposed/Transferredwas already covered; OPS-28 closes the registry-side sibling. - Location:
RUNBOOK.md§3 (Trust-model monitoring table) - Description: §4.1.1 explicitly recommends delegating
rateLimitAdminto a hot key; without monitoring, a delegation calldata trace was the only signal of the change. - Impact: Operator-visibility gap on legitimate-but-significant calls.
- Recommendation: Add to monitoring table.
- Severity: INFO
- Status: FIXED — RUNBOOK §0.2 now includes a
cast callprobe block for likely mint surfaces on BSC ON (mint(address,uint256),owner(),totalSupply()). The bridge'sMAX_CCIP_MINTED = 100Massumes the BSC supply is fixed at 100M; if BSC ON had a minter and supply exceeded 100M, excess BSC ON could be locked but not reflect to ETH, stranding users with the surplus. ARCHITECTURE.md §13 cross-references OPS-29 in the open-items list. - Location:
RUNBOOK.md§0.2,docs/ARCHITECTURE.md§13 - Description: ETH ON is tagged "non-mintable" in CLAUDE.md / README; BSC ON was tagged "non-upgradeable" only — a weaker property.
- Impact: Cap-vs-supply asymmetry under a future mint event.
- Recommendation: Document + verify via
cast call.
- Severity: LOW (forward-compat / defence-in-depth)
- Status: FIXED — added
RoleAdminChanged(role, prev, new)onMINTER_ROLE/BURNER_ROLE(wON) to the monitoring table at High. Annotated as forward-compat because OZ AccessControl 5.x's_setRoleAdminis internal; wON calls it exactly once atinitialize(the one-timeUPGRADER_ROLEself-admin — UPG-1, mitigation #3) and does not expose it externally, so no post-deploy admin rotation ofMINTER_ROLE/BURNER_ROLEis reachable — but the monitor catches one if a future impl ever adds an external path. - Location:
RUNBOOK.md§3 (monitoring table) - Description: §3.1 alerts on
RoleGrantedbut not on aRoleAdminChangedswap that would silently re-parent who can grant. - Impact: Forward-compat only.
- Recommendation: Add row.
Added 2026-06-23: wON became UUPS-upgradeable behind an ERC1967Proxy in branch
feat/won-upgradeable. The "non-upgradeable by design" stance documented in an earlier
version of this file is superseded. History: the original rationale was "migration path =
redeploy + re-register"; that remains an option but is no longer the only path.
- Severity: HIGH (by design — mitigated to DESIGN ACK)
- Status: DESIGN ACK (mitigations documented below). The
UPGRADER_ROLE-admin timelock-bypass flagged in PR #47 review was CLOSED 2026-06-23 — see mitigation #3. - Description:
_authorizeUpgradeis gated byUPGRADER_ROLE, held by theTimelockController. A TimelockController whose proposer/executor roles are held by a compromised multisig can schedule and execute an upgrade to a malicious implementation, which could drain the wON reserve, mint unbacked wON pastMAX_CCIP_MINTED, or destroy any other state. This is a custody-grade risk analogous to the BSC pool'ssetRebalancerpath. - Mitigations:
- 48h mandatory delay (the
TimelockControllerdefault;minDelay = 172800 seconds). Any upgrade attempt is visible on-chain for 48 hours before it can execute. Monitoring onCallScheduledfrom the timelock gives the community and security team time to respond (revoke, cancel, or redeploy). This window is genuinely enforced because of mitigation #3. - Emergency pause — the multisig's
PAUSER_ROLElets it halt value paths immediately if a malicious upgrade is in flight, limiting damage before the timelock executes. - Self-administered
UPGRADER_ROLE(on-chain enforced) —initializecalls_setRoleAdmin(UPGRADER_ROLE, UPGRADER_ROLE), so the role's admin isUPGRADER_ROLEitself (held only by theTimelockController), NOTDEFAULT_ADMIN_ROLE. Without this, OZ's default makesDEFAULT_ADMIN_ROLE(the ops multisig post-handoff) the admin ofUPGRADER_ROLE, letting itgrantRole(UPGRADER_ROLE, itself)andupgradeToAndCallin one transaction with no delay — making mitigation #1's 48h window advisory, not enforced. With self-administration, only the timelock can grant/revoke upgrade authority and every such grant is itself a 48h-timelocked tx; the role is set to the timelock atinitializeand never granted to an EOA. Tests:test_UpgraderRoleIsSelfAdministered,test_DefaultAdminCannotGrantUpgraderRole,test_TimelockCanGrantUpgraderRole(WrappedONUpgrade.t.sol). - Implementation address monitoring —
Upgraded(implementation)on the proxy (ERC1967 standard event) must be a Critical alert in the monitoring table (RUNBOOK §Trust model). - Storage hygiene — state is in ERC-7201 namespaced storage; accidental collision from a future impl adding fields is prevented by the namespace isolation. Field ordering in
WrappedONStoragemust not change across upgrades.
- 48h mandatory delay (the
PAUSER_ROLEadmin: intentionally left as the OZ defaultDEFAULT_ADMIN_ROLE(the ops multisig manages pausers). Acceptable because pause is halt-only (UPG-4) — a liveness authority, not an upgrade/custody one; a malicious pauser can at worst freeze the value paths, whichunpause(also multisig) reverses. Pinned bytest_PauserRoleAdminIsDefaultAdmin.- Residual risk: A compromised multisig that also holds
PAUSER_ROLE(post-handoff) could pause AND schedule an upgrade — but it must still route the upgrade through the 48h timelock (mitigation #3 removes the no-delay bypass), so the window stands. Key management of the multisig signers is the load-bearing control.
- Severity: HIGH (if absent) — mitigated by code
- Status: FIXED (code; present in shipped
WrappedON.sol) - Description: An implementation contract with an open
initializefunction can be taken over by a third party (anyone can callinitializeon the bare implementation and set themselves as admin). OZ's standard mitigation is_disableInitializers()in the implementation constructor. - Fix:
WrappedONconstructor calls_disableInitializers()with the@custom:oz-upgrades-unsafe-allow constructorNatSpec. Test:test_ImplCannotBeInitialized(or equivalent). - Impact if removed: A third party could claim
DEFAULT_ADMIN_ROLE,PAUSER_ROLE, ands_ccipAdminon the bare implementation (not the proxy). The implementation holds no value, but registering it inTokenAdminRegistryor misleading integrators about the proxy's state would be an attack surface.
- Severity: MEDIUM (if unaddressed) — mitigated by code
- Status: DESIGN ACK (mitigated by ERC-7201 namespacing + documented invariant)
- Description: UUPS upgrades that extend storage by inserting new fields in the middle of a struct break existing storage slot mappings for all fields that follow, causing silent data corruption.
- Mitigation: All persistent state lives in a single
WrappedONStoragestruct at slot0xc9356e8aa19da270b9a132fda93e9af24668c8487450db15f9b9e8baeb751900(the ERC-7201 namespace fororochi.storage.WrappedON, verified againstcast index-erc7201). New fields may only be appended to the end ofWrappedONStorage. This constraint is enforced by convention (documented in RUNBOOK §4.7), not by on-chain checks. The foundry-upgrades FFI plugin was evaluated and intentionally NOT adopted to keep CI forge-only; the storage-preservation invariant is verified instead via the upgrade state-preservation test suite. - Operator note: Any upgrade PR must include a diff showing no existing field was moved or resized. Code review is the gate.
- Severity: MEDIUM (awareness / threat-model clarity)
- Status: DESIGN ACK (documented)
- Description:
pause()haltsmint,burn*,deposit, andwithdraw— all four value paths carrywhenNotPaused— but ERC20transfer/transferFromstay live. So a paused bridge DOES block even a compromisedMINTER_ROLEpool from minting. Pause is nonetheless an emergency stop, not theft-prevention: the underlying exploit (compromised pool, bad upgrade, etc.) still exists and needs separate remediation while paused. A compromisedPAUSER_ROLEcan only grief — indefinitely halt the value paths — and cannot move funds, since transfers stay live and pause grants no spending authority. - Impact (compromised pauser): Griefing —
mint/burnhalted while CCIP messages queue;deposit/withdrawhalted for ETH-side users. Resumable by any multisig signer callingunpause. - Impact (compromised pool with
MINTER_ROLE): A paused bridge BLOCKS the compromised pool's mint path too — pause inadvertently provides partial mitigation against a compromised pool if the multisig can pause before the attacker mints. - Residual risk: A compromised multisig could unpause immediately after pausing; the 48h timelock does not cover
pause/unpause(intentional — emergency response requires speed). Key management of the Safe signers and threshold is the load-bearing control.
UPG-5: upgrade-hygiene hardening — base-slot self-check, reinitializer scaffold, supportsInterface checklist
- Severity: LOW
- Status: FIXED (2026-06-23) — issue #60. Three pre-emptive hardening items; none was a defect (the contract is upgrade-safe and collision-proof), each removes a way a future upgrade PR could regress silently.
- Base-slot self-check. The member-layout guard (
make check-storage-layout, UPG-3) catches reorder/insert/remove/retype but NOT a relocation of the wholeWrappedONStoragestruct via a changed storage-location annotation /_STORAGE_LOCATIONconstant (member layout stays byte-identical).test_StorageSlotsUnchangedAfterUpgradecaught it only via a hand-copied base-slot constant that could drift. Addedtest_Erc7201BaseSlotMatchesNamespace(WrappedONUpgrade.t.sol): it DERIVES the base slot from the namespace stringorochi.storage.WrappedONand asserts (a) the test constant matches it and (b) V1 state actually lives there — so a_STORAGE_LOCATIONno longer matching the namespace fails the suite. No hand-copied constant in the load-bearing assertion. reinitializer(2)scaffold. Addedtest/mocks/WrappedONReinitV2Mock.sol(new field in its ownorochi.storage.WrappedON.v2namespace,initializeV2gated byreinitializer(2)) + teststest_ReinitializerV2RunsExactlyOnce,test_ReinitializerV2DoesNotCollideWithV1,test_V1InitializeCannotBeReplayedAfterV2. Proves the first stateful-upgrade pattern runs exactly once, keeps V1 state, and cannot reopen version 1. RUNBOOK §4.7 documents the atomicupgradeToAndCall(newImpl, abi.encodeCall(initializeV2, …))pattern.supportsInterfacechecklist.supportsInterfaceenumerates interface IDs manually; added an explicit "extendsupportsInterface" item to the RUNBOOK §4.7 upgrade-PR checklist so a future interface-advertising module is not silently un-advertised.
- Base-slot self-check. The member-layout guard (
- Location:
test/WrappedONUpgrade.t.sol,test/mocks/WrappedONReinitV2Mock.sol,RUNBOOK.md §4.7.
UPG-6: DEFAULT_ADMIN_ROLE → MINTER_ROLE self-grant is a no-delay mint path (parallel to, but NOT gated by, the upgrade timelock)
- Severity: INFO (documentation of an accepted, inherent property)
- Status: DESIGN ACK (documented 2026-06-23).
MINTER_ROLE,BURNER_ROLE, andPAUSER_ROLEare all admin'd byDEFAULT_ADMIN_ROLE(OZ default —initializereassigns only the admin ofUPGRADER_ROLE, the UPG-1 self-admin). So aDEFAULT_ADMIN_ROLEholder (the ops multisig post-handoff) cangrantRole(MINTER_ROLE, self)andmintup toMAX_CCIP_MINTED = 100Munbacked wON in a single multisig transaction — there is NO 48h delay on this path, unlikeupgradeToAndCallwhich the UPGRADER timelock gates. This is inherent to the AccessControl model and consistent with the documented trust model (the multisig is a custody-grade authority on the ETH side); it is called out explicitly here so the asymmetry vs the timelocked upgrade path is on the record. - Location:
src/WrappedON.sol(initializerole wiring;mint). - Description: Upgrades are deliberately slowed by the 48h
TimelockController(UPG-1). Minting is not — it is bounded by theMAX_CCIP_MINTEDcap and by who holdsMINTER_ROLE, not by any delay. A compromised or malicious multisig is the dominant ETH-side custody risk regardless, and it could equally push a malicious upgrade (after the timelock) or grant itselfMINTER_ROLE(immediately). The mint path is the faster of the two. - Impact: No new vector beyond the already-accepted "multisig is custody-grade" assumption; the point is that the fastest custody-drain on ETH does not route through the timelock.
- Recommendation: Accepted (inherent to AccessControl; adding a delay would require a custom minter-gating module — rejected per the "keep surface small / use stock contracts" policy). Mitigation is operational: the RUNBOOK §3 monitoring table already pages on
RoleGranted(MINTER_ROLE, *)where grantee ≠ ETH pool at Critical — this is the correct severity precisely because it is the fastest no-delay drain path. Verified present (RUNBOOK monitoring table).
This review intentionally excludes vendor library audit findings — Chainlink CCIP
1.6.1 and OpenZeppelin 5.x are independently audited and pinned. Re-review is
recommended after any submodule bump, after any change to src/WrappedON.sol,
or before mainnet broadcast. The HIGH-severity findings should be closed prior
to mainnet rollout; MEDIUM findings should be triaged and either closed or
explicitly accepted with documented rationale.