Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/config-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
code=$?
echo "$out"
if [ "$code" -eq 1 ]; then
echo "::warning::roles-check found authority drift - a declared role no longer matches the live chain; investigate (docs/roles.md drift runbook)"
echo "::warning::roles-check exit 1: authority drift OR a refused read (a declared value that could not be read) - see the [FAIL] lines; docs/roles.md drift runbook, verdict (c) for refused reads"
elif [ "$code" -eq 2 ]; then
echo "::warning::roles-check: an RPC was unavailable - authority drift could not be determined (flake/missing secret, not drift)"
elif echo "$out" | grep -q "checked nothing"; then
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ its `rpcEnv` and `KEYSTORE_NAME` in `.env`, then `source .env`).
make deploy-token CHAIN=<chain> TOKEN_NAME="My Token" TOKEN_SYMBOL=MTK
make deploy-pool CHAIN=<chain> # BurnMint pool; token resolved from the registry
make doctor CHAIN=<chain> # verify on-chain code at the recorded addresses
# doctor ends VERIFIED (exit 0), INCOMPLETE, or FAILED (both nonzero); a run without the chain's
# RPC env is INCOMPLETE by design - see docs/operations/chains.md
```

`make deploy-new-chain CHAIN=<chain> SELECTOR=<sel>` runs `add-chain -> deploy-token -> deploy-pool ->
Expand Down
10 changes: 6 additions & 4 deletions docs/config-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ that need it, never exported. Targets that touch the API need only `curl` + `jq`
| `fmt-config` | Repair the canonical JSON of **both** stores: `config/chains/*.json` (`jq --indent 2 -S`, trailing newline) and the project store in every group (`project/*.json` **and** `project/*/*.json`, `jq --indent 2 -S`, **no** trailing newline). Repair tool only - never a required step | - | `jq` over every config + project file (all groups) |

| `snapshot-chain` | Backfill the declared `roles{}` authority block FROM the live chain into the project store (the bootstrap for `roles-check`; see [`roles.md`](roles.md)) | `CHAIN=` (required); `GROUP=` (optional) scopes to a token group; `TOKEN=` `TOKEN_POOL=` `TAR=` `SCAN_FROM_BLOCK=` (optional overrides) | `SnapshotChain.s.sol --sig "run(string)" <CHAIN>` → canonicalize the project file. Exit: pass/fail via make |
| `roles-check` | READ-ONLY reconcile of a chain's declared `roles{}` vs the live chain (see [`roles.md`](roles.md)). Make remaps the script's exit code to pass/fail; CI calls the script directly for the full contract: 0 `CLEAN` / 1 `ROLES_DRIFT` / 2 `RPC_UNAVAILABLE` | `CHAIN=` (optional - no arg checks every declaring chain); `GROUP=` (optional) scopes to one token group | `bash script/config/roles-check.sh [<CHAIN>]` → `RolesCheck.s.sol --sig "run(string)"` per chain |
| `roles-check` | READ-ONLY reconcile of a chain's declared `roles{}` vs the live chain (see [`roles.md`](roles.md)). Make remaps the script's exit code to pass/fail; CI calls the script directly for the full contract: 0 `CLEAN` / 1 `ROLES_DRIFT` (a declared value mismatches, or its read was refused - see [`roles.md`](roles.md) verdict (c)) / 2 `RPC_UNAVAILABLE` | `CHAIN=` (optional - no arg checks every declaring chain); `GROUP=` (optional) scopes to one token group | `bash script/config/roles-check.sh [<CHAIN>]` → `RolesCheck.s.sol --sig "run(string)"` per chain |
| `roles-check-all`| The same reconcile for every chain that declares `roles{}`, across the default group AND every `project/<group>/` (exit contract as above) | - | `bash script/config/roles-check.sh` (no args, no group filter) |
| `clean-scratch` | Remove test-scratch fixtures (`zz-scratch-*`, `zz-tt-*`, `local-*`) from `config/chains`, `project`, and `history` via explicit patterns - never `git clean -X`, which would also delete real gitignored project state | - | explicit `rm` patterns |

