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 {