From f924e83816f6dc935729b02fc3747fa235bc159a Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Wed, 16 Sep 2026 21:49:12 +0100 Subject: [PATCH] feat: carry real non-EVM CCIP addresses in a ccipNative{} plane --- config/chains/aptos-testnet.json | 8 ++ config/chains/solana-devnet.json | 11 ++ docs/config-architecture.md | 5 +- docs/config-schema.md | 35 ++++- docs/gotchas/index.md | 13 ++ docs/troubleshooting/index.md | 14 ++ script/config/SyncCcipConfig.s.sol | 123 ++++++++++++++++- script/config/ccip-chain-meta.sh | 38 +++++- script/config/test-tooling.sh | 124 ++++++++++++++++++ test/config/SyncToolingRules.t.sol | 63 +++++++++ .../ccip-api/chain-743186221051783445.json | 1 + 11 files changed, 422 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/ccip-api/chain-743186221051783445.json diff --git a/config/chains/aptos-testnet.json b/config/chains/aptos-testnet.json index b596624..810e6fd 100644 --- a/config/chains/aptos-testnet.json +++ b/config/chains/aptos-testnet.json @@ -9,6 +9,13 @@ "tokenAdminRegistry": "0x0000000000000000000000000000000000000000", "tokenPoolFactory": "0x0000000000000000000000000000000000000000" }, + "ccipNative": { + "feeQuoter": "0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45", + "link": "0x3d5d565c271d6b9c52f1a963f2b7bddad3453b0de2ace5e254b8db6549cc335e", + "rmnProxy": "0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45", + "router": "0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45", + "tokenAdminRegistry": "0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45" + }, "chainFamily": "aptos", "chainId": "0", "chainNameIdentifier": "APTOS_TESTNET", @@ -17,6 +24,7 @@ "environment": "testnet", "explorerUrl": "https://explorer.aptoslabs.com/?network=testnet", "name": "aptos-testnet", + "nativeChainId": "2", "nativeCurrencySymbol": "APT", "rpcEnv": "APTOS_TESTNET_RPC_URL" } diff --git a/config/chains/solana-devnet.json b/config/chains/solana-devnet.json index 80bdc49..6152cc5 100644 --- a/config/chains/solana-devnet.json +++ b/config/chains/solana-devnet.json @@ -9,6 +9,16 @@ "tokenAdminRegistry": "0x0000000000000000000000000000000000000000", "tokenPoolFactory": "0x0000000000000000000000000000000000000000" }, + "ccipNative": { + "feeQuoter": "FeeQPGkKDeRV1MgoYfMH6L8o3KeuYjwUZrgn4LRKfjHi", + "link": "LinkhB3afbBKb2EQQu7s7umdZceV3wcvAUJhQAfQ23L", + "rmnProxy": "RmnXLft1mSEwDgMKu2okYuHkiazxntFFcZFrrcXxYg7", + "router": "Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C", + "tokenPoolPrograms": { + "burnMint": "41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB", + "lockRelease": "8eqh8wppT9c5rw4ERqNCffvU6cNFJWff9WmkcYtmGiqC" + } + }, "chainFamily": "svm", "chainId": "0", "chainNameIdentifier": "SOLANA_DEVNET", @@ -17,6 +27,7 @@ "environment": "testnet", "explorerUrl": "https://explorer.solana.com?cluster=devnet", "name": "solana-devnet", + "nativeChainId": "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", "nativeCurrencySymbol": "SOL", "rpcEnv": "SOLANA_DEVNET_RPC_URL" } diff --git a/docs/config-architecture.md b/docs/config-architecture.md index 00a072a..5bc4777 100644 --- a/docs/config-architecture.md +++ b/docs/config-architecture.md @@ -24,7 +24,7 @@ that need it, never exported. Targets that touch the API need only `curl` + `jq` | `add-lane` | Append a `lanes{}` policy entry LOCAL → REMOTE (writes **only** the `lanes` subtree; no API fetch) | `LOCAL=` `REMOTE=` `CAPACITY=` `RATE=` (all required); `INBOUND_CAPACITY=` + `INBOUND_RATE=` (paired, optional) add the `inbound{}` block; `BOTH=1` adds the reciprocal; `GROUP=` (optional) scopes to a token group | `SyncCcipConfig.s.sol --sig "addLane(string,string,uint256,uint256)" ` (6-arg overload with the inbound pair; twice with `BOTH=1`) → canonicalize both files | | `remove-lane` | Remove a `lanes{}` policy entry LOCAL → REMOTE (undo of `add-lane`; writes **only** the `lanes` subtree, declaration only - an applied lane must also be removed on-chain via `RemoveChain`, or `RemoveRemotePool` for a single pool) | `LOCAL=` `REMOTE=` (both required); `BOTH=1` removes the reciprocal; `GROUP=` (optional) scopes to a token group | `SyncCcipConfig.s.sol --sig "removeLane(string,string)" ` (twice with `BOTH=1`) → canonicalize both files | | `adopt-token` | Adopt an externally deployed token (and optionally its pool) into the project store's `addresses{}` after on-chain validation (needs the chain's `rpcEnv` RPC; see [`enabling-existing-token.md`](enabling-existing-token.md)) | EVM: `CHAIN=` **+** `TOKEN=` (both required), `TOKEN_POOL=` optional. Non-EVM: `CHAIN=` **+** `TOKEN_B58=`, `POOL_B58=` optional. `GROUP=` (optional) scopes to a token group | EVM: `AdoptToken.s.sol --sig "run(string,address,address)" `. Non-EVM: `--sig "runNonEvm(string,string,string)" ` | -| `sync` | Refresh one chain's API-served fields (`ccip{}` + identity/metadata) from the API | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "run(string)" ` → canonicalize | +| `sync` | Refresh one chain's API-served fields (`ccip{}` or `ccipNative{}` + identity/metadata) from the API | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "run(string)" ` → canonicalize | | `sync-preview` | Fetch + log a chain's `ccip{}` from the API **without writing** | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "preview(string)" ` | | `sync-all` | Refresh every configured chain (non-EVM SKIP; failures collected) | - | loops `--sig "run(string)"` over `config/chains/*.json` + canonicalize each | | `sync-check` | Read-only drift check vs the live API (pass/fail via make) | `CHAIN=` (optional) | `bash script/config/sync-check.sh []` → `SyncCcipConfig --sig "check(string)"` | @@ -141,7 +141,8 @@ Project state lives in two files per chain, both keyed by the canonical **select - **`config/chains/.json`** - pure API/chain facts. Everything the CCIP REST API serves (`ccip{}` addresses + the identity/metadata fields `displayName`/`chainFamily`/`environment`/ - `explorerUrl`/`nativeCurrencySymbol`) is API-owned, written by the sync. The keys the API serves nothing + `explorerUrl`/`nativeCurrencySymbol`, plus `ccipNative{}`/`nativeChainId` on non-EVM chains) is + API-owned, written by the sync. The keys the API serves nothing for are hand-authored in reviewed PRs (`chainNameIdentifier`, `rpcEnv`, the optional `verifier{}` block, the optional `evmVersion`), and the join keys (`name`/`chainSelector`/`chainId`) are seeded once and guard-validated. diff --git a/docs/config-schema.md b/docs/config-schema.md index 7fbc5c5..bfe531d 100644 --- a/docs/config-schema.md +++ b/docs/config-schema.md @@ -124,6 +124,8 @@ API serves nothing for it, so a reviewed PR owns it and the sync preserves it ve | -------------------------------- | ------------------------------------- | -------------------- | ------------------------------------------------- | --------------------------------------------- | | `name` | string (canonical selectorName) | **API sync (guard)** | `chain.name` | file key + basename; validated by the sync | | `chainId` | quoted decimal string (`"0"` non-EVM) | **API sync (guard)** | `chain.chainId` (EVM; `"0"` placeholder non-EVM) | `ChainConfig.chainId`; sync identity guard | +| `nativeChainId` | string, **non-EVM only** | **API sync** | `chain.chainId` (base58 / hash) | the chain's real id, kept OUT of `chainId` | +| `ccipNative{}` | strings, **non-EVM only** | **API sync** | `chainConfig` in the chain's own encoding | the native address plane (see below) | | `chainSelector` | quoted `uint64` string | **API sync (guard)** | `chain.chainSelector` | `ChainConfig.load`; the primary join key | | `displayName` | string | **API sync** | `chain.displayName` | `ChainConfig.load` → `chainName`; log output | | `chainFamily` | `"evm"` \| `"svm"` \| `"aptos"` | **API sync** | `chain.chainFamily` (lowercased) | `ChainConfig.load`; EVM/non-EVM dispatch | @@ -725,7 +727,22 @@ source). `config/chains/solana-devnet.json` keeps the same shape but: identity; the sync/doctor selectorName guard is what protects a non-EVM file from a wrong selector. - The `ccip{}` block is **all-zero** and `feeTokens` is empty: non-EVM chains have no EVM-shaped `chainConfig`, so they are excluded from the API **address** sync (the sync SKIPs the `ccip{}` - transform cleanly). + transform cleanly). It stays as the EVM-typed skeleton every `.ccip.*` reader needs - a base58 value + there makes `HelperConfig`'s constructor scan abort on UNRELATED chains, because it parses every + configured file's `.ccip.*` as an EVM address. +- The real addresses live in the sibling **`ccipNative{}`** block, with **`nativeChainId`** next to it, + both API-synced and both non-EVM only. Values are family-native STRINGS (base58 on SVM, 32-byte hex on + Aptos). Key names mirror `ccip{}` where the concept is shared (`rmn` -> `rmnProxy`, `registryModule` -> + `registryModuleOwnerCustom`), plus `tokenPoolPrograms{burnMint,lockRelease}` - SVM ships both pool + programs and lock-release is a first-class use case, not a burn-mint fallback. **A key the API does not + serve for that family is ABSENT, not zero**: which contracts exist differs by family (SVM has no + `tokenAdminRegistry`; Aptos has one but no `registryModule` and no pool programs), and an absent key + says "not a thing here" where a zero would say "missing". `feeTokens` is not carried - `link` is the one + fee token the repo keys on. +- **`nativeChainId` is where the chain's real id goes; `chainId` keeps its `"0"` sentinel.** Putting the + base58 genesis hash in `chainId` makes the chain VANISH from `HelperConfig`'s configured-chain scan, and + Aptos's real `"2"` silently empties its remote-pool resolution. Several `HelperConfig` paths key off the + sentinel (the constructor scan, the broadcast guard, `getSelectorName`, the chainId lookups). - The non-EVM chain's own **project store** carries **no `lanes{}`**: lanes are outbound policy, and non-EVM chains are destination-only here - an EVM chain may declare a lane **to** `solana-devnet` (exempt from the doctor's reciprocity rung), but `make add-lane LOCAL=solana-devnet ...` is refused (no lanes to @@ -779,11 +796,27 @@ source). `config/chains/solana-devnet.json` keeps the same shape but: "link": "0x00...00", "feeTokens": [] }, + "ccipNative": { + // the same plane in Solana's own encoding - non-EVM only, API-synced, absent keys are unserved + "router": "Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C", + "rmnProxy": "RmnXLft1mSEwDgMKu2okYuHkiazxntFFcZFrrcXxYg7", + "feeQuoter": "FeeQPGkKDeRV1MgoYfMH6L8o3KeuYjwUZrgn4LRKfjHi", + "link": "LinkhB3afbBKb2EQQu7s7umdZceV3wcvAUJhQAfQ23L", + "tokenPoolPrograms": { + "burnMint": "41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB", + "lockRelease": "8eqh8wppT9c5rw4ERqNCffvU6cNFJWff9WmkcYtmGiqC" + } + }, + "nativeChainId": "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", // <- chain.chainId (API-synced) "explorerUrl": "https://explorer.solana.com?cluster=devnet", // <- chainMetadata.explorer.url (API-synced) "nativeCurrencySymbol": "SOL" // <- chainMetadata.nativeCurrency.symbol (API-synced) } ``` +Aptos is a THIRD shape and carries the same block: `tokenAdminRegistry` present, `tokenPoolPrograms` and +`registryModuleOwnerCustom` absent, one Move package address serving router/feeQuoter/tokenAdminRegistry/rmn. +It is read-only here. + The Solana deployed addresses live in `project/solana-devnet.json` `addresses{}` as base58 strings, not in this file. diff --git a/docs/gotchas/index.md b/docs/gotchas/index.md index edd02f2..e947a89 100644 --- a/docs/gotchas/index.md +++ b/docs/gotchas/index.md @@ -143,6 +143,19 @@ link to. peer's project store, so a peer store you do not keep locally is reported as unchecked, not as clean, and the on-chain lane itself is only removed by `RemoveChain`. + +- **A non-EVM address in `ccip{}` breaks scripts on UNRELATED chains, and no guard catches it.** + `HelperConfig` scans every `config/chains/*.json` and parses each one's `.ccip.*` as an EVM address, so + one base58 or Move value anywhere aborts the scan for everybody: a run against Fuji dies with + ``vm.parseJsonAddress: failed parsing "Ccip842gz..." as type `address`: invalid string length``, naming + the value but not the file it came from. Measured: `make doctor` still reports VERIFIED on both the + offending chain and the unrelated one, because `VerifyChain` never builds a `HelperConfig`. Non-EVM + addresses belong in the sibling `ccipNative{}` block, which the sync owns; `ccip{}` stays the all-zero + EVM skeleton every `.ccip.*` reader needs. The same applies to the id: the chain's real one goes in + `nativeChainId` and `chainId` keeps its `"0"` sentinel, which several `HelperConfig` paths key off - + and the schema rung does not catch a violation there either, since it asserts the values are quoted + strings, not that they are decimal. + - **A successful call can still revert your frame.** `try C(a).f() returns (string memory)` routes a REVERT to its catch, but the return data is decoded in the CALLER's frame after the call already diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md index 47ce453..c0a28fb 100644 --- a/docs/troubleshooting/index.md +++ b/docs/troubleshooting/index.md @@ -226,6 +226,20 @@ after a `forge script` run, with no `--broadcast`. DEST_CHAIN= TOKEN= AMOUNT= RECEIVER=` reproduces the revert before a real send. - **Verify.** The same `make preflight` returns `GO:`, and `make doctor CHAIN=` is VERIFIED. +## `vm.parseJsonAddress: failed parsing "..." as type `address` + +- **Diagnosis.** Some chain config holds a non-EVM value in `ccip{}`, and `HelperConfig`'s constructor + parses every configured file's `.ccip.*` as an EVM address - so the run that fails is usually against a + DIFFERENT, healthy chain, and the message names the value but not the file. Find the culprit: + `jq -r 'select((.ccip | to_entries | map(.value | strings | startswith("0x") | not) | any)) | input_filename' config/chains/*.json`. + `make doctor` will NOT find it: `VerifyChain` builds no `HelperConfig`, so both chains still verify. +- **Fix.** Non-EVM addresses live in the sibling `ccipNative{}` block (base58 on SVM, 32-byte hex on + Aptos) and the chain's real id in `nativeChainId`; `ccip{}` keeps the all-zero EVM skeleton and + `chainId` its `"0"` sentinel. Both are API-owned: `make sync CHAIN=` rewrites them, so the + repair is a sync, not a hand edit. +- **Verify.** The failing script runs again, and `make sync-check CHAIN=` reports + `CLEAN ... identity metadata + ccipNative match the live API`. + ## Advanced forensics: a leg that failed then recovered The REST API and `ccip-cli show` report only the final `SUCCESS` and hide an earlier failed attempt. To diff --git a/script/config/SyncCcipConfig.s.sol b/script/config/SyncCcipConfig.s.sol index f12b190..44cf42a 100644 --- a/script/config/SyncCcipConfig.s.sol +++ b/script/config/SyncCcipConfig.s.sol @@ -35,6 +35,9 @@ import {ProjectStore} from "../../src/utils/ProjectStore.sol"; /// `registryModuleOwnerCustom`, `link`, `feeQuoter`, `tokenPoolFactory`, `feeTokens[]`) — AND the /// API-served identity + metadata fields (`displayName`, `chainFamily`, `environment`, `explorerUrl`, /// `nativeCurrencySymbol`), all of which `GET /v2/chains/{selector}` serves, so none is hand-typed. +/// For NON-EVM families it also owns `ccipNative{}` + `nativeChainId` — the same addresses in the +/// chain's own encoding (base58, 32-byte hex), which `ccip{}` cannot hold because every `.ccip.*` +/// reader parses EVM addresses. `chainId` keeps its `"0"` sentinel either way. /// What it PRESERVES (never touches): the genuinely hand-authored keys the API serves nothing for /// (`chainNameIdentifier`, `rpcEnv`, the optional `verifier{}` block, and the optional `evmVersion`) and the immutable join keys /// (`name`/`chainSelector`/`chainId`) which are GUARD-validated, not rewritten. One writer per field. @@ -46,8 +49,8 @@ import {ProjectStore} from "../../src/utils/ProjectStore.sol"; /// a wrong-but-valid selector can never silently write another chain's contracts. /// - non-EVM SKIP: non-EVM chain families (e.g. solana-devnet) skip the EVM `ccip{}` transform — an /// SVM file keeps its zeroed `ccip{}` block — but their chain-level identity + metadata (served -/// for every family) ARE validated + refreshed. The guard is Solidity-side so every entrypoint -/// is covered. +/// for every family) ARE validated + refreshed, and their native-encoded addresses are written to +/// the sibling `ccipNative{}` block. The guard is Solidity-side so every entrypoint is covered. /// - chain-name validation: config names become file paths and shell arguments, so `init` only /// accepts `[a-z0-9][a-z0-9_-]*` (dash + underscore, both used by canonical selectorNames like /// `binance_smart_chain-mainnet`; no path traversal, no spaces, no shell metacharacters). @@ -261,7 +264,10 @@ contract SyncCcipConfig is Script { string memory meta = _fetchChainMeta(selector); _requireSelectorName(name, vm.parseJsonString(json, ".name"), vm.parseJsonString(meta, ".apiName")); _refreshMetadata(path, meta); - console.log(string.concat("[sync] refreshed identity metadata for ", name, " -> ", path)); + _requireNativeRouter(name, meta); + vm.writeJson(string.concat("\"", vm.parseJsonString(meta, ".nativeChainId"), "\""), path, ".nativeChainId"); + vm.writeJson(_buildNativeJson(name, meta), path, ".ccipNative"); + console.log(string.concat("[sync] refreshed identity metadata + ccipNative for ", name, " -> ", path)); return; } @@ -372,6 +378,59 @@ contract SyncCcipConfig is Script { ]; } + /// @notice THE single list of `ccipNative{}` keys (shared by the non-EVM write and drift-check). + /// Same names as `ccipAddressKeys()` where the concept is shared, so one vocabulary spans both + /// planes. Every key is OPTIONAL: which contracts exist differs by family (SVM serves no + /// tokenAdminRegistry, Aptos no registryModule), and the writer omits what the API does not serve. + function ccipNativeKeys() public pure returns (string[6] memory) { + return ["router", "rmnProxy", "tokenAdminRegistry", "registryModuleOwnerCustom", "feeQuoter", "link"]; + } + + /// @notice The pool-program variants carried under `ccipNative.tokenPoolPrograms` (SVM serves + /// both; lock-release is a first-class use case, not a burn-mint fallback). + function tokenPoolProgramKeys() public pure returns (string[2] memory) { + return ["burnMint", "lockRelease"]; + } + + /// @dev Serialize the non-EVM `ccipNative{}` object from the meta row's `native{}` block. Values + /// are family-native STRINGS (base58 on SVM, 32-byte hex on Aptos) - they are deliberately NOT + /// written into `ccip{}`, whose readers parse every value as an EVM address: one base58 string + /// there makes `HelperConfig`'s constructor scan abort on UNRELATED chains. + function _buildNativeJson(string memory name, string memory meta) internal returns (string memory out) { + string memory obj = string.concat("ccip-native-", name); + string[6] memory keys = ccipNativeKeys(); + for (uint256 i = 0; i < keys.length; i++) { + string memory path = string.concat(".native.", keys[i]); + if (vm.keyExistsJson(meta, path)) out = vm.serializeString(obj, keys[i], vm.parseJsonString(meta, path)); + } + string memory programs = _buildTokenPoolProgramsJson(name, meta); + if (bytes(programs).length > 0) out = vm.serializeString(obj, "tokenPoolPrograms", programs); + } + + function _buildTokenPoolProgramsJson(string memory name, string memory meta) internal returns (string memory out) { + string memory obj = string.concat("ccip-native-pools-", name); + string[2] memory keys = tokenPoolProgramKeys(); + for (uint256 i = 0; i < keys.length; i++) { + string memory path = string.concat(".native.tokenPoolPrograms.", keys[i]); + if (vm.keyExistsJson(meta, path)) out = vm.serializeString(obj, keys[i], vm.parseJsonString(meta, path)); + } + } + + /// @dev CORE guard for the native plane: a non-EVM chain with no router cannot be onboarded, and + /// an empty `ccipNative{}` would otherwise be written silently on an API shape change. It fires on + /// an ABSENT router only - an inactive entry is still onboarded, since the transform's `pick()` + /// prefers `isActive` but falls back to the first entry, matching the EVM `act()`. + function _requireNativeRouter(string memory name, string memory meta) internal view { + require( + vm.keyExistsJson(meta, ".native.router"), + string.concat( + "[sync] ", + name, + ": the API serves no router entry for this non-EVM chain - it cannot be onboarded yet ('make discover' lists valid selectors)" + ) + ); + } + // ================================================================ // init — add-chain: generate config/chains/.json FROM the API // ================================================================ @@ -430,12 +489,15 @@ contract SyncCcipConfig is Script { // EVM-shaped ccip block to fetch, so it gets the zeroed skeleton (all eight keys present, every // address zero), which the doctor's schema rung requires for every family. string memory ccipBlock; + string memory nativeBlock; if (isEvm) { string memory flat = _source().fetchActiveCcipConfig(uint64(selector)); _warnZeroedOptionalContracts(localName, flat); ccipBlock = _buildCcipJson(localName, flat); } else { ccipBlock = _zeroedCcipJson(localName); + _requireNativeRouter(localName, meta); + nativeBlock = _buildNativeJson(localName, meta); } // Build the whole file in memory, then write it ONCE. lanes{}/roles{} + deployed addresses live @@ -460,6 +522,12 @@ contract SyncCcipConfig is Script { // explorerUrl/nativeCurrencySymbol come from the API's chainMetadata (served for every family). vm.serializeString(root, "explorerUrl", vm.parseJsonString(meta, ".explorerUrl")); vm.serializeString(root, "nativeCurrencySymbol", vm.parseJsonString(meta, ".nativeCurrencySymbol")); + // The native plane, non-EVM only: the chain's own chainId (base58/hash - `chainId` keeps its + // "0" sentinel, which three EVM-typed readers depend on) and its native-encoded addresses. + if (!isEvm) { + vm.serializeString(root, "nativeChainId", vm.parseJsonString(meta, ".nativeChainId")); + vm.serializeString(root, "ccipNative", nativeBlock); + } // forge-lint: disable-end(unused-return) string memory complete = vm.serializeString(root, "ccip", ccipBlock); vm.writeFile(path, complete); @@ -598,13 +666,13 @@ contract SyncCcipConfig is Script { // ARE served and drift-checkable. Validate the selectorName and diff the metadata fields. string memory meta = _fetchChainMeta(selector); _requireSelectorName(name, vm.parseJsonString(json, ".name"), vm.parseJsonString(meta, ".apiName")); - uint256 metaDrift = _checkMetadata(name, json, meta); + uint256 metaDrift = _checkMetadata(name, json, meta) + _checkNative(name, json, meta); if (metaDrift > 0) { revert( string.concat( "CONFIG_DRIFT: ", vm.toString(metaDrift), - " metadata field(s) drifted for ", + " metadata/ccipNative field(s) drifted for ", name, " - refresh with: make sync CHAIN=", name, @@ -615,7 +683,9 @@ contract SyncCcipConfig is Script { ) ); } - console.log(string.concat("[sync-check] CLEAN ", name, " - identity metadata matches the live API")); + console.log( + string.concat("[sync-check] CLEAN ", name, " - identity metadata + ccipNative match the live API") + ); return; } @@ -674,6 +744,47 @@ contract SyncCcipConfig is Script { } } + /// @dev Diff the non-EVM `ccipNative{}` plane (+ `nativeChainId`) against the meta row, one + /// greppable `DRIFT .ccipNative.` line per divergence. An absent key compares as + /// the empty string on BOTH sides, so "the API stopped serving it" and "we never wrote it" are + /// both drift - which is what makes a schema change without a config backfill fail loudly here + /// rather than in the nightly. + function _checkNative(string memory name, string memory json, string memory meta) + internal + view + returns (uint256 drift) + { + drift = _diffString(name, ".nativeChainId", json, ".nativeChainId", meta, ".nativeChainId"); + string[6] memory keys = ccipNativeKeys(); + for (uint256 i = 0; i < keys.length; i++) { + string memory field = string.concat(".ccipNative.", keys[i]); + drift += _diffString(name, field, json, field, meta, string.concat(".native.", keys[i])); + } + string[2] memory programs = tokenPoolProgramKeys(); + for (uint256 i = 0; i < programs.length; i++) { + string memory field = string.concat(".ccipNative.tokenPoolPrograms.", programs[i]); + drift += _diffString( + name, field, json, field, meta, string.concat(".native.tokenPoolPrograms.", programs[i]) + ); + } + } + + /// @dev Compare one optional string field across the two documents; absent reads as "". + function _diffString( + string memory name, + string memory label, + string memory json, + string memory jsonPath, + string memory src, + string memory srcPath + ) internal view returns (uint256) { + string memory cur = vm.keyExistsJson(json, jsonPath) ? vm.parseJsonString(json, jsonPath) : ""; + string memory live = vm.keyExistsJson(src, srcPath) ? vm.parseJsonString(src, srcPath) : ""; + if (keccak256(bytes(cur)) == keccak256(bytes(live))) return 0; + console.log(string.concat("DRIFT ", name, " ", label, " '", cur, "' -> '", live, "'")); + return 1; + } + function _checkFeeTokens(string memory name, string memory json, string memory flat) internal pure diff --git a/script/config/ccip-chain-meta.sh b/script/config/ccip-chain-meta.sh index 45f0505..bf40031 100755 --- a/script/config/ccip-chain-meta.sh +++ b/script/config/ccip-chain-meta.sh @@ -13,6 +13,10 @@ # schema): # { apiName, displayName, chainFamily, environment, chainId, chainSelector, # explorerUrl, nativeCurrencySymbol } +# plus, for NON-EVM families only, the native address plane the EVM-typed `ccip{}` block cannot hold: +# { nativeChainId, native: { router, rmnProxy, tokenAdminRegistry, registryModuleOwnerCustom, +# feeQuoter, link, tokenPoolPrograms: { burnMint, lockRelease } } } +# Every native key is a family-native STRING and every one is optional - see the transform below. # # Consumed by SyncCcipConfig.init() (add-chain), SyncCcipConfig.run()/check() on the NON-EVM path # (EVM uses ccip-config-source.sh, which carries the same fields), and VerifyChain (the doctor's @@ -57,13 +61,39 @@ case "$http_code" in ;; esac -jq -c '{ +# `native{}` + `nativeChainId` are emitted for NON-EVM families only: they carry the chain's own +# address encoding (base58 on SVM, 32-byte hex on Aptos, never parseable as an EVM address), so they +# are a sibling plane to `ccip{}`, not a replacement for it. Key names mirror the `ccip{}` block where +# the concept is shared (rmn -> rmnProxy, registryModule -> registryModuleOwnerCustom); a key the API +# does not serve for this family is ABSENT, not zero - the families differ in which contracts exist +# (SVM has tokenPoolPrograms and no tokenAdminRegistry; Aptos has tokenAdminRegistry and no +# registryModule), and an absent key says "not a thing here" where a zero would say "missing". +# pick() prefers an ACTIVE entry but falls back to the first one, exactly as the EVM act() does: an +# inactive row is still the chain's address, and refusing it would diverge from the EVM path. +jq -c ' + def pick(a): ((a // []) | ((map(select(.isActive == true))[0]) // .[0]) | (.address? // null)); + (((.chain.chainFamily // "EVM") | ascii_downcase)) as $fam + | .chainConfig as $c + | (($c.tokenPoolPrograms // {}) + | {burnMint: pick(.burnMint), lockRelease: pick(.lockRelease)} + | with_entries(select(.value != null))) as $tpp + | { apiName: (.chain.name // error("no .chain.name in API body")), displayName: (.chain.displayName // .chain.name // ""), - chainFamily: ((.chain.chainFamily // "EVM") | ascii_downcase), + chainFamily: $fam, environment: (.chain.environment // "testnet"), chainId: ((.chain.chainId // 0) | tostring), chainSelector: (.chain.chainSelector | tostring), explorerUrl: (.chainMetadata.explorer.url // ""), - nativeCurrencySymbol: (.chainMetadata.nativeCurrency.symbol // "") -}' "$body_file" + nativeCurrencySymbol: (.chainMetadata.nativeCurrency.symbol // ""), + nativeChainId: (if $fam == "evm" then null else ((.chain.chainId // "") | tostring) end), + native: (if $fam == "evm" then null else ({ + router: pick($c.router), + rmnProxy: pick($c.rmn), + tokenAdminRegistry: pick($c.tokenAdminRegistry), + registryModuleOwnerCustom: pick($c.registryModule), + feeQuoter: pick($c.feeQuoter), + link: ((($c.feeTokens // []) | map(select(.tokenSymbol == "LINK")) | .[0].tokenAddress) // null), + tokenPoolPrograms: (if ($tpp | length) > 0 then $tpp else null end) + } | with_entries(select(.value != null))) end) +} | with_entries(select(.value != null))' "$body_file" diff --git a/script/config/test-tooling.sh b/script/config/test-tooling.sh index 9a56160..122a17c 100755 --- a/script/config/test-tooling.sh +++ b/script/config/test-tooling.sh @@ -32,6 +32,9 @@ SEPOLIA_SELECTOR="16015286601757825753" # Committed API body for solana-devnet: serves the non-EVM metadata fetch offline (section 12d). SVM_FIXTURE="test/fixtures/ccip-api/chain-16423721717087811551.json" SVM_META_SELECTOR="16423721717087811551" +# Committed API body for aptos-testnet: the THIRD chain shape (32-byte hex, HAS tokenAdminRegistry, +# LACKS registryModule and tokenPoolPrograms) - the ccipNative{} writer must emit absent keys, not zeros. +APTOS_FIXTURE="test/fixtures/ccip-api/chain-743186221051783445.json" # A real, non-bundled chain used by the add-chain print-names case; its generated config is a # throwaway removed on exit (never committed). FUJI_CHAIN="avalanche-testnet-fuji" @@ -609,6 +612,12 @@ if offline_enabled; then SVM_SEL="9900030000000000003" SVMO_CHAIN="zz-scratch-svm-onboard" SVMO_FILE="config/chains/$SVMO_CHAIN.json" + APT_SEL="9900030000000000004" + APTO_CHAIN="zz-scratch-aptos-onboard" + APTO_FILE="config/chains/$APTO_CHAIN.json" + SVMR_SEL="9900030000000000005" + SVMR_CHAIN="zz-scratch-svm-norouter" + SVMR_FILE="config/chains/$SVMR_CHAIN.json" python3 -c " import json sep = json.load(open('$FIXTURE')); sol = json.load(open('$SVM_FIXTURE')) @@ -622,8 +631,30 @@ core['chainConfig']['router'] = [] # core contract absent -> a json.dump(core, open('$server_dir/chains/$CORE_SEL','w')) svm = json.loads(json.dumps(sol)) svm['chain']['name'] = '$SVMO_CHAIN'; svm['chain']['chainSelector'] = '$SVM_SEL' +# Inactive decoy AHEAD of the real router: the router literal asserted below then proves pick() +# prefers isActive over index 0 (no non-EVM fixture otherwise carries an inactive entry). +svm['chainConfig']['router'] = [dict(svm['chainConfig']['router'][0], + address='DecoyRouter1111111111111111111111111111111', isActive=False)] + svm['chainConfig']['router'] json.dump(svm, open('$server_dir/chains/$SVM_SEL','w')) +apt = json.load(open('$APTOS_FIXTURE')) +apt['chain']['name'] = '$APTO_CHAIN'; apt['chain']['chainSelector'] = '$APT_SEL' +json.dump(apt, open('$server_dir/chains/$APT_SEL','w')) +svmr = json.loads(json.dumps(sol)) +svmr['chain']['name'] = '$SVMR_CHAIN'; svmr['chain']['chainSelector'] = '$SVMR_SEL' +svmr['chainConfig']['router'] = [] # native CORE contract absent -> add-chain refuses +json.dump(svmr, open('$server_dir/chains/$SVMR_SEL','w')) " + # Vacuity guard: the ccipNative assertions below are only meaningful if the fixture SERVER is the + # source. Prove the body with the pool programs is actually being served before running them. + if curl -sf "http://127.0.0.1:$port/chains/$SVM_SEL" \ + | jq -e '.chainConfig.tokenPoolPrograms.burnMint[0].address' > /dev/null; then + pass=$((pass + 1)) + echo "[PASS] fixture server serves the SVM body with tokenPoolPrograms (assertions below are not vacuous)" + else + fail=$((fail + 1)) + failures+=("svm fixture not served") + echo "[FAIL] fixture server is not serving $SVM_SEL - the ccipNative cases below would be vacuous" + fi # A freshly add-chain'd non-EVM chain gets the 8-key zeroed ccip skeleton and passes doctor. rm_fixture_config "$SVMO_FILE" run_case "add-chain non-EVM generates the config" zero "generated config/chains/$SVMO_CHAIN.json" -- \ @@ -646,20 +677,113 @@ print('SKELETON_OK') failures+=("non-EVM skeleton shape") echo "[FAIL] non-EVM skeleton shape: $out" fi + # The native plane: real API values in ccipNative{}, BOTH pool programs, and chainId still the + # "0" sentinel (the real base58 genesis hash there makes the chain vanish from HelperConfig). + out="$(python3 -c " +import json +c = json.load(open('$SVMO_FILE')) +n = c['ccipNative'] +assert n['router'] == 'Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C', n['router'] +assert n['rmnProxy'] == 'RmnXLft1mSEwDgMKu2okYuHkiazxntFFcZFrrcXxYg7', n['rmnProxy'] +assert n['feeQuoter'] == 'FeeQPGkKDeRV1MgoYfMH6L8o3KeuYjwUZrgn4LRKfjHi', n['feeQuoter'] +assert n['link'] == 'LinkhB3afbBKb2EQQu7s7umdZceV3wcvAUJhQAfQ23L', n['link'] +p = n['tokenPoolPrograms'] +assert p['burnMint'] == '41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB', p['burnMint'] +assert p['lockRelease'] == '8eqh8wppT9c5rw4ERqNCffvU6cNFJWff9WmkcYtmGiqC', p['lockRelease'] +assert p['lockRelease'] != p['burnMint'], 'burnMint copied into lockRelease' +assert 'tokenAdminRegistry' not in n, 'SVM serves no tokenAdminRegistry' +assert c['nativeChainId'] == 'EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG', c['nativeChainId'] +assert c['chainId'] == '0', ('chainId sentinel overwritten', c['chainId']) +print('NATIVE_OK') +" 2>&1)" + if grep -q NATIVE_OK <<< "$out"; then + pass=$((pass + 1)) + echo "[PASS] add-chain SVM writes real ccipNative{} (both pool programs) + nativeChainId, chainId still 0" + else + fail=$((fail + 1)) + failures+=("svm ccipNative shape") + echo "[FAIL] svm ccipNative shape: $out" + fi + # INCOMPLETE, not VERIFIED: the schema and selectorName rungs pass, but a non-EVM chain's # on-chain state has no EVM JSON-RPC path, so nothing on-chain was read. run_case "doctor ends INCOMPLETE on the freshly add-chain'd non-EVM config" nonzero "check-chain INCOMPLETE for $SVMO_CHAIN" -- \ env CCIP_API_BASE="http://127.0.0.1:$port" bash -c \ "FOUNDRY_PROFILE=sync forge script script/config/VerifyChain.s.sol --tc VerifyChain --sig 'run(string)' $SVMO_CHAIN" + + # The native plane is drift-checked, not just written: a stale ccipNative value is CONFIG_DRIFT. + # This is the guard that makes a schema change without a config backfill fail in the PR rather + # than in the nightly. + svmo_tmp="$(mktemp)" && jq '.ccipNative.router = "StaleRouter11111111111111111111111111111111"' "$SVMO_FILE" > "$svmo_tmp" && mv "$svmo_tmp" "$SVMO_FILE" + run_case "sync-check flags a stale ccipNative value as drift" nonzero "DRIFT $SVMO_CHAIN .ccipNative.router" -- \ + env CCIP_API_BASE="http://127.0.0.1:$port" bash -c \ + "FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig 'check(string)' $SVMO_CHAIN" rm_fixture_config "$SVMO_FILE" rm -f "project/$SVMO_CHAIN.json" + # The third shape: Aptos has a tokenAdminRegistry and NO registryModule/tokenPoolPrograms, so the + # family-generic writer must omit those keys rather than write zeros for them. The values are + # pinned as literals because sync-check compares the transform against itself and so cannot catch + # a transform bug; Aptos is the only fixture where feeTokens[0] is NOT link, so the link literal + # is what fails a dropped `tokenSymbol == "LINK"` filter by name. + rm_fixture_config "$APTO_FILE" + run_case "add-chain Aptos generates the config" zero "generated config/chains/$APTO_CHAIN.json" -- \ + env CCIP_API_BASE="http://127.0.0.1:$port" bash -c \ + "FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig 'init(string,uint256)' $APTO_CHAIN $APT_SEL" + out="$(python3 -c " +import json +c = json.load(open('$APTO_FILE')) +n = c['ccipNative'] +pkg = '0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45' +assert n['router'] == pkg, n['router'] +assert n['rmnProxy'] == pkg, n['rmnProxy'] +assert n['feeQuoter'] == pkg, n['feeQuoter'] +assert n['link'] == '0x3d5d565c271d6b9c52f1a963f2b7bddad3453b0de2ace5e254b8db6549cc335e', n['link'] +tar = n['tokenAdminRegistry'] +assert tar == pkg, tar +assert tar.startswith('0x') and len(tar) == 66, ('not 32-byte hex', tar) +assert 'tokenPoolPrograms' not in n, 'Aptos serves no tokenPoolPrograms' +assert 'registryModule' not in n and 'registryModuleOwnerCustom' not in n, 'Aptos serves no registryModule' +assert c['nativeChainId'] == '2', c['nativeChainId'] +assert c['chainId'] == '0', ('chainId sentinel overwritten', c['chainId']) +print('APTOS_OK') +" 2>&1)" + if grep -q APTOS_OK <<< "$out"; then + pass=$((pass + 1)) + echo "[PASS] add-chain Aptos writes the third shape with the real API values (link is NOT feeTokens[0])" + else + fail=$((fail + 1)) + failures+=("aptos ccipNative shape") + echo "[FAIL] aptos ccipNative shape: $out" + fi + rm_fixture_config "$APTO_FILE" + rm -f "project/$APTO_CHAIN.json" + + # A non-EVM chain whose API row carries NO router entry at all cannot be onboarded: refuse by name + # and leave no partial file (the same no-orphan contract the EVM CORE split has). An inactive entry + # is still onboardable - pick() prefers isActive but falls back to it, as the EVM act() does. + rm_fixture_config "$SVMR_FILE" + run_case "add-chain refuses a non-EVM chain whose API row has no router entry" nonzero "serves no router entry" -- \ + env CCIP_API_BASE="http://127.0.0.1:$port" bash -c \ + "FOUNDRY_PROFILE=sync forge script script/config/SyncCcipConfig.s.sol --sig 'init(string,uint256)' $SVMR_CHAIN $SVMR_SEL" + if [ ! -f "$SVMR_FILE" ]; then + pass=$((pass + 1)) + echo "[PASS] no-orphan: a router-less non-EVM add-chain failure leaves NO config file" + else + fail=$((fail + 1)) + failures+=("orphan on native router-missing") + echo "[FAIL] orphan check: a router-less non-EVM add-chain left an orphan $SVMR_FILE" + rm_fixture_config "$SVMR_FILE" + fi + # Optional contract absent: a missing tokenPoolFactory syncs to 0x0 AND emits the [sync] WARN. rm_fixture_config "$OPT_FILE" out="$(env CCIP_API_BASE="http://127.0.0.1:$port" FOUNDRY_PROFILE=sync \ forge script script/config/SyncCcipConfig.s.sol --sig "init(string,uint256)" "$OPT_CHAIN" "$OPT_SEL" 2>&1)" tpf="$(jq -r '.ccip.tokenPoolFactory' "$OPT_FILE" 2> /dev/null)" + # ... and the native plane stays OFF an EVM chain: its addresses are EVM-typed in ccip{}. if grep -q "\[sync\] WARN $OPT_CHAIN: no active tokenPoolFactory" <<< "$out" \ + && jq -e 'has("ccipNative") or has("nativeChainId") | not' "$OPT_FILE" > /dev/null \ && [ "$tpf" = "0x0000000000000000000000000000000000000000" ]; then pass=$((pass + 1)) echo "[PASS] optional contract: a missing tokenPoolFactory syncs to 0x0 + emits the [sync] WARN" diff --git a/test/config/SyncToolingRules.t.sol b/test/config/SyncToolingRules.t.sol index ab7609c..eb8304f 100644 --- a/test/config/SyncToolingRules.t.sol +++ b/test/config/SyncToolingRules.t.sol @@ -93,6 +93,69 @@ contract SyncToolingRulesTest is Test { _assertCcipBlockExact("config/chains/solana-devnet.json"); } + /// @dev The native plane is NON-EVM ONLY and additive: the EVM configs must not grow it (their + /// addresses are EVM-typed in `ccip{}`), and the non-EVM ones must carry a real value for every + /// key the API serves for that family - the committed zeros in `ccip{}` are the EVM-typed + /// skeleton three `.ccip.*` readers require, not chain facts. + function test_CcipNativeIsNonEvmOnly() public view { + string memory evm = vm.readFile("config/chains/ethereum-testnet-sepolia.json"); + assertFalse(vm.keyExistsJson(evm, ".ccipNative"), "EVM config grew a ccipNative block"); + assertFalse(vm.keyExistsJson(evm, ".nativeChainId"), "EVM config grew a nativeChainId"); + + string memory svm = vm.readFile("config/chains/solana-devnet.json"); + assertEq(vm.parseJsonString(svm, ".chainId"), "0", "the chainId sentinel must stay 0"); + assertTrue(bytes(vm.parseJsonString(svm, ".nativeChainId")).length > 0, "solana nativeChainId empty"); + string[3] memory svmKeys = ["router", "rmnProxy", "feeQuoter"]; + for (uint256 i = 0; i < svmKeys.length; i++) { + assertTrue( + bytes(vm.parseJsonString(svm, string.concat(".ccipNative.", svmKeys[i]))).length > 0, + string.concat("solana ccipNative.", svmKeys[i], " empty") + ); + } + // Both pool-program variants, and distinct: one program id copied into both keys is the + // mutation a bare non-empty check misses, and lock-release is a first-class Solana use case. + string[2] memory programs = sync.tokenPoolProgramKeys(); + string memory burnMint = vm.parseJsonString(svm, ".ccipNative.tokenPoolPrograms.burnMint"); + string memory lockRelease = vm.parseJsonString(svm, ".ccipNative.tokenPoolPrograms.lockRelease"); + assertEq(programs[0], "burnMint", "program key order changed"); + assertEq(programs[1], "lockRelease", "program key order changed"); + assertTrue(bytes(burnMint).length > 0 && bytes(lockRelease).length > 0, "a pool program is empty"); + assertTrue(keccak256(bytes(burnMint)) != keccak256(bytes(lockRelease)), "one program id in both keys"); + } + + /// @dev Aptos is the THIRD shape and is out of scope for writes, but the schema is family-generic: + /// a key the API does not serve for a family must be ABSENT, never a zero or an empty string. + function test_CcipNativeOmitsUnservedKeys() public view { + string memory aptos = vm.readFile("config/chains/aptos-testnet.json"); + assertEq(vm.parseJsonString(aptos, ".chainId"), "0", "the chainId sentinel must stay 0"); + assertTrue(bytes(vm.parseJsonString(aptos, ".ccipNative.tokenAdminRegistry")).length > 0, "aptos TAR missing"); + assertFalse(vm.keyExistsJson(aptos, ".ccipNative.tokenPoolPrograms"), "aptos has no pool programs"); + assertFalse(vm.keyExistsJson(aptos, ".ccipNative.registryModuleOwnerCustom"), "aptos has no registryModule"); + + string memory svm = vm.readFile("config/chains/solana-devnet.json"); + assertFalse(vm.keyExistsJson(svm, ".ccipNative.tokenAdminRegistry"), "solana has no tokenAdminRegistry"); + } + + /// @dev Every `ccipNative{}` key present in a committed config must come from the shared key list + /// (plus `tokenPoolPrograms`), so the writer, the drift-check and the files cannot diverge. + function test_CcipNativeKeysAreFromTheKeyList() public view { + _assertNativeKeysKnown("config/chains/solana-devnet.json"); + _assertNativeKeysKnown("config/chains/aptos-testnet.json"); + } + + function _assertNativeKeysKnown(string memory path) internal view { + string memory json = vm.readFile(path); + string[6] memory known = sync.ccipNativeKeys(); + string[] memory actual = vm.parseJsonKeys(json, ".ccipNative"); + for (uint256 i = 0; i < actual.length; i++) { + bool found = keccak256(bytes(actual[i])) == keccak256(bytes("tokenPoolPrograms")); + for (uint256 k = 0; k < known.length && !found; k++) { + found = keccak256(bytes(actual[i])) == keccak256(bytes(known[k])); + } + assertTrue(found, string.concat(path, ": unknown ccipNative key ", actual[i])); + } + } + function _assertCcipBlockExact(string memory path) internal view { string memory json = vm.readFile(path); string[7] memory keys = sync.ccipAddressKeys(); diff --git a/test/fixtures/ccip-api/chain-743186221051783445.json b/test/fixtures/ccip-api/chain-743186221051783445.json new file mode 100644 index 0000000..2e7d981 --- /dev/null +++ b/test/fixtures/ccip-api/chain-743186221051783445.json @@ -0,0 +1 @@ +{"chain":{"name":"aptos-testnet","displayName":"Aptos Testnet","chainSelector":"743186221051783445","chainId":"2","chainFamily":"APTOS","environment":"testnet","isPrivate":false,"deprecated":false},"chainConfig":{"chainFamily":"APTOS","router":[{"address":"0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45","type":"Router","version":"1.6.0","isActive":true}],"feeQuoter":[{"address":"0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45","type":"FeeQuoter","version":"1.6.0","isActive":true}],"feeTokens":[{"tokenAddress":"0x000000000000000000000000000000000000000000000000000000000000000a","tokenSymbol":"APT","tokenName":"Aptos Coin","decimals":8},{"tokenAddress":"0x3d5d565c271d6b9c52f1a963f2b7bddad3453b0de2ace5e254b8db6549cc335e","tokenSymbol":"LINK","tokenName":"ChainLink Token","decimals":8}],"tokenAdminRegistry":[{"address":"0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45","type":"TokenAdminRegistry","version":"1.6.0","isActive":true}],"mcms":null,"rmn":[{"address":"0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45","type":"RMNRemote","version":"1.6.0","isActive":true}]},"chainMetadata":{"explorer":{"name":"Aptos Explorer","url":"https://explorer.aptoslabs.com/?network=testnet","txPath":"https://explorer.aptoslabs.com/txn/{resourceId}?network=testnet","addressPath":"https://explorer.aptoslabs.com/account/{resourceId}?network=testnet","tokenPath":"https://explorer.aptoslabs.com/fungible_asset/{resourceId}?network=testnet"},"nativeCurrency":{"name":"Aptos Coin","symbol":"APT","decimals":8}}} \ No newline at end of file