Expand Down Expand Up @@ -240,9 +240,11 @@ lane); the doctor's **mesh rung** proves the property across the whole directory
**to** e.g. `solana-devnet` is checked for resolution but exempt from reciprocity (a SKIP, not a FAIL).

The mesh rung proves the committed policy agrees with itself; the **lanes rung** (the doctor's last
rung) proves it agrees with the **chain**. It is RPC-gated like the TAR reconciliation (a clean SKIP
when the chain's `rpcEnv` is unset) and pool-gated (a SKIP naming `make adopt-token` / the deploy
scripts when no `tokenPool` is recorded in the registry). With a fork and a pool it resolves the pool's
rung) proves it agrees with the **chain**. It is RPC-gated like the TAR reconciliation (a
`[SKIP] UNVERIFIED` when the chain's `rpcEnv` is unset - the run then ends INCOMPLETE, see
[operations/chains.md](operations/chains.md)) and pool-gated (a plain SKIP naming `make adopt-token` /
the deploy scripts when no `tokenPool` is recorded in the registry: nothing deployed means nothing
checkable, so that skip never makes the run INCOMPLETE). With a fork and a pool it resolves the pool's
contract version (`PoolVersion.tryResolve`; an unrecognized version WARNs and reads degrade to best
effort) and reconciles **both directions**:

Expand Down
10 changes: 10 additions & 0 deletions docs/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ carry an honest **`complete` marker**: `true` only when the token enumerates its
`snapshot-chain SCAN_FROM_BLOCK=<n>` event scan proved the list; `false` (candidate seed) otherwise, and
the auditor WARNs so a partial list is never read as full.

**Absence is the unread state.** A chain-readable field the snapshot could not read is **not written** -
never written as `0x0` / `false` / `[]`, which are what a failed probe returns and are indistinguishable
from real values once on disk. The snapshot logs each such field as `UNREAD` and omits it; the audit then
refuses to compare a declared value against a read that failed (`could not be read, so nothing was
compared` - a FAIL, not a PASS), and a declared `hooks`/`lockbox` contract that answers none of its
getters fails as unauditable outright. A declaration can still carry zeros
that were produced by failed reads (a snapshot written by older tooling, or a hand edit): they were
never facts, and against a still-unreadable contract they FAIL instead of reconciling - re-run
`make snapshot-chain` on a working RPC to rebuild the block from actual reads.

The token block **dispatches on a declared `type`**, because the admin model differs per template - the
engine never assumes one:

Expand Down
8 changes: 4 additions & 4 deletions docs/guides/expand-the-mesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ verification loop for `2N` lanes, not a fixed two.

## Prove the mesh is complete

`make doctor` at 0 FAIL on every chain does not prove the mesh transfers. The doctor checks two things,
and neither reads the remote pool address that a release validates against:
`make doctor` ending VERIFIED on every chain does not prove the mesh transfers. The doctor checks two
things, and neither reads the remote pool address that a release validates against:

- The **mesh rung** checks declaration reciprocity only: for each declared lane it confirms the remote's
`config/chains/<remote>.json` exists and its stored `remoteSelector` matches, and that the reciprocal
Expand All @@ -61,8 +61,8 @@ and neither reads the remote pool address that a release validates against:
rate-limit and policy value matches the live local pool. It does not read which remote pool address the
local pool has registered for that lane.

