diff --git a/docs/gotchas/index.md b/docs/gotchas/index.md index 296d87f..edd02f2 100644 --- a/docs/gotchas/index.md +++ b/docs/gotchas/index.md @@ -127,6 +127,22 @@ link to. must return `0x` (otherwise the endpoint is not running `eth_call` at all). Check `eth_chainId` matches the chain you meant, because public RPC directories carry chainId collisions. + +- **A silo isolates a chain only while that chain has no DIRECT lane to a differently-boxed sibling.** A + Siloed 2.0 pool maps each remote chain to an `ERC20LockBox`, and a release is paid out of the box mapped + to the chain the message CAME from. A remote-to-remote lane between two DIFFERENT boxes moves supply + without moving liquidity: value that entered through box A leaves from the other chain, whose box never + received it. Measured on staging (1.0 in each box, 1.0 supply on each remote): a 0.4 hop between the two + remotes left both boxes untouched at 1.0 while the supplies moved to 0.6 and 1.4, so 0.4 of one chain's + tokens are backed by the other chain's box. Nothing reverts at the time - the first return larger than + the box does, with `InsufficientBalance(requested, available)`, and until then the drift is invisible. + **Routing through the hub is fine and is the intended topology**: the inbound leg releases from one box + and the outbound leg locks into the other, so liquidity and supply move together (measured: a hub-routed + hop left every box equal to its own chain's supply). Chains that should trade directly belong on the + SAME box. `make doctor` FAILs such a pair when it can see both sides - it reads DECLARED lanes from each + peer's project store, so a peer store you do not keep locally is reported as unchecked, not as clean, + and the on-chain lane itself is only removed by `RemoveChain`. + - **A successful call can still revert your frame.** `try C(a).f() returns (string memory)` routes a REVERT to its catch, but the return data is decoded in the CALLER's frame after the call already diff --git a/docs/operations/liquidity.md b/docs/operations/liquidity.md index c6d8767..6ef7429 100644 --- a/docs/operations/liquidity.md +++ b/docs/operations/liquidity.md @@ -179,6 +179,13 @@ LOCK_BOXES=AVALANCHE_TESTNET_FUJI=,ETHEREUM_TESTNET_SEPOLIA_BASE_1= \ (Safe mode warns instead, since the batch may map it). - `configureLockBoxes` never removes an entry, and does not check that the pool may use the box; `ConfigureLockBoxes` refuses when it may not. +- **A silo isolates a chain only while that chain has no DIRECT lane to a differently-boxed sibling.** Two + remotes served by DIFFERENT boxes must not have a lane to each other: supply moves while liquidity does + not, so one box ends up holding tokens no supply can claim and the other cannot cover its own chain. + Routing between them THROUGH the hub is fine - that path releases from one box and locks into the other. + Chains that should trade directly belong on the SAME box. `make doctor` fails such a pair when it can + read both peers' project stores, and says so when it cannot; see + [the gotcha](../gotchas/index.md#silos-need-no-second-route) for the measured drift. - `make doctor` fails a supported chain with no box, a box that does not authorize the pool, or a box for another token, and warns about a box still mapped to a removed chain. `snapshot-chain` records the boxes under `roles.lockboxes`, and `roles-check` audits each one. diff --git a/docs/operations/pools.md b/docs/operations/pools.md index e7c50d8..5f7f189 100644 --- a/docs/operations/pools.md +++ b/docs/operations/pools.md @@ -142,6 +142,11 @@ forge script \ The token already exists on both chains independently. Each chain needs its own `ERC20LockBox` and `LockReleaseTokenPool`. +This is a liquidity bridge, not a mint/burn mesh: every release is paid out of the destination's own +liquidity, so the busy direction drains and somebody has to rebalance. Fund and monitor both sides; +`make doctor` warns once per such lane. It is a different thing from two silos of ONE pool talking to each +other, which never balances and is refused - see [siloed pools](liquidity.md#siloed-pools). + ```bash # 1. Deploy ERC20LockBox on Ethereum Sepolia forge script \ diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md index 2afa62c..47ce453 100644 --- a/docs/troubleshooting/index.md +++ b/docs/troubleshooting/index.md @@ -192,6 +192,40 @@ after a `forge script` run, with no `--broadcast`. re-adding a lane silently reactivates stale config. - **Verify.** `make doctor` reports the lane with a remote pool present. +## `make doctor` FAILs that two chains "hold their liquidity in different lock boxes" + +- **Diagnosis.** The Siloed 2.0 pool maps those two remotes to different `ERC20LockBox`es, and the two + declare a lane to each other, so a token locked into one box is released from the other. Read the + mapping with `PROJECT_GROUP= forge script script/configure/siloed/GetSiloedPoolState.s.sol + --rpc-url "$(bash script/config/rpc-url.sh )"` (the chain comes from the RPC, not an env + var) and the lane with + `jq '.lanes' project/[/].json`. Either side declaring the lane is enough to break the + backing. +- **Fix.** Decide which the two chains are. Meant to be isolated: **`RemoveChain` on both remote pools + first** - that is the write that stops the traffic - and only then `make remove-lane LOCAL= + REMOTE= BOTH=1`, which edits the declaration. Meant to trade freely: put them on one box - move the + liquidity (`WithdrawFromLockBox` then `DepositToLockBox`) and `ConfigureLockBoxes` the second chain onto + the first one's box. `configureLockBoxes` is set-only, so the remapping is the write; the emptied box + stays deployed. +- **Verify.** Read the CHAIN, not the declaration: `cast call "isSupportedChain(uint64)(bool)" + --rpc-url ` must answer `false` on both sides. Removing only the + declaration silences this finding while the lane keeps draining - doctor reads declared lanes, so a + green run after `remove-lane` alone proves nothing. Then `make doctor CHAIN=` returns + VERIFIED. See [the gotcha](../gotchas/index.md#silos-need-no-second-route). + +## A release into a Siloed 2.0 pool reverts with `InsufficientBalance` + +- **Diagnosis.** The box mapped to the source chain holds less than the message asks for. Two causes: + the silo was never funded to that level, or two remotes on different boxes have a lane to each other + and have been trading over it, which moves supply without moving liquidity (the gotcha above). Compare + each box's balance against its chain's supply: `cast call 'balanceOf(address)(uint256)' ` + on the pool's chain vs `cast call 'totalSupply()(uint256)' --rpc-url `. A box + holding MORE than its chain's supply, with another box holding less, is the cross-lane case. +- **Fix.** Fund the box (`DepositToLockBox`) to cover the in-flight amount, then remove the cross lane or + consolidate the two chains onto one box so the drift cannot recur. `make preflight SOURCE_CHAIN= + DEST_CHAIN= TOKEN= AMOUNT= RECEIVER=` reproduces the revert before a real send. +- **Verify.** The same `make preflight` returns `GO:`, and `make doctor CHAIN=` is VERIFIED. + ## Advanced forensics: a leg that failed then recovered The REST API and `ccip-cli show` report only the final `SUCCESS` and hide an earlier failed attempt. To diff --git a/script/config/VerifyChain.s.sol b/script/config/VerifyChain.s.sol index eebbb11..5e6a668 100644 --- a/script/config/VerifyChain.s.sol +++ b/script/config/VerifyChain.s.sol @@ -1782,8 +1782,11 @@ contract VerifyChain is Script { ) { _reconcileSiloedLockBoxes(pool); } + bool localLockRelease = + versionKnown && PoolVersion._isLockReleaseFamily(PoolVersion._typePrefixOf(typeAndVersion)); uint64[] memory declaredSelectors = new uint64[](declared.length); for (uint256 i = 0; i < declared.length; i++) { + if (localLockRelease) _warnLockAndLock(declared[i]); declaredSelectors[i] = _checkDeclaredLaneOnChain(projectJson, pool, version, declared[i]); } bool reverseChecked = _checkOnChainLanesDeclared(name, pool, declaredSelectors); @@ -2210,7 +2213,19 @@ contract VerifyChain is Script { ); } } catch { - _fail(string.concat("lockboxes: ", label, " does not answer getAllAuthorizedCallers()")); + // A custom box may implement only ILockBox (deposit/withdraw/isTokenSupported), where + // getAllAuthorizedCallers is absent by design: unverifiable, not broken. + if (token != address(0) && _answersIsTokenSupported(box, token)) { + _warn( + string.concat( + "lockboxes: ", + label, + " does not expose getAllAuthorizedCallers() - authorization NOT verified; confirm the pool may call it" + ) + ); + } else { + _fail(string.concat("lockboxes: ", label, " does not answer getAllAuthorizedCallers()")); + } } if (token != address(0)) { try s_probe.lockBoxSupports(box, token) returns (bool ok) { @@ -2229,6 +2244,7 @@ contract VerifyChain is Script { ); } } + _checkSiloIsolation(boxes, chains); if (s_fails == failsBefore && s_warns == warnsBefore) { _pass( string.concat( @@ -2242,6 +2258,221 @@ contract VerifyChain is Script { } } + /// @dev Lock-release on BOTH ends of a lane is a liquidity bridge, not a mint/burn mesh: a transfer + /// locks on one side and pays out of the other side's own liquidity, which drains in the busy + /// direction until someone rebalances. Legitimate (a canonical token that exists on both chains), + /// so it WARNs once per lane rather than failing - unlike two silos of one pool talking to each + /// other, which is never sound and FAILs above. Peer type comes from the peer's own project store. + function _warnLockAndLock(string memory remote) private { + string memory key = _peerPoolKey(remote); + if (bytes(key).length == 0 || !_contains(key, "LockReleaseTokenPool_")) return; + // A SILOED peer is a different shape: it pays from the lock box mapped to this chain, not from one + // pooled balance, so the rebalancing duty is per silo. Same hazard, different place to look. + bool siloedPeer = _contains(key, "SiloedLockReleaseTokenPool_"); + _warn( + string.concat( + "lanes: ", + remote, + siloedPeer ? " runs a siloed lock-release pool too (" : " runs a lock-release pool too (", + key, + siloedPeer + ? ") - this lane pays each release out of the lock box that peer maps to this chain, so it" + " drains one way and needs rebalancing per silo (docs/operations/liquidity.md)." + : ") - this lane pays each release out of the destination's own liquidity, so it drains one" + " way and needs rebalancing; fund and monitor both sides (docs/operations/liquidity.md)." + ) + ); + } + + /// @dev The peer's `deployments{}` key naming its ACTIVE pool (the key carries the pool type and + /// version), or "" when the peer store is absent, unreadable, or names no pool. + function _peerPoolKey(string memory remote) private view returns (string memory) { + string memory path = ProjectStore._path(remote); + if (!vm.exists(path)) return ""; + string memory json; + try s_probe.readFileFor(path) returns (string memory data) { + json = data; + } catch { + return ""; + } + string memory active; + try s_probe.parseString(json, ".addresses.active.tokenPool") returns (string memory a) { + active = a; + } catch { + return ""; + } + string[] memory keys; + try s_probe.keysOf(json, ".addresses.deployments") returns (string[] memory k) { + keys = k; + } catch { + return ""; + } + for (uint256 i = 0; i < keys.length; i++) { + // Bracket notation: version keys (`..._2.0.0`) carry dots that dot-path notation mis-splits. + try s_probe.parseString(json, string.concat(".addresses.deployments[\"", keys[i], "\"]")) returns ( + string memory v + ) { + if (_eqIgnoreCase(v, active)) return keys[i]; + } catch {} + } + return ""; + } + + function _eqIgnoreCase(string memory a, string memory b) private pure returns (bool) { + bytes memory x = bytes(a); + bytes memory y = bytes(b); + if (x.length != y.length) return false; + for (uint256 i = 0; i < x.length; i++) { + bytes1 cx = (x[i] >= "A" && x[i] <= "Z") ? bytes1(uint8(x[i]) + 32) : x[i]; + bytes1 cy = (y[i] >= "A" && y[i] <= "Z") ? bytes1(uint8(y[i]) + 32) : y[i]; + if (cx != cy) return false; + } + return true; + } + + /// @dev A silo isolates a chain only while that chain has no OTHER route into the mesh. Two remotes + /// served by DIFFERENT boxes that also talk to each other break it: tokens locked into box A can + /// leave via the other chain and come back demanding a release from box B, which never received + /// them - the transfer reverts and box A holds liquidity no supply can claim any more. Chains + /// mapped to the SAME box share liquidity by design and are unaffected. + /// @dev Reads each remote's DECLARED lanes from its own project store (the mesh rung's evidence), not + /// the remote chain: this run is forked to the local chain only. + function _checkSiloIsolation(SiloedLockReleaseTokenPool.LockBoxConfig[] memory boxes, uint64[] memory supported) + private + { + // Resolve each box once: the catalog and every peer store are read one time, not once per pair. + string[] memory names = new string[](boxes.length); + string[][] memory lanes = new string[][](boxes.length); + bool[] memory usable = new bool[](boxes.length); + for (uint256 i = 0; i < boxes.length; i++) { + // A stale entry for a chain the pool no longer serves already WARNs above; it carries no route. + if (!_containsSelector(supported, boxes[i].remoteChainSelector)) continue; + // Only a chain that could be isolated FROM another box raises an isolation question at all. + if (!_sharesNoBoxWithSomeSibling(boxes, supported, i)) continue; + names[i] = _chainNameBySelector(boxes[i].remoteChainSelector); + if (bytes(names[i]).length == 0) { + _warn( + string.concat( + "lockboxes: silo isolation NOT checked for ", + _selectorLabel(boxes[i].remoteChainSelector), + " - no chain file names that selector (make add-chain), so its lanes cannot be read" + ) + ); + continue; + } + (uint8 status, string[] memory declared) = _declaredLaneNamesStatus(names[i]); + if (status == LANES_UNREADABLE) { + _fail( + string.concat( + "lockboxes: silo isolation NOT checked for ", + names[i], + " - its project store exists but does not parse; repair it (make fmt-config) and re-run" + ) + ); + continue; + } + if (status == LANES_ABSENT) { + _warn( + string.concat( + "lockboxes: silo isolation NOT checked for ", + names[i], + " - no project store here, so a lane it declares to another silo cannot be seen" + ) + ); + continue; + } + lanes[i] = declared; + usable[i] = true; + } + // One finding per PAIR (i < j), and a lane declared from either side proves the route exists. + for (uint256 i = 0; i < boxes.length; i++) { + if (!usable[i]) continue; + string memory nameI = names[i]; + string[] memory lanesI = lanes[i]; + for (uint256 j = i + 1; j < boxes.length; j++) { + if (boxes[i].lockBox == boxes[j].lockBox) continue; + if (!usable[j]) continue; + string memory nameJ = names[j]; + if (!_namesContain(lanesI, nameJ) && !_namesContain(lanes[j], nameI)) continue; + _fail( + string.concat( + "lockboxes: ", + nameI, + " and ", + nameJ, + " hold their liquidity in different lock boxes (", + vm.toString(boxes[i].lockBox), + " vs ", + vm.toString(boxes[j].lockBox), + ") yet declare a lane to each other - tokens locked for one are released from the", + " other's box, so the silos stop backing their own chain. Remove that lane, or map", + " both chains to one box (configure/siloed/ConfigureLockBoxes.s.sol)." + ) + ); + } + } + } + + /// @dev Whether some other live box in the set holds different liquidity from box `i` - the only case + /// where a lane between the two would break the backing, and so the only case worth reporting on. + function _sharesNoBoxWithSomeSibling( + SiloedLockReleaseTokenPool.LockBoxConfig[] memory boxes, + uint64[] memory supported, + uint256 i + ) private pure returns (bool) { + for (uint256 j = 0; j < boxes.length; j++) { + if (j == i || boxes[j].lockBox == boxes[i].lockBox) continue; + if (_containsSelector(supported, boxes[j].remoteChainSelector)) return true; + } + return false; + } + + uint8 internal constant LANES_OK = 0; + uint8 internal constant LANES_ABSENT = 1; + uint8 internal constant LANES_UNREADABLE = 2; + + /// @dev The remote names a chain declares lanes to, from its own project store, WITH why the answer is + /// empty: a store this operator does not keep (ABSENT) reads differently from one that does not + /// parse (UNREADABLE). Collapsing both into "no lanes" turns "could not look" into "looks fine". + function _declaredLaneNamesStatus(string memory name) private view returns (uint8, string[] memory) { + string memory path = ProjectStore._path(name); + string[] memory none = new string[](0); + if (!vm.exists(path)) return (LANES_ABSENT, none); + string memory json; + try s_probe.readFileFor(path) returns (string memory data) { + json = data; + } catch { + return (LANES_UNREADABLE, none); + } + try s_probe.hasKey(json, ".lanes") returns (bool has) { + if (!has) return (LANES_OK, none); + } catch { + return (LANES_UNREADABLE, none); + } + try s_probe.keysOf(json, ".lanes") returns (string[] memory keys) { + return (LANES_OK, keys); + } catch { + return (LANES_UNREADABLE, none); + } + } + + /// @dev Whether the address answers the ILockBox token query at all - the tell that it is a lock box + /// with a narrower ABI rather than a wrong or dead address. + function _answersIsTokenSupported(address box, address token) private view returns (bool) { + try s_probe.lockBoxSupports(box, token) returns (bool) { + return true; + } catch { + return false; + } + } + + function _namesContain(string[] memory names, string memory name) private pure returns (bool) { + for (uint256 i = 0; i < names.length; i++) { + if (keccak256(bytes(names[i])) == keccak256(bytes(name))) return true; + } + return false; + } + function _hasLockBox(SiloedLockReleaseTokenPool.LockBoxConfig[] memory boxes, uint64 selector) private pure diff --git a/test/config/VerifyChainSiloIsolation.t.sol b/test/config/VerifyChainSiloIsolation.t.sol new file mode 100644 index 0000000..0ad774b --- /dev/null +++ b/test/config/VerifyChainSiloIsolation.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {VerifyChain} from "../../script/config/VerifyChain.s.sol"; +import {ProjectStore} from "../../src/utils/ProjectStore.sol"; +import {LaneReconcileScratch} from "./VerifyChainLaneReconcile.t.sol"; +import {MockSiloedPool, MockSiloedLockBox} from "./VerifyChainSiloedLockBoxes.t.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; + +/// @dev A 2.0 LockRelease pool with one lane and disabled buckets, so a declared 0/0 lane reconciles clean +/// and only the lock-and-lock rung can speak. +contract MockLockReleaseV2Pool { + uint64 private immutable i_selector; + + constructor(uint64 selector) { + i_selector = selector; + } + + function typeAndVersion() external pure returns (string memory) { + return "LockReleaseTokenPool 2.0.0"; + } + + function isSupportedChain(uint64 selector) external view returns (bool) { + return selector == i_selector; + } + + function getSupportedChains() external view returns (uint64[] memory chains) { + chains = new uint64[](1); + chains[0] = i_selector; + } + + function getCurrentRateLimiterState(uint64, bool) + external + pure + returns (RateLimiter.TokenBucket memory outbound, RateLimiter.TokenBucket memory inbound) + {} +} + +/// @notice doctor's silo-isolation rung: two remotes of one siloed pool that are served by DIFFERENT lock +/// boxes must not declare a lane to each other, or tokens locked for one are released from the other's box. +/// Chains mapped to the SAME box share liquidity by design and stay allowed. +contract VerifyChainSiloIsolationTest is LaneReconcileScratch { + // One set of basenames per test: forge runs a suite's tests in parallel and they would race. + string[5] internal SUITES = ["fail", "sharedbox", "nolane", "reverse", "lockandlock"]; + address internal constant TOKEN = address(0x7070); + + function setUp() public { + for (uint256 i = 0; i < SUITES.length; i++) { + _cleanOne(SUITES[i]); + } + } + + function _names(string memory t) internal pure returns (string memory home, string memory a, string memory b) { + home = string.concat("zz-scratch-siloiso-", t, "-home"); + a = string.concat("zz-scratch-siloiso-", t, "-a"); + b = string.concat("zz-scratch-siloiso-", t, "-b"); + } + + function _cleanOne(string memory t) private { + (string memory home, string memory a, string memory b) = _names(t); + _cleanupScratchOne(home); + _cleanupScratchOne(a); + _cleanupScratchOne(b); + } + + /// @dev A siloed pool serving A and B, with `sharedBox` deciding whether they share liquidity, and + /// `laneAtoB` whether A declares a lane to B in its own store. + function _run(string memory t, uint256 seed, bool sharedBox, bool laneAtoB, bool laneBtoA) + internal + returns (uint256 fails, uint256 warns) + { + (string memory HOME, string memory A, string memory B) = _names(t); + uint64 SEL_A = uint64(8_878_000_000_000_000_001 + seed * 1_000_000_000); + uint64 SEL_B = uint64(8_878_000_000_000_000_002 + seed * 1_000_000_000); + _writeScratchChain(HOME, 887_800_000 + seed * 10, uint64(8_878_000_000_000_000_003 + seed * 1_000_000_000)); + _writeScratchChain(A, 887_800_001 + seed * 10, SEL_A); + _writeScratchChain(B, 887_800_002 + seed * 10, SEL_B); + + MockSiloedPool pool = new MockSiloedPool("SiloedLockReleaseTokenPool 2.0.0", TOKEN); + pool.addChain(SEL_A); + pool.addChain(SEL_B); + address boxA = address(new MockSiloedLockBox(TOKEN, address(pool))); + address boxB = sharedBox ? boxA : address(new MockSiloedLockBox(TOKEN, address(pool))); + pool.mapBox(SEL_A, boxA); + pool.mapBox(SEL_B, boxB); + + ProjectStore._seedIfAbsent(A); + ProjectStore._seedIfAbsent(B); + if (laneAtoB) _declareLane(A, B, _laneEntry(SEL_B, 1, 1, "")); + if (laneBtoA) _declareLane(B, A, _laneEntry(SEL_A, 1, 1, "")); + + (fails, warns) = new VerifyChain().checkLanesOnChainForTest(HOME, address(pool)); + _cleanOne(t); + } + + function test_Fail_TwoSilosTalkingToEachOther() public { + (uint256 fails,) = _run("fail", 1, false, true, false); + assertEq(fails, 1, "a lane between two differently-boxed silos must FAIL"); + } + + function test_Pass_SameBoxChainsMayTalk() public { + (uint256 fails,) = _run("sharedbox", 2, true, true, true); + assertEq(fails, 0, "chains sharing one box share liquidity by design"); + } + + /// @dev The route exists whichever side declared it: one finding per pair, from either store. + function test_Fail_LaneDeclaredOnlyByTheFarSide() public { + (uint256 fails,) = _run("reverse", 4, false, false, true); + assertEq(fails, 1, "a lane declared only in the peer's store still breaks isolation, and is reported once"); + } + + /// @dev Lock-release on BOTH ends is a liquidity bridge: legitimate, but it drains one way, so it WARNs + /// rather than failing. The peer's type comes from its own store's deployments key. + function test_Warn_LockReleaseOnBothEnds() public { + (string memory HOME, string memory PEER,) = _names("lockandlock"); + uint64 peerSelector = 8_878_900_010_000_000_001; + _writeScratchChain(HOME, 887_890_001, 8_878_900_020_000_000_001); + _writeScratchChain(PEER, 887_890_002, peerSelector); + + ProjectStore._seedIfAbsent(PEER); + address peerPool = address(0xCAFE); + vm.writeJson( + string.concat( + "{\"active\":{\"tokenPool\":\"", + vm.toString(peerPool), + "\"},\"deployments\":{\"ZZ_LockReleaseTokenPool_2.0.0\":\"", + vm.toString(peerPool), + "\"}}" + ), + _projPath(PEER), + ".addresses" + ); + _declareLane(HOME, PEER, _laneEntry(peerSelector, 0, 0, "")); + + MockLockReleaseV2Pool pool = new MockLockReleaseV2Pool(peerSelector); + (uint256 fails, uint256 warns) = new VerifyChain().checkLanesOnChainForTest(HOME, address(pool)); + assertEq(fails, 0, "a lock-and-lock lane is legitimate, never a FAIL"); + assertEq(warns, 1, "it warns once, naming the rebalancing duty"); + _cleanOne("lockandlock"); + } + + function test_Pass_DifferentBoxesWithoutALane() public { + (uint256 fails,) = _run("nolane", 3, false, false, false); + assertEq(fails, 0, "different boxes are the point of siloing; only a lane between them breaks it"); + } +} diff --git a/test/config/VerifyChainSiloIsolationGaps.t.sol b/test/config/VerifyChainSiloIsolationGaps.t.sol new file mode 100644 index 0000000..fa74188 --- /dev/null +++ b/test/config/VerifyChainSiloIsolationGaps.t.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {VerifyChain} from "../../script/config/VerifyChain.s.sol"; +import {ProjectStore} from "../../src/utils/ProjectStore.sol"; +import {LaneReconcileScratch} from "./VerifyChainLaneReconcile.t.sol"; +import {MockSiloedPool, MockSiloedLockBox} from "./VerifyChainSiloedLockBoxes.t.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; + +/// @dev A box that implements only the ILockBox surface (no getAllAuthorizedCallers) - the shape a custom +/// adapter would have. +contract MockIlockBoxOnly { + address internal immutable i_token; + + constructor(address token) { + i_token = token; + } + + function isTokenSupported(address token) external view returns (bool) { + return token == i_token; + } +} + +/// @dev A siloed pool that also answers the rate-limit getter, so a declared 0/0 lane reconciles clean and +/// only the lock-and-lock rung speaks. +contract MockSiloedPoolWithBuckets is MockSiloedPool { + constructor(string memory version, address token_) MockSiloedPool(version, token_) {} + + function getCurrentRateLimiterState(uint64, bool) + external + pure + returns (RateLimiter.TokenBucket memory outbound, RateLimiter.TokenBucket memory inbound) + {} +} + +/// @notice The silo-isolation rung must never answer "looks fine" when it could not look: a missing chain +/// file, an absent or malformed peer store, a box for a chain the pool dropped, a narrower custom box. +contract VerifyChainSiloIsolationGapsTest is LaneReconcileScratch { + address internal constant TOKEN = address(0x7171); + + function _names(string memory t) internal pure returns (string memory home, string memory a, string memory b) { + home = string.concat("zz-scratch-repro-", t, "-home"); + a = string.concat("zz-scratch-repro-", t, "-a"); + b = string.concat("zz-scratch-repro-", t, "-b"); + } + + function _clean(string memory t) private { + (string memory home, string memory a, string memory b) = _names(t); + _cleanupScratchOne(home); + _cleanupScratchOne(a); + _cleanupScratchOne(b); + } + + /// F2: B's chain file is missing from config/chains, so the pair is skipped - silently. + function test_Warn_SelectorWithNoChainFile() public { + string memory t = "f2"; + _clean(t); + (string memory HOME, string memory A, string memory B) = _names(t); + uint64 SEL_A = 7_101_000_000_000_000_001; + uint64 SEL_B = 7_101_000_000_000_000_002; + _writeScratchChain(HOME, 710_100_003, 7_101_000_000_000_000_003); + _writeScratchChain(A, 710_100_001, SEL_A); + // B: NO chain file written on purpose. + + MockSiloedPool pool = new MockSiloedPool("SiloedLockReleaseTokenPool 2.0.0", TOKEN); + pool.addChain(SEL_A); + pool.addChain(SEL_B); + address boxA = address(new MockSiloedLockBox(TOKEN, address(pool))); + address boxB = address(new MockSiloedLockBox(TOKEN, address(pool))); + pool.mapBox(SEL_A, boxA); + pool.mapBox(SEL_B, boxB); + + ProjectStore._seedIfAbsent(A); + ProjectStore._seedIfAbsent(B); + _declareLane(A, B, _laneEntry(SEL_B, 1, 1, "")); + + (uint256 fails, uint256 warns) = new VerifyChain().checkLanesOnChainForTest(HOME, address(pool)); + _clean(t); + assertEq(fails, 0, "an unreadable catalog is not proof of a hazard"); + assertGt(warns, 0, "a boxed selector with no chain file must be reported, not skipped in silence"); + } + + /// F3: A's project store is unreadable (malformed JSON) -> _declaredLaneNames returns "no lanes". + function test_Fail_UnreadablePeerStore() public { + string memory t = "f3"; + _clean(t); + (string memory HOME, string memory A, string memory B) = _names(t); + uint64 SEL_A = 7_102_000_000_000_000_001; + uint64 SEL_B = 7_102_000_000_000_000_002; + _writeScratchChain(HOME, 710_200_003, 7_102_000_000_000_000_003); + _writeScratchChain(A, 710_200_001, SEL_A); + _writeScratchChain(B, 710_200_002, SEL_B); + + MockSiloedPool pool = new MockSiloedPool("SiloedLockReleaseTokenPool 2.0.0", TOKEN); + pool.addChain(SEL_A); + pool.addChain(SEL_B); + address boxA = address(new MockSiloedLockBox(TOKEN, address(pool))); + address boxB = address(new MockSiloedLockBox(TOKEN, address(pool))); + pool.mapBox(SEL_A, boxA); + pool.mapBox(SEL_B, boxB); + + ProjectStore._seedIfAbsent(A); + ProjectStore._seedIfAbsent(B); + _declareLane(A, B, _laneEntry(SEL_B, 1, 1, "")); + // Corrupt A's store AFTER declaring the lane: the hazard is real, the evidence is unreadable. + vm.writeFile(_projPath(A), "{ this is not json"); + + (uint256 fails,) = new VerifyChain().checkLanesOnChainForTest(HOME, address(pool)); + _clean(t); + assertEq(fails, 1, "a peer store that exists but does not parse FAILs: could not look is not looks fine"); + } + + /// F4: a box mapped to a chain the pool no longer supports still takes part in the pair check. + function test_Pass_StaleBoxForRemovedChain() public { + string memory t = "f4"; + _clean(t); + (string memory HOME, string memory A, string memory B) = _names(t); + uint64 SEL_A = 7_103_000_000_000_000_001; + uint64 SEL_B = 7_103_000_000_000_000_002; + _writeScratchChain(HOME, 710_300_003, 7_103_000_000_000_000_003); + _writeScratchChain(A, 710_300_001, SEL_A); + _writeScratchChain(B, 710_300_002, SEL_B); + + MockSiloedPool pool = new MockSiloedPool("SiloedLockReleaseTokenPool 2.0.0", TOKEN); + pool.addChain(SEL_A); // B was REMOVED from the pool (RemoveChain), A is still live. + address boxA = address(new MockSiloedLockBox(TOKEN, address(pool))); + address boxB = address(new MockSiloedLockBox(TOKEN, address(pool))); + pool.mapBox(SEL_A, boxA); + pool.mapBox(SEL_B, boxB); // stale: configureLockBoxes cannot remove it + + ProjectStore._seedIfAbsent(A); + ProjectStore._seedIfAbsent(B); + _declareLane(A, B, _laneEntry(SEL_B, 1, 1, "")); + + (uint256 fails,) = new VerifyChain().checkLanesOnChainForTest(HOME, address(pool)); + _clean(t); + assertEq(fails, 0, "a box left behind for a chain the pool dropped carries no route, so it cannot FAIL"); + } + + /// F7: the peer runs a SILOED lock-release pool; the substring match calls it a lock-and-lock bridge. + function test_Warn_SiloedPeerUsesSiloedWording() public { + string memory t = "f7"; + _clean(t); + (string memory HOME, string memory PEER,) = _names(t); + uint64 peerSelector = 7_104_000_000_000_000_001; + _writeScratchChain(HOME, 710_400_001, 7_104_000_000_000_000_002); + _writeScratchChain(PEER, 710_400_002, peerSelector); + + ProjectStore._seedIfAbsent(PEER); + address peerPool = address(0xBEEF); + vm.writeJson( + string.concat( + "{\"active\":{\"tokenPool\":\"", + vm.toString(peerPool), + "\"},\"deployments\":{\"ZZ_SiloedLockReleaseTokenPool_2.0.0\":\"", + vm.toString(peerPool), + "\"}}" + ), + _projPath(PEER), + ".addresses" + ); + _declareLane(HOME, PEER, _laneEntry(peerSelector, 0, 0, "")); + + MockSiloedPoolWithBuckets pool = new MockSiloedPoolWithBuckets("SiloedLockReleaseTokenPool 2.0.0", TOKEN); + pool.addChain(peerSelector); + address box = address(new MockSiloedLockBox(TOKEN, address(pool))); + pool.mapBox(peerSelector, box); + + VerifyChain checker = new VerifyChain(); + (, uint256 warns) = checker.checkLanesOnChainForTest(HOME, address(pool)); + string memory warned = checker.lastWarnForTest(); + _clean(t); + assertGt(warns, 0, "a siloed peer still warns"); + // The wording must name the silo, not the plain two-sided bridge docs/operations/pools.md distinguishes. + assertTrue(vm.indexOf(warned, "siloed lock-release pool") != type(uint256).max, warned); + assertTrue(vm.indexOf(warned, "per silo") != type(uint256).max, warned); + } + + /// F8: a box implementing only ILockBox (no getAllAuthorizedCallers) hard-FAILs the lock box rung. + function test_Warn_IlockBoxOnlyAdapterIsUnverified() public { + string memory t = "f8"; + _clean(t); + (string memory HOME, string memory A,) = _names(t); + uint64 SEL_A = 7_105_000_000_000_000_001; + _writeScratchChain(HOME, 710_500_003, 7_105_000_000_000_000_003); + _writeScratchChain(A, 710_500_001, SEL_A); + + MockSiloedPool pool = new MockSiloedPool("SiloedLockReleaseTokenPool 2.0.0", TOKEN); + pool.addChain(SEL_A); + pool.mapBox(SEL_A, address(new MockIlockBoxOnly(TOKEN))); + + ProjectStore._seedIfAbsent(A); + + (uint256 fails, uint256 warns) = new VerifyChain().checkLanesOnChainForTest(HOME, address(pool)); + _clean(t); + assertEq(fails, 0, "a box that answers ILockBox is not broken just because it exposes no caller list"); + assertGt(warns, 0, "but the authorization is unverified, and says so"); + } +} diff --git a/test/config/VerifyChainSiloedLockBoxes.t.sol b/test/config/VerifyChainSiloedLockBoxes.t.sol index 743f6be..7db3de7 100644 --- a/test/config/VerifyChainSiloedLockBoxes.t.sol +++ b/test/config/VerifyChainSiloedLockBoxes.t.sol @@ -104,7 +104,9 @@ contract VerifyChainSiloedLockBoxesTest is LaneReconcileScratch { pool.mapBox(CHAIN_B, address(new MockSiloedLockBox(TOKEN, address(pool)))); (uint256 fails, uint256 warns) = _run(pool); assertEq(fails, 0, "no FAIL"); - assertEq(warns, 2, "only the two undeclared-lane WARNs"); + // Two boxes, two undeclared lanes, and neither selector has a chain file here: the isolation rung + // says so per chain rather than skipping the pair in silence. + assertEq(warns, 4, "two undeclared-lane WARNs + two 'isolation not checked' WARNs"); } function test_Fail_ChainWithoutLockBox() public {