diff --git a/GLAMSTERDAM_GAS_UPDATE_RUNBOOK.md b/GLAMSTERDAM_GAS_UPDATE_RUNBOOK.md new file mode 100644 index 0000000000..f8712a4d57 --- /dev/null +++ b/GLAMSTERDAM_GAS_UPDATE_RUNBOOK.md @@ -0,0 +1,465 @@ +# Glamsterdam Gas Config Update — Operator Runbook + +Audience: an engineer (or AI agent) who needs to run the Glamsterdam gas-config changesets and +generate an MCMS proposal, with no prior context on this feature. Companion doc: +`GLAMSTERDAM_GAS_UPDATE_PLAN.md` (design spec, field-by-field mapping table, code appendix). + +If you just want to generate both proposals with minimal reading, skip to **§3 (Quick path)**. +Everything else is context/troubleshooting for when something doesn't work on the first try. + +## 0. What this is + +Two `ChangeSetV2` implementations in `chainlink-ccip` (branch `glamsterdam-changeset` at time of +writing) automate updating source-side gas config on every lane pointed at a chain that's moving +to the Glamsterdam hard fork: + +- `UpdateGasConfigForGlamsterdamV2` — `chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update.go` +- `UpdateGasConfigForGlamsterdamV16` — `chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update.go` + +Both changesets never execute directly — they always produce an MCMS timelock proposal, even if +the deployer key happens to own the contract. + +**Important — which domain to use is version-dependent, not a free choice:** + +| Version | Domain | Environment (this rehearsal) | Why | +|---|---|---|---| +| v1.6 | `chainlink-deployments/domains/ccip` | `testnet` | v1.6 OnRamp/FeeQuoter/OffRamp are mature and broadly deployed under the `ccip` domain's testnet datastore. | +| v2.0 | `chainlink-deployments/domains/ccv` | `prod_testnet` | v2.0 OnRamp + CommitteeVerifier are being rolled out under the **`ccv`** domain, not `ccip`. As of writing, `ccip`'s testnet datastore has zero v2.0 `OnRamp`/`CommitteeVerifier` entries (only `FeeQuoter` has been upgraded to 2.0.0 there) — running the v2.0 changeset against `ccip`/testnet will discover lanes but produce zero writes. Verify this is still true before you start (§9) — a real rollout may have changed it. | + +## 1. Prerequisites + +- Two sibling checkouts on disk: `chainlink-ccip` (your feature branch) and `chainlink-deployments`, + as siblings (i.e. `.../chainlink-ccip` and `.../chainlink-deployments` share a parent directory). + This matters because the local `replace` directives in step 2 use relative paths + (`../../../chainlink-ccip`). +- A third sibling checkout, `chainlink-ccv` (yes, confusingly similar name to the `ccv` domain + above — it's a separate repo). Only needed for the v2.0 path; see §4b. +- VPN connected. Several RPC endpoints in `.config/networks/.yaml` are internal proxies + (`rpcs.cldev.sh`) that only resolve on VPN. +- No `secrets-.toml` is required for a dry run — RPC endpoints come straight from + `domains//.config/networks/.yaml`. You only need secrets for something that signs + and broadcasts for real. +- Go 1.26+ (matches `go.mod`; the `ccv` domain's `go.mod` may auto-bump to 1.26.5+ the first time + you `go mod tidy` it — that's expected, not an error). + +## 2. Registration status (already done on this branch) + +Both changesets are already registered in `chainlink-deployments` as of this writing — this is +tracked in `chainlink-deployments`, a separate repo from `chainlink-ccip`, so it doesn't show up +in `chainlink-ccip`'s git history. **Check these are still present before running** — see the +"keeps getting reset" note in §8; something in this environment periodically reverts uncommitted +edits to these two files: + +- `domains/ccip/testnet/durable_pipelines.go` — look for `registry.Add("update_gas_config_glamsterdam_v16", ...)` +- `domains/ccv/pkg/pipelines/evm_pipelines.go` — look for `registry.Add("update_gas_config_glamsterdam_v2", ...)` + +If either is missing, see §5 (v1.6 setup) or §6 (v2.0 setup) to re-add it — those sections have the +exact code to paste back in. + +## 3. Quick path — generate both proposals + +If registration (§2), the `chainlink-ccip => ../../../chainlink-ccip` replace directives, and the +`chainlink-ccv` patch (§6b) are all already in place, this is the whole workflow: + +```bash +# one-time per fresh shell session (see §7.1 for why) +mkdir -p /tmp/solana-programs-bde34821-69b6-47f8-a833-77b47b7e9b36 + +# v1.6 — ccip domain, testnet +cd chainlink-deployments/domains/ccip/cmd +go run . pipeline run --environment testnet --input-file glamsterdam_gas_update_v16.yaml --dry-run + +# v2.0 — ccv domain, prod_testnet +cd ../../ccv/cmd +go run . pipeline run --environment prod_testnet --input-file glamsterdam_gas_update_v2.yaml --dry-run +``` + +Both input files already exist (checked into your working tree, not yet committed) at: +- `domains/ccip/testnet/durable_pipelines/inputs/glamsterdam_gas_update_v16.yaml` +- `domains/ccv/prod_testnet/durable_pipelines/inputs/glamsterdam_gas_update_v2.yaml` + +Expect each run to take several minutes (§7 has timing/troubleshooting notes) and expect to hit at +least one flaky/dead-chain RPC — see §7.2 for the fix pattern (disable that one chain block in +`.config/networks/.yaml`, don't stop the whole rehearsal for it). + +**If either command errors**, work through §5 (v1.6-specific) or §6 (v2.0-specific) end to end — +they cover every setup step from scratch, since your local state may differ from what's described +above. Once you've done that once, only §3 is needed for subsequent runs. + +## 4. Verifying a proposal (do this after every run, not optional) + +See §9 for the full 3-tier checklist (report → decoded diff → fork-execute). At minimum, do tier +(a) and (b) before trusting a proposal — this catches most real bugs, including the two described +in §10. + +## 5. v1.6 setup (ccip domain, testnet) + +### 5a. Point `domains/ccip` at your local chainlink-ccip branch + +```bash +cd chainlink-deployments/domains/ccip +``` +`go.mod` already ships this block commented out around line 44 — uncomment it: +``` +github.com/smartcontractkit/chainlink-ccip => ../../../chainlink-ccip +github.com/smartcontractkit/chainlink-ccip/chains/evm => ../../../chainlink-ccip/chains/evm +github.com/smartcontractkit/chainlink-ccip/deployment => ../../../chainlink-ccip/deployment +``` +Then: +```bash +go mod tidy +``` +**If this fails with** `module ... does not contain package .../gobindings/generated/vX_Y_Z/...` +**— your local chainlink-ccip branch is behind what this domain's pinned commit expects.** +Fetch/merge/rebase your branch against `origin/main` in `chainlink-ccip`, then rerun `go mod tidy`. +This is a real version-skew issue, not a config mistake — don't work around it by hand-pinning +`require` versions. + +### 5b. Register the changeset + +In `domains/ccip/testnet/durable_pipelines.go`, near the other `v1_6_1` changeset registrations +(e.g. next to `DurablePipeline_migrate_hybrid_lock_release_liquidity`): +```go +ccip161evmchangesets "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/changesets" +... +registry.Add("update_gas_config_glamsterdam_v16", + cldf_changeset.Configure(ccip161evmchangesets.UpdateGasConfigForGlamsterdamV16(cciputilschangeset.GetRegistry())).WithEnvInput()) +``` +Confirm it builds: `go build ./testnet/...` from `domains/ccip`. + +### 5c. Write the input YAML + +`domains/ccip/testnet/durable_pipelines/inputs/glamsterdam_gas_update_v16.yaml`: +```yaml +environment: testnet +domain: ccip +changesets: + - update_gas_config_glamsterdam_v16: + payload: + cfg: + targetChainSelector: 16015286601757825753 # ethereum-testnet-sepolia + skipChainSelectors: [] + mcms: + timelockAction: "schedule" + validUntil: 1893456000 + qualifier: "CLLCCIP" + description: "Glamsterdam gas config update (v1.6) dry run — Sepolia target" +``` +See §8 for why `cfg` and `mcms` must be siblings under `payload` — a very easy mistake to make. + +### 5d. Run it + +```bash +mkdir -p /tmp/solana-programs-bde34821-69b6-47f8-a833-77b47b7e9b36 # see §7.1 +cd domains/ccip/cmd +go run . pipeline run --environment testnet --input-file glamsterdam_gas_update_v16.yaml --dry-run +``` +Takes ~5-10 minutes (row 5 does a per-token `getTokenTransferFeeConfig` read for every lane). +Output lands at `domains/ccip/testnet/proposals/*update_gas_config_glamsterdam_v16*.json`. + +## 6. v2.0 setup (ccv domain, prod_testnet) + +This path has two extra wrinkles v1.6 doesn't: a missing local-env config file, and a version +mismatch in a third repo (`chainlink-ccv`). Both are one-time fixes. + +### 6a. Point `domains/ccv` at your local chainlink-ccip branch + +```bash +cd chainlink-deployments/domains/ccv +``` +Add to the existing `replace (...)` block in `go.mod` (there's no commented-out template here, +unlike `ccip` — just add the three lines): +``` +github.com/smartcontractkit/chainlink-ccip => ../../../chainlink-ccip +github.com/smartcontractkit/chainlink-ccip/chains/evm => ../../../chainlink-ccip/chains/evm +github.com/smartcontractkit/chainlink-ccip/deployment => ../../../chainlink-ccip/deployment +``` + +### 6b. Patch the `chainlink-ccv` version mismatch + +`domains/ccv` also depends on a separate repo, `chainlink-ccv`, whose `integration/evm/adapters` +package imports two `chainlink-ccip` paths that moved upstream +(`v2_0_0/operations/{lombard_verifier,cctp_verifier}` → `v2_1_0/operations/...`). +`chainlink-ccv`'s `main` branch hasn't caught up to that move yet. Symptom: +``` +module github.com/smartcontractkit/chainlink-ccip/chains/evm@latest found (...), but does not +contain package .../v2_0_0/operations/cctp_verifier +``` + +**Check first whether this is still broken** — try `go mod tidy` in `domains/ccv` (after 6a) and +see if it succeeds. If `chainlink-ccv` has caught up upstream by the time you read this, skip +straight to 6c. If not: + +1. Create a worktree of `chainlink-ccv` at `origin/main`, as a sibling of `chainlink-ccip` / + `chainlink-deployments` (don't touch your real `chainlink-ccv` checkout): + ```bash + cd chainlink-ccv + git fetch origin main + git worktree add ../chainlink-ccv-glamsterdam-patch origin/main + ``` +2. In the worktree, fix the two broken import paths (`v2_0_0` → `v2_1_0`) in: + - `integration/evm/adapters/ccv_indexer_config.go` (both `cctpverifier` and `lombardverifier` imports) + - `integration/evm/adapters/ccv_token_verifier_config.go` (`cctpverifier` import) +3. Point `domains/ccv/go.mod`'s `replace` block at the patched worktree: + ``` + github.com/smartcontractkit/chainlink-ccv/integration/evm => ../../../chainlink-ccv-glamsterdam-patch/integration/evm + ``` +4. You may also need to bump `chainlink-ccv`/`chainlink-ccv/integration/evm`/`chainlink-ccv/deployment` + to their own latest `@main` first if `go mod tidy` still complains about older transitive + incompatibilities: + ```bash + cd domains/ccv + go get github.com/smartcontractkit/chainlink-ccv/integration/evm@main + go get github.com/smartcontractkit/chainlink-ccv@main github.com/smartcontractkit/chainlink-ccv/deployment@main + ``` +5. `go mod tidy` again — should now succeed. + +**Remove this whole patch once `chainlink-ccv` catches up upstream** — it's a temporary +workaround for a real, independent bug in another team's repo, not something to keep long-term. + +### 6c. Create the missing local-env config + +`domains/ccv/.config/local/config.prod_testnet.yaml` doesn't exist by default (unlike +`config.prod_mainnet.yaml`, `config.staging_testnet.yaml`, etc., which do). Without it, **zero +chain loaders register for any chain family** — you'll see `"No chain loader available for chain +family, skipping"` for every single chain and `"valid":0,"successful":0"`, with no other error. +Create it (deployer key here is a shared placeholder already used by `prod_mainnet`/ +`staging-migration` configs — it never signs anything for real since writes always route through +MCMS): +```yaml +onchain: + evm: + deployer_key: "0eca7976bdf758fc689a5d5c572ee8a3898f9bb62abb65508f49a4d4b21a876b" +``` + +### 6d. Register the changeset + +In `domains/ccv/pkg/pipelines/evm_pipelines.go`, inside `registerEVMPipelines` (the file already +imports `evm_changesets "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/changesets"` +for `ActivateRMN` etc., so no new import needed): +```go +registry.Add("update_gas_config_glamsterdam_v2", + changeset.Configure(evm_changesets.UpdateGasConfigForGlamsterdamV2(mcmsRegistry)).WithEnvInput()) +``` +Confirm it builds: `go build ./pkg/... ./prod_testnet/...` from `domains/ccv`. + +### 6e. Write the input YAML + +`domains/ccv/prod_testnet/durable_pipelines/inputs/glamsterdam_gas_update_v2.yaml`: +```yaml +environment: prod_testnet +domain: ccv +changesets: + - update_gas_config_glamsterdam_v2: + payload: + cfg: + targetChainSelector: 16015286601757825753 # ethereum-testnet-sepolia + skipChainSelectors: [] + mcms: + qualifier: "CLLCCIP" + timelockAction: "schedule" + validUntil: 1893456000 + description: "Glamsterdam gas config update (v2.0) dry run — Sepolia target (ccv prod_testnet)" +``` + +### 6f. Run it + +```bash +mkdir -p /tmp/solana-programs-bde34821-69b6-47f8-a833-77b47b7e9b36 # see §7.1 +cd domains/ccv/cmd +go run . pipeline run --environment prod_testnet --input-file glamsterdam_gas_update_v2.yaml --dry-run +``` +Output lands at `domains/ccv/prod_testnet/proposals/*update_gas_config_glamsterdam_v2*.json`. + +## 7. Environment gotchas (check every fresh session) + +These get wiped by reboots/`/tmp` cleanup and will resurface even after you've fixed them once: + +1. **Solana placeholder directory.** The Solana chain loader validates that + `onchain.solana.programs_dir_path` (set in `domains//.config/local/config..yaml`, + currently a hardcoded path like `/tmp/solana-programs-`) exists — it only checks + existence, not contents. If it's missing you'll get + `failed to initialize Solana chain ...: required file does not exist: /tmp/solana-programs-...`. + Fix: `mkdir -p `. Unrelated to our EVM-only changesets; it's just a + precondition for loading the environment at all. +2. **Dead/flaky testnet RPCs block the entire run.** The environment loader requires **every** + registered chain in `.config/networks/.yaml` to load successfully before any changeset can + run — one dead chain fails the whole command, even if it's irrelevant to your target chain. + Retry once or twice first (some failures are transient); if a chain fails 2-3 runs in a row on + all its RPC endpoints, comment its block out in `.config/networks/.yaml` the same way the + file already does for other known-dead chains (search for `TEMPORARILY DISABLED`, e.g. + `ethereum-testnet-holesky-taiko-1`). Leave a comment explaining why and when, and don't leave it + disabled indefinitely without re-checking — do this locally, don't commit unless you're sure it + should stay disabled. + +## 8. Known workflow friction + +- **Schema gotcha (cost real debugging time):** the framework decodes the `payload:` key directly + into `cs_core.WithMCMS[Cfg]{ MCMS mcms.Input; Cfg Cfg }` with `DisallowUnknownFields`. That + struct has exactly two top-level fields — **`cfg` and `mcms` must be siblings**, with your actual + changeset config fields nested one level down under `cfg`. Putting `targetChainSelector` directly + under `payload` (flattened) produces a cryptic `unknown field "skipChainSelectors"` error (it + fails on the *second* alphabetically-sorted unknown key, not the first — confusing to debug + blind). The YAML templates in §5c/§6e already have this right. +- **Operation-result caching can serve stale output after a code change.** The durable-pipeline + runner caches sequence/operation results by input hash in + `domains///operations_reports/durable_pipelines/-reports.json`. + If you change the changeset's Go code and rerun with the *same* input YAML, you may get the + proposal from *before* your change, with no error or warning that anything was cached — the log + will show `"Sequence already executed. Returning previous result"` if you look closely. **If a + code change doesn't seem to take effect, delete that report file and rerun.** +- **`domains/ccip/testnet/durable_pipelines.go`, `domains/ccv/pkg/pipelines/evm_pipelines.go`, and + both domains' `go.mod` files have been observed reverting between sessions** (registration lines + and `replace` blocks disappearing without an explicit edit). Cause not identified — possibly a + formatter, a hook, or a stale-branch merge picking up a version without these changes. Always + re-check §2's two registration lines and the `chainlink-ccip => ../../../chainlink-ccip` replace + line are present before assuming "it's already set up." + +## 9. Verifying the proposal is doing what you expect + +Three checks, increasing in rigor. Do at least the first two before treating a proposal as +trustworthy. + +**a. Read the embedded report.** The proposal JSON's `description` field contains a full +per-chain, per-field trace: which chains were skipped (`SkipChainSelectors`), which had no lane to +the target, which had an unresolvable contract address, which had a lane genuinely disabled at the +contract level (see §10), and for every field actually touched: whether the current on-chain value +matched the doc's expected Prague baseline (→ literal Glamsterdam value applied) or didn't (→ +fallback value applied, logged as `MISMATCH`). Sanity check the counts look plausible for the +environment (e.g. "22 chains, no lane" is fine on testnet; "0 chains discovered" when you expected +dozens is not). + +**b. Decode the proposal into human-readable transactions:** +```bash +go run . mcms analyze-proposal-v2 \ + -e \ + -p ..//proposals/.json \ + -o /tmp/analysis.md +``` +This produces one collapsible section per chain/batch, with every call's decoded ABI inputs +(not raw calldata). For each chain, confirm: +- The field(s) you expect to change show the new (Glamsterdam or fallback) value. +- Every *other* field on the same struct (e.g. `MaxDataBytes`, `GasMultiplierWeiPerEth` on + `FeeQuoter.DestChainConfig`) is unchanged from its current on-chain value — this proves the + "merge current struct, only override the touched field" logic didn't clobber anything. +- Cross-reference against `GLAMSTERDAM_GAS_UPDATE_PLAN.md` §6's field table for the expected + Prague/Glamsterdam/fallback numbers per field. + +**c. Actually execute against a fork (strongest check, do this before a real mainnet run):** +```bash +go run . mcms execute-fork \ + -e \ + -p ..//proposals/.json \ + -s \ + --test-signer +``` +Forks that one chain with Anvil and actually runs set-root + execute-timelock against it — the +closest thing to a full dry run without touching real state. Do this per chain you're unsure +about, then read back the contract state to confirm it matches what the decoded proposal claimed. + +## 10. Known non-bugs / behaviors you'll likely rediscover + +- **Zero proposal / zero writes, no error**: usually means the discovery loop found lanes but a + *hard-required* contract address (e.g. `OnRamp`, `TokenAdminRegistry`) wasn't resolvable in the + datastore for any of them — check `operations_reports/.../-reports.json` for what actually + ran, and the proposal `description` (if a proposal exists) or changeset logs for + `AddUnresolvedContract` lines. This is what happened when v2.0 was run against `ccip`/testnet: + `CommitteeVerifier` was made optional (see below), but `OnRamp` v2.0 genuinely doesn't exist + there yet — hence running v2.0 against `ccv`/`prod_testnet` instead (§0). +- **A single stale/unreachable chain no longer aborts the whole discovery batch.** Both + `DiscoverLanesToTarget` sequences (`v1_6_1` and `v2_0_0`, under `sequences/glamsterdam/discovery.go`) + log a warning and skip that one chain (visible in the report as + `ERROR - failed to read FeeQuoter dest chain config: ..., skipping this chain`) rather than + failing the entire run. Seen in practice: `sei-testnet-atlantic`'s FeeQuoter address in the + testnet datastore has no contract code anymore (stale entry) — it's skipped, the other 16 chains + still got processed. +- **`CommitteeVerifier` is optional for the v2.0 changeset**, same as `OffRamp` already was — a + missing address just skips that chain's verifier-gas-for-verification write (row 8), it doesn't + drop the whole lane's OnRamp/FeeQuoter writes. +- **Disabled lanes are detected and skipped, not blindly written to.** `OnRamp.sol` (its `getFee` + check) and `BaseVerifier.sol` (which `CommitteeVerifier` extends — its comment literally says + *"The router can be zero to pause the remote chain"*) both use `router == address(0)` as the + contract's own canonical "this destination isn't configured / lane is paused" signal. The v2.0 + sequence (`sequences/glamsterdam/update_gas_config.go`) checks this before writing to OnRamp or + CommitteeVerifier, and skips that contract's write entirely if the router is zero — logged as + ` has no router configured for the target chain (router == address(0)) - lane is + disabled/not configured`. Earlier versions of this changeset didn't check this and would compute + a zero-value fallback write (`0 × ratio = 0`) for disabled lanes — harmless in that it wrote the + same zero back, but noisy, and the underlying principle (never touch a deliberately-disabled + lane) is worth preserving as this code evolves. If you see many `MISMATCH ... current value 0` + lines in a report, check whether those chains actually have a disabled lane (`router == + address(0)`) rather than assuming the values are wrong. + +## 11. Field-by-field mapping (condensed — see plan doc §6 for full detail/notes) + +### v1.6 (all confirmed, no open questions) +| # | Field | Expected Prague | Glamsterdam | Fallback | +|---|---|---|---|---| +| 1 | `FeeQuoter.DestChainConfig.DestGasOverhead` | 300,000 | 500,000 | `applyRatio` (~1.667x) | +| 2 | `FeeQuoter.DestChainConfig.DefaultTokenDestGasOverhead` | 90,000 | 270,000 | `applyRatio` (3x) | +| 3 | OffRamp `GasForCallExactCheck` | 5,000 | 5,000 | n/a — read-only sanity check, immutable | +| 5 | `FeeQuoter.TokenTransferFeeConfig.DestGasOverhead` (keyed by dest+token, USDC lanes) | 180,000 | 540,000 (guesstimate) | `applyRatio` (3x) | + +(Row 4, Lombard, is dropped entirely for v1.6 — no v1.6 Lombard contract exists anywhere.) + +### v2.0 +| # | Field | Expected Prague | Glamsterdam | Fallback | +|---|---|---|---|---| +| 1 | `OnRamp.DestChainConfig.BaseExecutionGasCost` | 200,000 | 400,000 | `applyRatio` (2x) | +| 2 | `FeeQuoter.DestChainConfig.DefaultTokenDestGasOverhead` | 90,000 | 270,000 | `applyRatio` (3x) | +| 3 | `FeeQuoter.DestChainConfig.MaxPerMsgGasLimit` | 15,000,000 | 15,000,000 | no-op | +| 4 | `FeeQuoter.DestChainConfig.DestGasPerPayloadByteBase` | 20 | 64 | `applyRatio` (3.2x) | +| 5 | `FeeQuoter.DestChainConfig.DefaultTxGasLimit` | 200,000 | 400,000 | `applyRatio` (2x) | +| 6–7 | OffRamp immutable fields | 5,000 / 12,000 | same | n/a — read-only sanity check | +| 8 | `CommitteeVerifier.GasForVerification` | 75,000 | 85,000 | `applyRatio` (~1.133x) | +| 9 | Lombard pool `TokenTransferFeeConfig.DestGasOverhead` | 410,000 | 1,200,000 (guesstimate) | `applyRatio` (~2.93x) | +| 10 | USDC pool `TokenTransferFeeConfig.DestGasOverhead` | 250,000 | 750,000 (guesstimate) | `applyRatio` (3x) | +| 11 | LombardVerifier `GasForVerification` | 275,000 | 825,000 (guesstimate) | `applyRatio` (3x) | +| 12 | CCTPVerifier ("USDCVerifier") `GasForVerification` | 200,000 | 600,000 (guesstimate) | `applyRatio` (3x) | + +**Before the real mainnet run**: swap every "(guesstimate)" value above for a real post-testnet +measurement — it's a constant in `chains/evm/deployment/utils/glamsterdam/` / the version-specific +`fields.go` files, no code logic changes needed. + +## 12. Mainnet rollout notes + +- Batch mainnet's ~80 lanes using `SkipChainSelectors` in the input YAML's `cfg` block — put + everything except the batch you're running for that pass in the skip list. +- `SkipChainSelectors` entries are unconditionally excluded, not even checked for a lane — this is + the intended mechanism for controlled fan-out. +- Re-verify §0's domain choice before the mainnet run — confirm which domain (`ccip` vs `ccv`) and + environment (`mainnet` vs `prod_mainnet`) actually carries the mature v1.6/v2.0 contracts by then; + this may have changed since this rehearsal. + +## 13. Notes for an AI agent picking this up + +If you're an AI agent (Claude Code or otherwise) working through this runbook rather than a human: + +- **Don't guess at chain selectors, qualifiers, or addresses.** Every concrete value in this doc + (chain selectors, the `CLLCCIP` qualifier, the placeholder deployer key) was confirmed against + this repo's actual datastore/config files, not invented. If you need a different target chain or + environment, look it up the same way: chain selectors from + `chain-selectors` repo's `selectors.yml` or by grepping `.config/networks/.yaml`; + qualifiers by grepping existing archived pipeline inputs under + `domains///durable_pipelines/archived/*.yaml` for the same changeset family. +- **A `--dry-run` pipeline command still makes real RPC calls and can run for many minutes** (the + v1.6 run took ~8-10 minutes end to end). Don't kill it prematurely assuming it's hung — check + the log for periodic `"Executing operation"` lines showing forward progress first. If you must + run it as a background/monitored command, budget a timeout of at least 10 minutes. +- **A nonzero exit code from the pipeline command is not always a bug in the changeset.** Read the + actual error first: `no valid RPC clients created` / `required file does not exist: + /tmp/solana-programs-...` / `no chain loader available` are all environment issues covered in + §7, not code issues. Only chase changeset code once you've ruled those out. +- **Exit code 0 does not always mean the proposal you expected was generated.** Check the + `operations_reports/.../-reports.json` cache-staleness gotcha in §8 before + concluding a code change had no effect, and check for a proposal file's actual presence (§6/§7 of + the earlier revision covered this; a "no error, no proposal" outcome usually means zero batch ops + were produced — see §10's "Zero proposal" entry). +- **When something fails in a way this doc doesn't cover**, the two most useful things to check + are (1) the datastore for the domain/env you're targeting + (`domains///datastore/address_refs.json` — grep for the contract type/version you + expect) to confirm the prerequisite contracts actually exist there, and (2) whether the same + changeset family has a similar archived input file under `durable_pipelines/archived/` you can + diff your input against for a schema/qualifier mismatch. +- **Update this runbook if you discover a new gotcha.** It's meant to accumulate operational + knowledge across runs, not just describe the original rehearsal. diff --git a/chains/evm/deployment/utils/glamsterdam/glamsterdam.go b/chains/evm/deployment/utils/glamsterdam/glamsterdam.go new file mode 100644 index 0000000000..57c97ef4b8 --- /dev/null +++ b/chains/evm/deployment/utils/glamsterdam/glamsterdam.go @@ -0,0 +1,175 @@ +// Package glamsterdam holds the version-independent primitives shared by the v1.6 and v2.0 +// "update gas config for Glamsterdam" changesets: comparing an on-chain value against its +// expected Prague baseline, falling back to a derived value on mismatch, and formatting the +// resulting per-chain report. +package glamsterdam + +import ( + "fmt" + "math" + "strings" + + "golang.org/x/exp/constraints" +) + +// FieldSpec describes one on-chain gas-related field that needs to move from its Prague baseline +// to a Glamsterdam value, plus the fallback to compute instead when the current on-chain value +// doesn't match ExpectedPrague. +type FieldSpec[T comparable] struct { + // Name identifies the field for logging/reporting, e.g. "FeeQuoter.DestChainConfig.DestGasOverhead". + Name string + // ExpectedPrague is the baseline value the source doc assumed when deriving GlamsterdamValue. + ExpectedPrague T + // GlamsterdamValue is the literal value to apply when the current on-chain value matches + // ExpectedPrague. + GlamsterdamValue T + // Fallback computes the value to apply when the current on-chain value does not match + // ExpectedPrague. + Fallback func(current T) T +} + +// ApplyRatio returns a Fallback function that scales a mismatched current value by the same +// ratio the source doc used to go from its Prague baseline to its Glamsterdam value, rounded to +// the nearest integer. Panics if the scaled result overflows T rather than silently wrapping, +// since a wrapped fallback value would otherwise be written on-chain with no indication anything +// went wrong. +func ApplyRatio[T constraints.Integer](prague, glamsterdam T) func(current T) T { + return func(current T) T { + scaled := math.Round(float64(current) * float64(glamsterdam) / float64(prague)) + + result := T(scaled) + if float64(result) != scaled { + panic(fmt.Sprintf( + "ApplyRatio: scaled value %v overflows the target type (got %v after conversion)", + scaled, result, + )) + } + + return result + } +} + +// FieldResult is the outcome of resolving one FieldSpec against a chain's current on-chain value. +type FieldResult[T comparable] struct { + Spec FieldSpec[T] + Current T + Matched bool + // AlreadyApplied is true when current already equals spec.GlamsterdamValue, meaning a prior + // run (or partial retry of this same run) already migrated this field. AppliedValue is set to + // current unchanged in this case, so re-running is idempotent rather than compounding the + // fallback ratio on top of an already-migrated value. + AlreadyApplied bool + AppliedValue T +} + +// Resolve compares current against spec.GlamsterdamValue and spec.ExpectedPrague, in that order. +// If current already equals GlamsterdamValue, this field was already migrated by a prior run (or +// an earlier step of the same batch); resolving it again is a no-op. Otherwise, if current +// matches ExpectedPrague, the literal GlamsterdamValue is applied; if it matches neither, +// spec.Fallback(current) is applied instead. +func Resolve[T comparable](spec FieldSpec[T], current T) FieldResult[T] { + if current == spec.GlamsterdamValue { + return FieldResult[T]{Spec: spec, Current: current, AlreadyApplied: true, AppliedValue: current} + } + if current == spec.ExpectedPrague { + return FieldResult[T]{Spec: spec, Current: current, Matched: true, AppliedValue: spec.GlamsterdamValue} + } + return FieldResult[T]{Spec: spec, Current: current, Matched: false, AppliedValue: spec.Fallback(current)} +} + +// FieldResultString renders a FieldResult as a single human-readable report line for a given +// chain selector. +func FieldResultString[T comparable](chainSelector uint64, result FieldResult[T]) string { + if result.AlreadyApplied { + return fmt.Sprintf( + "chain %d: %s already matches Glamsterdam value %v (no-op, e.g. re-run after this "+ + "proposal already executed)", + chainSelector, result.Spec.Name, result.AppliedValue, + ) + } + if result.Matched { + return fmt.Sprintf( + "chain %d: %s matched expected Prague value %v, applying Glamsterdam value %v", + chainSelector, result.Spec.Name, result.Spec.ExpectedPrague, result.AppliedValue, + ) + } + return fmt.Sprintf( + "chain %d: %s MISMATCH - current value %v does not match expected Prague value %v, "+ + "applying fallback value %v instead of literal Glamsterdam value %v", + chainSelector, result.Spec.Name, result.Current, result.Spec.ExpectedPrague, + result.AppliedValue, result.Spec.GlamsterdamValue, + ) +} + +// Report accumulates the human-readable summary of a Glamsterdam gas-update run: which chains +// were skipped, which had no lane to the target, which fields matched or mismatched their +// expected Prague baseline, and which chains had a contract that couldn't be resolved. Intended +// for inclusion in the resulting MCMS proposal's Description and/or logs. +type Report struct { + // Lines are the accumulated human-readable report lines, in the order they were recorded. + Lines []string +} + +// NewReport returns an empty Report. +func NewReport() *Report { + return &Report{} +} + +// AddSkipped records a chain that was unconditionally skipped via SkipChainSelectors. +func (r *Report) AddSkipped(chainSelector uint64) { + r.Lines = append(r.Lines, fmt.Sprintf("chain %d: skipped (explicit SkipChainSelectors entry)", chainSelector)) +} + +// AddNoLane records a chain that was scanned but has no lane pointed at the target chain. +func (r *Report) AddNoLane(chainSelector uint64) { + r.Lines = append(r.Lines, fmt.Sprintf("chain %d: no lane to target chain, skipped", chainSelector)) +} + +// AddUnresolvedContract records a chain where an expected contract could not be resolved from +// the datastore. This chain is excluded from further processing, but the run continues for +// every other chain. +func (r *Report) AddUnresolvedContract(chainSelector uint64, contractName string) { + r.Lines = append(r.Lines, fmt.Sprintf( + "chain %d: ERROR - could not resolve %s address, skipping this chain", chainSelector, contractName, + )) +} + +// AddReadError records a chain where a required on-chain read failed partway through discovery +// or update (e.g. a stale datastore address with no contract code at that address anymore). This +// chain is excluded from further processing, but the run continues for every other chain, per the +// "never block the batch on a single chain" design principle. +func (r *Report) AddReadError(chainSelector uint64, description string, err error) { + r.Lines = append(r.Lines, fmt.Sprintf( + "chain %d: ERROR - failed to %s: %v, skipping this chain", chainSelector, description, err, + )) +} + +// AddDisabledLane records a chain/contract where the on-chain config for the target destination +// is not actually configured (router == address(0), the contract's own convention for "this +// remote chain is paused/not supported" — see e.g. OnRamp.sol's getFee check and +// BaseVerifier.sol's "_applyRemoteChainConfigUpdates" comment "The router can be zero to pause +// the remote chain"). No write is made for this contract on this chain: writing to a disabled +// lane would either be meaningless (zero-value fallback writes) or risk silently re-enabling a +// lane that was deliberately paused. +func (r *Report) AddDisabledLane(chainSelector uint64, contractName string) { + r.Lines = append(r.Lines, fmt.Sprintf( + "chain %d: %s has no router configured for the target chain (router == address(0)) - "+ + "lane is disabled/not configured, skipping this contract's write", chainSelector, contractName, + )) +} + +// AddLine appends an arbitrary pre-formatted line to the report, e.g. the output of +// FieldResultString. +func (r *Report) AddLine(line string) { + r.Lines = append(r.Lines, line) +} + +// AddField records the outcome of resolving a single FieldSpec on a given chain. +func AddField[T comparable](r *Report, chainSelector uint64, result FieldResult[T]) { + r.AddLine(FieldResultString(chainSelector, result)) +} + +// String joins all recorded lines into a single newline-separated report. +func (r *Report) String() string { + return strings.Join(r.Lines, "\n") +} diff --git a/chains/evm/deployment/utils/glamsterdam/glamsterdam_test.go b/chains/evm/deployment/utils/glamsterdam/glamsterdam_test.go new file mode 100644 index 0000000000..9029ec552f --- /dev/null +++ b/chains/evm/deployment/utils/glamsterdam/glamsterdam_test.go @@ -0,0 +1,145 @@ +package glamsterdam_test + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/stretchr/testify/require" +) + +func TestApplyRatio(t *testing.T) { + tests := []struct { + desc string + prague uint32 + glamsterdam uint32 + current uint32 + expectedFallback uint32 + }{ + { + desc: "2x ratio, exact", + prague: 200_000, + glamsterdam: 400_000, + current: 100_000, + expectedFallback: 200_000, + }, + { + desc: "3x ratio, exact", + prague: 90_000, + glamsterdam: 270_000, + current: 123_000, + expectedFallback: 369_000, + }, + { + desc: "3.2x ratio, rounds to nearest", + prague: 20, + glamsterdam: 64, + current: 25, + expectedFallback: 80, // 25 * 3.2 = 80 exactly + }, + { + desc: "ratio requiring rounding", + prague: 75_000, + glamsterdam: 85_000, + current: 100_000, + expectedFallback: 113_333, // 100000 * 85000/75000 = 113333.33 -> rounds to 113333 + }, + { + desc: "zero current stays zero", + prague: 200_000, + glamsterdam: 400_000, + current: 0, + expectedFallback: 0, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + fallback := glamsterdam.ApplyRatio(test.prague, test.glamsterdam) + require.Equal(t, test.expectedFallback, fallback(test.current)) + }) + } +} + +func TestResolve(t *testing.T) { + spec := glamsterdam.FieldSpec[uint32]{ + Name: "TestField", + ExpectedPrague: 200_000, + GlamsterdamValue: 400_000, + Fallback: glamsterdam.ApplyRatio[uint32](200_000, 400_000), + } + + t.Run("matched baseline applies literal glamsterdam value", func(t *testing.T) { + result := glamsterdam.Resolve(spec, uint32(200_000)) + require.True(t, result.Matched) + require.Equal(t, uint32(200_000), result.Current) + require.Equal(t, uint32(400_000), result.AppliedValue) + }) + + t.Run("mismatched baseline applies fallback", func(t *testing.T) { + result := glamsterdam.Resolve(spec, uint32(150_000)) + require.False(t, result.Matched) + require.Equal(t, uint32(150_000), result.Current) + require.Equal(t, uint32(300_000), result.AppliedValue) // 150000 * 2x + }) +} + +func TestFieldResultString(t *testing.T) { + spec := glamsterdam.FieldSpec[uint32]{ + Name: "TestField", + ExpectedPrague: 200_000, + GlamsterdamValue: 400_000, + Fallback: glamsterdam.ApplyRatio[uint32](200_000, 400_000), + } + + t.Run("matched", func(t *testing.T) { + result := glamsterdam.Resolve(spec, uint32(200_000)) + line := glamsterdam.FieldResultString(uint64(1), result) + require.Contains(t, line, "chain 1") + require.Contains(t, line, "TestField") + require.Contains(t, line, "matched expected Prague value 200000") + require.Contains(t, line, "applying Glamsterdam value 400000") + }) + + t.Run("mismatched", func(t *testing.T) { + result := glamsterdam.Resolve(spec, uint32(150_000)) + line := glamsterdam.FieldResultString(uint64(2), result) + require.Contains(t, line, "chain 2") + require.Contains(t, line, "MISMATCH") + require.Contains(t, line, "current value 150000") + require.Contains(t, line, "expected Prague value 200000") + require.Contains(t, line, "fallback value 300000") + require.Contains(t, line, "instead of literal Glamsterdam value 400000") + }) +} + +func TestReport(t *testing.T) { + r := glamsterdam.NewReport() + r.AddSkipped(1) + r.AddNoLane(2) + r.AddUnresolvedContract(3, "FeeQuoter") + + spec := glamsterdam.FieldSpec[uint32]{ + Name: "TestField", + ExpectedPrague: 200_000, + GlamsterdamValue: 400_000, + Fallback: glamsterdam.ApplyRatio[uint32](200_000, 400_000), + } + glamsterdam.AddField(r, uint64(4), glamsterdam.Resolve(spec, uint32(200_000))) + glamsterdam.AddField(r, uint64(5), glamsterdam.Resolve(spec, uint32(150_000))) + + out := r.String() + require.Contains(t, out, "chain 1: skipped (explicit SkipChainSelectors entry)") + require.Contains(t, out, "chain 2: no lane to target chain, skipped") + require.Contains(t, out, "chain 3: ERROR - could not resolve FeeQuoter address, skipping this chain") + require.Contains(t, out, "chain 4: TestField matched expected Prague value 200000") + require.Contains(t, out, "chain 5: TestField MISMATCH") + + lines := []string{ + "chain 1: skipped (explicit SkipChainSelectors entry)", + "chain 2: no lane to target chain, skipped", + "chain 3: ERROR - could not resolve FeeQuoter address, skipping this chain", + } + for i, want := range lines { + require.Contains(t, out, want, "line %d", i) + } +} diff --git a/chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update.go b/chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update.go new file mode 100644 index 0000000000..9b2c52db07 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update.go @@ -0,0 +1,187 @@ +package changesets + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/offramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/burn_mint_with_lock_release_flag_token_pool" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/token_pool" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/sequences/glamsterdam" + cs_core "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" +) + +// resolveUSDCTokenPoolRef finds the chain's non-canonical USDC token pool address ref, if any. +// v1.6.1 has no USDC-specific ContractType; by this version's convention (see +// adapters/non_canonical_usdc_chain.go), the USDC pool is deployed as a +// BurnMintWithLockReleaseFlagTokenPool, and there is at most one per chain, so the first match by +// type is returned regardless of version or qualifier. +func resolveUSDCTokenPoolRef(addrs []datastore.AddressRef, sel uint64) datastore.AddressRef { + for _, ref := range addrs { + if ref.ChainSelector == sel && ref.Type == datastore.ContractType(burn_mint_with_lock_release_flag_token_pool.ContractType) { + return ref + } + } + return datastore.AddressRef{} +} + +// GlamsterdamGasUpdateV16Cfg is configuration for the UpdateGasConfigForGlamsterdamV16 changeset. +type GlamsterdamGasUpdateV16Cfg struct { + // TargetChainSelector is the chain selector of the chain moving to Glamsterdam. + TargetChainSelector uint64 + // SkipChainSelectors are chain selectors to unconditionally skip — no lane-to-target check is + // even performed. Used to batch a high-fanout chain (e.g. mainnet, ~80 lanes) into smaller + // runs. + SkipChainSelectors []uint64 +} + +// UpdateGasConfigForGlamsterdamV16 discovers every v1.6 chain with a lane pointed at +// cfg.TargetChainSelector, reads the current on-chain gas config for every field in the v1.6 +// Glamsterdam mapping table, resolves each field against its expected Prague baseline, and +// packages the resulting writes into an MCMS timelock proposal. It never executes directly, even +// if the deployer key happens to be owner. +func UpdateGasConfigForGlamsterdamV16(mcmsRegistry *cs_core.MCMSReaderRegistry) cldf_deployment.ChangeSetV2[cs_core.WithMCMS[GlamsterdamGasUpdateV16Cfg]] { + validate := func(_ cldf_deployment.Environment, cfg cs_core.WithMCMS[GlamsterdamGasUpdateV16Cfg]) error { + if cfg.Cfg.TargetChainSelector == 0 { + return errors.New("target chain selector must be set") + } + return nil + } + + apply := func(e cldf_deployment.Environment, cfg cs_core.WithMCMS[GlamsterdamGasUpdateV16Cfg]) (cldf_deployment.ChangesetOutput, error) { + target := cfg.Cfg.TargetChainSelector + report := glamsterdamutils.NewReport() + + for _, sel := range cfg.Cfg.SkipChainSelectors { + report.AddSkipped(sel) + } + + excluded := make([]uint64, 0, len(cfg.Cfg.SkipChainSelectors)+1) + excluded = append(excluded, cfg.Cfg.SkipChainSelectors...) + excluded = append(excluded, target) + + candidates := e.BlockChains.ListChainSelectors( + cldf_chain.WithFamily(chain_selectors.FamilyEVM), + cldf_chain.WithChainSelectorsExclusion(excluded), + ) + + addressesByChain := make(map[uint64][]datastore.AddressRef, len(candidates)) + feeQuoterAddrByChain := make(map[uint64]common.Address, len(candidates)) + for _, sel := range candidates { + addrs := e.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(sel)) + addressesByChain[sel] = addrs + + fqRef := datastore_utils.GetAddressRef(addrs, sel, fee_quoter.ContractType, fee_quoter.Version, "") + if datastore_utils.IsAddressRefEmpty(fqRef) { + report.AddUnresolvedContract(sel, "FeeQuoter") + continue + } + feeQuoterAddrByChain[sel] = common.HexToAddress(fqRef.Address) + } + + discoveryReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: target, + FeeQuoterAddressByChain: feeQuoterAddrByChain, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to discover lanes to target chain %d: %w", target, err) + } + for _, sel := range discoveryReport.Output.NoLane { + report.AddNoLane(sel) + } + report.Lines = append(report.Lines, discoveryReport.Output.Report.Lines...) + + var ( + lanes []glamsterdamseq.LaneAddresses + tokenLanes []glamsterdamseq.TokenTransferFeeConfigLane + ) + + for _, sel := range discoveryReport.Output.LanesToUpdate { + addrs := addressesByChain[sel] + + lane := glamsterdamseq.LaneAddresses{ + ChainSelector: sel, + FeeQuoterAddress: feeQuoterAddrByChain[sel], + } + if offRampRef := datastore_utils.GetAddressRef(addrs, sel, offramp.ContractType, offramp.Version, ""); !datastore_utils.IsAddressRefEmpty(offRampRef) { + lane.OffRampAddress = common.HexToAddress(offRampRef.Address) + } + lanes = append(lanes, lane) + + usdcPoolRef := resolveUSDCTokenPoolRef(addrs, sel) + if datastore_utils.IsAddressRefEmpty(usdcPoolRef) { + // No non-canonical USDC pool deployed on this chain — nothing to update for the + // USDC-specific row of the v1.6 mapping table. + continue + } + + chain, ok := e.BlockChains.EVMChains()[sel] + if !ok { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("chain with selector %d not found", sel) + } + usdcTokenReport, err := cldf_ops.ExecuteOperation(e.OperationsBundle, token_pool.GetToken, chain, contract.FunctionInput[struct{}]{ + ChainSelector: sel, + Address: common.HexToAddress(usdcPoolRef.Address), + Args: struct{}{}, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to read underlying token for USDC pool %s on src %d: %w", usdcPoolRef.Address, sel, err) + } + tokenLanes = append(tokenLanes, glamsterdamseq.TokenTransferFeeConfigLane{ + ChainSelector: sel, + FeeQuoterAddress: feeQuoterAddrByChain[sel], + CandidateTokens: []common.Address{usdcTokenReport.Output}, + }) + } + + var batchOps []mcms_types.BatchOperation + + if len(lanes) > 0 { + gasCfgReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: target, + Lanes: lanes, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to update gas config for target chain %d: %w", target, err) + } + batchOps = append(batchOps, gasCfgReport.Output.BatchOps...) + report.Lines = append(report.Lines, gasCfgReport.Output.Report.Lines...) + } + + if len(tokenLanes) > 0 { + ttfcReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateTokenTransferFeeConfig, e.BlockChains, glamsterdamseq.UpdateTokenTransferFeeConfigInput{ + TargetChainSelector: target, + Lanes: tokenLanes, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to update token transfer fee config for target chain %d: %w", target, err) + } + batchOps = append(batchOps, ttfcReport.Output.BatchOps...) + report.Lines = append(report.Lines, ttfcReport.Output.Report.Lines...) + } + + mcmsInput := cfg.MCMS + if mcmsInput.Description == "" { + mcmsInput.Description = report.String() + } else { + mcmsInput.Description = mcmsInput.Description + "\n\n" + report.String() + } + + return cs_core.NewOutputBuilder(e, mcmsRegistry).WithBatchOps(batchOps).Build(mcmsInput) + } + + return cldf_deployment.CreateChangeSet(apply, validate) +} diff --git a/chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update_test.go b/chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update_test.go new file mode 100644 index 0000000000..41120ebf81 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/changesets/glamsterdam_gas_update_test.go @@ -0,0 +1,263 @@ +package changesets_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" + usdcerc20ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + rmnproxyops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/rmn_proxy" + routerops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" + fq16ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + orops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/offramp" + usdcpoolops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/burn_mint_with_lock_release_flag_token_pool" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/changesets" + ccipdeploymentutils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + cs_core "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" +) + +const ( + gg16TargetChainSel = uint64(3379446385462418246) + gg16SrcAChainSel = uint64(4949039107694359620) + gg16SrcBChainSel = uint64(5548718428018410741) + gg16SrcSkipChainSel = uint64(6433500567565415381) + gg16SrcNoLaneSel = uint64(4356164186791070119) +) + +var ( + gg16RmnAddr = common.HexToAddress("0x5555555555555555555555555555555555555555") + gg16TokenAdminRegistry = common.HexToAddress("0x6666666666666666666666666666666666666666") + gg16NonceManagerAddr = common.HexToAddress("0x8888888888888888888888888888888888888888") + gg16FeeQuoterFamily = [4]byte{0x28, 0x12, 0xd5, 0x2c} +) + +// gg16FeeQuoterFixture deploys a v1.6 FeeQuoter with a lane to gg16TargetChainSel and registers +// it in ds. usdcToken is only used (and must be non-zero) when usdcDestGasOverhead > 0. +func gg16FeeQuoterFixture(t *testing.T, e *cldf.Environment, ds datastore.MutableDataStore, chainSel uint64, hasLane bool, destGasOverhead uint32, usdcDestGasOverhead uint32, usdcToken common.Address) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + var destChainConfigArgs []fq16ops.DestChainConfigArgs + if hasLane { + destChainConfigArgs = []fq16ops.DestChainConfigArgs{ + { + DestChainSelector: gg16TargetChainSel, + DestChainConfig: fq16ops.DestChainConfig{ + IsEnabled: true, + MaxNumberOfTokensPerMsg: 5, + MaxPerMsgGasLimit: 3_000_000, + DestGasOverhead: destGasOverhead, + ChainFamilySelector: gg16FeeQuoterFamily, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + GasMultiplierWeiPerEth: 1e18, + }, + }, + } + } + + var tokenTransferFeeConfigArgs []fq16ops.TokenTransferFeeConfigArgs + if usdcDestGasOverhead > 0 { + tokenTransferFeeConfigArgs = []fq16ops.TokenTransferFeeConfigArgs{ + { + DestChainSelector: gg16TargetChainSel, + TokenTransferFeeConfigs: []fq16ops.TokenTransferFeeConfigSingleTokenArgs{ + { + Token: usdcToken, + TokenTransferFeeConfig: fq16ops.TokenTransferFeeConfig{ + MinFeeUSDCents: 50, + MaxFeeUSDCents: 500, + DestGasOverhead: usdcDestGasOverhead, + DestBytesOverhead: 100, + IsEnabled: true, + }, + }, + }, + }, + } + } + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq16ops.Deploy, chain, contract.DeployInput[fq16ops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fq16ops.ContractType, *fq16ops.Version), + Args: fq16ops.ConstructorArgs{ + StaticConfig: fq16ops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + TokenPriceStalenessThreshold: 3600, + }, + DestChainConfigArgs: destChainConfigArgs, + TokenTransferFeeConfigArgs: tokenTransferFeeConfigArgs, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(out.Output)) + + return common.HexToAddress(out.Output.Address) +} + +func gg16OffRampFixture(t *testing.T, e *cldf.Environment, ds datastore.MutableDataStore, chainSel uint64, gasForCallExactCheck uint16) { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, orops.Deploy, chain, contract.DeployInput[orops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(orops.ContractType, *orops.Version), + Args: orops.ConstructorArgs{ + StaticConfig: orops.StaticConfig{ + ChainSelector: chainSel, + GasForCallExactCheck: gasForCallExactCheck, + RmnRemote: gg16RmnAddr, + TokenAdminRegistry: gg16TokenAdminRegistry, + NonceManager: gg16NonceManagerAddr, + }, + DynamicConfig: orops.DynamicConfig{FeeQuoter: gg16RmnAddr}, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(out.Output)) +} + +// gg16USDCTokenPoolFixture deploys a real BurnMintERC20 token plus a +// BurnMintWithLockReleaseFlagTokenPool wrapping it, and registers both in ds. Per v1.6.1's +// non-canonical-USDC convention, this pool type is what the changeset resolves per chain (via +// resolveUSDCTokenPoolRef) to discover "the" USDC token address by calling getToken() on it. It +// returns the deployed token's address, which callers must also use when configuring the +// FeeQuoter's TokenTransferFeeConfig override for this chain, so the two agree on which token is +// "USDC". +func gg16USDCTokenPoolFixture(t *testing.T, e *cldf.Environment, ds datastore.MutableDataStore, chainSel uint64) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + // The pool constructor needs a real token, RMNProxy, and Router contract deployed on-chain + // (not just bare addresses), mirroring sequences.setupNonCanonicalTestEnvironment in + // v1_6_1/sequences/non_canonical_usdc_test.go. + tokenOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, usdcerc20ops.Deploy, chain, contract.DeployInput[usdcerc20ops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(usdcerc20ops.ContractType, *ccipdeploymentutils.Version_1_0_0), + Args: usdcerc20ops.ConstructorArgs{ + Name: "USD Coin", + Symbol: "USDC", + Decimals: 6, + MaxSupply: big.NewInt(0), + PreMint: big.NewInt(0), + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(tokenOut.Output)) + usdcTokenAddr := common.HexToAddress(tokenOut.Output.Address) + + rmnOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, rmnproxyops.Deploy, chain, contract.DeployInput[rmnproxyops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(rmnproxyops.ContractType, *rmnproxyops.Version), + Args: rmnproxyops.ConstructorArgs{RMN: chain.DeployerKey.From}, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(rmnOut.Output)) + rmnProxyAddr := common.HexToAddress(rmnOut.Output.Address) + + routerOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, routerops.Deploy, chain, contract.DeployInput[routerops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(routerops.ContractType, *routerops.Version), + Args: routerops.ConstructorArgs{ + WrappedNative: common.Address{}, + RMNProxy: rmnProxyAddr, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(routerOut.Output)) + routerAddr := common.HexToAddress(routerOut.Output.Address) + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, usdcpoolops.Deploy, chain, contract.DeployInput[usdcpoolops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(usdcpoolops.ContractType, *usdcpoolops.Version), + Args: usdcpoolops.ConstructorArgs{ + Token: usdcTokenAddr, + LocalTokenDecimals: 6, + RmnProxy: rmnProxyAddr, + Router: routerAddr, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(out.Output)) + + return usdcTokenAddr +} + +func TestUpdateGasConfigForGlamsterdamV16(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + gg16SrcAChainSel, + gg16SrcBChainSel, + gg16SrcSkipChainSel, + gg16SrcNoLaneSel, + })) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + // srcA: lane to target, Prague baseline matches exactly, plus a configured USDC override. + // The USDC pool must be deployed first so its token address can be used to configure the + // FeeQuoter's TokenTransferFeeConfig override for the same token. + usdcTokenA := gg16USDCTokenPoolFixture(t, e, ds, gg16SrcAChainSel) + gg16FeeQuoterFixture(t, e, ds, gg16SrcAChainSel, true, 300_000, 180_000, usdcTokenA) + gg16OffRampFixture(t, e, ds, gg16SrcAChainSel, 5_000) + // srcB: lane to target, FeeQuoter.DestGasOverhead mismatched -> exercises fallback. No USDC + // token pool on this chain, so the token-transfer-fee lane is skipped for it. + gg16FeeQuoterFixture(t, e, ds, gg16SrcBChainSel, true, 240_000, 0, common.Address{}) + // srcSkip: has a lane, but is passed in SkipChainSelectors -> must be excluded entirely. + gg16FeeQuoterFixture(t, e, ds, gg16SrcSkipChainSel, true, 300_000, 0, common.Address{}) + // srcNoLane: FeeQuoter deployed, but no dest chain config for target -> discovered, no lane. + gg16FeeQuoterFixture(t, e, ds, gg16SrcNoLaneSel, false, 300_000, 0, common.Address{}) + + // Only srcA and srcB will end up with batch ops, so only they need a real MCMS+Timelock + // deployment for the OutputBuilder to resolve chain metadata against. + for _, chainSel := range []uint64{gg16SrcAChainSel, gg16SrcBChainSel} { + chain := e.BlockChains.EVMChains()[chainSel] + _, mcmsAddrs := deployMCMSInstanceForTest(t, e.OperationsBundle, chain, chain.DeployerKey.From, ccipdeploymentutils.CLLQualifier) + for _, ref := range mcmsAddrs { + require.NoError(t, ds.Addresses().Add(ref)) + } + } + e.DataStore = ds.Seal() + + mcmsRegistry := cs_core.GetRegistry() + out, err := changesets.UpdateGasConfigForGlamsterdamV16(mcmsRegistry).Apply(*e, cs_core.WithMCMS[changesets.GlamsterdamGasUpdateV16Cfg]{ + MCMS: mcms.Input{}, + Cfg: changesets.GlamsterdamGasUpdateV16Cfg{ + TargetChainSelector: gg16TargetChainSel, + SkipChainSelectors: []uint64{gg16SrcSkipChainSel}, + }, + }) + require.NoError(t, err) + + require.Len(t, out.MCMSTimelockProposals, 1) + proposal := out.MCMSTimelockProposals[0] + + batchOpChains := make(map[uint64]int) + for _, op := range proposal.Operations { + batchOpChains[uint64(op.ChainSelector)]++ + } + // srcA: 1 FeeQuoter gas-config write + 1 FeeQuoter token-transfer-fee write. + require.Equal(t, 2, batchOpChains[gg16SrcAChainSel]) + // srcB: 1 FeeQuoter gas-config write only (no TokenAdminRegistry -> no token lane). + require.Equal(t, 1, batchOpChains[gg16SrcBChainSel]) + require.NotContains(t, batchOpChains, gg16SrcSkipChainSel) + require.NotContains(t, batchOpChains, gg16SrcNoLaneSel) + + require.Contains(t, proposal.Description, "chain 6433500567565415381: skipped (explicit SkipChainSelectors entry)") + require.Contains(t, proposal.Description, "chain 4356164186791070119: no lane to target chain, skipped") + require.Contains(t, proposal.Description, "chain 4949039107694359620: FeeQuoter.DestChainConfig.DestGasOverhead matched expected Prague value 300000, applying Glamsterdam value 500000") + require.Contains(t, proposal.Description, "chain 4949039107694359620: FeeQuoter.TokenTransferFeeConfig.DestGasOverhead (USDC) matched expected Prague value 180000, applying Glamsterdam value 540000") + require.Contains(t, proposal.Description, "chain 5548718428018410741: FeeQuoter.DestChainConfig.DestGasOverhead MISMATCH") + require.Contains(t, proposal.Description, "applying fallback value 400000 instead of literal Glamsterdam value 500000") + require.NotContains(t, proposal.Description, "TokenTransferFeeConfig.DestGasOverhead (USDC)) matched") +} diff --git a/chains/evm/deployment/v1_6_1/changesets/mcms_test_helpers_test.go b/chains/evm/deployment/v1_6_1/changesets/mcms_test_helpers_test.go new file mode 100644 index 0000000000..7c8fa4c24f --- /dev/null +++ b/chains/evm/deployment/v1_6_1/changesets/mcms_test_helpers_test.go @@ -0,0 +1,105 @@ +package changesets_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + mcms_ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations" + mcms_seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/sequences" + "github.com/smartcontractkit/chainlink-ccip/deployment/testhelpers" + common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" +) + +// deployMCMSInstanceForTest deploys a Proposer/Bypasser/Canceller MCMS, a Timelock, and a +// CallProxy under the given qualifier, and grants the CallProxy the executor role on the +// Timelock. This is what changesets.NewOutputBuilder's EVMMCMSReader needs on-chain in order to +// resolve chain metadata / timelock addresses for a real MCMS proposal. +func deployMCMSInstanceForTest( + t *testing.T, + b operations.Bundle, + chain evm.Chain, + deployer common.Address, + qualifier string, +) (timelockAddr common.Address, addresses []datastore.AddressRef) { + t.Helper() + mcmsCfg := testhelpers.SingleGroupMCMS() + qualifierPtr := &qualifier + + proposerReport, err := operations.ExecuteSequence(b, mcms_seq.SeqDeployMCMWithConfig, chain, mcms_seq.SeqMCMSDeploymentCfg{ + ChainSelector: chain.Selector, + ContractType: common_utils.ProposerManyChainMultisig, + MCMConfig: &mcmsCfg, + Qualifier: qualifierPtr, + }) + require.NoError(t, err) + require.NotEmpty(t, proposerReport.Output.Addresses) + addresses = append(addresses, proposerReport.Output.Addresses...) + + bypasserReport, err := operations.ExecuteSequence(b, mcms_seq.SeqDeployMCMWithConfig, chain, mcms_seq.SeqMCMSDeploymentCfg{ + ChainSelector: chain.Selector, + ContractType: common_utils.BypasserManyChainMultisig, + MCMConfig: &mcmsCfg, + Qualifier: qualifierPtr, + }) + require.NoError(t, err) + addresses = append(addresses, bypasserReport.Output.Addresses...) + + cancellerReport, err := operations.ExecuteSequence(b, mcms_seq.SeqDeployMCMWithConfig, chain, mcms_seq.SeqMCMSDeploymentCfg{ + ChainSelector: chain.Selector, + ContractType: common_utils.CancellerManyChainMultisig, + MCMConfig: &mcmsCfg, + Qualifier: qualifierPtr, + }) + require.NoError(t, err) + addresses = append(addresses, cancellerReport.Output.Addresses...) + + timelockRef, err := contract.MaybeDeployContract(b, mcms_ops.OpDeployTimelock, chain, contract.DeployInput[mcms_ops.OpDeployTimelockInput]{ + ChainSelector: chain.Selector, + Qualifier: qualifierPtr, + TypeAndVersion: deployment.NewTypeAndVersion(common_utils.RBACTimelock, *mcms_ops.MCMSVersion), + Args: mcms_ops.OpDeployTimelockInput{ + TimelockMinDelay: big.NewInt(1), + Proposers: []common.Address{common.HexToAddress(proposerReport.Output.Addresses[0].Address)}, + Bypassers: []common.Address{common.HexToAddress(bypasserReport.Output.Addresses[0].Address)}, + Cancellers: []common.Address{common.HexToAddress(cancellerReport.Output.Addresses[0].Address)}, + Admin: deployer, + Executors: []common.Address{}, + }, + }, nil) + require.NoError(t, err) + timelockAddr = common.HexToAddress(timelockRef.Address) + addresses = append(addresses, timelockRef) + + callProxyRef, err := contract.MaybeDeployContract(b, mcms_ops.OpDeployCallProxy, chain, contract.DeployInput[mcms_ops.OpDeployCallProxyInput]{ + ChainSelector: chain.Selector, + Qualifier: qualifierPtr, + TypeAndVersion: deployment.NewTypeAndVersion(common_utils.CallProxy, *mcms_ops.MCMSVersion), + Args: mcms_ops.OpDeployCallProxyInput{ + TimelockAddress: timelockAddr, + }, + }, nil) + require.NoError(t, err) + addresses = append(addresses, callProxyRef) + callProxyAddr := common.HexToAddress(callProxyRef.Address) + + _, err = operations.ExecuteOperation(b, mcms_ops.OpGrantRoleTimelock, chain, contract.FunctionInput[mcms_ops.OpGrantRoleTimelockInput]{ + ChainSelector: chain.Selector, + Address: timelockAddr, + Args: mcms_ops.OpGrantRoleTimelockInput{ + RoleID: mcms_ops.EXECUTOR_ROLE.ID, + Account: callProxyAddr, + }, + }) + require.NoError(t, err) + + return timelockAddr, addresses +} diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/discovery.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/discovery.go new file mode 100644 index 0000000000..26f2649e59 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/discovery.go @@ -0,0 +1,90 @@ +// Package glamsterdam contains the sequences backing the v1.6 "update gas config for +// Glamsterdam" changeset. +package glamsterdam + +import ( + "fmt" + "sort" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" +) + +// DiscoverLanesToTargetInput is the input to DiscoverLanesToTarget. +type DiscoverLanesToTargetInput struct { + // TargetChainSelector is the chain selector moving to Glamsterdam. + TargetChainSelector uint64 + // FeeQuoterAddressByChain maps each candidate source chain selector to its v1.6 FeeQuoter + // address on that chain. Chains whose FeeQuoter address couldn't be resolved from the + // datastore should be omitted here and reported separately by the caller. + FeeQuoterAddressByChain map[uint64]common.Address +} + +// DiscoverLanesToTargetOutput is the output of DiscoverLanesToTarget. +type DiscoverLanesToTargetOutput struct { + // LanesToUpdate are the candidate chain selectors with a confirmed lane pointed at the + // target chain, sorted ascending for deterministic output. + LanesToUpdate []uint64 + // NoLane are candidate chain selectors that were scanned but have no lane pointed at the + // target chain, sorted ascending. + NoLane []uint64 + // Report accumulates human-readable lines for any candidate chain whose FeeQuoter read + // failed (e.g. a stale datastore address with no contract code there anymore). That chain is + // skipped rather than aborting discovery for every other candidate. + Report *glamsterdamutils.Report +} + +// DiscoverLanesToTarget checks, for every candidate chain in the input, whether its v1.6 +// FeeQuoter has an enabled DestChainConfig pointed at the target chain — i.e. whether that chain +// has a lane sending into the Glamsterdam target chain and therefore needs its source-side gas +// config updated. This is a read-only scan; it makes one RPC call per candidate chain. +var DiscoverLanesToTarget = cldf_ops.NewSequence( + "DiscoverLanesToTargetV1_6", + semver.MustParse("1.6.0"), + "Discovers which v1.6 chains have a lane pointed at the Glamsterdam target chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input DiscoverLanesToTargetInput) (DiscoverLanesToTargetOutput, error) { + output := DiscoverLanesToTargetOutput{Report: glamsterdamutils.NewReport()} + + candidates := make([]uint64, 0, len(input.FeeQuoterAddressByChain)) + for sel := range input.FeeQuoterAddressByChain { + candidates = append(candidates, sel) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i] < candidates[j] }) + + for _, src := range candidates { + chain, ok := chains.EVMChains()[src] + if !ok { + return DiscoverLanesToTargetOutput{}, fmt.Errorf("chain with selector %d not found", src) + } + + report, err := cldf_ops.ExecuteOperation(b, fee_quoter.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: src, + Address: input.FeeQuoterAddressByChain[src], + Args: input.TargetChainSelector, + }) + if err != nil { + line := fmt.Sprintf( + "failed to read FeeQuoter dest chain config for src %d, dst %d: %v", src, input.TargetChainSelector, err, + ) + b.Logger.Warn(line) + output.Report.AddReadError(src, "read FeeQuoter dest chain config", err) + continue + } + + if report.Output.IsEnabled { + output.LanesToUpdate = append(output.LanesToUpdate, src) + } else { + output.NoLane = append(output.NoLane, src) + } + } + + return output, nil + }, +) diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/discovery_test.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/discovery_test.go new file mode 100644 index 0000000000..81367e3229 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/discovery_test.go @@ -0,0 +1,148 @@ +package glamsterdam_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + fq16ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/sequences/glamsterdam" +) + +const ( + discoveryTargetChainSel = uint64(3379446385462418246) + discoveryLaneChainSelA = uint64(4949039107694359620) + discoveryLaneChainSelB = uint64(5548718428018410741) + discoveryNoLaneChainSel = uint64(6433500567565415381) +) + +// evmFamilySelector is bytes4(keccak256("CCIP ChainFamilySelector EVM")) = 0x2812d52c, required +// by FeeQuoter.applyDestChainConfigUpdates for any enabled dest chain config. +var evmFamilySelector = [4]byte{0x28, 0x12, 0xd5, 0x2c} + +func deployDiscoveryFeeQuoter( + t *testing.T, + e *cldf.Environment, + chainSel uint64, + destChainConfigArgs []fq16ops.DestChainConfigArgs, +) common.Address { + t.Helper() + + chain := e.BlockChains.EVMChains()[chainSel] + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq16ops.Deploy, chain, contract.DeployInput[fq16ops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fq16ops.ContractType, *fq16ops.Version), + Args: fq16ops.ConstructorArgs{ + StaticConfig: fq16ops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + TokenPriceStalenessThreshold: 3600, + }, + DestChainConfigArgs: destChainConfigArgs, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func TestDiscoverLanesToTarget(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + discoveryLaneChainSelA, + discoveryLaneChainSelB, + discoveryNoLaneChainSel, + })) + require.NoError(t, err) + + enabledDestChainConfig := fq16ops.DestChainConfig{ + IsEnabled: true, + MaxNumberOfTokensPerMsg: 5, + MaxPerMsgGasLimit: 3_000_000, + DestGasOverhead: 300_000, + ChainFamilySelector: evmFamilySelector, + DefaultTxGasLimit: 200_000, + GasMultiplierWeiPerEth: 1e18, + } + laneAAddr := deployDiscoveryFeeQuoter(t, e, discoveryLaneChainSelA, []fq16ops.DestChainConfigArgs{ + {DestChainSelector: discoveryTargetChainSel, DestChainConfig: enabledDestChainConfig}, + }) + laneBAddr := deployDiscoveryFeeQuoter(t, e, discoveryLaneChainSelB, []fq16ops.DestChainConfigArgs{ + {DestChainSelector: discoveryTargetChainSel, DestChainConfig: enabledDestChainConfig}, + }) + noLaneAddr := deployDiscoveryFeeQuoter(t, e, discoveryNoLaneChainSel, nil) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: discoveryTargetChainSel, + FeeQuoterAddressByChain: map[uint64]common.Address{ + discoveryLaneChainSelA: laneAAddr, + discoveryLaneChainSelB: laneBAddr, + discoveryNoLaneChainSel: noLaneAddr, + }, + }) + require.NoError(t, err) + + require.Equal(t, []uint64{discoveryLaneChainSelA, discoveryLaneChainSelB}, report.Output.LanesToUpdate) + require.Equal(t, []uint64{discoveryNoLaneChainSel}, report.Output.NoLane) +} + +// TestDiscoverLanesToTarget_SkipsChainWithReadError confirms that a candidate chain whose +// FeeQuoter address has no contract code there (e.g. a stale datastore entry) is skipped with a +// report line, rather than aborting discovery for every other candidate. +func TestDiscoverLanesToTarget_SkipsChainWithReadError(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + discoveryLaneChainSelA, + discoveryLaneChainSelB, + })) + require.NoError(t, err) + + enabledDestChainConfig := fq16ops.DestChainConfig{ + IsEnabled: true, + MaxNumberOfTokensPerMsg: 5, + MaxPerMsgGasLimit: 3_000_000, + DestGasOverhead: 300_000, + ChainFamilySelector: evmFamilySelector, + DefaultTxGasLimit: 200_000, + GasMultiplierWeiPerEth: 1e18, + } + laneAAddr := deployDiscoveryFeeQuoter(t, e, discoveryLaneChainSelA, []fq16ops.DestChainConfigArgs{ + {DestChainSelector: discoveryTargetChainSel, DestChainConfig: enabledDestChainConfig}, + }) + + // discoveryLaneChainSelB is a real, loaded chain, but this address has no contract deployed + // there — mirrors a stale datastore entry pointing at a redeployed/decommissioned contract. + staleAddr := common.HexToAddress("0x0000000000000000000000000000000000dEaD") + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: discoveryTargetChainSel, + FeeQuoterAddressByChain: map[uint64]common.Address{ + discoveryLaneChainSelA: laneAAddr, + discoveryLaneChainSelB: staleAddr, + }, + }) + require.NoError(t, err) + + require.Equal(t, []uint64{discoveryLaneChainSelA}, report.Output.LanesToUpdate) + require.Empty(t, report.Output.NoLane) + require.Contains(t, report.Output.Report.String(), "chain 5548718428018410741: ERROR - failed to read FeeQuoter dest chain config") +} + +func TestDiscoverLanesToTarget_UnknownChain(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{discoveryLaneChainSelA})) + require.NoError(t, err) + + _, err = cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: discoveryTargetChainSel, + FeeQuoterAddressByChain: map[uint64]common.Address{ + discoveryNoLaneChainSel: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "chain with selector") +} diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/fields.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/fields.go new file mode 100644 index 0000000000..c18edca760 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/fields.go @@ -0,0 +1,45 @@ +package glamsterdam + +import ( + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" +) + +// Field specs for the v1.6 Glamsterdam gas-config mapping table. Values sourced from +// "Glamsterdam Gas Configuration.md" (embedded in GLAMSTERDAM_GAS_UPDATE_PLAN.md §6, V1.6 table). +var ( + // FeeQuoterDestGasOverhead is table row 1. The doc labels this "OnRamp::baseExecutionGasCost", + // but v1.6 OnRamp has no gas field at all — this concept lives on FeeQuoter for v1.6 (see + // GLAMSTERDAM_GAS_UPDATE_PLAN.md §2.1). + FeeQuoterDestGasOverhead = glamsterdamutils.FieldSpec[uint32]{ + Name: "FeeQuoter.DestChainConfig.DestGasOverhead", + ExpectedPrague: 300_000, + GlamsterdamValue: 500_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](300_000, 500_000), + } + + // FeeQuoterDefaultTokenDestGasOverhead is table row 2. + FeeQuoterDefaultTokenDestGasOverhead = glamsterdamutils.FieldSpec[uint32]{ + Name: "FeeQuoter.DestChainConfig.DefaultTokenDestGasOverhead", + ExpectedPrague: 90_000, + GlamsterdamValue: 270_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](90_000, 270_000), + } + + // USDCTokenPoolDestGasOverhead is table row 5. The doc labels this "USDCTokenPool", but it is + // actually a FeeQuoter.TokenTransferFeeConfig.DestGasOverhead setting for v1.6, keyed by + // (destChainSelector, token) — see GLAMSTERDAM_GAS_UPDATE_PLAN.md §2.3. A single FeeQuoter + // setting governs the gas overhead for all v1.6 pools of that token on the lane; there is no + // per-pool override in v1.6. Guesstimate value (real value needs testnet measurement), applied + // as-is for the first (testnet) run. + USDCTokenPoolDestGasOverhead = glamsterdamutils.FieldSpec[uint32]{ + Name: "FeeQuoter.TokenTransferFeeConfig.DestGasOverhead (USDC)", + ExpectedPrague: 180_000, + GlamsterdamValue: 540_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](180_000, 540_000), + } +) + +// OffRamp.GasForCallExactCheck (table row 3) is immutable, set only in the constructor, with no +// setter. It is read-only sanity checked (never written) against its expected Prague baseline, +// which should equal its Glamsterdam value. +const OffRampExpectedGasForCallExactCheck = uint16(5_000) diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/token_transfer_fee_config.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/token_transfer_fee_config.go new file mode 100644 index 0000000000..318d0bfe93 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/token_transfer_fee_config.go @@ -0,0 +1,148 @@ +package glamsterdam + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" +) + +// applyFeeQuoterTokenTransferFeeConfigUpdates mirrors fee_quoter.ApplyTokenTransferFeeConfigUpdates, +// but always routes the write through MCMS regardless of who the deployer key is, per this +// feature's "always MCMS" decision. +var applyFeeQuoterTokenTransferFeeConfigUpdates = contract.NewWrite(contract.WriteParams[fee_quoter.ApplyTokenTransferFeeConfigUpdatesArgs, *fee_quoter.FeeQuoterContract]{ + Name: "glamsterdam:fee-quoter:apply-token-transfer-fee-config-updates", + Version: semver.MustParse("1.6.0"), + Description: "Calls applyTokenTransferFeeConfigUpdates on FeeQuoter, always producing an MCMS proposal", + ContractType: fee_quoter.ContractType, + ContractABI: fee_quoter.FeeQuoterABI, + NewContract: fee_quoter.NewFeeQuoterContract, + IsAllowedCaller: contract.NoCallersAllowed[*fee_quoter.FeeQuoterContract, fee_quoter.ApplyTokenTransferFeeConfigUpdatesArgs], + Validate: func(fee_quoter.ApplyTokenTransferFeeConfigUpdatesArgs) error { return nil }, + CallContract: func(c *fee_quoter.FeeQuoterContract, opts *bind.TransactOpts, args fee_quoter.ApplyTokenTransferFeeConfigUpdatesArgs) (*types.Transaction, error) { + return c.ApplyTokenTransferFeeConfigUpdates(opts, args.TokenTransferFeeConfigArgs, args.TokensToUseDefaultFeeConfigs) + }, +}) + +// TokenTransferFeeConfigLane is one source chain with a confirmed lane pointed at the Glamsterdam +// target chain, along with the candidate tokens to check for an existing +// FeeQuoter.TokenTransferFeeConfig override on that lane. +type TokenTransferFeeConfigLane struct { + ChainSelector uint64 + FeeQuoterAddress common.Address + // CandidateTokens are token addresses to check for an existing TokenTransferFeeConfig + // override on this lane. This row of the v1.6 mapping table is scoped to USDC lanes only, so + // callers should populate this with just the chain's resolved USDC token address, not every + // token the TokenAdminRegistry knows about — a non-USDC token with an enabled override would + // otherwise wrongly get the USDC-specific ratio applied to it. Tokens with no override + // configured for this lane are skipped silently, since that's the common case. + CandidateTokens []common.Address +} + +// UpdateTokenTransferFeeConfigInput is the input to UpdateTokenTransferFeeConfig. +type UpdateTokenTransferFeeConfigInput struct { + // TargetChainSelector is the chain selector moving to Glamsterdam. + TargetChainSelector uint64 + // Lanes are the source chains with a confirmed lane pointed at the target chain. + Lanes []TokenTransferFeeConfigLane +} + +// UpdateTokenTransferFeeConfigOutput is the output of UpdateTokenTransferFeeConfig. +type UpdateTokenTransferFeeConfigOutput struct { + // BatchOps contains at most one MCMS batch operation per chain in Lanes (chains with no + // enabled token transfer fee override for any candidate token produce no batch operation). + BatchOps []mcms_types.BatchOperation + // Report is a human-readable summary of every field resolved on every lane, for inclusion in + // the changeset's MCMS proposal description. + Report *glamsterdamutils.Report +} + +// UpdateTokenTransferFeeConfig reads the current FeeQuoter.TokenTransferFeeConfig for every +// candidate token on every confirmed lane. Tokens with no enabled override on that lane are +// skipped. For tokens with an enabled override, DestGasOverhead is resolved against its expected +// Prague baseline (applying the literal Glamsterdam value on a match, or the fallback rule on a +// mismatch), and all resolved tokens for one chain are grouped into a single MCMS batch +// operation. Every write is routed through MCMS regardless of who the deployer key is. +var UpdateTokenTransferFeeConfig = cldf_ops.NewSequence( + "UpdateTokenTransferFeeConfigV1_6", + semver.MustParse("1.6.0"), + "Updates v1.6 FeeQuoter token transfer fee config for lanes pointed at the Glamsterdam target chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input UpdateTokenTransferFeeConfigInput) (UpdateTokenTransferFeeConfigOutput, error) { + output := UpdateTokenTransferFeeConfigOutput{Report: glamsterdamutils.NewReport()} + + for _, lane := range input.Lanes { + chain, ok := chains.EVMChains()[lane.ChainSelector] + if !ok { + return UpdateTokenTransferFeeConfigOutput{}, fmt.Errorf("chain with selector %d not found", lane.ChainSelector) + } + + var singleTokenArgs []fee_quoter.TokenTransferFeeConfigSingleTokenArgs + + for _, token := range lane.CandidateTokens { + cur, err := cldf_ops.ExecuteOperation(b, fee_quoter.GetTokenTransferFeeConfig, chain, contract.FunctionInput[fee_quoter.GetTokenTransferFeeConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.FeeQuoterAddress, + Args: fee_quoter.GetTokenTransferFeeConfigArgs{ + DestChainSelector: input.TargetChainSelector, + Token: token, + }, + }) + if err != nil { + return UpdateTokenTransferFeeConfigOutput{}, fmt.Errorf( + "failed to read FeeQuoter token transfer fee config for src %d, dst %d, token %s: %w", + lane.ChainSelector, input.TargetChainSelector, token, err, + ) + } + if !cur.Output.IsEnabled { + // No override configured for this token on this lane — nothing to update. + continue + } + + result := glamsterdamutils.Resolve(USDCTokenPoolDestGasOverhead, cur.Output.DestGasOverhead) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, result) + + newConfig := cur.Output + newConfig.DestGasOverhead = result.AppliedValue + singleTokenArgs = append(singleTokenArgs, fee_quoter.TokenTransferFeeConfigSingleTokenArgs{ + Token: token, + TokenTransferFeeConfig: newConfig, + }) + } + + if len(singleTokenArgs) == 0 { + continue + } + + write, err := cldf_ops.ExecuteOperation(b, applyFeeQuoterTokenTransferFeeConfigUpdates, chain, contract.FunctionInput[fee_quoter.ApplyTokenTransferFeeConfigUpdatesArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.FeeQuoterAddress, + Args: fee_quoter.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: []fee_quoter.TokenTransferFeeConfigArgs{ + {DestChainSelector: input.TargetChainSelector, TokenTransferFeeConfigs: singleTokenArgs}, + }, + }, + }) + if err != nil { + return UpdateTokenTransferFeeConfigOutput{}, fmt.Errorf("failed to apply FeeQuoter token transfer fee config update for src %d: %w", lane.ChainSelector, err) + } + + batchOp, err := contract.NewBatchOperationFromWrites([]contract.WriteOutput{write.Output}) + if err != nil { + return UpdateTokenTransferFeeConfigOutput{}, fmt.Errorf("failed to build batch operation for src %d: %w", lane.ChainSelector, err) + } + output.BatchOps = append(output.BatchOps, batchOp) + } + + return output, nil + }, +) diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/token_transfer_fee_config_test.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/token_transfer_fee_config_test.go new file mode 100644 index 0000000000..ef80ed15cb --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/token_transfer_fee_config_test.go @@ -0,0 +1,157 @@ +package glamsterdam_test + +import ( + "math/big" + "strings" + "testing" + + ethabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + fq16ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/sequences/glamsterdam" +) + +const ( + ttfcTargetChainSel = uint64(3379446385462418246) + ttfcBaselineChain = uint64(4949039107694359620) + ttfcMismatchedChain = uint64(5548718428018410741) +) + +var ( + ttfcUSDCToken = common.HexToAddress("0x1111111111111111111111111111111111111111") + ttfcUnconfiguredTok = common.HexToAddress("0x2222222222222222222222222222222222222222") +) + +func deployTTFCFeeQuoter(t *testing.T, e *cldf.Environment, chainSel uint64, usdcDestGasOverhead uint32) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq16ops.Deploy, chain, contract.DeployInput[fq16ops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fq16ops.ContractType, *fq16ops.Version), + Args: fq16ops.ConstructorArgs{ + StaticConfig: fq16ops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + TokenPriceStalenessThreshold: 3600, + }, + TokenTransferFeeConfigArgs: []fq16ops.TokenTransferFeeConfigArgs{ + { + DestChainSelector: ttfcTargetChainSel, + TokenTransferFeeConfigs: []fq16ops.TokenTransferFeeConfigSingleTokenArgs{ + { + Token: ttfcUSDCToken, + TokenTransferFeeConfig: fq16ops.TokenTransferFeeConfig{ + MinFeeUSDCents: 50, + MaxFeeUSDCents: 500, + DestGasOverhead: usdcDestGasOverhead, + DestBytesOverhead: 100, + IsEnabled: true, + }, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func decodeApplyTokenTransferFeeConfigUpdatesArgs(t *testing.T, data []byte) fq16ops.ApplyTokenTransferFeeConfigUpdatesArgs { + t.Helper() + parsed, err := ethabi.JSON(strings.NewReader(fq16ops.FeeQuoterABI)) + require.NoError(t, err) + vals, err := parsed.Methods["applyTokenTransferFeeConfigUpdates"].Inputs.Unpack(data[4:]) + require.NoError(t, err) + feeConfigArgs := *ethabi.ConvertType(vals[0], new([]fq16ops.TokenTransferFeeConfigArgs)).(*[]fq16ops.TokenTransferFeeConfigArgs) + removeArgs := *ethabi.ConvertType(vals[1], new([]fq16ops.TokenTransferFeeConfigRemoveArgs)).(*[]fq16ops.TokenTransferFeeConfigRemoveArgs) + return fq16ops.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: feeConfigArgs, + TokensToUseDefaultFeeConfigs: removeArgs, + } +} + +func TestUpdateTokenTransferFeeConfig(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + ttfcBaselineChain, + ttfcMismatchedChain, + })) + require.NoError(t, err) + + fqBaselineAddr := deployTTFCFeeQuoter(t, e, ttfcBaselineChain, 180_000) + // Mismatched chain: USDC's current DestGasOverhead doesn't match the expected Prague baseline + // (180,000) — exercises the fallback path instead of the literal Glamsterdam value. + fqMismatchedAddr := deployTTFCFeeQuoter(t, e, ttfcMismatchedChain, 150_000) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateTokenTransferFeeConfig, e.BlockChains, glamsterdamseq.UpdateTokenTransferFeeConfigInput{ + TargetChainSelector: ttfcTargetChainSel, + Lanes: []glamsterdamseq.TokenTransferFeeConfigLane{ + { + ChainSelector: ttfcBaselineChain, + FeeQuoterAddress: fqBaselineAddr, + // ttfcUnconfiguredTok has no override on this lane -> must be silently skipped. + CandidateTokens: []common.Address{ttfcUSDCToken, ttfcUnconfiguredTok}, + }, + { + ChainSelector: ttfcMismatchedChain, + FeeQuoterAddress: fqMismatchedAddr, + CandidateTokens: []common.Address{ttfcUSDCToken}, + }, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 2, "chains with an enabled override should each produce exactly one batch op") + for _, batchOp := range report.Output.BatchOps { + require.Len(t, batchOp.Transactions, 1) + } + + reportStr := report.Output.Report.String() + + t.Run("baseline chain applies literal Glamsterdam value and preserves untouched fields", func(t *testing.T) { + require.Contains(t, reportStr, "chain 4949039107694359620: FeeQuoter.TokenTransferFeeConfig.DestGasOverhead (USDC) matched expected Prague value 180000, applying Glamsterdam value 540000") + + args := decodeApplyTokenTransferFeeConfigUpdatesArgs(t, report.Output.BatchOps[0].Transactions[0].Data) + require.Len(t, args.TokenTransferFeeConfigArgs, 1) + require.Len(t, args.TokenTransferFeeConfigArgs[0].TokenTransferFeeConfigs, 1, "unconfigured token must not appear in the write") + single := args.TokenTransferFeeConfigArgs[0].TokenTransferFeeConfigs[0] + require.Equal(t, ttfcUSDCToken, single.Token) + require.Equal(t, uint32(540_000), single.TokenTransferFeeConfig.DestGasOverhead) + require.Equal(t, uint32(100), single.TokenTransferFeeConfig.DestBytesOverhead) // preserved untouched field + require.True(t, single.TokenTransferFeeConfig.IsEnabled) + }) + + t.Run("mismatched chain applies fallback instead of literal value", func(t *testing.T) { + require.Contains(t, reportStr, "chain 5548718428018410741: FeeQuoter.TokenTransferFeeConfig.DestGasOverhead (USDC) MISMATCH") + require.Contains(t, reportStr, "current value 150000") + require.Contains(t, reportStr, "applying fallback value 450000 instead of literal Glamsterdam value 540000") + }) + + t.Run("no writes executed directly, always routed through MCMS", func(t *testing.T) { + for _, lane := range []struct { + feeQuoter common.Address + chainSel uint64 + }{ + {fqBaselineAddr, ttfcBaselineChain}, + {fqMismatchedAddr, ttfcMismatchedChain}, + } { + chain := e.BlockChains.EVMChains()[lane.chainSel] + cur, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq16ops.GetTokenTransferFeeConfig, chain, contract.FunctionInput[fq16ops.GetTokenTransferFeeConfigArgs]{ + ChainSelector: lane.chainSel, + Address: lane.feeQuoter, + Args: fq16ops.GetTokenTransferFeeConfigArgs{DestChainSelector: ttfcTargetChainSel, Token: ttfcUSDCToken}, + }) + require.NoError(t, err) + require.NotEqual(t, uint32(540_000), cur.Output.DestGasOverhead) + } + }) +} diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/update_gas_config.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/update_gas_config.go new file mode 100644 index 0000000000..4ac8e473b7 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/update_gas_config.go @@ -0,0 +1,152 @@ +package glamsterdam + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/offramp" +) + +// applyFeeQuoterDestChainConfigUpdates mirrors fee_quoter.ApplyDestChainConfigUpdates, but always +// routes the write through MCMS regardless of who the deployer key is, per this feature's +// "always MCMS" decision. +var applyFeeQuoterDestChainConfigUpdates = contract.NewWrite(contract.WriteParams[[]fee_quoter.DestChainConfigArgs, *fee_quoter.FeeQuoterContract]{ + Name: "glamsterdam:fee-quoter:apply-dest-chain-config-updates", + Version: semver.MustParse("1.6.0"), + Description: "Calls applyDestChainConfigUpdates on FeeQuoter, always producing an MCMS proposal", + ContractType: fee_quoter.ContractType, + ContractABI: fee_quoter.FeeQuoterABI, + NewContract: fee_quoter.NewFeeQuoterContract, + IsAllowedCaller: contract.NoCallersAllowed[*fee_quoter.FeeQuoterContract, []fee_quoter.DestChainConfigArgs], + Validate: func([]fee_quoter.DestChainConfigArgs) error { return nil }, + CallContract: func(c *fee_quoter.FeeQuoterContract, opts *bind.TransactOpts, args []fee_quoter.DestChainConfigArgs) (*types.Transaction, error) { + return c.ApplyDestChainConfigUpdates(opts, args) + }, +}) + +// LaneAddresses is the set of contract addresses to update on one source chain that has a +// confirmed lane pointed at the Glamsterdam target chain. +type LaneAddresses struct { + ChainSelector uint64 + FeeQuoterAddress common.Address + // OffRampAddress is optional. If set, OffRamp's immutable GasForCallExactCheck field is read + // and sanity checked against its expected Prague baseline (no write is ever made — there is + // no setter). Leave as the zero address to skip this chain's check. + OffRampAddress common.Address +} + +// UpdateGasConfigInput is the input to UpdateGasConfig. +type UpdateGasConfigInput struct { + // TargetChainSelector is the chain selector moving to Glamsterdam. + TargetChainSelector uint64 + // Lanes are the source chains with a confirmed lane pointed at the target chain. + Lanes []LaneAddresses +} + +// UpdateGasConfigOutput is the output of UpdateGasConfig. +type UpdateGasConfigOutput struct { + // BatchOps contains one MCMS batch operation per chain in Lanes. + BatchOps []mcms_types.BatchOperation + // Report is a human-readable summary of every field resolved on every lane, for inclusion in + // the changeset's MCMS proposal description. + Report *glamsterdamutils.Report +} + +// UpdateGasConfig reads the current on-chain gas config for every confirmed lane, resolves each +// field in the v1.6 Glamsterdam mapping table against its expected Prague baseline (applying the +// literal Glamsterdam value on a match, or the field's fallback rule on a mismatch), and packages +// the resulting write into one MCMS batch operation per chain. Every write is routed through MCMS +// regardless of who the deployer key is. +var UpdateGasConfig = cldf_ops.NewSequence( + "UpdateGasConfigV1_6", + semver.MustParse("1.6.0"), + "Updates v1.6 source-side gas config for lanes pointed at the Glamsterdam target chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input UpdateGasConfigInput) (UpdateGasConfigOutput, error) { + output := UpdateGasConfigOutput{Report: glamsterdamutils.NewReport()} + + for _, lane := range input.Lanes { + chain, ok := chains.EVMChains()[lane.ChainSelector] + if !ok { + return UpdateGasConfigOutput{}, fmt.Errorf("chain with selector %d not found", lane.ChainSelector) + } + + var writes []contract.WriteOutput + + // --- FeeQuoter: DestGasOverhead, DefaultTokenDestGasOverhead (rows 1-2) --- + fqCur, err := cldf_ops.ExecuteOperation(b, fee_quoter.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.ChainSelector, + Address: lane.FeeQuoterAddress, + Args: input.TargetChainSelector, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf( + "failed to read FeeQuoter dest chain config for src %d, dst %d: %w", lane.ChainSelector, input.TargetChainSelector, err, + ) + } + + destGasOverheadResult := glamsterdamutils.Resolve(FeeQuoterDestGasOverhead, fqCur.Output.DestGasOverhead) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, destGasOverheadResult) + + defaultTokenDestGasOverheadResult := glamsterdamutils.Resolve(FeeQuoterDefaultTokenDestGasOverhead, fqCur.Output.DefaultTokenDestGasOverhead) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, defaultTokenDestGasOverheadResult) + + newFQConfig := fqCur.Output + newFQConfig.DestGasOverhead = destGasOverheadResult.AppliedValue + newFQConfig.DefaultTokenDestGasOverhead = defaultTokenDestGasOverheadResult.AppliedValue + + fqWrite, err := cldf_ops.ExecuteOperation(b, applyFeeQuoterDestChainConfigUpdates, chain, contract.FunctionInput[[]fee_quoter.DestChainConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.FeeQuoterAddress, + Args: []fee_quoter.DestChainConfigArgs{ + {DestChainSelector: input.TargetChainSelector, DestChainConfig: newFQConfig}, + }, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to apply FeeQuoter dest chain config update for src %d: %w", lane.ChainSelector, err) + } + writes = append(writes, fqWrite.Output) + + // --- OffRamp: GasForCallExactCheck (row 3) --- + // Read-only sanity check: this field is immutable with no setter, so no write is ever + // produced here, but an unexpected on-chain value is still worth surfacing. + if lane.OffRampAddress != (common.Address{}) { + offRampCur, err := cldf_ops.ExecuteOperation(b, offramp.GetStaticConfig, chain, contract.FunctionInput[struct{}]{ + ChainSelector: lane.ChainSelector, + Address: lane.OffRampAddress, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to read OffRamp static config for src %d: %w", lane.ChainSelector, err) + } + + if offRampCur.Output.GasForCallExactCheck != OffRampExpectedGasForCallExactCheck { + line := fmt.Sprintf( + "chain %d: OffRamp.StaticConfig.GasForCallExactCheck is immutable with no setter, but current "+ + "value %d does not match expected Prague baseline %d - unexpected deployment, investigate manually", + lane.ChainSelector, offRampCur.Output.GasForCallExactCheck, OffRampExpectedGasForCallExactCheck, + ) + b.Logger.Warn(line) + output.Report.AddLine(line) + } + } + + batchOp, err := contract.NewBatchOperationFromWrites(writes) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to build batch operation for src %d: %w", lane.ChainSelector, err) + } + output.BatchOps = append(output.BatchOps, batchOp) + } + + return output, nil + }, +) diff --git a/chains/evm/deployment/v1_6_1/sequences/glamsterdam/update_gas_config_test.go b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/update_gas_config_test.go new file mode 100644 index 0000000000..8412f52fc5 --- /dev/null +++ b/chains/evm/deployment/v1_6_1/sequences/glamsterdam/update_gas_config_test.go @@ -0,0 +1,189 @@ +package glamsterdam_test + +import ( + "math/big" + "strings" + "testing" + + ethabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + fq16ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + orops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/offramp" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/sequences/glamsterdam" +) + +const ( + gasCfgTargetChainSel = uint64(3379446385462418246) + gasCfgBaselineChain = uint64(4949039107694359620) + gasCfgMismatchedChain = uint64(5548718428018410741) +) + +var ( + gasCfgRmnAddr = common.HexToAddress("0x5555555555555555555555555555555555555555") + gasCfgTokenAdminRegistry = common.HexToAddress("0x6666666666666666666666666666666666666666") + gasCfgNonceManagerAddr = common.HexToAddress("0x8888888888888888888888888888888888888888") +) + +func deployGasCfgFeeQuoter( + t *testing.T, + e *cldf.Environment, + chainSel uint64, + destChainConfig fq16ops.DestChainConfig, +) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq16ops.Deploy, chain, contract.DeployInput[fq16ops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fq16ops.ContractType, *fq16ops.Version), + Args: fq16ops.ConstructorArgs{ + StaticConfig: fq16ops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + TokenPriceStalenessThreshold: 3600, + }, + DestChainConfigArgs: []fq16ops.DestChainConfigArgs{ + {DestChainSelector: gasCfgTargetChainSel, DestChainConfig: destChainConfig}, + }, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func deployGasCfgOffRamp(t *testing.T, e *cldf.Environment, chainSel uint64, gasForCallExactCheck uint16) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, orops.Deploy, chain, contract.DeployInput[orops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(orops.ContractType, *orops.Version), + Args: orops.ConstructorArgs{ + StaticConfig: orops.StaticConfig{ + ChainSelector: chainSel, + GasForCallExactCheck: gasForCallExactCheck, + RmnRemote: gasCfgRmnAddr, + TokenAdminRegistry: gasCfgTokenAdminRegistry, + NonceManager: gasCfgNonceManagerAddr, + }, + DynamicConfig: orops.DynamicConfig{ + FeeQuoter: gasCfgRmnAddr, // placeholder, not exercised by this test + }, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +// decodeApplyDestChainConfigUpdatesArgs decodes the []fq16ops.DestChainConfigArgs passed to a +// FeeQuoter applyDestChainConfigUpdates call from its raw calldata, mirroring the abi.ConvertType +// pattern the generated read operations already use in this codebase. +func decodeApplyDestChainConfigUpdatesArgs(t *testing.T, data []byte) []fq16ops.DestChainConfigArgs { + t.Helper() + parsed, err := ethabi.JSON(strings.NewReader(fq16ops.FeeQuoterABI)) + require.NoError(t, err) + vals, err := parsed.Methods["applyDestChainConfigUpdates"].Inputs.Unpack(data[4:]) + require.NoError(t, err) + converted := *ethabi.ConvertType(vals[0], new([]fq16ops.DestChainConfigArgs)).(*[]fq16ops.DestChainConfigArgs) + return converted +} + +func TestUpdateGasConfig(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + gasCfgBaselineChain, + gasCfgMismatchedChain, + })) + require.NoError(t, err) + + baselineFQConfig := fq16ops.DestChainConfig{ + IsEnabled: true, + MaxNumberOfTokensPerMsg: 5, + MaxPerMsgGasLimit: 3_000_000, + DestGasOverhead: 300_000, + ChainFamilySelector: evmFamilySelector, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + GasMultiplierWeiPerEth: 1e18, + } + + fqBaselineAddr := deployGasCfgFeeQuoter(t, e, gasCfgBaselineChain, baselineFQConfig) + offRampBaselineAddr := deployGasCfgOffRamp(t, e, gasCfgBaselineChain, 5_000) + + // Mismatched chain: FeeQuoter.DestGasOverhead doesn't match the expected Prague baseline + // (300,000) — exercises the fallback path instead of the literal Glamsterdam value. + mismatchedFQConfig := baselineFQConfig + mismatchedFQConfig.DestGasOverhead = 240_000 + fqMismatchedAddr := deployGasCfgFeeQuoter(t, e, gasCfgMismatchedChain, mismatchedFQConfig) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: gasCfgTargetChainSel, + Lanes: []glamsterdamseq.LaneAddresses{ + { + ChainSelector: gasCfgBaselineChain, + FeeQuoterAddress: fqBaselineAddr, + OffRampAddress: offRampBaselineAddr, + }, + { + ChainSelector: gasCfgMismatchedChain, + FeeQuoterAddress: fqMismatchedAddr, + }, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 2) + for _, batchOp := range report.Output.BatchOps { + require.Len(t, batchOp.Transactions, 1, "expected a single FeeQuoter write per chain") + } + + reportStr := report.Output.Report.String() + + t.Run("baseline chain applies literal Glamsterdam values and preserves untouched fields", func(t *testing.T) { + require.Contains(t, reportStr, "chain 4949039107694359620: FeeQuoter.DestChainConfig.DestGasOverhead matched expected Prague value 300000, applying Glamsterdam value 500000") + require.Contains(t, reportStr, "chain 4949039107694359620: FeeQuoter.DestChainConfig.DefaultTokenDestGasOverhead matched expected Prague value 90000, applying Glamsterdam value 270000") + + batchOp := report.Output.BatchOps[0] + fqArgs := decodeApplyDestChainConfigUpdatesArgs(t, batchOp.Transactions[0].Data) + require.Len(t, fqArgs, 1) + require.Equal(t, uint32(500_000), fqArgs[0].DestChainConfig.DestGasOverhead) + require.Equal(t, uint32(270_000), fqArgs[0].DestChainConfig.DefaultTokenDestGasOverhead) + // untouched fields preserved across the merge + require.Equal(t, uint32(3_000_000), fqArgs[0].DestChainConfig.MaxPerMsgGasLimit) + require.True(t, fqArgs[0].DestChainConfig.IsEnabled) + require.Equal(t, uint16(5), fqArgs[0].DestChainConfig.MaxNumberOfTokensPerMsg) + }) + + t.Run("mismatched chain applies fallback instead of literal value", func(t *testing.T) { + require.Contains(t, reportStr, "chain 5548718428018410741: FeeQuoter.DestChainConfig.DestGasOverhead MISMATCH") + require.Contains(t, reportStr, "current value 240000") + require.Contains(t, reportStr, "applying fallback value 400000 instead of literal Glamsterdam value 500000") + }) + + t.Run("no writes executed directly, always routed through MCMS", func(t *testing.T) { + for _, lane := range []struct { + feeQuoter common.Address + chainSel uint64 + }{ + {fqBaselineAddr, gasCfgBaselineChain}, + {fqMismatchedAddr, gasCfgMismatchedChain}, + } { + chain := e.BlockChains.EVMChains()[lane.chainSel] + cur, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq16ops.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.chainSel, + Address: lane.feeQuoter, + Args: gasCfgTargetChainSel, + }) + require.NoError(t, err) + require.NotEqual(t, uint32(500_000), cur.Output.DestGasOverhead) + } + }) +} diff --git a/chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update.go b/chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update.go new file mode 100644 index 0000000000..c801b22ebe --- /dev/null +++ b/chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update.go @@ -0,0 +1,255 @@ +package changesets + +import ( + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/cctp_through_ccv_token_pool" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/committee_verifier" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/offramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/onramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/siloed_usdc_token_pool" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_1_0/operations/cctp_verifier" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_1_0/operations/lombard_token_pool" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_1_0/operations/lombard_verifier" + cs_core "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" +) + +// supportedLombardVerifierVersions and supportedCCTPVerifierVersions are the datastore-tagged +// contract versions this changeset knows how to drive. The v2.1.0 client/ABI (imported above) is +// backward-compatible with a v2.0.0-deployed contract for the two methods this changeset calls +// (GetRemoteChainConfig / applyRemoteChainConfigUpdates have identical signatures in both ABI +// versions), so a single client library can safely call either. Listing both here means a chain +// with only a v2.0.0-tagged verifier in the datastore is still resolved and updated, instead of +// being silently skipped because the datastore lookup only ever checked for v2.1.0. +var ( + supportedLombardVerifierVersions = []*semver.Version{lombard_verifier.Version, semver.MustParse("2.0.0")} + supportedCCTPVerifierVersions = []*semver.Version{cctp_verifier.Version, semver.MustParse("2.0.0")} +) + +// resolveAddressRefsAllVersions returns every distinct address matching contractType across +// versions for the given chain — unlike a single-version lookup, it doesn't stop at the first +// match. This is used for verifier contracts where a chain can have more than one version live +// at once during a migration window, and all of them need updating, not just one. +func resolveAddressRefsAllVersions( + addrs []datastore.AddressRef, sel uint64, contractType cldf_deployment.ContractType, + versions []*semver.Version, qualifier string, +) []common.Address { + seen := make(map[common.Address]bool, len(versions)) + var out []common.Address + for _, v := range versions { + ref := datastore_utils.GetAddressRef(addrs, sel, contractType, v, qualifier) + if datastore_utils.IsAddressRefEmpty(ref) { + continue + } + addr := common.HexToAddress(ref.Address) + if !seen[addr] { + seen[addr] = true + out = append(out, addr) + } + } + return out +} + +// resolveAddressRefsAllQualifiers returns every distinct address matching contractType and +// version for the given chain, regardless of qualifier — unlike GetAddressRef, it doesn't stop at +// the first match. Lombard deployment supports multiple qualifier-scoped pools per chain (e.g. one +// per remote-chain grouping), and all of them need updating, not just whichever one has an empty +// qualifier. +func resolveAddressRefsAllQualifiers( + addrs []datastore.AddressRef, contractType cldf_deployment.ContractType, version *semver.Version, +) []common.Address { + seen := make(map[common.Address]bool) + var out []common.Address + for _, ref := range addrs { + if ref.Type != datastore.ContractType(contractType) || !ref.Version.Equal(version) { + continue + } + addr := common.HexToAddress(ref.Address) + if !seen[addr] { + seen[addr] = true + out = append(out, addr) + } + } + return out +} + +// usdcTokenPoolContractTypes lists every TokenPool implementation that can own a USDC +// target-lane TokenTransferFeeConfig: the standard SiloedUSDCTokenPool, and the +// CCTPThroughCCVTokenPool used for CCTP-capable EVM remotes. Both must be checked, since a chain +// can have either (or both) configured, and each is a distinct on-chain contract with its own +// DestGasOverhead to migrate. +var usdcTokenPoolContractTypes = []struct { + ContractType cldf_deployment.ContractType + Version *semver.Version +}{ + {siloed_usdc_token_pool.ContractType, siloed_usdc_token_pool.Version}, + {cctp_through_ccv_token_pool.ContractType, cctp_through_ccv_token_pool.Version}, +} + +// GlamsterdamGasUpdateCfg is configuration for the UpdateGasConfigForGlamsterdamV2 changeset. +type GlamsterdamGasUpdateCfg struct { + // TargetChainSelector is the chain selector of the chain moving to Glamsterdam. + TargetChainSelector uint64 + // SkipChainSelectors are chain selectors to unconditionally skip — no lane-to-target check is + // even performed. Used to batch a high-fanout chain (e.g. mainnet, ~80 lanes) into smaller + // runs. + SkipChainSelectors []uint64 +} + +// UpdateGasConfigForGlamsterdamV2 discovers every v2.0 chain with a lane pointed at +// cfg.TargetChainSelector, reads the current on-chain gas config for every field in the v2.0 +// Glamsterdam mapping table, resolves each field against its expected Prague baseline, and +// packages the resulting writes into an MCMS timelock proposal. It never executes directly, even +// if the deployer key happens to be owner. +func UpdateGasConfigForGlamsterdamV2(mcmsRegistry *cs_core.MCMSReaderRegistry) cldf_deployment.ChangeSetV2[cs_core.WithMCMS[GlamsterdamGasUpdateCfg]] { + validate := func(_ cldf_deployment.Environment, cfg cs_core.WithMCMS[GlamsterdamGasUpdateCfg]) error { + if cfg.Cfg.TargetChainSelector == 0 { + return errors.New("target chain selector must be set") + } + return nil + } + + apply := func(e cldf_deployment.Environment, cfg cs_core.WithMCMS[GlamsterdamGasUpdateCfg]) (cldf_deployment.ChangesetOutput, error) { + target := cfg.Cfg.TargetChainSelector + report := glamsterdamutils.NewReport() + + for _, sel := range cfg.Cfg.SkipChainSelectors { + report.AddSkipped(sel) + } + + excluded := make([]uint64, 0, len(cfg.Cfg.SkipChainSelectors)+1) + excluded = append(excluded, cfg.Cfg.SkipChainSelectors...) + excluded = append(excluded, target) + + candidates := e.BlockChains.ListChainSelectors( + cldf_chain.WithFamily(chain_selectors.FamilyEVM), + cldf_chain.WithChainSelectorsExclusion(excluded), + ) + + addressesByChain := make(map[uint64][]datastore.AddressRef, len(candidates)) + feeQuoterAddrByChain := make(map[uint64]common.Address, len(candidates)) + for _, sel := range candidates { + addrs := e.DataStore.Addresses().Filter(datastore.AddressRefByChainSelector(sel)) + addressesByChain[sel] = addrs + + fqRef := datastore_utils.GetAddressRef(addrs, sel, fee_quoter.ContractType, fee_quoter.Version, "") + if datastore_utils.IsAddressRefEmpty(fqRef) { + report.AddUnresolvedContract(sel, "FeeQuoter") + continue + } + feeQuoterAddrByChain[sel] = common.HexToAddress(fqRef.Address) + } + + discoveryReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: target, + FeeQuoterAddressByChain: feeQuoterAddrByChain, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to discover lanes to target chain %d: %w", target, err) + } + for _, sel := range discoveryReport.Output.NoLane { + report.AddNoLane(sel) + } + report.Lines = append(report.Lines, discoveryReport.Output.Report.Lines...) + + var ( + lanes []glamsterdamseq.LaneAddresses + lombardPools []glamsterdamseq.TokenPoolLane + usdcPools []glamsterdamseq.TokenPoolLane + ) + + for _, sel := range discoveryReport.Output.LanesToUpdate { + addrs := addressesByChain[sel] + + onRampRef := datastore_utils.GetAddressRef(addrs, sel, onramp.ContractType, onramp.Version, "") + if datastore_utils.IsAddressRefEmpty(onRampRef) { + report.AddUnresolvedContract(sel, "OnRamp") + continue + } + lane := glamsterdamseq.LaneAddresses{ + ChainSelector: sel, + OnRampAddress: common.HexToAddress(onRampRef.Address), + FeeQuoterAddress: feeQuoterAddrByChain[sel], + } + if cvRef := datastore_utils.GetAddressRef(addrs, sel, committee_verifier.ContractType, committee_verifier.Version, ""); !datastore_utils.IsAddressRefEmpty(cvRef) { + lane.CommitteeVerifierAddress = common.HexToAddress(cvRef.Address) + } else { + report.AddUnresolvedContract(sel, "CommitteeVerifier") + } + lane.LombardVerifierAddresses = resolveAddressRefsAllVersions(addrs, sel, lombard_verifier.ContractType, supportedLombardVerifierVersions, "") + lane.CCTPVerifierAddresses = resolveAddressRefsAllVersions(addrs, sel, cctp_verifier.ContractType, supportedCCTPVerifierVersions, "") + if offRampRef := datastore_utils.GetAddressRef(addrs, sel, offramp.ContractType, offramp.Version, ""); !datastore_utils.IsAddressRefEmpty(offRampRef) { + lane.OffRampAddress = common.HexToAddress(offRampRef.Address) + } + lanes = append(lanes, lane) + + for _, lombardAddr := range resolveAddressRefsAllQualifiers(addrs, lombard_token_pool.ContractType, lombard_token_pool.Version) { + lombardPools = append(lombardPools, glamsterdamseq.TokenPoolLane{ + ChainSelector: sel, + PoolAddress: lombardAddr, + }) + } + for _, usdcType := range usdcTokenPoolContractTypes { + for _, usdcAddr := range resolveAddressRefsAllQualifiers(addrs, usdcType.ContractType, usdcType.Version) { + usdcPools = append(usdcPools, glamsterdamseq.TokenPoolLane{ + ChainSelector: sel, + PoolAddress: usdcAddr, + }) + } + } + } + + var batchOps []mcms_types.BatchOperation + + if len(lanes) > 0 { + gasCfgReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: target, + Lanes: lanes, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to update gas config for target chain %d: %w", target, err) + } + batchOps = append(batchOps, gasCfgReport.Output.BatchOps...) + report.Lines = append(report.Lines, gasCfgReport.Output.Report.Lines...) + } + + if len(lombardPools) > 0 || len(usdcPools) > 0 { + tokenPoolReport, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateTokenPoolGasConfig, e.BlockChains, glamsterdamseq.UpdateTokenPoolGasConfigInput{ + TargetChainSelector: target, + LombardPools: lombardPools, + USDCPools: usdcPools, + }) + if err != nil { + return cldf_deployment.ChangesetOutput{}, fmt.Errorf("failed to update token pool gas config for target chain %d: %w", target, err) + } + batchOps = append(batchOps, tokenPoolReport.Output.BatchOps...) + report.Lines = append(report.Lines, tokenPoolReport.Output.Report.Lines...) + } + + mcmsInput := cfg.MCMS + if mcmsInput.Description == "" { + mcmsInput.Description = report.String() + } else { + mcmsInput.Description = mcmsInput.Description + "\n\n" + report.String() + } + + return cs_core.NewOutputBuilder(e, mcmsRegistry).WithBatchOps(batchOps).Build(mcmsInput) + } + + return cldf_deployment.CreateChangeSet(apply, validate) +} diff --git a/chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update_test.go b/chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update_test.go new file mode 100644 index 0000000000..cb4ee2309b --- /dev/null +++ b/chains/evm/deployment/v2_0_0/changesets/glamsterdam_gas_update_test.go @@ -0,0 +1,268 @@ +package changesets_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + bnmops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/changesets" + cvops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/committee_verifier" + fqops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + rmpops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/onramp" + usdcpoolops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/siloed_usdc_token_pool" + tpops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/token_pool" + ccipdeploymentutils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + cs_core "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" +) + +const ( + ggTargetChainSel = uint64(3379446385462418246) + ggSrcAChainSel = uint64(4949039107694359620) + ggSrcBChainSel = uint64(5548718428018410741) + ggSrcSkipChainSel = uint64(6433500567565415381) + ggSrcNoLaneSel = uint64(4356164186791070119) +) + +var ( + ggRouterAddr = common.HexToAddress("0x1111111111111111111111111111111111111111") + ggDefaultExecutorAddr = common.HexToAddress("0x2222222222222222222222222222222222222222") + ggDefaultCCVAddr = common.HexToAddress("0x3333333333333333333333333333333333333333") + ggOffRampBytesAddr = common.HexToAddress("0x4444444444444444444444444444444444444444") + ggRmnAddr = common.HexToAddress("0x5555555555555555555555555555555555555555") + ggTokenAdminRegistry = common.HexToAddress("0x6666666666666666666666666666666666666666") + ggLinkToken = common.HexToAddress("0x7777777777777777777777777777777777777777") + ggEVMFamilySelector = [4]byte{0x28, 0x12, 0xd5, 0x2c} +) + +// ggLaneFixture deploys FeeQuoter, OnRamp, and CommitteeVerifier on chainSel with a lane pointed +// at ggTargetChainSel, registers them in ds, and returns whether a lane was actually configured +// (hasLane=false leaves the FeeQuoter's dest chain config for the target disabled, so discovery +// finds no lane). +func ggLaneFixture(t *testing.T, e *cldf.Environment, ds datastore.MutableDataStore, chainSel uint64, hasLane bool, onRampBaseExecGasCost uint32) { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + fqDestChainConfigArgs := []fqops.DestChainConfigArgs{} + if hasLane { + fqDestChainConfigArgs = []fqops.DestChainConfigArgs{ + { + DestChainSelector: ggTargetChainSel, + DestChainConfig: fqops.DestChainConfig{ + IsEnabled: true, + MaxPerMsgGasLimit: 15_000_000, + DestGasOverhead: 300_000, + DestGasPerPayloadByteBase: 20, + ChainFamilySelector: ggEVMFamilySelector, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + LinkFeeMultiplierPercent: 100, + }, + }, + } + } + + fqOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fqops.Deploy, chain, contract.DeployInput[fqops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fqops.ContractType, *fqops.Version), + Args: fqops.ConstructorArgs{ + StaticConfig: fqops.StaticConfig{MaxFeeJuelsPerMsg: big.NewInt(1e18), LinkToken: ggLinkToken}, + DestChainConfigArgs: fqDestChainConfigArgs, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(fqOut.Output)) + fqAddr := common.HexToAddress(fqOut.Output.Address) + + onRampOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, rmpops.Deploy, chain, contract.DeployInput[rmpops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(rmpops.ContractType, *rmpops.Version), + Args: rmpops.ConstructorArgs{ + StaticConfig: rmpops.StaticConfig{ + ChainSelector: chainSel, + RmnRemote: ggRmnAddr, + TokenAdminRegistry: ggTokenAdminRegistry, + MaxUSDCentsPerMessage: 1_000_000, + }, + DynamicConfig: rmpops.DynamicConfig{FeeQuoter: fqAddr}, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(onRampOut.Output)) + onRampAddr := common.HexToAddress(onRampOut.Output.Address) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, rmpops.ApplyDestChainConfigUpdates, chain, contract.FunctionInput[[]rmpops.DestChainConfigArgs]{ + ChainSelector: chainSel, + Address: onRampAddr, + Args: []rmpops.DestChainConfigArgs{ + { + DestChainSelector: ggTargetChainSel, + Router: ggRouterAddr, + AddressBytesLength: 20, + BaseExecutionGasCost: onRampBaseExecGasCost, + DefaultCCVs: []common.Address{ggDefaultCCVAddr}, + DefaultExecutor: ggDefaultExecutorAddr, + OffRamp: ggOffRampBytesAddr.Bytes(), + }, + }, + }) + require.NoError(t, err) + + cvOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, cvops.Deploy, chain, contract.DeployInput[cvops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(cvops.ContractType, *cvops.Version), + Args: cvops.ConstructorArgs{ + DynamicConfig: cvops.DynamicConfig{FeeAggregator: ggRouterAddr}, + Rmn: ggRmnAddr, + VersionTag: [4]byte{0x01, 0x02, 0x03, 0x04}, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(cvOut.Output)) + cvAddr := common.HexToAddress(cvOut.Output.Address) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, cvops.ApplyRemoteChainConfigUpdates, chain, contract.FunctionInput[[]cvops.RemoteChainConfigArgs]{ + ChainSelector: chainSel, + Address: cvAddr, + Args: []cvops.RemoteChainConfigArgs{ + {Router: ggRouterAddr, RemoteChainSelector: ggTargetChainSel, GasForVerification: 75_000}, + }, + }) + require.NoError(t, err) +} + +// ggUSDCPoolFixture deploys a SiloedUSDCTokenPool with a lane to ggTargetChainSel and registers +// it in ds, standing in for the "USDCTokenPool" table row. +func ggUSDCPoolFixture(t *testing.T, e *cldf.Environment, ds datastore.MutableDataStore, chainSel uint64) { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + tokenOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, bnmops.Deploy, chain, contract.DeployInput[bnmops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(bnmops.ContractType, *ccipdeploymentutils.Version_1_0_0), + Args: bnmops.ConstructorArgs{ + Name: "TEST", Symbol: "TEST", Decimals: 6, MaxSupply: big.NewInt(0), PreMint: big.NewInt(0), + }, + }) + require.NoError(t, err) + tokenAddr := common.HexToAddress(tokenOut.Output.Address) + + poolOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, usdcpoolops.Deploy, chain, contract.DeployInput[usdcpoolops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(usdcpoolops.ContractType, *usdcpoolops.Version), + Args: usdcpoolops.ConstructorArgs{ + Token: tokenAddr, + LocalTokenDecimals: 6, + AdvancedPoolHooks: common.Address{}, + RmnProxy: ggRmnAddr, + Router: ggRouterAddr, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Addresses().Add(poolOut.Output)) + poolAddr := common.HexToAddress(poolOut.Output.Address) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, tpops.ApplyChainUpdates, chain, contract.FunctionInput[tpops.ApplyChainUpdatesArgs]{ + ChainSelector: chainSel, + Address: poolAddr, + Args: tpops.ApplyChainUpdatesArgs{ + ChainsToAdd: []tpops.ChainUpdate{ + { + RemoteChainSelector: ggTargetChainSel, + RemoteTokenAddress: ggRmnAddr.Bytes(), + OutboundRateLimiterConfig: tpops.Config{IsEnabled: false, Capacity: big.NewInt(0), Rate: big.NewInt(0)}, + InboundRateLimiterConfig: tpops.Config{IsEnabled: false, Capacity: big.NewInt(0), Rate: big.NewInt(0)}, + }, + }, + }, + }) + require.NoError(t, err) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, tpops.ApplyTokenTransferFeeConfigUpdates, chain, contract.FunctionInput[tpops.ApplyTokenTransferFeeConfigUpdatesArgs]{ + ChainSelector: chainSel, + Address: poolAddr, + Args: tpops.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: []tpops.TokenTransferFeeConfigArgs{ + { + DestChainSelector: ggTargetChainSel, + TokenTransferFeeConfig: tpops.TokenTransferFeeConfig{DestGasOverhead: 250_000, IsEnabled: true}, + }, + }, + }, + }) + require.NoError(t, err) +} + +func TestUpdateGasConfigForGlamsterdamV2(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + ggSrcAChainSel, + ggSrcBChainSel, + ggSrcSkipChainSel, + ggSrcNoLaneSel, + })) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + // srcA: lane to target, Prague baseline matches exactly. + ggLaneFixture(t, e, ds, ggSrcAChainSel, true, 200_000) + ggUSDCPoolFixture(t, e, ds, ggSrcAChainSel) + // srcB: lane to target, OnRamp.BaseExecutionGasCost mismatched -> exercises fallback. + ggLaneFixture(t, e, ds, ggSrcBChainSel, true, 150_000) + // srcSkip: has a lane, but is passed in SkipChainSelectors -> must be excluded entirely. + ggLaneFixture(t, e, ds, ggSrcSkipChainSel, true, 200_000) + // srcNoLane: FeeQuoter deployed, but no dest chain config for target -> discovered, no lane. + ggLaneFixture(t, e, ds, ggSrcNoLaneSel, false, 200_000) + + // Only srcA and srcB will end up with batch ops, so only they need a real MCMS+Timelock + // deployment for the OutputBuilder to resolve chain metadata against. + for _, chainSel := range []uint64{ggSrcAChainSel, ggSrcBChainSel} { + chain := e.BlockChains.EVMChains()[chainSel] + _, mcmsAddrs := deployMCMSInstanceForTest(t, e.OperationsBundle, chain, chain.DeployerKey.From, ccipdeploymentutils.CLLQualifier) + for _, ref := range mcmsAddrs { + require.NoError(t, ds.Addresses().Add(ref)) + } + } + e.DataStore = ds.Seal() + + mcmsRegistry := cs_core.GetRegistry() + out, err := changesets.UpdateGasConfigForGlamsterdamV2(mcmsRegistry).Apply(*e, cs_core.WithMCMS[changesets.GlamsterdamGasUpdateCfg]{ + MCMS: mcms.Input{}, + Cfg: changesets.GlamsterdamGasUpdateCfg{ + TargetChainSelector: ggTargetChainSel, + SkipChainSelectors: []uint64{ggSrcSkipChainSel}, + }, + }) + require.NoError(t, err) + + require.Len(t, out.MCMSTimelockProposals, 1) + proposal := out.MCMSTimelockProposals[0] + + batchOpChains := make(map[uint64]int) + for _, op := range proposal.Operations { + batchOpChains[uint64(op.ChainSelector)]++ + } + // srcA: 3 gas-config writes (OnRamp+FeeQuoter+CommitteeVerifier) + 1 USDC token-pool write. + require.Equal(t, 2, batchOpChains[ggSrcAChainSel]) + // srcB: 3 gas-config writes, no token pool. + require.Equal(t, 1, batchOpChains[ggSrcBChainSel]) + // srcSkip must never appear: it was unconditionally skipped. + require.NotContains(t, batchOpChains, ggSrcSkipChainSel) + // srcNoLane must never appear: discovery found no lane to target. + require.NotContains(t, batchOpChains, ggSrcNoLaneSel) + + require.Contains(t, proposal.Description, "chain 6433500567565415381: skipped (explicit SkipChainSelectors entry)") + require.Contains(t, proposal.Description, "chain 4356164186791070119: no lane to target chain, skipped") + require.Contains(t, proposal.Description, "chain 4949039107694359620: OnRamp.DestChainConfig.BaseExecutionGasCost matched expected Prague value 200000, applying Glamsterdam value 400000") + require.Contains(t, proposal.Description, "chain 5548718428018410741: OnRamp.DestChainConfig.BaseExecutionGasCost MISMATCH") + require.Contains(t, proposal.Description, "applying fallback value 300000 instead of literal Glamsterdam value 400000") + require.Contains(t, proposal.Description, "chain 4949039107694359620: TokenPool.TokenTransferFeeConfig.DestGasOverhead (USDC) matched expected Prague value 250000, applying Glamsterdam value 750000") +} diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/discovery.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/discovery.go new file mode 100644 index 0000000000..b2078f226b --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/discovery.go @@ -0,0 +1,90 @@ +// Package glamsterdam contains the sequences backing the v2.0 "update gas config for +// Glamsterdam" changeset. +package glamsterdam + +import ( + "fmt" + "sort" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" +) + +// DiscoverLanesToTargetInput is the input to DiscoverLanesToTarget. +type DiscoverLanesToTargetInput struct { + // TargetChainSelector is the chain selector moving to Glamsterdam. + TargetChainSelector uint64 + // FeeQuoterAddressByChain maps each candidate source chain selector to its FeeQuoter address + // on that chain. Chains whose FeeQuoter address couldn't be resolved from the datastore + // should be omitted here and reported separately by the caller. + FeeQuoterAddressByChain map[uint64]common.Address +} + +// DiscoverLanesToTargetOutput is the output of DiscoverLanesToTarget. +type DiscoverLanesToTargetOutput struct { + // LanesToUpdate are the candidate chain selectors with a confirmed lane pointed at the + // target chain, sorted ascending for deterministic output. + LanesToUpdate []uint64 + // NoLane are candidate chain selectors that were scanned but have no lane pointed at the + // target chain, sorted ascending. + NoLane []uint64 + // Report accumulates human-readable lines for any candidate chain whose FeeQuoter read + // failed (e.g. a stale datastore address with no contract code there anymore). That chain is + // skipped rather than aborting discovery for every other candidate. + Report *glamsterdamutils.Report +} + +// DiscoverLanesToTarget checks, for every candidate chain in the input, whether its FeeQuoter +// has an enabled DestChainConfig pointed at the target chain — i.e. whether that chain has a +// lane sending into the Glamsterdam target chain and therefore needs its source-side gas config +// updated. This is a read-only scan; it makes one RPC call per candidate chain. +var DiscoverLanesToTarget = cldf_ops.NewSequence( + "DiscoverLanesToTargetV2", + semver.MustParse("2.0.0"), + "Discovers which v2.0 chains have a lane pointed at the Glamsterdam target chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input DiscoverLanesToTargetInput) (DiscoverLanesToTargetOutput, error) { + output := DiscoverLanesToTargetOutput{Report: glamsterdamutils.NewReport()} + + candidates := make([]uint64, 0, len(input.FeeQuoterAddressByChain)) + for sel := range input.FeeQuoterAddressByChain { + candidates = append(candidates, sel) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i] < candidates[j] }) + + for _, src := range candidates { + chain, ok := chains.EVMChains()[src] + if !ok { + return DiscoverLanesToTargetOutput{}, fmt.Errorf("chain with selector %d not found", src) + } + + report, err := cldf_ops.ExecuteOperation(b, fee_quoter.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: src, + Address: input.FeeQuoterAddressByChain[src], + Args: input.TargetChainSelector, + }) + if err != nil { + line := fmt.Sprintf( + "failed to read FeeQuoter dest chain config for src %d, dst %d: %v", src, input.TargetChainSelector, err, + ) + b.Logger.Warn(line) + output.Report.AddReadError(src, "read FeeQuoter dest chain config", err) + continue + } + + if report.Output.IsEnabled { + output.LanesToUpdate = append(output.LanesToUpdate, src) + } else { + output.NoLane = append(output.NoLane, src) + } + } + + return output, nil + }, +) diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/discovery_test.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/discovery_test.go new file mode 100644 index 0000000000..25256cf63f --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/discovery_test.go @@ -0,0 +1,149 @@ +package glamsterdam_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + fq20ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences/glamsterdam" +) + +const ( + discoveryTargetChainSel = uint64(3379446385462418246) + discoveryLaneChainSelA = uint64(4949039107694359620) + discoveryLaneChainSelB = uint64(5548718428018410741) + discoveryNoLaneChainSel = uint64(6433500567565415381) +) + +// evmFamilySelector is bytes4(keccak256("CCIP ChainFamilySelector EVM")) = 0x2812d52c, required +// by FeeQuoter.applyDestChainConfigUpdates for any enabled dest chain config. +var evmFamilySelector = [4]byte{0x28, 0x12, 0xd5, 0x2c} + +func deployDiscoveryFeeQuoter( + t *testing.T, + e *cldf.Environment, + chainSel uint64, + destChainConfigArgs []fq20ops.DestChainConfigArgs, +) common.Address { + t.Helper() + + chain := e.BlockChains.EVMChains()[chainSel] + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fq20ops.Deploy, chain, contract.DeployInput[fq20ops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fq20ops.ContractType, *fq20ops.Version), + Args: fq20ops.ConstructorArgs{ + StaticConfig: fq20ops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), + }, + DestChainConfigArgs: destChainConfigArgs, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func TestDiscoverLanesToTarget(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + discoveryLaneChainSelA, + discoveryLaneChainSelB, + discoveryNoLaneChainSel, + })) + require.NoError(t, err) + + enabledDestChainConfig := fq20ops.DestChainConfig{ + IsEnabled: true, + MaxPerMsgGasLimit: 3_000_000, + DefaultTxGasLimit: 200_000, + ChainFamilySelector: evmFamilySelector, + LinkFeeMultiplierPercent: 100, + } + laneAAddr := deployDiscoveryFeeQuoter(t, e, discoveryLaneChainSelA, []fq20ops.DestChainConfigArgs{ + { + DestChainSelector: discoveryTargetChainSel, + DestChainConfig: enabledDestChainConfig, + }, + }) + laneBAddr := deployDiscoveryFeeQuoter(t, e, discoveryLaneChainSelB, []fq20ops.DestChainConfigArgs{ + { + DestChainSelector: discoveryTargetChainSel, + DestChainConfig: enabledDestChainConfig, + }, + }) + noLaneAddr := deployDiscoveryFeeQuoter(t, e, discoveryNoLaneChainSel, nil) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: discoveryTargetChainSel, + FeeQuoterAddressByChain: map[uint64]common.Address{ + discoveryLaneChainSelA: laneAAddr, + discoveryLaneChainSelB: laneBAddr, + discoveryNoLaneChainSel: noLaneAddr, + }, + }) + require.NoError(t, err) + + require.Equal(t, []uint64{discoveryLaneChainSelA, discoveryLaneChainSelB}, report.Output.LanesToUpdate) + require.Equal(t, []uint64{discoveryNoLaneChainSel}, report.Output.NoLane) +} + +// TestDiscoverLanesToTarget_SkipsChainWithReadError confirms that a candidate chain whose +// FeeQuoter address has no contract code there (e.g. a stale datastore entry) is skipped with a +// report line, rather than aborting discovery for every other candidate. +func TestDiscoverLanesToTarget_SkipsChainWithReadError(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + discoveryLaneChainSelA, + discoveryLaneChainSelB, + })) + require.NoError(t, err) + + enabledDestChainConfig := fq20ops.DestChainConfig{ + IsEnabled: true, + MaxPerMsgGasLimit: 3_000_000, + DefaultTxGasLimit: 200_000, + ChainFamilySelector: evmFamilySelector, + LinkFeeMultiplierPercent: 100, + } + laneAAddr := deployDiscoveryFeeQuoter(t, e, discoveryLaneChainSelA, []fq20ops.DestChainConfigArgs{ + {DestChainSelector: discoveryTargetChainSel, DestChainConfig: enabledDestChainConfig}, + }) + + // discoveryLaneChainSelB is a real, loaded chain, but this address has no contract deployed + // there — mirrors a stale datastore entry pointing at a redeployed/decommissioned contract. + staleAddr := common.HexToAddress("0x0000000000000000000000000000000000dEaD") + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: discoveryTargetChainSel, + FeeQuoterAddressByChain: map[uint64]common.Address{ + discoveryLaneChainSelA: laneAAddr, + discoveryLaneChainSelB: staleAddr, + }, + }) + require.NoError(t, err) + + require.Equal(t, []uint64{discoveryLaneChainSelA}, report.Output.LanesToUpdate) + require.Empty(t, report.Output.NoLane) + require.Contains(t, report.Output.Report.String(), "chain 5548718428018410741: ERROR - failed to read FeeQuoter dest chain config") +} + +func TestDiscoverLanesToTarget_UnknownChain(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{discoveryLaneChainSelA})) + require.NoError(t, err) + + _, err = cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.DiscoverLanesToTarget, e.BlockChains, glamsterdamseq.DiscoverLanesToTargetInput{ + TargetChainSelector: discoveryTargetChainSel, + FeeQuoterAddressByChain: map[uint64]common.Address{ + discoveryNoLaneChainSel: common.HexToAddress("0x0000000000000000000000000000000000000001"), + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "chain with selector") +} diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/fields.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/fields.go new file mode 100644 index 0000000000..e059855a76 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/fields.go @@ -0,0 +1,107 @@ +package glamsterdam + +import ( + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" +) + +// Field specs for the v2.0 Glamsterdam gas-config mapping table. Values sourced from +// "Glamsterdam Gas Configuration.md" (embedded in GLAMSTERDAM_GAS_UPDATE_PLAN.md §6, V2.0 table). +var ( + // OnRampBaseExecutionGasCost is table row 1. + OnRampBaseExecutionGasCost = glamsterdamutils.FieldSpec[uint32]{ + Name: "OnRamp.DestChainConfig.BaseExecutionGasCost", + ExpectedPrague: 200_000, + GlamsterdamValue: 400_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](200_000, 400_000), + } + + // FeeQuoterDefaultTokenDestGasOverhead is table row 2. + FeeQuoterDefaultTokenDestGasOverhead = glamsterdamutils.FieldSpec[uint32]{ + Name: "FeeQuoter.DestChainConfig.DefaultTokenDestGasOverhead", + ExpectedPrague: 90_000, + GlamsterdamValue: 270_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](90_000, 270_000), + } + + // FeeQuoterMaxPerMsgGasLimit is table row 3. Prague and Glamsterdam values are identical per + // the doc ("needs to be re-tested once testnet is live"), so this is a no-op for now: on a + // mismatch, the current value is left unchanged rather than scaled. + FeeQuoterMaxPerMsgGasLimit = glamsterdamutils.FieldSpec[uint32]{ + Name: "FeeQuoter.DestChainConfig.MaxPerMsgGasLimit", + ExpectedPrague: 15_000_000, + GlamsterdamValue: 15_000_000, + Fallback: func(current uint32) uint32 { return current }, + } + + // FeeQuoterDestGasPerPayloadByteBase is table row 4. The "+DA" components + // (DestGasPerDataAvailabilityByte et al.) are confirmed unchanged by the doc and are not + // part of this field spec. + FeeQuoterDestGasPerPayloadByteBase = glamsterdamutils.FieldSpec[uint8]{ + Name: "FeeQuoter.DestChainConfig.DestGasPerPayloadByteBase", + ExpectedPrague: 20, + GlamsterdamValue: 64, + Fallback: glamsterdamutils.ApplyRatio[uint8](20, 64), + } + + // FeeQuoterDefaultTxGasLimit is table row 5. + FeeQuoterDefaultTxGasLimit = glamsterdamutils.FieldSpec[uint32]{ + Name: "FeeQuoter.DestChainConfig.DefaultTxGasLimit", + ExpectedPrague: 200_000, + GlamsterdamValue: 400_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](200_000, 400_000), + } + + // CommitteeVerifierGasForVerification is table row 8. + CommitteeVerifierGasForVerification = glamsterdamutils.FieldSpec[uint32]{ + Name: "CommitteeVerifier.RemoteChainConfigArgs.GasForVerification", + ExpectedPrague: 75_000, + GlamsterdamValue: 85_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](75_000, 85_000), + } + + // LombardTokenPoolDestGasOverhead is table row 9. Guesstimate value (real value needs + // testnet measurement per §0/§2.4), applied as-is for the first (testnet) run. + LombardTokenPoolDestGasOverhead = glamsterdamutils.FieldSpec[uint32]{ + Name: "TokenPool.TokenTransferFeeConfig.DestGasOverhead (Lombard)", + ExpectedPrague: 410_000, + GlamsterdamValue: 1_200_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](410_000, 1_200_000), + } + + // USDCTokenPoolDestGasOverhead is table row 10. Guesstimate value (real value needs testnet + // measurement per §0/§2.4), applied as-is for the first (testnet) run. + USDCTokenPoolDestGasOverhead = glamsterdamutils.FieldSpec[uint32]{ + Name: "TokenPool.TokenTransferFeeConfig.DestGasOverhead (USDC)", + ExpectedPrague: 250_000, + GlamsterdamValue: 750_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](250_000, 750_000), + } + + // LombardVerifierGasForVerification is table row 11. Guesstimate value (real value needs + // testnet measurement per §0/§2.4), applied as-is for the first (testnet) run. + LombardVerifierGasForVerification = glamsterdamutils.FieldSpec[uint32]{ + Name: "LombardVerifier.RemoteChainConfigArgs.GasForVerification", + ExpectedPrague: 275_000, + GlamsterdamValue: 825_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](275_000, 825_000), + } + + // USDCVerifierGasForVerification is table row 12. The doc calls this contract "USDCVerifier"; + // the Solidity/Go type is CCTPVerifier. Guesstimate value (real value needs testnet + // measurement per §0/§2.4), applied as-is for the first (testnet) run. + USDCVerifierGasForVerification = glamsterdamutils.FieldSpec[uint32]{ + Name: "CCTPVerifier.RemoteChainConfigArgs.GasForVerification", + ExpectedPrague: 200_000, + GlamsterdamValue: 600_000, + Fallback: glamsterdamutils.ApplyRatio[uint32](200_000, 600_000), + } +) + +// OffRamp fields (table rows 6-7) are immutable, set only in the constructor, with no setter — +// StaticConfigCannotBeChanged reverts any attempt to change them. They are read-only sanity +// checked (never written) against their expected Prague baseline, which should equal their +// Glamsterdam value. +const ( + OffRampExpectedGasForCallExactCheck = uint16(5_000) + OffRampExpectedMaxGasBufferToUpdateState = uint32(12_000) +) diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/token_pool_gas_config.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/token_pool_gas_config.go new file mode 100644 index 0000000000..374a4ae7d8 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/token_pool_gas_config.go @@ -0,0 +1,148 @@ +package glamsterdam + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/token_pool" +) + +// applyTokenPoolTokenTransferFeeConfigUpdates mirrors +// token_pool.ApplyTokenTransferFeeConfigUpdates, but always routes the write through MCMS. Both +// LombardTokenPool and (Siloed)USDCTokenPool inherit this function unmodified from the shared +// TokenPool base contract, so the base TokenPoolABI binds to either address correctly. +var applyTokenPoolTokenTransferFeeConfigUpdates = contract.NewWrite(contract.WriteParams[token_pool.ApplyTokenTransferFeeConfigUpdatesArgs, *token_pool.TokenPoolContract]{ + Name: "glamsterdam:token-pool:apply-token-transfer-fee-config-updates", + Version: semver.MustParse("2.0.0"), + Description: "Calls applyTokenTransferFeeConfigUpdates on TokenPool, always producing an MCMS proposal", + ContractType: token_pool.ContractType, + ContractABI: token_pool.TokenPoolABI, + NewContract: token_pool.NewTokenPoolContract, + IsAllowedCaller: contract.NoCallersAllowed[*token_pool.TokenPoolContract, token_pool.ApplyTokenTransferFeeConfigUpdatesArgs], + Validate: func(token_pool.ApplyTokenTransferFeeConfigUpdatesArgs) error { return nil }, + CallContract: func(c *token_pool.TokenPoolContract, opts *bind.TransactOpts, args token_pool.ApplyTokenTransferFeeConfigUpdatesArgs) (*types.Transaction, error) { + return c.ApplyTokenTransferFeeConfigUpdates(opts, args.TokenTransferFeeConfigArgs, args.DisableTokenTransferFeeConfigs) + }, +}) + +// TokenPoolLane is one token pool (Lombard or USDC) on a source chain with a confirmed lane +// pointed at the Glamsterdam target chain. +type TokenPoolLane struct { + ChainSelector uint64 + PoolAddress common.Address +} + +// UpdateTokenPoolGasConfigInput is the input to UpdateTokenPoolGasConfig. +type UpdateTokenPoolGasConfigInput struct { + // TargetChainSelector is the chain selector moving to Glamsterdam. + TargetChainSelector uint64 + // LombardPools are LombardTokenPool addresses per chain (table row 9). + LombardPools []TokenPoolLane + // USDCPools are (Siloed)USDCTokenPool addresses per chain (table row 10). + USDCPools []TokenPoolLane +} + +// UpdateTokenPoolGasConfigOutput is the output of UpdateTokenPoolGasConfig. +type UpdateTokenPoolGasConfigOutput struct { + // BatchOps contains one MCMS batch operation per pool (a chain with both a Lombard and a + // USDC pool produces two batch operations, since they are different contracts). + BatchOps []mcms_types.BatchOperation + // Report is a human-readable summary of every field resolved on every pool, for inclusion in + // the changeset's MCMS proposal description. + Report *glamsterdamutils.Report +} + +// UpdateTokenPoolGasConfig reads the current DestGasOverhead for each Lombard/USDC token pool +// pointed at the target chain, resolves it against its expected Prague baseline (applying the +// literal Glamsterdam value on a match, or the field's fallback rule on a mismatch), and packages +// the write into an MCMS batch operation per pool. Every write is routed through MCMS regardless +// of who the deployer key is. +var UpdateTokenPoolGasConfig = cldf_ops.NewSequence( + "UpdateTokenPoolGasConfigV2", + semver.MustParse("2.0.0"), + "Updates v2.0 Lombard/USDC token pool gas config for lanes pointed at the Glamsterdam target chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input UpdateTokenPoolGasConfigInput) (UpdateTokenPoolGasConfigOutput, error) { + output := UpdateTokenPoolGasConfigOutput{Report: glamsterdamutils.NewReport()} + + processLane := func(lane TokenPoolLane, spec glamsterdamutils.FieldSpec[uint32]) error { + chain, ok := chains.EVMChains()[lane.ChainSelector] + if !ok { + return fmt.Errorf("chain with selector %d not found", lane.ChainSelector) + } + + cur, err := cldf_ops.ExecuteOperation(b, token_pool.GetTokenTransferFeeConfig, chain, contract.FunctionInput[token_pool.GetTokenTransferFeeConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.PoolAddress, + Args: token_pool.GetTokenTransferFeeConfigArgs{DestChainSelector: input.TargetChainSelector}, + }) + if err != nil { + return fmt.Errorf( + "failed to read TokenPool(%s) transfer fee config for src %d, dst %d: %w", + lane.PoolAddress, lane.ChainSelector, input.TargetChainSelector, err, + ) + } + if !cur.Output.IsEnabled { + // No enabled config for this lane (either no override configured, or the + // destination chain isn't supported by this pool yet). Skip: TokenPool. + // applyTokenTransferFeeConfigUpdates rejects writes with isEnabled=false and + // reverts on an unsupported chain, so building a write here would make the whole + // MCMS batch revert on execution. + output.Report.AddLine(fmt.Sprintf( + "chain %d: TokenPool(%s) has no enabled token transfer fee config for dst %d, skipping", + lane.ChainSelector, lane.PoolAddress, input.TargetChainSelector, + )) + return nil + } + + result := glamsterdamutils.Resolve(spec, cur.Output.DestGasOverhead) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, result) + + newConfig := cur.Output + newConfig.DestGasOverhead = result.AppliedValue + + write, err := cldf_ops.ExecuteOperation(b, applyTokenPoolTokenTransferFeeConfigUpdates, chain, contract.FunctionInput[token_pool.ApplyTokenTransferFeeConfigUpdatesArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.PoolAddress, + Args: token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: []token_pool.TokenTransferFeeConfigArgs{ + {DestChainSelector: input.TargetChainSelector, TokenTransferFeeConfig: newConfig}, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to apply TokenPool(%s) transfer fee config update for src %d: %w", lane.PoolAddress, lane.ChainSelector, err) + } + + batchOp, err := contract.NewBatchOperationFromWrites([]contract.WriteOutput{write.Output}) + if err != nil { + return fmt.Errorf("failed to build batch operation for TokenPool(%s) on src %d: %w", lane.PoolAddress, lane.ChainSelector, err) + } + output.BatchOps = append(output.BatchOps, batchOp) + + return nil + } + + for _, lane := range input.LombardPools { + if err := processLane(lane, LombardTokenPoolDestGasOverhead); err != nil { + return UpdateTokenPoolGasConfigOutput{}, err + } + } + for _, lane := range input.USDCPools { + if err := processLane(lane, USDCTokenPoolDestGasOverhead); err != nil { + return UpdateTokenPoolGasConfigOutput{}, err + } + } + + return output, nil + }, +) diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/token_pool_gas_config_test.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/token_pool_gas_config_test.go new file mode 100644 index 0000000000..4889e5a090 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/token_pool_gas_config_test.go @@ -0,0 +1,205 @@ +package glamsterdam_test + +import ( + "math/big" + "strings" + "testing" + + ethabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + bnmops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + usdcpoolops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/siloed_usdc_token_pool" + tpops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/token_pool" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences/glamsterdam" + ccipdeploymentutils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" +) + +const ( + tpGasCfgTargetChainSel = uint64(3379446385462418246) + tpGasCfgBaselineChain = uint64(4949039107694359620) + tpGasCfgMismatchedChain = uint64(5548718428018410741) + tpGasCfgLombardFieldName = "TokenPool.TokenTransferFeeConfig.DestGasOverhead (Lombard)" + tpGasCfgUSDCFieldName = "TokenPool.TokenTransferFeeConfig.DestGasOverhead (USDC)" +) + +var ( + tpGasCfgRmnProxyAddr = common.HexToAddress("0x3333333333333333333333333333333333333333") + tpGasCfgRouterAddr = common.HexToAddress("0x4444444444444444444444444444444444444444") + tpGasCfgRemoteToken = common.HexToAddress("0x5555555555555555555555555555555555555555").Bytes() +) + +// deployTokenPoolFixture deploys a SiloedUSDCTokenPool as a stand-in fixture for both the Lombard +// and USDC "slots" in this test: the sequence under test only calls the shared TokenPool-ABI +// methods (applyChainUpdates/applyTokenTransferFeeConfigUpdates/getTokenTransferFeeConfig), which +// both concrete pool types inherit unmodified from the same TokenPool base contract, so a single +// concrete deployable stand-in is sufficient to exercise the sequence's logic for both lanes. +func deployTokenPoolFixture(t *testing.T, e *cldf.Environment, chainSel uint64, qualifier string, destGasOverhead uint32) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + tokenOut, err := cldf_ops.ExecuteOperation(e.OperationsBundle, bnmops.Deploy, chain, contract.DeployInput[bnmops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(bnmops.ContractType, *ccipdeploymentutils.Version_1_0_0), + Qualifier: &qualifier, + Args: bnmops.ConstructorArgs{ + Name: "TEST", + Symbol: "TEST", + Decimals: 6, + MaxSupply: big.NewInt(0), + PreMint: big.NewInt(0), + }, + }) + require.NoError(t, err) + tokenAddr := common.HexToAddress(tokenOut.Output.Address) + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, usdcpoolops.Deploy, chain, contract.DeployInput[usdcpoolops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(usdcpoolops.ContractType, *usdcpoolops.Version), + Qualifier: &qualifier, + Args: usdcpoolops.ConstructorArgs{ + Token: tokenAddr, + LocalTokenDecimals: 6, + AdvancedPoolHooks: common.Address{}, + RmnProxy: tpGasCfgRmnProxyAddr, + Router: tpGasCfgRouterAddr, + }, + }) + require.NoError(t, err) + addr := common.HexToAddress(out.Output.Address) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, tpops.ApplyChainUpdates, chain, contract.FunctionInput[tpops.ApplyChainUpdatesArgs]{ + ChainSelector: chainSel, + Address: addr, + Args: tpops.ApplyChainUpdatesArgs{ + ChainsToAdd: []tpops.ChainUpdate{ + { + RemoteChainSelector: tpGasCfgTargetChainSel, + RemoteTokenAddress: tpGasCfgRemoteToken, + OutboundRateLimiterConfig: tpops.Config{ + IsEnabled: false, + Capacity: big.NewInt(0), + Rate: big.NewInt(0), + }, + InboundRateLimiterConfig: tpops.Config{ + IsEnabled: false, + Capacity: big.NewInt(0), + Rate: big.NewInt(0), + }, + }, + }, + }, + }) + require.NoError(t, err) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, tpops.ApplyTokenTransferFeeConfigUpdates, chain, contract.FunctionInput[tpops.ApplyTokenTransferFeeConfigUpdatesArgs]{ + ChainSelector: chainSel, + Address: addr, + Args: tpops.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: []tpops.TokenTransferFeeConfigArgs{ + { + DestChainSelector: tpGasCfgTargetChainSel, + TokenTransferFeeConfig: tpops.TokenTransferFeeConfig{ + DestGasOverhead: destGasOverhead, + DestBytesOverhead: 100, + IsEnabled: true, + }, + }, + }, + }, + }) + require.NoError(t, err) + + return addr +} + +func decodeApplyTokenTransferFeeConfigUpdatesArgs(t *testing.T, data []byte) tpops.ApplyTokenTransferFeeConfigUpdatesArgs { + t.Helper() + parsed, err := ethabi.JSON(strings.NewReader(tpops.TokenPoolABI)) + require.NoError(t, err) + vals, err := parsed.Methods["applyTokenTransferFeeConfigUpdates"].Inputs.Unpack(data[4:]) + require.NoError(t, err) + feeConfigArgs := *ethabi.ConvertType(vals[0], new([]tpops.TokenTransferFeeConfigArgs)).(*[]tpops.TokenTransferFeeConfigArgs) + disableArgs := *ethabi.ConvertType(vals[1], new([]uint64)).(*[]uint64) + return tpops.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: feeConfigArgs, + DisableTokenTransferFeeConfigs: disableArgs, + } +} + +func TestUpdateTokenPoolGasConfig(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + tpGasCfgBaselineChain, + tpGasCfgMismatchedChain, + })) + require.NoError(t, err) + + lombardBaselineAddr := deployTokenPoolFixture(t, e, tpGasCfgBaselineChain, "lombard", 410_000) + usdcBaselineAddr := deployTokenPoolFixture(t, e, tpGasCfgBaselineChain, "usdc", 250_000) + + // Mismatched chain: USDC pool's current DestGasOverhead doesn't match the expected Prague + // baseline (250,000) — exercises the fallback path instead of the literal Glamsterdam value. + lombardMismatchedAddr := deployTokenPoolFixture(t, e, tpGasCfgMismatchedChain, "lombard", 410_000) + usdcMismatchedAddr := deployTokenPoolFixture(t, e, tpGasCfgMismatchedChain, "usdc", 200_000) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateTokenPoolGasConfig, e.BlockChains, glamsterdamseq.UpdateTokenPoolGasConfigInput{ + TargetChainSelector: tpGasCfgTargetChainSel, + LombardPools: []glamsterdamseq.TokenPoolLane{ + {ChainSelector: tpGasCfgBaselineChain, PoolAddress: lombardBaselineAddr}, + {ChainSelector: tpGasCfgMismatchedChain, PoolAddress: lombardMismatchedAddr}, + }, + USDCPools: []glamsterdamseq.TokenPoolLane{ + {ChainSelector: tpGasCfgBaselineChain, PoolAddress: usdcBaselineAddr}, + {ChainSelector: tpGasCfgMismatchedChain, PoolAddress: usdcMismatchedAddr}, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 4) + for _, batchOp := range report.Output.BatchOps { + require.Len(t, batchOp.Transactions, 1) + } + + reportStr := report.Output.Report.String() + + t.Run("baseline chain applies literal Glamsterdam values", func(t *testing.T) { + require.Contains(t, reportStr, "chain 4949039107694359620: "+tpGasCfgLombardFieldName+" matched expected Prague value 410000, applying Glamsterdam value 1200000") + require.Contains(t, reportStr, "chain 4949039107694359620: "+tpGasCfgUSDCFieldName+" matched expected Prague value 250000, applying Glamsterdam value 750000") + + lombardArgs := decodeApplyTokenTransferFeeConfigUpdatesArgs(t, report.Output.BatchOps[0].Transactions[0].Data) + require.Len(t, lombardArgs.TokenTransferFeeConfigArgs, 1) + require.Equal(t, uint32(1_200_000), lombardArgs.TokenTransferFeeConfigArgs[0].TokenTransferFeeConfig.DestGasOverhead) + require.Equal(t, uint32(100), lombardArgs.TokenTransferFeeConfigArgs[0].TokenTransferFeeConfig.DestBytesOverhead) // preserved untouched field + require.True(t, lombardArgs.TokenTransferFeeConfigArgs[0].TokenTransferFeeConfig.IsEnabled) + }) + + t.Run("mismatched chain applies fallback instead of literal value", func(t *testing.T) { + require.Contains(t, reportStr, "chain 5548718428018410741: "+tpGasCfgUSDCFieldName+" MISMATCH") + require.Contains(t, reportStr, "current value 200000") + require.Contains(t, reportStr, "applying fallback value 600000 instead of literal Glamsterdam value 750000") + }) + + t.Run("no writes executed directly, always routed through MCMS", func(t *testing.T) { + for _, addr := range []common.Address{lombardBaselineAddr, usdcBaselineAddr, lombardMismatchedAddr, usdcMismatchedAddr} { + chainSel := tpGasCfgBaselineChain + if addr == lombardMismatchedAddr || addr == usdcMismatchedAddr { + chainSel = tpGasCfgMismatchedChain + } + chain := e.BlockChains.EVMChains()[chainSel] + cur, err := cldf_ops.ExecuteOperation(e.OperationsBundle, tpops.GetTokenTransferFeeConfig, chain, contract.FunctionInput[tpops.GetTokenTransferFeeConfigArgs]{ + ChainSelector: chainSel, + Address: addr, + Args: tpops.GetTokenTransferFeeConfigArgs{DestChainSelector: tpGasCfgTargetChainSel}, + }) + require.NoError(t, err) + require.NotEqual(t, uint32(1_200_000), cur.Output.DestGasOverhead) + require.NotEqual(t, uint32(750_000), cur.Output.DestGasOverhead) + } + }) +} diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/update_gas_config.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/update_gas_config.go new file mode 100644 index 0000000000..0d8dd430f5 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/update_gas_config.go @@ -0,0 +1,406 @@ +package glamsterdam + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + glamsterdamutils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/glamsterdam" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/committee_verifier" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/offramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/onramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_1_0/operations/cctp_verifier" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_1_0/operations/lombard_verifier" +) + +// applyOnRampDestChainConfigUpdates mirrors onramp.ApplyDestChainConfigUpdates, but always routes +// the write through MCMS regardless of who the deployer key is, per this feature's "always MCMS" +// decision. +var applyOnRampDestChainConfigUpdates = contract.NewWrite(contract.WriteParams[[]onramp.DestChainConfigArgs, *onramp.OnRampContract]{ + Name: "glamsterdam:onramp:apply-dest-chain-config-updates", + Version: semver.MustParse("2.0.0"), + Description: "Calls applyDestChainConfigUpdates on OnRamp, always producing an MCMS proposal", + ContractType: onramp.ContractType, + ContractABI: onramp.OnRampABI, + NewContract: onramp.NewOnRampContract, + IsAllowedCaller: contract.NoCallersAllowed[*onramp.OnRampContract, []onramp.DestChainConfigArgs], + Validate: func([]onramp.DestChainConfigArgs) error { return nil }, + CallContract: func(c *onramp.OnRampContract, opts *bind.TransactOpts, args []onramp.DestChainConfigArgs) (*types.Transaction, error) { + return c.ApplyDestChainConfigUpdates(opts, args) + }, +}) + +// applyFeeQuoterDestChainConfigUpdates mirrors fee_quoter.ApplyDestChainConfigUpdates, but always +// routes the write through MCMS. +var applyFeeQuoterDestChainConfigUpdates = contract.NewWrite(contract.WriteParams[[]fee_quoter.DestChainConfigArgs, *fee_quoter.FeeQuoterContract]{ + Name: "glamsterdam:fee-quoter:apply-dest-chain-config-updates", + Version: semver.MustParse("2.0.0"), + Description: "Calls applyDestChainConfigUpdates on FeeQuoter, always producing an MCMS proposal", + ContractType: fee_quoter.ContractType, + ContractABI: fee_quoter.FeeQuoterABI, + NewContract: fee_quoter.NewFeeQuoterContract, + IsAllowedCaller: contract.NoCallersAllowed[*fee_quoter.FeeQuoterContract, []fee_quoter.DestChainConfigArgs], + Validate: func([]fee_quoter.DestChainConfigArgs) error { return nil }, + CallContract: func(c *fee_quoter.FeeQuoterContract, opts *bind.TransactOpts, args []fee_quoter.DestChainConfigArgs) (*types.Transaction, error) { + return c.ApplyDestChainConfigUpdates(opts, args) + }, +}) + +// applyCommitteeVerifierRemoteChainConfigUpdates mirrors +// committee_verifier.ApplyRemoteChainConfigUpdates, but always routes the write through MCMS. +var applyCommitteeVerifierRemoteChainConfigUpdates = contract.NewWrite(contract.WriteParams[[]committee_verifier.RemoteChainConfigArgs, *committee_verifier.CommitteeVerifierContract]{ + Name: "glamsterdam:committee-verifier:apply-remote-chain-config-updates", + Version: semver.MustParse("2.0.0"), + Description: "Calls applyRemoteChainConfigUpdates on CommitteeVerifier, always producing an MCMS proposal", + ContractType: committee_verifier.ContractType, + ContractABI: committee_verifier.CommitteeVerifierABI, + NewContract: committee_verifier.NewCommitteeVerifierContract, + IsAllowedCaller: contract.NoCallersAllowed[*committee_verifier.CommitteeVerifierContract, []committee_verifier.RemoteChainConfigArgs], + Validate: func([]committee_verifier.RemoteChainConfigArgs) error { return nil }, + CallContract: func(c *committee_verifier.CommitteeVerifierContract, opts *bind.TransactOpts, args []committee_verifier.RemoteChainConfigArgs) (*types.Transaction, error) { + return c.ApplyRemoteChainConfigUpdates(opts, args) + }, +}) + +// applyLombardVerifierRemoteChainConfigUpdates mirrors +// lombard_verifier.ApplyRemoteChainConfigUpdates, but always routes the write through MCMS. +var applyLombardVerifierRemoteChainConfigUpdates = contract.NewWrite(contract.WriteParams[[]lombard_verifier.RemoteChainConfigArgs, *lombard_verifier.LombardVerifierContract]{ + Name: "glamsterdam:lombard-verifier:apply-remote-chain-config-updates", + Version: semver.MustParse("2.1.0"), + Description: "Calls applyRemoteChainConfigUpdates on LombardVerifier, always producing an MCMS proposal", + ContractType: lombard_verifier.ContractType, + ContractABI: lombard_verifier.LombardVerifierABI, + NewContract: lombard_verifier.NewLombardVerifierContract, + IsAllowedCaller: contract.NoCallersAllowed[*lombard_verifier.LombardVerifierContract, []lombard_verifier.RemoteChainConfigArgs], + Validate: func([]lombard_verifier.RemoteChainConfigArgs) error { return nil }, + CallContract: func(c *lombard_verifier.LombardVerifierContract, opts *bind.TransactOpts, args []lombard_verifier.RemoteChainConfigArgs) (*types.Transaction, error) { + return c.ApplyRemoteChainConfigUpdates(opts, args) + }, +}) + +// applyCCTPVerifierRemoteChainConfigUpdates mirrors cctp_verifier.ApplyRemoteChainConfigUpdates, +// but always routes the write through MCMS. The doc refers to this contract as "USDCVerifier"; +// the Solidity/Go type is CCTPVerifier. +var applyCCTPVerifierRemoteChainConfigUpdates = contract.NewWrite(contract.WriteParams[[]cctp_verifier.RemoteChainConfigArgs, *cctp_verifier.CCTPVerifierContract]{ + Name: "glamsterdam:cctp-verifier:apply-remote-chain-config-updates", + Version: semver.MustParse("2.1.0"), + Description: "Calls applyRemoteChainConfigUpdates on CCTPVerifier (USDCVerifier), always producing an MCMS proposal", + ContractType: cctp_verifier.ContractType, + ContractABI: cctp_verifier.CCTPVerifierABI, + NewContract: cctp_verifier.NewCCTPVerifierContract, + IsAllowedCaller: contract.NoCallersAllowed[*cctp_verifier.CCTPVerifierContract, []cctp_verifier.RemoteChainConfigArgs], + Validate: func([]cctp_verifier.RemoteChainConfigArgs) error { return nil }, + CallContract: func(c *cctp_verifier.CCTPVerifierContract, opts *bind.TransactOpts, args []cctp_verifier.RemoteChainConfigArgs) (*types.Transaction, error) { + return c.ApplyRemoteChainConfigUpdates(opts, args) + }, +}) + +// LaneAddresses is the set of contract addresses to update on one source chain that has a +// confirmed lane pointed at the Glamsterdam target chain. +type LaneAddresses struct { + ChainSelector uint64 + OnRampAddress common.Address + FeeQuoterAddress common.Address + // CommitteeVerifierAddress is optional. If set, CommitteeVerifier's GasForVerification (row 8) + // is read, resolved against its expected Prague baseline, and written back. Leave as the zero + // address to skip this chain's CommitteeVerifier update entirely (e.g. it isn't deployed/ + // tracked in the datastore yet). + CommitteeVerifierAddress common.Address + // LombardVerifierAddresses is optional and may contain more than one address — e.g. during a + // migration window a chain can have both a v2.0.0 and v2.1.0 LombardVerifier tracked in the + // datastore simultaneously, and both need their GasForVerification (row 11) updated in + // lockstep. Each address is read, resolved against its expected Prague baseline, and written + // back independently. Leave empty to skip this chain's LombardVerifier update entirely. + LombardVerifierAddresses []common.Address + // CCTPVerifierAddresses is optional and may contain more than one address, same reasoning as + // LombardVerifierAddresses above. CCTPVerifier's ("USDCVerifier" in the doc) + // GasForVerification (row 12) is read, resolved against its expected Prague baseline, and + // written back independently for each address. Leave empty to skip this chain's CCTPVerifier + // update entirely. + CCTPVerifierAddresses []common.Address + // OffRampAddress is optional. If set, OffRamp's immutable gas fields are read and sanity + // checked against their expected Prague baseline (no write is ever made — there is no + // setter for either field). Leave as the zero address to skip this chain's check. + OffRampAddress common.Address +} + +// UpdateGasConfigInput is the input to UpdateGasConfig. +type UpdateGasConfigInput struct { + // TargetChainSelector is the chain selector moving to Glamsterdam. + TargetChainSelector uint64 + // Lanes are the source chains with a confirmed lane pointed at the target chain. + Lanes []LaneAddresses +} + +// UpdateGasConfigOutput is the output of UpdateGasConfig. +type UpdateGasConfigOutput struct { + // BatchOps contains one MCMS batch operation per chain in Lanes. + BatchOps []mcms_types.BatchOperation + // Report is a human-readable summary of every field resolved on every lane, for inclusion in + // the changeset's MCMS proposal description. + Report *glamsterdamutils.Report +} + +// UpdateGasConfig reads the current on-chain gas config for every confirmed lane, resolves each +// field in the v2.0 Glamsterdam mapping table against its expected Prague baseline (applying the +// literal Glamsterdam value on a match, or the field's fallback rule on a mismatch), and packages +// the resulting writes into one MCMS batch operation per chain. Every write is routed through +// MCMS regardless of who the deployer key is. +var UpdateGasConfig = cldf_ops.NewSequence( + "UpdateGasConfigV2", + semver.MustParse("2.0.0"), + "Updates v2.0 source-side gas config for lanes pointed at the Glamsterdam target chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input UpdateGasConfigInput) (UpdateGasConfigOutput, error) { + output := UpdateGasConfigOutput{Report: glamsterdamutils.NewReport()} + + for _, lane := range input.Lanes { + chain, ok := chains.EVMChains()[lane.ChainSelector] + if !ok { + return UpdateGasConfigOutput{}, fmt.Errorf("chain with selector %d not found", lane.ChainSelector) + } + + var writes []contract.WriteOutput + + // --- OnRamp: BaseExecutionGasCost (row 1) --- + // Router == address(0) is OnRamp's own convention for "this destination isn't + // configured" (see OnRamp.sol getFee's DestinationChainNotSupported check) — skip the + // write entirely rather than applying a meaningless zero-value fallback or risking + // re-enabling a deliberately-paused lane. + onRampCur, err := cldf_ops.ExecuteOperation(b, onramp.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.ChainSelector, + Address: lane.OnRampAddress, + Args: input.TargetChainSelector, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf( + "failed to read OnRamp dest chain config for src %d, dst %d: %w", lane.ChainSelector, input.TargetChainSelector, err, + ) + } + if onRampCur.Output.Router == (common.Address{}) { + output.Report.AddDisabledLane(lane.ChainSelector, "OnRamp") + } else { + baseExecResult := glamsterdamutils.Resolve(OnRampBaseExecutionGasCost, onRampCur.Output.BaseExecutionGasCost) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, baseExecResult) + + onRampWrite, err := cldf_ops.ExecuteOperation(b, applyOnRampDestChainConfigUpdates, chain, contract.FunctionInput[[]onramp.DestChainConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.OnRampAddress, + Args: []onramp.DestChainConfigArgs{ + { + DestChainSelector: input.TargetChainSelector, + Router: onRampCur.Output.Router, + AddressBytesLength: onRampCur.Output.AddressBytesLength, + TokenReceiverAllowed: onRampCur.Output.TokenReceiverAllowed, + MessageNetworkFeeUSDCents: onRampCur.Output.MessageNetworkFeeUSDCents, + TokenNetworkFeeUSDCents: onRampCur.Output.TokenNetworkFeeUSDCents, + BaseExecutionGasCost: baseExecResult.AppliedValue, + DefaultCCVs: onRampCur.Output.DefaultCCVs, + LaneMandatedCCVs: onRampCur.Output.LaneMandatedCCVs, + DefaultExecutor: onRampCur.Output.DefaultExecutor, + OffRamp: onRampCur.Output.OffRamp, + }, + }, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to apply OnRamp dest chain config update for src %d: %w", lane.ChainSelector, err) + } + writes = append(writes, onRampWrite.Output) + } + + // --- FeeQuoter: DefaultTokenDestGasOverhead, MaxPerMsgGasLimit, + // DestGasPerPayloadByteBase, DefaultTxGasLimit (rows 2-5) --- + fqCur, err := cldf_ops.ExecuteOperation(b, fee_quoter.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.ChainSelector, + Address: lane.FeeQuoterAddress, + Args: input.TargetChainSelector, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf( + "failed to read FeeQuoter dest chain config for src %d, dst %d: %w", lane.ChainSelector, input.TargetChainSelector, err, + ) + } + + defaultTokenDestGasOverheadResult := glamsterdamutils.Resolve(FeeQuoterDefaultTokenDestGasOverhead, fqCur.Output.DefaultTokenDestGasOverhead) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, defaultTokenDestGasOverheadResult) + + maxPerMsgGasLimitResult := glamsterdamutils.Resolve(FeeQuoterMaxPerMsgGasLimit, fqCur.Output.MaxPerMsgGasLimit) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, maxPerMsgGasLimitResult) + + destGasPerPayloadByteBaseResult := glamsterdamutils.Resolve(FeeQuoterDestGasPerPayloadByteBase, fqCur.Output.DestGasPerPayloadByteBase) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, destGasPerPayloadByteBaseResult) + + defaultTxGasLimitResult := glamsterdamutils.Resolve(FeeQuoterDefaultTxGasLimit, fqCur.Output.DefaultTxGasLimit) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, defaultTxGasLimitResult) + + newFQConfig := fqCur.Output + newFQConfig.DefaultTokenDestGasOverhead = defaultTokenDestGasOverheadResult.AppliedValue + newFQConfig.MaxPerMsgGasLimit = maxPerMsgGasLimitResult.AppliedValue + newFQConfig.DestGasPerPayloadByteBase = destGasPerPayloadByteBaseResult.AppliedValue + newFQConfig.DefaultTxGasLimit = defaultTxGasLimitResult.AppliedValue + + fqWrite, err := cldf_ops.ExecuteOperation(b, applyFeeQuoterDestChainConfigUpdates, chain, contract.FunctionInput[[]fee_quoter.DestChainConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.FeeQuoterAddress, + Args: []fee_quoter.DestChainConfigArgs{ + {DestChainSelector: input.TargetChainSelector, DestChainConfig: newFQConfig}, + }, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to apply FeeQuoter dest chain config update for src %d: %w", lane.ChainSelector, err) + } + writes = append(writes, fqWrite.Output) + + // --- CommitteeVerifier: GasForVerification (row 8) --- + // Optional: skipped entirely if CommitteeVerifier isn't deployed/tracked in the + // datastore for this chain yet. + if lane.CommitteeVerifierAddress != (common.Address{}) { + cvCur, err := cldf_ops.ExecuteOperation(b, committee_verifier.GetRemoteChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.ChainSelector, + Address: lane.CommitteeVerifierAddress, + Args: input.TargetChainSelector, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf( + "failed to read CommitteeVerifier remote chain config for src %d, dst %d: %w", lane.ChainSelector, input.TargetChainSelector, err, + ) + } + if cvCur.Output.RemoteChainConfig.Router == (common.Address{}) { + output.Report.AddDisabledLane(lane.ChainSelector, "CommitteeVerifier") + } else { + gasForVerificationResult := glamsterdamutils.Resolve(CommitteeVerifierGasForVerification, cvCur.Output.RemoteChainConfig.GasForVerification) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, gasForVerificationResult) + + newCVConfig := cvCur.Output.RemoteChainConfig + newCVConfig.GasForVerification = gasForVerificationResult.AppliedValue + + cvWrite, err := cldf_ops.ExecuteOperation(b, applyCommitteeVerifierRemoteChainConfigUpdates, chain, contract.FunctionInput[[]committee_verifier.RemoteChainConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lane.CommitteeVerifierAddress, + Args: []committee_verifier.RemoteChainConfigArgs{newCVConfig}, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to apply CommitteeVerifier remote chain config update for src %d: %w", lane.ChainSelector, err) + } + writes = append(writes, cvWrite.Output) + } + } + + // --- LombardVerifier: GasForVerification (row 11) --- + // Optional: skipped entirely if no LombardVerifier is deployed/tracked in the + // datastore for this chain yet. If more than one version's address is present (e.g. + // during a migration window), every one of them gets updated. + for _, lombardVerifierAddress := range lane.LombardVerifierAddresses { + lvCur, err := cldf_ops.ExecuteOperation(b, lombard_verifier.GetRemoteChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.ChainSelector, + Address: lombardVerifierAddress, + Args: input.TargetChainSelector, + }) + if err != nil { + output.Report.AddReadError(lane.ChainSelector, "read LombardVerifier remote chain config", err) + } else if lvCur.Output.RemoteChainConfig.Router == (common.Address{}) { + output.Report.AddDisabledLane(lane.ChainSelector, "LombardVerifier") + } else { + gasForVerificationResult := glamsterdamutils.Resolve(LombardVerifierGasForVerification, lvCur.Output.RemoteChainConfig.GasForVerification) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, gasForVerificationResult) + + newLVConfig := lvCur.Output.RemoteChainConfig + newLVConfig.GasForVerification = gasForVerificationResult.AppliedValue + + lvWrite, err := cldf_ops.ExecuteOperation(b, applyLombardVerifierRemoteChainConfigUpdates, chain, contract.FunctionInput[[]lombard_verifier.RemoteChainConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: lombardVerifierAddress, + Args: []lombard_verifier.RemoteChainConfigArgs{newLVConfig}, + }) + if err != nil { + output.Report.AddReadError(lane.ChainSelector, "apply LombardVerifier remote chain config update", err) + } else { + writes = append(writes, lvWrite.Output) + } + } + } + + // --- CCTPVerifier ("USDCVerifier" in the doc): GasForVerification (row 12) --- + // Optional: skipped entirely if no CCTPVerifier is deployed/tracked in the datastore + // for this chain yet. If more than one version's address is present (e.g. during a + // migration window), every one of them gets updated. + for _, cctpVerifierAddress := range lane.CCTPVerifierAddresses { + cctpCur, err := cldf_ops.ExecuteOperation(b, cctp_verifier.GetRemoteChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.ChainSelector, + Address: cctpVerifierAddress, + Args: input.TargetChainSelector, + }) + if err != nil { + output.Report.AddReadError(lane.ChainSelector, "read CCTPVerifier remote chain config", err) + } else if cctpCur.Output.RemoteChainConfig.Router == (common.Address{}) { + output.Report.AddDisabledLane(lane.ChainSelector, "CCTPVerifier") + } else { + gasForVerificationResult := glamsterdamutils.Resolve(USDCVerifierGasForVerification, cctpCur.Output.RemoteChainConfig.GasForVerification) + glamsterdamutils.AddField(output.Report, lane.ChainSelector, gasForVerificationResult) + + newCCTPConfig := cctpCur.Output.RemoteChainConfig + newCCTPConfig.GasForVerification = gasForVerificationResult.AppliedValue + + cctpWrite, err := cldf_ops.ExecuteOperation(b, applyCCTPVerifierRemoteChainConfigUpdates, chain, contract.FunctionInput[[]cctp_verifier.RemoteChainConfigArgs]{ + ChainSelector: lane.ChainSelector, + Address: cctpVerifierAddress, + Args: []cctp_verifier.RemoteChainConfigArgs{newCCTPConfig}, + }) + if err != nil { + output.Report.AddReadError(lane.ChainSelector, "apply CCTPVerifier remote chain config update", err) + } else { + writes = append(writes, cctpWrite.Output) + } + } + } + + // --- OffRamp: GasForCallExactCheck, MaxGasBufferToUpdateState (rows 6-7) --- + // Read-only sanity check: both fields are immutable with no setter, so no write is + // ever produced here, but an unexpected on-chain value is still worth surfacing. + if lane.OffRampAddress != (common.Address{}) { + offRampCur, err := cldf_ops.ExecuteOperation(b, offramp.GetStaticConfig, chain, contract.FunctionInput[struct{}]{ + ChainSelector: lane.ChainSelector, + Address: lane.OffRampAddress, + }) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to read OffRamp static config for src %d: %w", lane.ChainSelector, err) + } + + if offRampCur.Output.GasForCallExactCheck != OffRampExpectedGasForCallExactCheck { + line := fmt.Sprintf( + "chain %d: OffRamp.StaticConfig.GasForCallExactCheck is immutable with no setter, but current "+ + "value %d does not match expected Prague baseline %d - unexpected deployment, investigate manually", + lane.ChainSelector, offRampCur.Output.GasForCallExactCheck, OffRampExpectedGasForCallExactCheck, + ) + b.Logger.Warn(line) + output.Report.AddLine(line) + } + if offRampCur.Output.MaxGasBufferToUpdateState != OffRampExpectedMaxGasBufferToUpdateState { + line := fmt.Sprintf( + "chain %d: OffRamp.StaticConfig.MaxGasBufferToUpdateState is immutable with no setter, but current "+ + "value %d does not match expected Prague baseline %d - unexpected deployment, investigate manually", + lane.ChainSelector, offRampCur.Output.MaxGasBufferToUpdateState, OffRampExpectedMaxGasBufferToUpdateState, + ) + b.Logger.Warn(line) + output.Report.AddLine(line) + } + } + + batchOp, err := contract.NewBatchOperationFromWrites(writes) + if err != nil { + return UpdateGasConfigOutput{}, fmt.Errorf("failed to build batch operation for src %d: %w", lane.ChainSelector, err) + } + output.BatchOps = append(output.BatchOps, batchOp) + } + + return output, nil + }, +) diff --git a/chains/evm/deployment/v2_0_0/sequences/glamsterdam/update_gas_config_test.go b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/update_gas_config_test.go new file mode 100644 index 0000000000..0d67832aa1 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/glamsterdam/update_gas_config_test.go @@ -0,0 +1,471 @@ +package glamsterdam_test + +import ( + "math/big" + "strings" + "testing" + + ethabi "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + cvops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/committee_verifier" + fqops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + orops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/offramp" + rmpops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/onramp" + glamsterdamseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences/glamsterdam" +) + +const ( + gasCfgTargetChainSel = uint64(3379446385462418246) + gasCfgBaselineChain = uint64(4949039107694359620) + gasCfgMismatchedChain = uint64(5548718428018410741) +) + +var ( + gasCfgRouterAddr = common.HexToAddress("0x1111111111111111111111111111111111111111") + gasCfgDefaultExecutorAddr = common.HexToAddress("0x2222222222222222222222222222222222222222") + gasCfgDefaultCCVAddr = common.HexToAddress("0x3333333333333333333333333333333333333333") + gasCfgOffRampBytesAddr = common.HexToAddress("0x4444444444444444444444444444444444444444") + gasCfgRmnAddr = common.HexToAddress("0x5555555555555555555555555555555555555555") + gasCfgTokenAdminRegistry = common.HexToAddress("0x6666666666666666666666666666666666666666") + gasCfgLinkToken = common.HexToAddress("0x7777777777777777777777777777777777777777") +) + +func deployGasCfgOnRamp(t *testing.T, e *cldf.Environment, chainSel uint64, baseExecutionGasCost uint32) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, rmpops.Deploy, chain, contract.DeployInput[rmpops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(rmpops.ContractType, *rmpops.Version), + Args: rmpops.ConstructorArgs{ + StaticConfig: rmpops.StaticConfig{ + ChainSelector: chainSel, + RmnRemote: gasCfgRmnAddr, + TokenAdminRegistry: gasCfgTokenAdminRegistry, + MaxUSDCentsPerMessage: 1_000_000, + }, + DynamicConfig: rmpops.DynamicConfig{ + FeeQuoter: gasCfgRouterAddr, // placeholder, not exercised by this test + }, + }, + }) + require.NoError(t, err) + addr := common.HexToAddress(out.Output.Address) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, rmpops.ApplyDestChainConfigUpdates, chain, contract.FunctionInput[[]rmpops.DestChainConfigArgs]{ + ChainSelector: chainSel, + Address: addr, + Args: []rmpops.DestChainConfigArgs{ + { + DestChainSelector: gasCfgTargetChainSel, + Router: gasCfgRouterAddr, + AddressBytesLength: 20, + BaseExecutionGasCost: baseExecutionGasCost, + DefaultCCVs: []common.Address{gasCfgDefaultCCVAddr}, + DefaultExecutor: gasCfgDefaultExecutorAddr, + OffRamp: gasCfgOffRampBytesAddr.Bytes(), + }, + }, + }) + require.NoError(t, err) + + return addr +} + +// deployGasCfgOnRampUnconfigured deploys OnRamp but never calls ApplyDestChainConfigUpdates for +// gasCfgTargetChainSel, leaving GetDestChainConfig(gasCfgTargetChainSel) at its zero value — +// Router == address(0), matching the contract's own "lane disabled/not configured" convention. +func deployGasCfgOnRampUnconfigured(t *testing.T, e *cldf.Environment, chainSel uint64) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, rmpops.Deploy, chain, contract.DeployInput[rmpops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(rmpops.ContractType, *rmpops.Version), + Args: rmpops.ConstructorArgs{ + StaticConfig: rmpops.StaticConfig{ + ChainSelector: chainSel, + RmnRemote: gasCfgRmnAddr, + TokenAdminRegistry: gasCfgTokenAdminRegistry, + MaxUSDCentsPerMessage: 1_000_000, + }, + DynamicConfig: rmpops.DynamicConfig{ + FeeQuoter: gasCfgRouterAddr, // placeholder, not exercised by this test + }, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func deployGasCfgFeeQuoter( + t *testing.T, + e *cldf.Environment, + chainSel uint64, + destChainConfig fqops.DestChainConfig, +) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, fqops.Deploy, chain, contract.DeployInput[fqops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(fqops.ContractType, *fqops.Version), + Args: fqops.ConstructorArgs{ + StaticConfig: fqops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: gasCfgLinkToken, + }, + DestChainConfigArgs: []fqops.DestChainConfigArgs{ + {DestChainSelector: gasCfgTargetChainSel, DestChainConfig: destChainConfig}, + }, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func deployGasCfgCommitteeVerifier(t *testing.T, e *cldf.Environment, chainSel uint64, gasForVerification uint32) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, cvops.Deploy, chain, contract.DeployInput[cvops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(cvops.ContractType, *cvops.Version), + Args: cvops.ConstructorArgs{ + DynamicConfig: cvops.DynamicConfig{FeeAggregator: gasCfgRouterAddr}, + Rmn: gasCfgRmnAddr, + VersionTag: [4]byte{0x01, 0x02, 0x03, 0x04}, + }, + }) + require.NoError(t, err) + addr := common.HexToAddress(out.Output.Address) + + _, err = cldf_ops.ExecuteOperation(e.OperationsBundle, cvops.ApplyRemoteChainConfigUpdates, chain, contract.FunctionInput[[]cvops.RemoteChainConfigArgs]{ + ChainSelector: chainSel, + Address: addr, + Args: []cvops.RemoteChainConfigArgs{ + { + Router: gasCfgRouterAddr, + RemoteChainSelector: gasCfgTargetChainSel, + GasForVerification: gasForVerification, + }, + }, + }) + require.NoError(t, err) + + return addr +} + +// deployGasCfgCommitteeVerifierUnconfigured deploys CommitteeVerifier but never calls +// ApplyRemoteChainConfigUpdates for gasCfgTargetChainSel, leaving GetRemoteChainConfig at its zero +// value — Router == address(0), matching BaseVerifier's "lane paused/not configured" convention. +func deployGasCfgCommitteeVerifierUnconfigured(t *testing.T, e *cldf.Environment, chainSel uint64) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, cvops.Deploy, chain, contract.DeployInput[cvops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(cvops.ContractType, *cvops.Version), + Args: cvops.ConstructorArgs{ + DynamicConfig: cvops.DynamicConfig{FeeAggregator: gasCfgRouterAddr}, + Rmn: gasCfgRmnAddr, + VersionTag: [4]byte{0x01, 0x02, 0x03, 0x04}, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +func deployGasCfgOffRamp(t *testing.T, e *cldf.Environment, chainSel uint64, gasForCallExactCheck uint16, maxGasBufferToUpdateState uint32) common.Address { + t.Helper() + chain := e.BlockChains.EVMChains()[chainSel] + + out, err := cldf_ops.ExecuteOperation(e.OperationsBundle, orops.Deploy, chain, contract.DeployInput[orops.ConstructorArgs]{ + ChainSelector: chainSel, + TypeAndVersion: cldf.NewTypeAndVersion(orops.ContractType, *orops.Version), + Args: orops.ConstructorArgs{ + StaticConfig: orops.StaticConfig{ + LocalChainSelector: chainSel, + GasForCallExactCheck: gasForCallExactCheck, + RmnRemote: gasCfgRmnAddr, + TokenAdminRegistry: gasCfgTokenAdminRegistry, + MaxGasBufferToUpdateState: maxGasBufferToUpdateState, + }, + }, + }) + require.NoError(t, err) + + return common.HexToAddress(out.Output.Address) +} + +// decodeApplyDestChainConfigUpdatesArgs decodes the []fqops.DestChainConfigArgs passed to a +// FeeQuoter applyDestChainConfigUpdates call from its raw calldata, mirroring the +// abi.ConvertType pattern the generated read operations already use in this codebase. +func decodeApplyDestChainConfigUpdatesArgs(t *testing.T, data []byte) []fqops.DestChainConfigArgs { + t.Helper() + parsed, err := ethabi.JSON(strings.NewReader(fqops.FeeQuoterABI)) + require.NoError(t, err) + vals, err := parsed.Methods["applyDestChainConfigUpdates"].Inputs.Unpack(data[4:]) + require.NoError(t, err) + converted := *ethabi.ConvertType(vals[0], new([]fqops.DestChainConfigArgs)).(*[]fqops.DestChainConfigArgs) + return converted +} + +func TestUpdateGasConfig(t *testing.T) { + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{ + gasCfgBaselineChain, + gasCfgMismatchedChain, + })) + require.NoError(t, err) + + baselineFQConfig := fqops.DestChainConfig{ + IsEnabled: true, + MaxDataBytes: 1_000, + MaxPerMsgGasLimit: 15_000_000, + DestGasOverhead: 300_000, + DestGasPerPayloadByteBase: 20, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + LinkFeeMultiplierPercent: 100, + } + + onRampBaselineAddr := deployGasCfgOnRamp(t, e, gasCfgBaselineChain, 200_000) + fqBaselineAddr := deployGasCfgFeeQuoter(t, e, gasCfgBaselineChain, baselineFQConfig) + cvBaselineAddr := deployGasCfgCommitteeVerifier(t, e, gasCfgBaselineChain, 75_000) + offRampBaselineAddr := deployGasCfgOffRamp(t, e, gasCfgBaselineChain, 5_000, 12_000) + + // Mismatched chain: OnRamp.BaseExecutionGasCost doesn't match the expected Prague baseline + // (200,000) — exercises the fallback path instead of the literal Glamsterdam value. + onRampMismatchedAddr := deployGasCfgOnRamp(t, e, gasCfgMismatchedChain, 150_000) + fqMismatchedAddr := deployGasCfgFeeQuoter(t, e, gasCfgMismatchedChain, baselineFQConfig) + cvMismatchedAddr := deployGasCfgCommitteeVerifier(t, e, gasCfgMismatchedChain, 75_000) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: gasCfgTargetChainSel, + Lanes: []glamsterdamseq.LaneAddresses{ + { + ChainSelector: gasCfgBaselineChain, + OnRampAddress: onRampBaselineAddr, + FeeQuoterAddress: fqBaselineAddr, + CommitteeVerifierAddress: cvBaselineAddr, + OffRampAddress: offRampBaselineAddr, + }, + { + ChainSelector: gasCfgMismatchedChain, + OnRampAddress: onRampMismatchedAddr, + FeeQuoterAddress: fqMismatchedAddr, + CommitteeVerifierAddress: cvMismatchedAddr, + }, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 2) + for _, batchOp := range report.Output.BatchOps { + require.Len(t, batchOp.Transactions, 3, "expected OnRamp + FeeQuoter + CommitteeVerifier writes per chain") + } + + // None of the writes should have executed directly — every write must always be routed + // through MCMS regardless of who the deployer key is. + reportStr := report.Output.Report.String() + + t.Run("baseline chain applies literal Glamsterdam values and preserves untouched fields", func(t *testing.T) { + require.Contains(t, reportStr, "chain 4949039107694359620: OnRamp.DestChainConfig.BaseExecutionGasCost matched expected Prague value 200000, applying Glamsterdam value 400000") + require.Contains(t, reportStr, "chain 4949039107694359620: FeeQuoter.DestChainConfig.DefaultTokenDestGasOverhead matched expected Prague value 90000, applying Glamsterdam value 270000") + require.Contains(t, reportStr, "chain 4949039107694359620: CommitteeVerifier.RemoteChainConfigArgs.GasForVerification matched expected Prague value 75000, applying Glamsterdam value 85000") + + batchOp := report.Output.BatchOps[0] + require.Equal(t, mcmsChainSelector(gasCfgBaselineChain), uint64(batchOp.ChainSelector)) + fqArgs := decodeApplyDestChainConfigUpdatesArgs(t, batchOp.Transactions[1].Data) + require.Len(t, fqArgs, 1) + require.Equal(t, uint32(270_000), fqArgs[0].DestChainConfig.DefaultTokenDestGasOverhead) + require.Equal(t, uint32(15_000_000), fqArgs[0].DestChainConfig.MaxPerMsgGasLimit) // no-op field, unchanged + require.Equal(t, uint8(64), fqArgs[0].DestChainConfig.DestGasPerPayloadByteBase) + require.Equal(t, uint32(400_000), fqArgs[0].DestChainConfig.DefaultTxGasLimit) + // untouched field preserved across the merge + require.Equal(t, uint32(300_000), fqArgs[0].DestChainConfig.DestGasOverhead) + require.True(t, fqArgs[0].DestChainConfig.IsEnabled) + require.Equal(t, uint32(1_000), fqArgs[0].DestChainConfig.MaxDataBytes) + }) + + t.Run("mismatched chain applies fallback instead of literal value", func(t *testing.T) { + require.Contains(t, reportStr, "chain 5548718428018410741: OnRamp.DestChainConfig.BaseExecutionGasCost MISMATCH") + require.Contains(t, reportStr, "current value 150000") + require.Contains(t, reportStr, "applying fallback value 300000 instead of literal Glamsterdam value 400000") + }) + + t.Run("no writes executed directly, always routed through MCMS", func(t *testing.T) { + for _, lane := range []struct { + onRamp, feeQuoter, committeeVerifier common.Address + chainSel uint64 + }{ + {onRampBaselineAddr, fqBaselineAddr, cvBaselineAddr, gasCfgBaselineChain}, + {onRampMismatchedAddr, fqMismatchedAddr, cvMismatchedAddr, gasCfgMismatchedChain}, + } { + chain := e.BlockChains.EVMChains()[lane.chainSel] + cur, err := cldf_ops.ExecuteOperation(e.OperationsBundle, rmpops.GetDestChainConfig, chain, contract.FunctionInput[uint64]{ + ChainSelector: lane.chainSel, + Address: lane.onRamp, + Args: gasCfgTargetChainSel, + }) + require.NoError(t, err) + // on-chain value must be unchanged (200_000 or 150_000, whichever was seeded), proving + // the write was never actually sent. + require.NotEqual(t, uint32(400_000), cur.Output.BaseExecutionGasCost) + } + }) +} + +// mcmsChainSelector exists purely so the test reads naturally when comparing against a +// mcms_types.ChainSelector value. +func mcmsChainSelector(sel uint64) uint64 { return sel } + +// TestUpdateGasConfig_MissingCommitteeVerifier confirms that a lane with no +// CommitteeVerifierAddress set (e.g. CommitteeVerifier isn't deployed/tracked in the datastore for +// that chain yet) still gets its OnRamp/FeeQuoter gas config updated, rather than being dropped +// entirely. +func TestUpdateGasConfig_MissingCommitteeVerifier(t *testing.T) { + const gasCfgNoVerifierChain = uint64(6395199058653144747) + + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{gasCfgNoVerifierChain})) + require.NoError(t, err) + + baselineFQConfig := fqops.DestChainConfig{ + IsEnabled: true, + MaxDataBytes: 1_000, + MaxPerMsgGasLimit: 15_000_000, + DestGasOverhead: 300_000, + DestGasPerPayloadByteBase: 20, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + LinkFeeMultiplierPercent: 100, + } + + onRampAddr := deployGasCfgOnRamp(t, e, gasCfgNoVerifierChain, 200_000) + fqAddr := deployGasCfgFeeQuoter(t, e, gasCfgNoVerifierChain, baselineFQConfig) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: gasCfgTargetChainSel, + Lanes: []glamsterdamseq.LaneAddresses{ + { + ChainSelector: gasCfgNoVerifierChain, + OnRampAddress: onRampAddr, + FeeQuoterAddress: fqAddr, + // CommitteeVerifierAddress deliberately left as the zero address. + }, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 1) + require.Len(t, report.Output.BatchOps[0].Transactions, 2, "expected OnRamp + FeeQuoter writes only, no CommitteeVerifier write") + + reportStr := report.Output.Report.String() + require.Contains(t, reportStr, "chain 6395199058653144747: OnRamp.DestChainConfig.BaseExecutionGasCost matched expected Prague value 200000, applying Glamsterdam value 400000") + require.NotContains(t, reportStr, "CommitteeVerifier.RemoteChainConfigArgs.GasForVerification") +} + +// TestUpdateGasConfig_SkipsDisabledOnRampLane confirms that a chain whose OnRamp has no dest +// chain config for the target (Router == address(0), OnRamp's own "not configured" convention) is +// not written to at all — no zero-value fallback write, and the lane isn't silently re-enabled. +// FeeQuoter and CommitteeVerifier for the same chain still get their normal writes. +func TestUpdateGasConfig_SkipsDisabledOnRampLane(t *testing.T) { + const gasCfgDisabledOnRampChain = uint64(7729159394445656398) + + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{gasCfgDisabledOnRampChain})) + require.NoError(t, err) + + baselineFQConfig := fqops.DestChainConfig{ + IsEnabled: true, + MaxDataBytes: 1_000, + MaxPerMsgGasLimit: 15_000_000, + DestGasOverhead: 300_000, + DestGasPerPayloadByteBase: 20, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + LinkFeeMultiplierPercent: 100, + } + + onRampAddr := deployGasCfgOnRampUnconfigured(t, e, gasCfgDisabledOnRampChain) + fqAddr := deployGasCfgFeeQuoter(t, e, gasCfgDisabledOnRampChain, baselineFQConfig) + cvAddr := deployGasCfgCommitteeVerifier(t, e, gasCfgDisabledOnRampChain, 75_000) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: gasCfgTargetChainSel, + Lanes: []glamsterdamseq.LaneAddresses{ + { + ChainSelector: gasCfgDisabledOnRampChain, + OnRampAddress: onRampAddr, + FeeQuoterAddress: fqAddr, + CommitteeVerifierAddress: cvAddr, + }, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 1) + require.Len(t, report.Output.BatchOps[0].Transactions, 2, "expected FeeQuoter + CommitteeVerifier writes only, no OnRamp write") + + reportStr := report.Output.Report.String() + require.Contains(t, reportStr, "chain 7729159394445656398: OnRamp has no router configured for the target chain (router == address(0)) - lane is disabled/not configured, skipping this contract's write") + require.NotContains(t, reportStr, "OnRamp.DestChainConfig.BaseExecutionGasCost") +} + +// TestUpdateGasConfig_SkipsDisabledCommitteeVerifierLane confirms that a chain whose +// CommitteeVerifier has no remote chain config for the target (Router == address(0)) doesn't get +// a GasForVerification write, while OnRamp/FeeQuoter for the same chain still do. +func TestUpdateGasConfig_SkipsDisabledCommitteeVerifierLane(t *testing.T) { + const gasCfgDisabledCVChain = uint64(8901935845869299604) + + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{gasCfgDisabledCVChain})) + require.NoError(t, err) + + baselineFQConfig := fqops.DestChainConfig{ + IsEnabled: true, + MaxDataBytes: 1_000, + MaxPerMsgGasLimit: 15_000_000, + DestGasOverhead: 300_000, + DestGasPerPayloadByteBase: 20, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + LinkFeeMultiplierPercent: 100, + } + + onRampAddr := deployGasCfgOnRamp(t, e, gasCfgDisabledCVChain, 200_000) + fqAddr := deployGasCfgFeeQuoter(t, e, gasCfgDisabledCVChain, baselineFQConfig) + cvAddr := deployGasCfgCommitteeVerifierUnconfigured(t, e, gasCfgDisabledCVChain) + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, glamsterdamseq.UpdateGasConfig, e.BlockChains, glamsterdamseq.UpdateGasConfigInput{ + TargetChainSelector: gasCfgTargetChainSel, + Lanes: []glamsterdamseq.LaneAddresses{ + { + ChainSelector: gasCfgDisabledCVChain, + OnRampAddress: onRampAddr, + FeeQuoterAddress: fqAddr, + CommitteeVerifierAddress: cvAddr, + }, + }, + }) + require.NoError(t, err) + + require.Len(t, report.Output.BatchOps, 1) + require.Len(t, report.Output.BatchOps[0].Transactions, 2, "expected OnRamp + FeeQuoter writes only, no CommitteeVerifier write") + + reportStr := report.Output.Report.String() + require.Contains(t, reportStr, "chain 8901935845869299604: CommitteeVerifier has no router configured for the target chain (router == address(0)) - lane is disabled/not configured, skipping this contract's write") + require.NotContains(t, reportStr, "CommitteeVerifier.RemoteChainConfigArgs.GasForVerification") +} diff --git a/chains/evm/go.mod b/chains/evm/go.mod index ede1936418..997d05d2f1 100644 --- a/chains/evm/go.mod +++ b/chains/evm/go.mod @@ -16,7 +16,7 @@ require ( github.com/smartcontractkit/chain-selectors v1.0.107 github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260608180601-efa81bfdfda9 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518100439-9564f35fd264 - github.com/smartcontractkit/chainlink-deployments-framework v0.119.0 + github.com/smartcontractkit/chainlink-deployments-framework v0.120.0 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee github.com/smartcontractkit/chainlink-evm/gethwrappers/helpers v0.0.0-20260710181111-6417709a55ee diff --git a/chains/evm/go.sum b/chains/evm/go.sum index 990f65b7aa..5aa1689fe4 100644 --- a/chains/evm/go.sum +++ b/chains/evm/go.sum @@ -681,8 +681,8 @@ github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f3 github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f39eba3e2bc6/go.mod h1:6NefaCIMH4zFBN3T+cvsFGYoy3oTSPKDaxPAyBzlYTU= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= -github.com/smartcontractkit/chainlink-deployments-framework v0.119.0 h1:rua9ByyfPDG6fXVYI2ehNJdREzvtOqnaxviDVWFGwsI= -github.com/smartcontractkit/chainlink-deployments-framework v0.119.0/go.mod h1:SlTrQhE34qRvfOvct8hLT5pTxpVPM1RnlwVbYHUxF/o= +github.com/smartcontractkit/chainlink-deployments-framework v0.120.0 h1:uhnYPlzk9V5rnCuPeSQ6PD96YuzfSRzlaFI48FATB3g= +github.com/smartcontractkit/chainlink-deployments-framework v0.120.0/go.mod h1:SlTrQhE34qRvfOvct8hLT5pTxpVPM1RnlwVbYHUxF/o= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee h1:IuokVSAsKPigmvNAU8PA6w06O3t70+vcs9f6GNV1pXU= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee/go.mod h1:SvIbfOGno4GWINRRuqU6HCjiRVV60WrZLayjtGdYeok= github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee h1:MfUoOYDYeE4vzk2FXDw65V0CjDlVczYd5ohCBRwByjk= diff --git a/devenv/go.mod b/devenv/go.mod index 0c12bc7309..33046acf9a 100644 --- a/devenv/go.mod +++ b/devenv/go.mod @@ -44,7 +44,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260529212215-bfcc2ee071c8 github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260706103253-30ab530178da github.com/smartcontractkit/chainlink-common v0.11.2-0.20260518100439-9564f35fd264 - github.com/smartcontractkit/chainlink-deployments-framework v0.119.0 + github.com/smartcontractkit/chainlink-deployments-framework v0.120.0 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee // indirect github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee github.com/smartcontractkit/chainlink-testing-framework/framework v0.16.6 diff --git a/devenv/go.sum b/devenv/go.sum index f573fbb810..2d6199c68b 100644 --- a/devenv/go.sum +++ b/devenv/go.sum @@ -1204,8 +1204,8 @@ github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f3 github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f39eba3e2bc6/go.mod h1:6NefaCIMH4zFBN3T+cvsFGYoy3oTSPKDaxPAyBzlYTU= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= -github.com/smartcontractkit/chainlink-deployments-framework v0.119.0 h1:rua9ByyfPDG6fXVYI2ehNJdREzvtOqnaxviDVWFGwsI= -github.com/smartcontractkit/chainlink-deployments-framework v0.119.0/go.mod h1:SlTrQhE34qRvfOvct8hLT5pTxpVPM1RnlwVbYHUxF/o= +github.com/smartcontractkit/chainlink-deployments-framework v0.120.0 h1:uhnYPlzk9V5rnCuPeSQ6PD96YuzfSRzlaFI48FATB3g= +github.com/smartcontractkit/chainlink-deployments-framework v0.120.0/go.mod h1:SlTrQhE34qRvfOvct8hLT5pTxpVPM1RnlwVbYHUxF/o= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee h1:IuokVSAsKPigmvNAU8PA6w06O3t70+vcs9f6GNV1pXU= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee/go.mod h1:SvIbfOGno4GWINRRuqU6HCjiRVV60WrZLayjtGdYeok= github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee h1:MfUoOYDYeE4vzk2FXDw65V0CjDlVczYd5ohCBRwByjk= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 0f3e25f6df..b7cfe32953 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -35,7 +35,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260529212215-bfcc2ee071c8 github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260608180601-efa81bfdfda9 - github.com/smartcontractkit/chainlink-deployments-framework v0.119.0 + github.com/smartcontractkit/chainlink-deployments-framework v0.120.0 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee github.com/smartcontractkit/mcms v0.52.1 diff --git a/integration-tests/go.sum b/integration-tests/go.sum index 3d7bbe3377..29a77a0d80 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -861,8 +861,8 @@ github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f3 github.com/smartcontractkit/chainlink-common/keystore v1.2.1-0.20260623104656-f39eba3e2bc6/go.mod h1:6NefaCIMH4zFBN3T+cvsFGYoy3oTSPKDaxPAyBzlYTU= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= -github.com/smartcontractkit/chainlink-deployments-framework v0.119.0 h1:rua9ByyfPDG6fXVYI2ehNJdREzvtOqnaxviDVWFGwsI= -github.com/smartcontractkit/chainlink-deployments-framework v0.119.0/go.mod h1:SlTrQhE34qRvfOvct8hLT5pTxpVPM1RnlwVbYHUxF/o= +github.com/smartcontractkit/chainlink-deployments-framework v0.120.0 h1:uhnYPlzk9V5rnCuPeSQ6PD96YuzfSRzlaFI48FATB3g= +github.com/smartcontractkit/chainlink-deployments-framework v0.120.0/go.mod h1:SlTrQhE34qRvfOvct8hLT5pTxpVPM1RnlwVbYHUxF/o= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee h1:IuokVSAsKPigmvNAU8PA6w06O3t70+vcs9f6GNV1pXU= github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260710181111-6417709a55ee/go.mod h1:SvIbfOGno4GWINRRuqU6HCjiRVV60WrZLayjtGdYeok= github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee h1:MfUoOYDYeE4vzk2FXDw65V0CjDlVczYd5ohCBRwByjk=