So a pool can be doctor 0-FAIL for a lane, with `isSupportedChain(remote)` true and every rate limit
matching, while the remote pool it has registered points at a decommissioned pool. A transfer over that
So a pool can pass the doctor (VERIFIED) for a lane, with `isSupportedChain(remote)` true and every
rate limit matching, while the remote pool it has registered points at a decommissioned pool. A transfer over that
lane then reverts `InvalidSourcePoolAddress` on release, because the destination pool validates the
message's `sourcePoolAddress` against its registered remote pools and the wired-in pool is not in the set.
See [Remove a remote pool](../operations/lanes-and-remotes.md#remove-a-remote-pool) for the same check at
Expand Down
8 changes: 5 additions & 3 deletions docs/guides/health-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ the complete gate.

## What a clean run looks like

`make doctor` green up to the expected project-placeholder warnings, `make roles-check` at exit 0, every
contract verified on its explorer, and a `SUCCESS` smoke transfer each way. Anything less is a launch
blocker, not a warning to note and move past.
`make doctor` ends VERIFIED (with any warnings beyond the expected project-placeholder ones
investigated), `make roles-check` at exit 0, every contract verified on its explorer, and a `SUCCESS`
smoke transfer each way. A doctor run that ends INCOMPLETE checked less than it claims - close the
`[SKIP] UNVERIFIED` gaps (usually an unset RPC env) before reading anything into it. Anything less is a
launch blocker, not a warning to note and move past.
12 changes: 11 additions & 1 deletion docs/operations/chains.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,17 @@ with `_` so the derived `rpcEnv` is a valid shell variable name (`0g-testnet-gal
`_0G_TESTNET_GALILEO_1`). `add-chain` prints the exact `chainNameIdentifier` and
`rpcEnv` names it generated, plus your next steps: add the chain's RPC env var to `.env`, review the
generated defaults in the config file, wire a lane with `make add-lane`, and re-run the doctor until it
reports 0 FAIL.
reports VERIFIED.

The doctor ends in one of three verdicts, not a pass/fail boolean. FAILED means a check ran and
contradicted the config - including a declared `roles{}` value whose read failed, which the roles rung
refuses as a FAIL rather than skipping. INCOMPLETE means no check failed but an infrastructure gap kept
checks from running at all - an unset RPC env, a pool that did not answer the lanes reverse-check - and
the run exits nonzero rather than clean: a wrapper reading exit 0 there would treat an unchecked
chain as a healthy one. Each counted gap is tagged
`[SKIP] UNVERIFIED` in the output. VERIFIED (exit 0) means every applicable check ran. Designed
absences (a non-EVM chain's EVM rungs, an optional block that is not declared) are plain SKIPs and
never make a run INCOMPLETE.

Names may contain underscores, which some CCIP selectorNames use (e.g.
`binance_smart_chain-mainnet`); pass the name from `make discover` verbatim.
Expand Down
9 changes: 7 additions & 2 deletions docs/operations/dynamic-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,10 @@ ROUTER=0xYourRouterAddress \
| Env var | Required | Description |
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `ROUTER` | No | The CCIP Router address to set on the pool (default: current on-chain value) |
| `RATE_LIMIT_ADMIN` | No | Rate limit admin address (default: current on-chain value, then broadcaster) |
| `FEE_ADMIN` | No | Fee admin address (default: current on-chain value, then broadcaster). Set to `address(0)` to restrict fee withdrawal to the owner only |
| `RATE_LIMIT_ADMIN` | No | Rate limit admin address (default: current on-chain value, verbatim) |
| `FEE_ADMIN` | No | Fee admin address (default: current on-chain value, verbatim). Set to `address(0)` to restrict fee withdrawal to the owner only |

An unset variable preserves the pool's current value exactly, `address(0)` included. The call writes the
whole struct, so any other default would turn a one-field update into a silent grant of the others: a
broadcaster fallback, for example, would hand both admin slots to the acting account on a `ROUTER`-only
run whenever they are unset on chain.
3 changes: 3 additions & 0 deletions docs/operations/finality.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,6 @@ it is not.
| `INBOUND_RATE_LIMIT_CAPACITY` | No | uint128, inbound token bucket capacity (fast finality bucket) |
| `INBOUND_RATE_LIMIT_RATE` | No | uint128, inbound token bucket refill rate (tokens/second) |
| `INBOUND_RATE_LIMIT_ENABLED` | No | Override `isEnabled` explicitly (`true`/`false`; defaults to `true` when `CAPACITY` or `RATE` are set) |

The rate-limit inputs are all-or-nothing per direction; a partial set is refused naming the missing
variable. The full rule is in [Rate limits](rate-limits.md).
3 changes: 3 additions & 0 deletions docs/operations/lanes-and-remotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ DEST_CHAIN=MANTLE_SEPOLIA \
| `INBOUND_RATE_LIMIT_RATE` | No | Token bucket refill rate (tokens/second) for inbound transfers |
| `INBOUND_RATE_LIMIT_ENABLED` | No | Override `isEnabled` explicitly (`true`/`false`; defaults to `true` when CAPACITY or RATE are set) |

These env buckets follow the same all-or-nothing rule per direction as `UpdateRateLimiters`; see
[Rate limits](rate-limits.md).

`ApplyChainUpdates` only configures the standard finality rate limit bucket. To configure the fast
finality bucket, run `UpdateRateLimiters` with `FAST_FINALITY=true` after the lane is set up (see [Rate
limits](rate-limits.md)).
Expand Down
8 changes: 8 additions & 0 deletions docs/operations/rate-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ is no need to pass a separate enabled flag. A bucket is enabled automatically wh
`*_RATE` is set; pass `OUTBOUND_RATE_LIMIT_ENABLED=false` (or the inbound equivalent) to disable it
explicitly.

Within a direction the inputs are all-or-nothing: an enabled bucket needs `*_CAPACITY` and `*_RATE`
supplied together, and a run that supplies only one is refused naming the missing variable. An unset
field never becomes a `0` written on chain - a capacity without a rate would enable a bucket that lets N
tokens through and then stays shut with `TokenRateLimitReached`, while the run prints success. The
one exception is `*_ENABLED=false` on its own, which is a complete instruction: a disabled bucket's
capacity and rate are 0 by protocol rule. Values that do not fit `uint128` are refused rather than
truncated into a different live value.

The golden path for v2 lanes is to declare the policy in the local chain config and apply from the
declaration: with no rate-limit env vars, a direction resolves from the `lanes{}` entry in
`project/<local>.json` (the standard bucket from `capacity`/`rate` plus the optional `inbound{}` block,
Expand Down
37 changes: 36 additions & 1 deletion docs/primitives/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,48 @@
"when_to_use": "Configure a remote chain on a token pool: the remote pool addresses, the remote token address, and both rate limiter configs.",
"preconditions": "The executing account owns the pool. Every remote address is already chain-family encoded (EVM: abi.encode(address); SVM: raw 32 bytes).",
"postconditions": "The chain's entry on the pool matches the payload exactly.",
"failure_modes": "REPLACE, NOT MERGE: re-applying an already-configured chain removes its whole entry first, including every registered remote pool and both rate limiter configs, so anything the payload omits is lost. Mid-migration that drops the old remote pool a lane still needs (in-flight messages then fail InvalidSourcePoolAddress) or un-throttles a lane throttled on purpose. Repeating a selector inside one payload reverts NonExistentChain, because the first removal already took it out."
"failure_modes": "REPLACE, NOT MERGE: re-applying an already-configured chain removes its whole entry first, including every registered remote pool and both rate limiter configs, so anything the payload omits is lost. Mid-migration that drops the old remote pool a lane still needs (in-flight messages then fail InvalidSourcePoolAddress) or un-throttles a lane throttled on purpose. Repeating a selector inside one payload reverts NonExistentChain, because the first removal already took it out.",
"inputs": {
"OUTBOUND_RATE_LIMIT_CAPACITY": "uint128 token-bucket capacity. Per direction the inputs are all-or-nothing: an enabled bucket needs CAPACITY and RATE together, and a partial set is refused naming the missing variable.",
"OUTBOUND_RATE_LIMIT_RATE": "uint128 token-bucket refill rate. An enabled bucket needs CAPACITY and RATE together.",
"OUTBOUND_RATE_LIMIT_ENABLED": "true/false; defaults to true when CAPACITY or RATE are set. false stands alone as a disable.",
"INBOUND_RATE_LIMIT_CAPACITY": "uint128 token-bucket capacity (inbound). Same all-or-nothing rule per direction.",
"INBOUND_RATE_LIMIT_RATE": "uint128 token-bucket refill rate (inbound).",
"INBOUND_RATE_LIMIT_ENABLED": "true/false; defaults to true when CAPACITY or RATE are set. false stands alone as a disable."
}
},
"ClaimAndAcceptAdmin": {
"when_to_use": "Register a token you control in the TokenAdminRegistry in one atomic step. Prefer this over separate ClaimAdmin then AcceptAdminRole when you want a single Safe batch: AcceptAdminRole on its own preflight-requires the pending administrator to already be set, so the two standalone steps cannot be deferred into one batch, whereas this pair executes together.",
"preconditions": "The executing account must be resolvable as the token's admin through one of the three claim paths, auto-detected in precedence: getCCIPAdmin(), then owner(), then AccessControl DEFAULT_ADMIN_ROLE.",
"postconditions": "The TokenAdminRegistry administrator for the token is the executing account.",
"example": "CCIP_ADMIN_ADDRESS=0xYourAdmin forge script script/setup/ClaimAndAcceptAdmin.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast",
"failure_modes": "Reverts if no claim path resolves the executing account as the token admin, or if the token is already registered to a different administrator."
},
"SetDynamicConfig": {
"inputs": {
"ROUTER": "Optional. Unset preserves the pool's current on-chain router verbatim.",
"RATE_LIMIT_ADMIN": "Optional. Unset preserves the current on-chain value verbatim, address(0) included.",
"FEE_ADMIN": "Optional. Unset preserves the current value verbatim; an explicit address(0) restricts fee withdrawal to the owner."
}
},
"UpdateRateLimiters": {
"inputs": {
"OUTBOUND_RATE_LIMIT_CAPACITY": "uint128 token-bucket capacity. Per direction the inputs are all-or-nothing: an enabled bucket needs CAPACITY and RATE together, and a partial set is refused naming the missing variable.",
"OUTBOUND_RATE_LIMIT_RATE": "uint128 token-bucket refill rate. An enabled bucket needs CAPACITY and RATE together.",
"OUTBOUND_RATE_LIMIT_ENABLED": "true/false; defaults to true when CAPACITY or RATE are set. false stands alone as a disable.",
"INBOUND_RATE_LIMIT_CAPACITY": "uint128 token-bucket capacity (inbound). Same all-or-nothing rule per direction.",
"INBOUND_RATE_LIMIT_RATE": "uint128 token-bucket refill rate (inbound).",
"INBOUND_RATE_LIMIT_ENABLED": "true/false; defaults to true when CAPACITY or RATE are set. false stands alone as a disable."
}
},
"SetFinalityConfig": {
"inputs": {
"OUTBOUND_RATE_LIMIT_CAPACITY": "uint128 token-bucket capacity. Per direction the inputs are all-or-nothing: an enabled bucket needs CAPACITY and RATE together, and a partial set is refused naming the missing variable.",
"OUTBOUND_RATE_LIMIT_RATE": "uint128 token-bucket refill rate. An enabled bucket needs CAPACITY and RATE together.",
"OUTBOUND_RATE_LIMIT_ENABLED": "true/false; defaults to true when CAPACITY or RATE are set. false stands alone as a disable.",
"INBOUND_RATE_LIMIT_CAPACITY": "uint128 token-bucket capacity (inbound). Same all-or-nothing rule per direction.",
"INBOUND_RATE_LIMIT_RATE": "uint128 token-bucket refill rate (inbound).",
"INBOUND_RATE_LIMIT_ENABLED": "true/false; defaults to true when CAPACITY or RATE are set. false stands alone as a disable."
}
}
}
Loading
Loading