From a02099bf46c2f1e5a1e9cf9e23601e297c74504c Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 17 Jul 2026 13:03:37 +0100 Subject: [PATCH 1/2] feat(adapter-evm): add opt-in mainnet-L1 fallback for forward and reverse ENS resolution Adds an opt-in seam (enableMainnetL1MissFallback) that, when enabled, consults Ethereum mainnet L1 as a fallback for both forward and reverse ENS resolution on bound chains that return a definitive empty result. - Never-silent: fallback fires only on a definitive bound-chain miss, never on errors; failures surface as typed NameResolutionError. - Cross-network provenance triplet (resolvedViaNetworkFallback, queriedOnNetworkId, resolvedOnNetworkId) emitted only on a bound-empty-miss that resolved on L1, so non-UR direct-L1 reverse is not mislabeled as a fallback. - Threads the opt-in through adapter-evm createRuntime -> capability factory so it reaches the runtime (fixes the seam that left it off). - Requires @openzeppelin/ui-types ^3.3.0 (carries the triplet fields); floor bumped on adapter-evm and adapter-evm-core. - Changeset bumps both adapter-evm and adapter-evm-core (minor); adapter-evm-core is private and bundled, so adapter-evm must bump for the fix to reach npm. - vitest resolve.dedupe(@openzeppelin/ui-types) so the multi-version workspace loads a single RuntimeDisposedError class in tests. Co-authored-by: Cursor --- .changeset/ens-mainnet-l1-opt-in-fallback.md | 6 + .../CHANGELOG.md | 38 ++ .../README.md | 98 ++++ .../integration-guide.md | 120 +++++ .../docs/name-resolution/README.md | 59 ++- .../docs/name-resolution/api-reference.md | 198 +++++-- .../docs/name-resolution/examples/README.md | 4 +- .../examples/reverse-miss-fallback/README.md | 23 +- .../resolve-miss-fallback.ts | 28 +- .../docs/name-resolution/integration-guide.md | 236 +++++---- packages/adapter-evm-core/package.json | 4 +- .../src/capabilities/name-resolution.ts | 25 +- packages/adapter-evm-core/src/index.ts | 5 + .../src/name-resolution/__tests__/fixtures.ts | 7 + .../__tests__/helpers/fallback-provenance.ts | 60 +++ .../network-fallback-provenance.test.ts | 336 ++++++++++++ .../__tests__/provenance.test.ts | 119 ++++- .../__tests__/service.forward-l1.test.ts | 488 ++++++++++++++++++ .../service.mainnet-l1-opt-in.test.ts | 488 ++++++++++++++++++ .../__tests__/service.reverse-l1.test.ts | 202 ++++++-- .../src/name-resolution/index.ts | 14 +- .../src/name-resolution/provenance.ts | 45 ++ .../src/name-resolution/service.ts | 161 ++++-- packages/adapter-evm/package.json | 4 +- .../src/__tests__/profile-runtime.test.ts | 23 + packages/adapter-evm/src/profiles/shared.ts | 60 ++- .../test/sf-5-published-release.test.ts | 188 +++++++ pnpm-lock.yaml | 456 +++++++++++----- vitest.shared.config.ts | 3 + 29 files changed, 3085 insertions(+), 413 deletions(-) create mode 100644 .changeset/ens-mainnet-l1-opt-in-fallback.md create mode 100644 docs/releases/003-ens-mainnet-l1-opt-in-fallback/CHANGELOG.md create mode 100644 docs/releases/003-ens-mainnet-l1-opt-in-fallback/README.md create mode 100644 docs/releases/003-ens-mainnet-l1-opt-in-fallback/integration-guide.md create mode 100644 packages/adapter-evm-core/src/name-resolution/__tests__/helpers/fallback-provenance.ts create mode 100644 packages/adapter-evm-core/src/name-resolution/__tests__/network-fallback-provenance.test.ts create mode 100644 packages/adapter-evm-core/src/name-resolution/__tests__/service.forward-l1.test.ts create mode 100644 packages/adapter-evm-core/src/name-resolution/__tests__/service.mainnet-l1-opt-in.test.ts create mode 100644 packages/adapter-evm/test/sf-5-published-release.test.ts diff --git a/.changeset/ens-mainnet-l1-opt-in-fallback.md b/.changeset/ens-mainnet-l1-opt-in-fallback.md new file mode 100644 index 0000000..5cea491 --- /dev/null +++ b/.changeset/ens-mainnet-l1-opt-in-fallback.md @@ -0,0 +1,6 @@ +--- +"@openzeppelin/adapter-evm": minor +"@openzeppelin/adapter-evm-core": minor +--- + +Opt-in mainnet-L1 miss-fallback for forward+reverse ENS resolution (definitive-miss only, never-silent) with cross-network provenance triplet; requires @openzeppelin/ui-types ^3.3.0. diff --git a/docs/releases/003-ens-mainnet-l1-opt-in-fallback/CHANGELOG.md b/docs/releases/003-ens-mainnet-l1-opt-in-fallback/CHANGELOG.md new file mode 100644 index 0000000..f7df247 --- /dev/null +++ b/docs/releases/003-ens-mainnet-l1-opt-in-fallback/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog — 003 Mainnet-L1 Opt-In Fallback + +> Draft release notes. Changesets merges this body into `packages/adapter-evm/CHANGELOG.md` (and core +> changelog if generated) when the version PR runs `changeset version`. + +## [@openzeppelin/adapter-evm@2.3.0] — pending publish + +### Minor Changes + +- **Opt-in mainnet-L1 miss-fallback (default OFF)** for ENS `resolveName` and `resolveAddress` on + non-mainnet-bound adapters. When `enableMainnetL1MissFallback: true` and `ensL1Client` is wired, + consult mainnet L1 only after a **definitive bound-chain empty** / `NAME_NOT_FOUND` on UR-carrying + chains. Gateway, transport, and timeout failures remain terminal (never-silent-fallback). +- **Cross-network fallback provenance triplet** on L1 miss-fallback successes after UR bound-empty + miss: `resolvedViaNetworkFallback`, `queriedOnNetworkId`, `resolvedOnNetworkId` on base + `ResolutionProvenance`. Non-UR direct L1 and canonical forward `001` 1b paths omit the triplet. + `scopedToNetworkId` stays absent on L1 fallback hits (global display preserved). +- **Reframes `002` reverse L1 ladder** behind the same opt-in — always-on reverse miss-fallback is + no longer the default; integrators opt in explicitly. + +### Changed + +- Raise `@openzeppelin/ui-types` peer/dev floor from `^3.2.0` to **`^3.3.0`** (triplet fields). + Consumers must install ui-types `^3.3.0` alongside this adapter version. + +### Migration Guide + +1. `pnpm add @openzeppelin/adapter-evm@^2.3.0 @openzeppelin/ui-types@^3.3.0` +2. Default behavior unchanged if you omit `enableMainnetL1MissFallback`. +3. To restore cross-network ENS after bound-empty miss, pass `enableMainnetL1MissFallback: true` on + `createNameResolution` options. +4. Read fallback disclaimer via `resolvedViaNetworkFallback === true` — not from absent + `scopedToNetworkId`. + +## [@openzeppelin/adapter-evm-core@1.3.0] — workspace only (private) + +Bundled into `@openzeppelin/adapter-evm` — not published to npm independently. Version advances in +lockstep for workspace truth and changelog attribution. diff --git a/docs/releases/003-ens-mainnet-l1-opt-in-fallback/README.md b/docs/releases/003-ens-mainnet-l1-opt-in-fallback/README.md new file mode 100644 index 0000000..31c4b18 --- /dev/null +++ b/docs/releases/003-ens-mainnet-l1-opt-in-fallback/README.md @@ -0,0 +1,98 @@ +# Release — 003 Mainnet-L1 Opt-In Fallback (`@openzeppelin/adapter-evm`) + +> Ships opt-in mainnet-L1 miss-fallback for ENS forward and reverse resolution (default **OFF**), +> cross-network fallback provenance on definitive bound-empty misses, and the `002` reverse ladder +> reframe — exclusively through a **public `@openzeppelin/adapter-evm` npm bump** that bundles +> private `@openzeppelin/adapter-evm-core`. + +## Overview + +**Audience:** npm consumers of `@openzeppelin/adapter-evm`, release engineers, and UIKit integrators +pinning adapter + `@openzeppelin/ui-types` together. + +**What this release delivers:** + +- **SF-1** — `enableMainnetL1MissFallback` opt-in (strict `true` only; default OFF). +- **SF-2** — Cross-network provenance triplet on base `ResolutionProvenance` when L1 miss-fallback + succeeds after a **UR bound-empty miss** (`resolvedViaNetworkFallback`, `queriedOnNetworkId`, + `resolvedOnNetworkId`). Non-UR direct L1 omits the triplet. +- **SF-3 / SF-4** — Reverse and forward mainnet-L1 miss-fallback ladders (definitive-miss only; + never-silent-fallback on gateway/transport failures). + +**Prerequisite:** `@openzeppelin/ui-types@^3.3.0` (triplet fields). Publish ui-types **before** or +alongside this adapter release. + +**Release-trap guard:** `@openzeppelin/adapter-evm-core` is `private: true` and bundled via `tsdown` +`noExternal`. A core-only version bump does **not** reach npm consumers — the `002` failure mode. +This release uses a **dual-package changeset** bumping **both** `adapter-evm` and `adapter-evm-core`. + +## Quick Start (consumer) + +```bash +pnpm add @openzeppelin/adapter-evm@^2.3.0 @openzeppelin/ui-types@^3.3.0 +``` + +Opt-in is **explicit** — wiring `ensL1Client` alone does not enable miss-fallback: + +```ts +import { createNameResolution } from '@openzeppelin/adapter-evm'; + +const capability = createNameResolution(networkConfig, { + publicClient: boundClient, + ensL1Client: mainnetClient, + enableMainnetL1MissFallback: true, // default OFF when omitted +}); +``` + +Default posture is unchanged: without `enableMainnetL1MissFallback: true`, bound-empty reverse and +bound `NAME_NOT_FOUND` forward stay terminal (no L1 consult). + +## Expected versions (after `changeset version`) + +| Package | From | To | +|---------|------|-----| +| `@openzeppelin/adapter-evm` | `2.2.0` | `2.3.0` (minor) | +| `@openzeppelin/adapter-evm-core` | `1.2.0` | `1.3.0` (minor, private) | +| Linked adapters (`midnight`, `polkadot`, `solana`, `stellar`) | `2.2.0` | `2.3.0` (Changesets linked group) | + +Exact numbers are produced by Changesets — do not hand-edit `package.json` versions. + +## Key Concepts + +- **Install `adapter-evm`, not core.** npm consumers receive bundled core in `adapter-evm/dist`. + Workspace `adapter-evm-core@1.3.0` alone does not satisfy SC-005. +- **Dual changeset is mandatory.** `.changeset/ens-mainnet-l1-opt-in-fallback.md` lists both packages. +- **ui-types floor `^3.3.0`.** Required for triplet field types at compile time and runtime honesty. +- **Emission rule.** Triplet appears only after UR bound-empty → L1 success with opt-in ON; canonical + non-UR forward L1 (`001` 1b) and non-UR direct reverse omit it. +- **Dist verification.** Post-build grep on `packages/adapter-evm/dist` proves the bundle contains the + 003 delta (see Integration Guide — maintainer checklist). + +## Feature documentation + +Runtime behavior is documented in the name-resolution package docs: + +- [`packages/adapter-evm-core/docs/name-resolution/README.md`](../../packages/adapter-evm-core/docs/name-resolution/README.md) +- [`integration-guide.md`](../../packages/adapter-evm-core/docs/name-resolution/integration-guide.md) — + Patterns 8–10 (opt-in ladders + triplet) + +## Integration Guide + +See [integration-guide.md](./integration-guide.md) for consumer adoption, migration, and maintainer +release verification. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) for the release notes body (merged into package CHANGELOGs by +Changesets on version PR). + +## Safety + +- **Default OFF** — no silent cross-network resolution; integrators must opt in explicitly. +- **Never-silent-fallback** — bound gateway/timeout failures do not fall through to L1. +- **Do not infer fallback** from absent `scopedToNetworkId`; use `resolvedViaNetworkFallback === true`. +- **Pin ui-types `^3.3.0`** with the adapter bump — older ui-types lack triplet fields. + +## License + +Inherits the `@openzeppelin/adapter-evm` package license. diff --git a/docs/releases/003-ens-mainnet-l1-opt-in-fallback/integration-guide.md b/docs/releases/003-ens-mainnet-l1-opt-in-fallback/integration-guide.md new file mode 100644 index 0000000..6e52002 --- /dev/null +++ b/docs/releases/003-ens-mainnet-l1-opt-in-fallback/integration-guide.md @@ -0,0 +1,120 @@ +# Integration Guide — 003 Mainnet-L1 Opt-In Fallback Release + +## Pattern 1: Consumer upgrade (npm) + +```bash +pnpm add @openzeppelin/adapter-evm@^2.3.0 @openzeppelin/ui-types@^3.3.0 +``` + +After publish, confirm the installed version: + +```bash +npm view @openzeppelin/adapter-evm version +``` + +**Smoke check** — bundled dist should expose the opt-in seam (no separate core install): + +```bash +rg 'enableMainnetL1MissFallback' node_modules/@openzeppelin/adapter-evm/dist/ +rg 'resolvedViaNetworkFallback' node_modules/@openzeppelin/adapter-evm/dist/ +``` + +Both patterns should match. If absent, you are on a pre-003 publish (`2.2.0` or earlier) or a stale +lockfile. + +## Pattern 2: Opt-in wiring (dapp / UIKit) + +Shared runtime profiles wire `ensL1Client` but **do not** pass `enableMainnetL1MissFallback: true` — +safe default preserved. To enable cross-network ENS after bound-empty miss: + +```ts +import { createNameResolution } from '@openzeppelin/adapter-evm'; + +const nameResolution = createNameResolution(networkConfig, { + publicClient: boundClient, + ensL1Client: mainnetClient, + enableMainnetL1MissFallback: true, +}); +``` + +Read cross-network disclaimer from provenance (UIKit: `@openzeppelin/ui-utils` `isCrossNetworkFallback`): + +```ts +import type { ResolutionProvenance } from '@openzeppelin/ui-types'; + +function isCrossNetworkFallback( + p: Pick, +): boolean { + return p.resolvedViaNetworkFallback === true; +} +``` + +Full behavior matrices: [`name-resolution/integration-guide.md`](../../packages/adapter-evm-core/docs/name-resolution/integration-guide.md) Patterns 8–10. + +## Pattern 3: Migration from pre-003 (`adapter-evm@2.2.0`) + +| Topic | Before (`2.2.0` npm) | After (`2.3.0`+) | +|-------|----------------------|------------------| +| Reverse L1 after bound-empty | Always-on when `ensL1Client` wired (002 workspace only; **not** on npm `2.2.0`) | Opt-in gated; **OFF** by default | +| Forward UR bound miss | Bound-UR-authoritative (`NAME_NOT_FOUND` terminal) | Opt-in enables one L1 consult | +| Provenance triplet | Absent | Present on UR bound-empty → L1 success only | +| ui-types floor | `^3.2.0` | `^3.3.0` required | + +**No breaking default:** omitting `enableMainnetL1MissFallback` preserves safe posture. Apps that +relied on workspace-only 002 always-on reverse must explicitly opt in after upgrade. + +## Pattern 4: Maintainer release verification (pre-merge) + +Run after `pnpm build` at monorepo root (post-changeset version PR or local `changeset version`): + +```bash +DIST=packages/adapter-evm/dist +for pat in \ + enableMainnetL1MissFallback \ + mayConsultL1ForMissFallback \ + resolvedViaNetworkFallback \ + queriedOnNetworkId \ + resolvedOnNetworkId \ + networkFallbackProvenanceFields \ + ethereum-mainnet +do + rg -l "$pat" "$DIST"/*.mjs "$DIST"/*.cjs || { echo "FAIL: $pat"; exit 1; } +done +echo "SF-5 dist verification PASS" +``` + +**Release-trap checklist:** + +- [ ] Changeset lists **both** `@openzeppelin/adapter-evm` and `@openzeppelin/adapter-evm-core` +- [ ] ui-types floor `^3.3.0` in both EVM `package.json` files (Code stage — already applied) +- [ ] Dist grep V-1–V-7 PASS +- [ ] `@openzeppelin/ui-types@3.3.0` on npm before publish +- [ ] Do **not** merge core-only changeset + +## Pattern 5: Monorepo test infra (`vitest` ui-types dedupe) + +The workspace may resolve multiple `@openzeppelin/ui-types` copies (adapter + UIKit sibling). Shared +vitest config dedupes ui-types for consistent `instanceof` / type identity in tests: + +```ts +// vitest.shared.config.ts +resolve: { + dedupe: ['@openzeppelin/ui-types', '@openzeppelin/ui-renderer'], +}, +``` + +This is a **test-infra** fix for multi-version workspace layouts — not a runtime consumer requirement. + +## Common Mistakes + +- **Bumping only `adapter-evm-core`.** Does not republish npm bundle — 002 trap repeats. +- **Expecting triplet on non-UR direct L1.** Canonical `001` 1b paths omit it. +- **Upgrading adapter without ui-types `^3.3.0`.** Triplet fields missing on consumer types. +- **Assuming `ensL1Client` implies opt-in.** Gate is separate; default remains OFF. +- **Trusting workspace core version without dist grep.** Version bump ≠ bundled bits. + +## See also + +- [README](./README.md) — release overview +- [CHANGELOG](./CHANGELOG.md) — release notes draft +- `.changeset/ens-mainnet-l1-opt-in-fallback.md` — source changeset body diff --git a/packages/adapter-evm-core/docs/name-resolution/README.md b/packages/adapter-evm-core/docs/name-resolution/README.md index feb4e95..d70856c 100644 --- a/packages/adapter-evm-core/docs/name-resolution/README.md +++ b/packages/adapter-evm-core/docs/name-resolution/README.md @@ -15,10 +15,13 @@ covering both directions plus ENS v2 and mainnet-L1 reverse: - **ENS v2** (L1-only: CCIP-Read / offchain gateways + cross-chain via `coinType`), SF-5 — every forward result now carries an EVM-specific `EnsProvenance` with **observed** offchain facts you narrow to via `isEnsProvenance`, and an L2-bound runtime can resolve chain-scoped names on L1. -- **Mainnet-L1 reverse miss-fallback**, SF-1 (002) — on non-mainnet-bound adapters, reverse tries - the bound chain first; on a **definitive empty** only, consults the gated mainnet L1 client for the - default primary name (+ avatar), with provenance that marks **global vs network-local** scope via - base `scopedToNetworkId`. +- **Mainnet-L1 miss-fallback (003)**, SF-1–SF-4 — on non-mainnet-bound adapters, **opt-in gated** + (default **OFF**): after a **definitive bound-chain empty** on UR-carrying chains, consult mainnet L1 + for the default primary / forward record when the integrator sets + `enableMainnetL1MissFallback: true`. Reverse reframes 002's always-on ladder behind the same switch. + L1-fallback successes emit a **chain-agnostic fallback triplet** on base `ResolutionProvenance` + (`resolvedViaNetworkFallback`, `queriedOnNetworkId`, `resolvedOnNetworkId`) — owned by + `@openzeppelin/ui-types@3.3.0`, not adapter-invented fields. ## Overview @@ -168,18 +171,28 @@ always-present `system: 'ens'` discriminant; `label` is a display string that is mismatched name. Either it returns a name that provably forward-resolves back to the queried address (`forwardVerified: true`), or it returns `ADDRESS_NOT_FOUND`. There is no `forwardVerified: false` path in this adapter — the anti-spoofing decision is made *for* you. -- **Reverse miss-fallback (002 SF-1, Option B).** On a non-mainnet-bound adapter with a wired - `ensL1Client`: when the bound chain has a Universal Resolver, bound reverse runs first; only on a - **definitive empty** (no usable primary — not a gateway/transport failure) does the adapter consult - mainnet L1 for the default primary (`coinType` 60). Bound-local hits win without L1; bound failures - surface typed errors and **never** silently fall through to L1. Non-UR + L1 → L1 direct; non-UR + no - L1 → `UNSUPPORTED_NETWORK`; mainnet-bound → bound only. +- **Reverse miss-fallback (003 SF-3 / 002 reframe, opt-in gated).** On a non-mainnet-bound adapter + with a wired `ensL1Client` and **`enableMainnetL1MissFallback: true`**: when the bound chain has a + Universal Resolver, bound reverse runs first; only on a **definitive empty** (no usable primary — not + a gateway/transport failure) does the adapter consult mainnet L1 for the default primary (`coinType` + 60). With opt-in **OFF** (default), bound-empty returns `ADDRESS_NOT_FOUND` without L1. Bound-local + hits win without L1; bound failures surface typed errors and **never** silently fall through to L1. + Non-UR + opt-in ON → L1 direct (**no** fallback triplet — canonical `001` 1b path); non-UR + no L1 + → `UNSUPPORTED_NETWORK`; mainnet-bound → bound only. +- **Cross-network fallback provenance (003 SF-2).** When L1 miss-fallback succeeds after a **real + bound-empty miss** on a UR-carrying chain (forward or reverse), provenance carries the coordinated + base triplet: `resolvedViaNetworkFallback: true`, `queriedOnNetworkId` (bound `networkConfig.id`), + `resolvedOnNetworkId` (`ethereum-mainnet`). **`scopedToNetworkId` stays absent** on these hits + (002 D-R7 — global display on any row). The triplet is **omitted** on bound-local hits, mainnet-bound + hits, and canonical non-UR forward L1 (`001` SF-5 branch 1b). Classify with + `resolvedViaNetworkFallback === true` only — never infer from absent `scopedToNetworkId`, `label`, or + `external` (Principle II). UIKit copy uses `@openzeppelin/ui-utils` `isCrossNetworkFallback`. - **Reverse provenance scope (chain-agnostic gate).** Distinguish global vs network-local using base `scopedToNetworkId` **only** — absent ⇒ global / mainnet identity (show on any row); present ⇒ network-local (hide on rows scoped to a different network). **Never** use `isEnsProvenance` or - `coinType` as the display-safety gate (Principle II). L1 reverse hits may carry `EnsProvenance` - (`coinType: 60`) as adapter-internal enrichment; Sepolia-local hits carry `scopedToNetworkId` without - `EnsProvenance`. + `coinType` as the display-safety gate (Principle II). L1 miss-fallback hits may carry `EnsProvenance` + (`coinType: 60`) as adapter-internal enrichment; the **fallback disclaimer** uses the triplet, not + `EnsProvenance`. Sepolia-local hits carry `scopedToNetworkId` without `EnsProvenance`. - **Avatar is best-effort and isolated.** The reverse call fetches the avatar in a separate, failure- and latency-isolated step. A slow, failing, or missing avatar can only omit `avatarUrl` — it never delays past the reverse read's error surface and never fails the result. @@ -248,14 +261,18 @@ registration, the consumer resolve loop, and common mistakes. Runnable examples - **Forward provenance carries no avatar.** `EnsProvenance` (`system`, `coinType`, `scopedToNetworkId`, `external`) describes how the *address* was resolved, not display metadata. Avatars are reverse-only (`avatarUrl` on `ResolvedName`). -- **ENS resolution starts on L1 (forward); reverse may miss-fallback to L1.** The forward path uses - the bound client when the bound chain carries an ENS Universal Resolver (mainnet-bound, `coinType` 60); - otherwise, **iff** the runtime wired an `ensL1Client`, it resolves the name chain-scoped on L1 - (`coinType = toCoinType(boundChainId)`) and stamps `scopedToNetworkId`. A bound network with no - Universal Resolver **and** no `ensL1Client` returns `UNSUPPORTED_NETWORK` **before any I/O** — there - is never a silent cross-chain fallback. The reverse path (`resolveAddress`) uses the same injected - `ensL1Client` under the Option B ladder (002 SF-1): bound-first on UR-carrying chains, L1 only after - definitive empty or when the bound chain has no UR. +- **ENS resolution starts on L1 (forward); reverse/forward may miss-fallback to L1 when opted in.** + The forward path uses the bound client when the bound chain carries an ENS Universal Resolver + (mainnet-bound, `coinType` 60); otherwise, **iff** the runtime wired an `ensL1Client`, it resolves + the name chain-scoped on L1 (`coinType = toCoinType(boundChainId)`) — the canonical `001` 1b path, + **not** miss-fallback. UR-carrying bound forward miss-fallback to mainnet L1 runs **only** when + `enableMainnetL1MissFallback: true` (003 SF-4). A bound network with no Universal Resolver **and** no + `ensL1Client` returns `UNSUPPORTED_NETWORK` **before any I/O**. The reverse path uses the same + `ensL1Client` under the Option B ladder (003 SF-3), gated by the same opt-in. +- **Opt-in default OFF (fund-safety).** Wiring `ensL1Client` does **not** enable miss-fallback. + Runtime profiles must pass `enableMainnetL1MissFallback: true` explicitly to permit L1 consult after + bound-empty / bound `NAME_NOT_FOUND`. Transport/gateway/timeout failures remain terminal — opt-in + relaxes **where** we look after a clean miss, not error discipline. - **Chain-scoped addresses must be bound to their network.** When `provenance.scopedToNetworkId` is present on a **forward** result (`coinType !== 60`), the resolved address is meaningful **only** on that network. On **reverse**, `scopedToNetworkId` marks a **bound-local primary name** (network-local diff --git a/packages/adapter-evm-core/docs/name-resolution/api-reference.md b/packages/adapter-evm-core/docs/name-resolution/api-reference.md index cf96c84..a753735 100644 --- a/packages/adapter-evm-core/docs/name-resolution/api-reference.md +++ b/packages/adapter-evm-core/docs/name-resolution/api-reference.md @@ -1,4 +1,4 @@ -# API Reference — ENS Name Resolution (forward SF-2 · reverse SF-3 · ENS v2 SF-5 · L1 reverse SF-1) +# API Reference — ENS Name Resolution (forward SF-2 · reverse SF-3 · ENS v2 SF-5 · L1 miss-fallback 003) Complete typed surface for the EVM name-resolution capability, as exported from `@openzeppelin/adapter-evm-core`. Value types (`ResolutionResult`, `ResolvedAddress`, @@ -6,13 +6,11 @@ Complete typed surface for the EVM name-resolution capability, as exported from interface are **owned by `@openzeppelin/ui-types`** — this package implements against them and re-exports nothing of them; import them from `@openzeppelin/ui-types`. -> Scope: this reference covers all four delivered slices — the **forward** path (name → address, -> SF-2), the **reverse** path (`resolveAddress`, `forwardVerified`, avatar, SF-3 + 002 SF-1 -> miss-fallback), and **ENS v2** (SF-5): the `EnsProvenance` extension type + `isEnsProvenance` guard -> carried on every forward success and on **L1 reverse** successes, the observed-`external` upgrade, -> the optional `ensL1Client` for L1 cross-chain **and** reverse miss-fallback, and -> `coinType` / `scopedToNetworkId` chain-scoping. Reverse scope for chain-agnostic consumers rides -> base `scopedToNetworkId` only (INV-28). +> Scope: this reference covers all delivered slices — the **forward** path (name → address, +> SF-2), the **reverse** path (`resolveAddress`, `forwardVerified`, avatar, SF-3 + 003 SF-3 +> opt-in miss-fallback), **ENS v2** (SF-5), and **003 cross-network fallback provenance** (SF-2: +> base triplet on L1 miss-fallback successes). Reverse scope for chain-agnostic show/hide rides +> base `scopedToNetworkId` only (INV-28); fallback disclaimer rides the triplet only. --- @@ -27,6 +25,9 @@ re-exports nothing of them; import them from `@openzeppelin/ui-types`. | `isValidName` | function | ✅ | | `normalizeName` | function | ✅ | | `baseEnsProvenance` | function | ✅ (mainnet-bound reverse provenance, SF-3 / 002) | +| `MAINNET_NETWORK_ID` | constant | ✅ **(003 SF-2)** — canonical mainnet repo network id (`ethereum-mainnet`) | +| `networkFallbackProvenanceFields` | function | ✅ **(003 SF-2)** — DRY triplet builder | +| `composeNetworkFallbackProvenance` | function | ✅ **(003 SF-2)** — spread triplet onto existing provenance | | `EnsProvenance` | type | ✅ **(SF-5)** — forward-result provenance extension | | `isEnsProvenance` | function (type guard) | ✅ **(SF-5)** — the sole sanctioned narrowing path | | `buildEnsProvenance` | function | ✅ **(SF-5)** — the forward provenance builder | @@ -75,7 +76,8 @@ no RPC resource of its own, because it borrows the injected client (see `CreateN ```ts interface CreateNameResolutionOptions { readonly publicClient: PublicClient; - readonly ensL1Client?: PublicClient; // SF-5, optional + readonly ensL1Client?: PublicClient; // SF-5 + 003 — optional mainnet client + readonly enableMainnetL1MissFallback?: boolean; // 003 SF-1 — default OFF (strict `true` only) } ``` @@ -93,20 +95,29 @@ Dependencies injected into `createNameResolution`. When the bound network's chain has **no** Universal Resolver and no `ensL1Client` is wired, `resolveName` returns a typed `UNSUPPORTED_NETWORK` — it does not throw. -- `ensL1Client` (`viem.PublicClient`, **optional — SF-5 + 002 SF-1**) — a dedicated **mainnet** viem - client. **Forward (SF-5):** used when the bound network has no Universal Resolver, to resolve an ENS - name **chain-scoped to the bound network** on L1 (`coinType = toCoinType(boundChainId)`, ENSIP-9/11). - **Reverse (002 SF-1):** used on non-mainnet-bound profiles for miss-fallback (after bound definitive - empty on UR-carrying chains) or direct L1 reverse when the bound chain has no UR. Like +- `ensL1Client` (`viem.PublicClient`, **optional — SF-5 + 003**) — a dedicated **mainnet** viem + client. **Forward (SF-5 / `001` 1b):** used when the bound network has no Universal Resolver, to + resolve an ENS name **chain-scoped to the bound network** on L1 (`coinType = toCoinType(boundChainId)`). + **Miss-fallback (003 SF-3 / SF-4):** used after a definitive bound-empty / `NAME_NOT_FOUND` on + UR-carrying non-mainnet chains **only when** `enableMainnetL1MissFallback === true`. Like `publicClient`, it is **borrowed, never disposed**. - When **present** and the bound network is an L2 (no UR): `resolveName` resolves on L1 with scoped - `coinType`; `resolveAddress` uses L1 reverse direct. - - When **present** and the bound network is UR-carrying non-mainnet (e.g. Sepolia): `resolveAddress` - tries bound reverse first, then L1 only on definitive empty — never on bound gateway failure. + `coinType` (canonical 1b — **no** fallback triplet); `resolveAddress` uses L1 reverse direct when + opted in (**no** triplet — not preceded by bound miss). + - When **present** and the bound network is UR-carrying non-mainnet (e.g. Sepolia) **with opt-in ON**: + `resolveAddress` / `resolveName` try bound first, then L1 only on definitive empty / `NAME_NOT_FOUND` + — never on bound gateway failure. L1 success after bound miss emits the SF-2 fallback triplet. + - When **present** but opt-in **OFF** (default): `ensL1Client` still powers forward `001` 1b on + non-UR chains; UR-carrying bound-empty reverse/forward does **not** consult L1. - When **absent**: L2-bound forward/reverse (no UR) returns `UNSUPPORTED_NETWORK`. - When the bound network **already** has a Universal Resolver **and** is mainnet: `ensL1Client` is - **not** consulted for reverse (bound-only); forward uses bound client with `coinType` 60. + **not** consulted for miss-fallback; forward uses bound client with `coinType` 60. + +- `enableMainnetL1MissFallback` (`boolean`, **optional — 003 SF-1**) — consumer opt-in for mainnet-L1 + miss-fallback on **both** directions. Normalized to strict `=== true` at construction; absent, + `false`, or any other value ⇒ **OFF**. Does not relax never-silent-fallback: bound transport/gateway + failures remain terminal. Wiring `ensL1Client` alone does **not** imply opt-in. --- @@ -189,8 +200,9 @@ validation, so an input that is *both* malformed *and* on an unsupported network | 1d | No UR **and** no `ensL1Client` | `{ ok: false, error: { code: 'UNSUPPORTED_NETWORK', networkId } }` — **sync, before any I/O** | | 2 | `isValidName(name)` is `false` | `{ ok: false, error: { code: 'UNSUPPORTED_NAME', name, reason } }` | | 3 | `normalizeName(name)` throws | `{ ok: false, error: { code: 'UNSUPPORTED_NAME', name, reason } }` | -| 4a | `getEnsAddress` returns `null` (empty record) | `{ ok: false, error: { code: 'NAME_NOT_FOUND', name } }` | -| 4b | `getEnsAddress` returns an address | `{ ok: true, value: { name, address, provenance } }` (provenance = `EnsProvenance`) | +| 4a | `getEnsAddress` returns `null` (empty record) on **bound UR** branch | `{ ok: false, error: { code: 'NAME_NOT_FOUND', name } }` — **unless** opt-in ON → one L1 `getEnsAddress` (003 SF-4); L1 success spreads fallback triplet | +| 4a′ | `getEnsAddress` returns `null` on **1b** chain-scoped L1 | `{ ok: false, error: { code: 'NAME_NOT_FOUND', name } }` — terminal (not miss-fallback) | +| 4b | `getEnsAddress` returns an address | `{ ok: true, value: { name, address, provenance } }` (provenance = `EnsProvenance`; triplet absent on bound hit) | | 5 | Revert `ResolverNotFound` / `ResolverNotContract` | `{ ok: false, error: { code: 'NAME_NOT_FOUND', name } }` | | 5 | Revert `UnsupportedResolverProfile` | `{ ok: false, error: { code: 'UNSUPPORTED_NAME', name, reason } }` | | 5 | Anything else (gateway / offchain / timeout / transport / unclassifiable) | mapped by the error layer → `EXTERNAL_GATEWAY_ERROR` \| `RESOLUTION_TIMEOUT` \| `ADAPTER_ERROR` | @@ -229,19 +241,22 @@ interface ResolvedName { > `getEnsName` forward-verifies inside the Universal Resolver; mismatches fold to empty / > `ADDRESS_NOT_FOUND`. Render `rev.value.name` directly. -> **Provenance by path (002 SF-1 / D-R7).** Scope for chain-agnostic consumers is **base -> `scopedToNetworkId` only** (INV-28): +> **Provenance by path (003 SF-2 / D-R7).** Show/hide for chain-agnostic consumers uses **base +> `scopedToNetworkId` only** (INV-28). Cross-network fallback disclaimer uses the **triplet** only +> (`resolvedViaNetworkFallback`, `queriedOnNetworkId`, `resolvedOnNetworkId`) — never `isEnsProvenance` +> / `coinType` / `label`: -| Path | `scopedToNetworkId` | Typical `provenance` shape | `isEnsProvenance` | -|------|---------------------|----------------------------|-------------------| -| Bound hit on **mainnet** | **absent** | `baseEnsProvenance()` | `false` | -| Bound hit on **non-mainnet** UR chain | **present** = bound `networkId` | base provenance + `scopedToNetworkId` (internal `boundReverseProvenance`) | `false` | -| **L1** hit (miss-fallback or direct) | **absent** | `buildEnsProvenance({ coinType: 60, … })` | **`true`** | +| Path | `scopedToNetworkId` | Fallback triplet | Typical `provenance` shape | `isEnsProvenance` | +|------|---------------------|------------------|----------------------------|-------------------| +| Bound hit on **mainnet** | **absent** | absent | `baseEnsProvenance()` | `false` | +| Bound hit on **non-mainnet** UR chain | **present** = bound `networkId` | absent | base + `scopedToNetworkId` | `false` | +| **L1** after **bound-empty miss** (opt-in ON) | **absent** | **present** (complete triplet) | `buildEnsProvenance` + triplet | **`true`** | +| **L1** direct (non-UR, opt-in ON) | **absent** | **absent** (`001` 1b parity) | `buildEnsProvenance` only | **`true`** | -L1 successes carry `EnsProvenance` (`coinType: 60`, observed `external`) as **adapter-internal +L1 successes may carry `EnsProvenance` (`coinType: 60`, observed `external`) as **adapter-internal enrichment** — chain-agnostic display gates must **not** branch on `isEnsProvenance` / `coinType`. -Non-mainnet bound-local scope is a **minor behavioral change** vs `001` SF-3 (now sets -`scopedToNetworkId`); see `.changeset/ens-mainnet-l1-reverse.md`. +The fallback triplet is emitted **only** when L1 follows a real bound-empty miss on a UR-carrying +chain (`precededByBoundMiss === true`), not on non-UR direct L1. > **L1 reverse uses default primary (`coinType` 60).** L1 `getEnsName` omits `coinType` override. > Addresses with only an ENSIP-19 L2 primary and no coinType-60 primary → `ADDRESS_NOT_FOUND` after @@ -251,7 +266,7 @@ The `address` is echoed exactly as supplied. `avatarUrl` is spread conditionally absent** when no avatar was found. Avatar is fetched from the **same client** that produced the name (bound client for bound hit; L1 client for L1 hit). -**Option B ladder precedence** (002 SF-1): +**Option B ladder precedence** (003 SF-3; inherits 002; L1 tiers gated by `mayConsultL1ForMissFallback()`): | Order | Condition | Result | |------|-----------|--------| @@ -259,10 +274,10 @@ absent** when no avatar was found. Avatar is fetched from the **same client** th | 1 | `address` is not a well-formed EVM address | `{ ok: false, error: { code: 'ADDRESS_NOT_FOUND', address } }` — **sync, before I/O** | | 2a | Bound chain **has** UR → bound `getEnsName` **success** | `{ ok: true, value }` with bound provenance (D-R7 row) — **no L1** | | 2b | Bound chain has UR → bound **failure** (gateway/timeout/transport) | mapped typed error — **no L1 miss-fallback** (INV-9) | -| 2c | Bound chain has UR → bound **empty** + `ensL1Client` + not mainnet-bound | L1 `getEnsName` (one consult) | -| 2d | Bound chain has UR → bound **empty** + (no L1 or mainnet-bound) | `{ ok: false, error: { code: 'ADDRESS_NOT_FOUND', address } }` | -| 3 | No UR + `ensL1Client` + not mainnet-bound | L1 reverse direct | -| 4 | No UR and no `ensL1Client` | `{ ok: false, error: { code: 'UNSUPPORTED_NETWORK', networkId } }` — **sync, before I/O** | +| 2c | Bound chain has UR → bound **empty** + opt-in ON + `ensL1Client` + not mainnet-bound | L1 `getEnsName` (one consult) → triplet on success | +| 2d | Bound chain has UR → bound **empty** + opt-in OFF (or no L1 / mainnet-bound) | `{ ok: false, error: { code: 'ADDRESS_NOT_FOUND', address } }` | +| 3 | No UR + opt-in ON + `ensL1Client` + not mainnet-bound | L1 reverse direct — **no** fallback triplet | +| 4 | No UR and (opt-in OFF or no `ensL1Client`) | `{ ok: false, error: { code: 'UNSUPPORTED_NETWORK', networkId } }` or `ADDRESS_NOT_FOUND` per branch — **sync where applicable** | **Definitive empty** (miss-fallback-eligible on bound): `null`, `ReverseAddressMismatch`, and resolver-semantic reverts (`ResolverNotFound`, `ResolverNotContract`, `ResolverError`, @@ -274,7 +289,7 @@ No third client after L1. Gates 1 and 4 run **before any network round-trip**. Every `getEnsName` uses `strict: true`. Reverse I/O uses `deriveObservingClient` for truthful `viaGateway` on errors. -See [Integration Guide — Pattern 8](./integration-guide.md#pattern-8-reverse-miss-fallback-ladder--chain-agnostic-scope-gate-002-sf-1) and [Error surface](#error-surface). +See [Integration Guide — Pattern 10](./integration-guide.md#pattern-10-forward-miss-fallback-on-ur-bound-chains-003-sf-4) and [Pattern 9](./integration-guide.md#pattern-9-cross-network-fallback-provenance-003-sf-2) and [Error surface](#error-surface). ### `service.dispose()` @@ -295,7 +310,8 @@ early-returns on a disposed capability). function createEvmNameResolutionService( networkConfig: TypedEvmNetworkConfig, publicClient: PublicClient, - ensL1Client?: PublicClient, // SF-5, optional + ensL1Client?: PublicClient, // SF-5 + 003, optional + options?: { enableMainnetL1MissFallback?: boolean }, // 003 SF-1, default OFF ): EvmNameResolutionService ``` @@ -379,6 +395,114 @@ bound-local reverse hits use the same base shape with `scopedToNetworkId` set (m --- +## Cross-network fallback provenance (003 SF-2) + +The **public contract** for L1 miss-fallback successes lives on base `ResolutionProvenance` in +`@openzeppelin/ui-types@3.3.0` — three **additive optional readonly** fields. The adapter **emits** +them; it does not invent alternate names or adapter-only encodings. + +### `ResolutionProvenance` fallback fields (`@openzeppelin/ui-types`) + +```ts +interface ResolutionProvenance { + readonly label: string; + readonly external: boolean; + readonly scopedToNetworkId?: string; + + /** `true` iff success followed bound-tier definitive miss → L1 consult (opt-in ON). */ + readonly resolvedViaNetworkFallback?: boolean; + + /** Network where the record was found — always `ethereum-mainnet` for 003 L1 fallback. */ + readonly resolvedOnNetworkId?: string; + + /** Bound adapter network that missed first — repo `networkConfig.id` slug. */ + readonly queriedOnNetworkId?: string; +} +``` + +**Triplet integrity** (runtime invariant): + +| `resolvedViaNetworkFallback` | `queriedOnNetworkId` | `resolvedOnNetworkId` | Valid? | +|---|---|---|---| +| `true` | non-empty string | non-empty string | **Yes** — canonical fallback | +| `true` | absent / empty | any | **No** | +| `undefined` / `false` | absent | absent | **Yes** — non-fallback paths | + +**Emission rule (finalized):** spread the triplet **only** when all hold: + +1. `enableMainnetL1MissFallback === true` (SF-1 gate), +2. Success came from `ensL1Client` after a **definitive bound-tier empty** / `NAME_NOT_FOUND` on a + UR-carrying chain (`precededByBoundMiss === true`), +3. Adapter is not mainnet-bound. + +**Excluded paths (triplet absent):** bound-local hits; mainnet-bound hits; non-UR direct L1 reverse; +forward `001` SF-5 branch 1b chain-scoped L1; bound gateway/transport failures (no L1 consult). + +On L1 miss-fallback successes, `scopedToNetworkId` is **absent** (D-R7) — triplet carries bound-miss +context; scope gate and fallback disclaimer are **orthogonal**. + +**Golden example (Sepolia bound-empty → L1 reverse success):** + +```ts +{ + label: 'ENS', + external: false, + // scopedToNetworkId intentionally absent + resolvedViaNetworkFallback: true, + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: 'ethereum-mainnet', + // adapter-only (do NOT branch disclaimer on these): + system: 'ens', + coinType: 60, +} +``` + +Chain-agnostic classification: `resolvedViaNetworkFallback === true` only — **never** infer from +absent `scopedToNetworkId`, `label`, or `external`. UIKit uses `@openzeppelin/ui-utils` +`isCrossNetworkFallback` / `getFallbackNetworks`. + +### `MAINNET_NETWORK_ID` (003 SF-2) + +```ts +const MAINNET_NETWORK_ID: 'ethereum-mainnet'; +``` + +Canonical mainnet repo network id — matches `packages/adapter-evm/src/networks/mainnet.ts`. Used as +`resolvedOnNetworkId` on every 003 L1 miss-fallback success. + +### `networkFallbackProvenanceFields(args)` + +```ts +function networkFallbackProvenanceFields(args: { + readonly queriedOnNetworkId: string; + readonly resolvedOnNetworkId: string; +}): Pick< + ResolutionProvenance, + 'resolvedViaNetworkFallback' | 'queriedOnNetworkId' | 'resolvedOnNetworkId' +>; +``` + +Single DRY builder for the triplet. Sets `resolvedViaNetworkFallback: true` atomically with both ids. +Fresh object per call. Does **not** set `label`, `external`, or `scopedToNetworkId`. + +### `composeNetworkFallbackProvenance(provenance, args)` + +```ts +function composeNetworkFallbackProvenance( + provenance: ResolutionProvenance, + args: { + readonly queriedOnNetworkId: string; + readonly resolvedOnNetworkId: string; + }, +): ResolutionProvenance; +``` + +Spreads {@link networkFallbackProvenanceFields} onto an existing success provenance (reverse L1 after +bound miss; forward SF-4 L1 after bound `NAME_NOT_FOUND`). Preserves underlying `label` / `external` / +`scopedToNetworkId`. + +--- + ## ENS v2 provenance surface (SF-5) The v2 layer adds one exported **type** (`EnsProvenance`), one **type guard** (`isEnsProvenance`), diff --git a/packages/adapter-evm-core/docs/name-resolution/examples/README.md b/packages/adapter-evm-core/docs/name-resolution/examples/README.md index 521dcd3..80f9612 100644 --- a/packages/adapter-evm-core/docs/name-resolution/examples/README.md +++ b/packages/adapter-evm-core/docs/name-resolution/examples/README.md @@ -6,11 +6,11 @@ Copy-paste-adaptable examples for the EVM name-resolution capability. |---------|---------------| | [`forward-resolve/`](./forward-resolve) | End-to-end name → address against a real viem mainnet client: feature-detect, sync `isValidName` pre-check, async `resolveName`, and the error-code switch. | | [`reverse-resolve/`](./reverse-resolve) | End-to-end address → verified name (+ avatar) against a real viem mainnet client: feature-detect, async `resolveAddress`, and the error-code switch. Highlights the always-verified name and the best-effort, untrusted avatar. | -| [`reverse-miss-fallback/`](./reverse-miss-fallback) | **002 SF-1** mocked Sepolia miss-fallback: bound empty → L1 primary, chain-agnostic `scopedToNetworkId` gate, `isEnsProvenance` enrichment note. | +| [`reverse-miss-fallback/`](./reverse-miss-fallback) | **003** mocked Sepolia bound-empty → L1: opt-in, fallback triplet, scope gate. | | [`ens-v2-resolve/`](./ens-v2-resolve) | ENS v2 against a real viem mainnet client: narrowing the result's `provenance` with `isEnsProvenance`, reading the **observed** `external` (CCIP-Read traversal) and `coinType`, and — with the optional `ensL1Client` wired — L1 cross-chain resolution carrying `scopedToNetworkId`. | For registration into a runtime and the mocked-client test pattern, see the [Integration Guide](../integration-guide.md). > These examples cover all four delivered slices: forward (SF-2), reverse + avatar (SF-3), ENS v2 -> (SF-5), and mainnet-L1 reverse miss-fallback (002 SF-1). +> (SF-5), and 003 mainnet-L1 opt-in miss-fallback (SF-3 reverse + SF-4 forward). diff --git a/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/README.md b/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/README.md index 2dccc7e..b521d71 100644 --- a/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/README.md +++ b/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/README.md @@ -1,29 +1,16 @@ -# Example: reverse miss-fallback + scope gate (002 SF-1) +# Example: reverse miss-fallback + fallback triplet (003 SF-3 / SF-2) -Mocked **Sepolia-bound** reverse resolution: bound `getEnsName` returns empty, gated mainnet L1 -returns `vitalik.eth`. No live RPC required. +Mocked **Sepolia-bound** reverse: bound empty → L1 primary with **`enableMainnetL1MissFallback: true`**. ## Run ```bash -# from this directory pnpm tsx resolve-miss-fallback.ts ``` -Expected highlights: - -- One L1 `getEnsName` call after bound empty (miss-fallback). -- `scopedToNetworkId` **absent** → global / mainnet identity (chain-agnostic gate shows on any row). -- `isEnsProvenance === true` with `coinType: 60` → adapter-internal enrichment, **not** the gate. - -## What to copy - -1. **Scope gate** — `visibleOnRow(provenance.scopedToNetworkId, rowNetworkId)` using base field only. -2. **Do not** branch display safety on `isEnsProvenance` / `coinType` (Principle II / INV-28). -3. **Bound failure ≠ L1** — if bound `getEnsName` throws a transport error, the adapter returns a - typed error and L1 is never called (not demonstrated here; see Integration Guide Pattern 8). +Expected: complete fallback triplet (`resolvedViaNetworkFallback`, `queriedOnNetworkId`, +`resolvedOnNetworkId`), absent `scopedToNetworkId`, one L1 call. ## See also -- [Integration Guide — Pattern 8](../../integration-guide.md#pattern-8-reverse-miss-fallback-ladder--chain-agnostic-scope-gate-002-sf-1) -- [reverse-resolve](../reverse-resolve/) — live mainnet-bound reverse example +- [Integration Guide — Pattern 9](../../integration-guide.md#pattern-9-cross-network-fallback-provenance-003-sf-2) diff --git a/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/resolve-miss-fallback.ts b/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/resolve-miss-fallback.ts index 0cfe1d4..3c1f92e 100644 --- a/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/resolve-miss-fallback.ts +++ b/packages/adapter-evm-core/docs/name-resolution/examples/reverse-miss-fallback/resolve-miss-fallback.ts @@ -1,10 +1,5 @@ /** - * Mocked 002 SF-1 reverse miss-fallback: Sepolia-bound, empty bound reverse, mainnet L1 primary. - * - * Demonstrates: - * - Option B ladder (bound first → definitive empty → L1 consult) - * - Chain-agnostic scope gate via base `scopedToNetworkId` only - * - `isEnsProvenance === true` on L1 hit is enrichment, NOT the display gate + * Mocked 003 SF-3 reverse miss-fallback + SF-2 triplet: Sepolia bound-empty → L1 primary. * * Run (from this directory): * pnpm tsx resolve-miss-fallback.ts @@ -12,6 +7,7 @@ import type { PublicClient } from 'viem'; import { createEvmNameResolutionService, isEnsProvenance } from '@openzeppelin/adapter-evm-core'; +import type { ResolutionProvenance } from '@openzeppelin/ui-types'; const VITALIK = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; @@ -20,7 +16,12 @@ const sepoliaConfig = { chainId: 11155111, } as never; -/** Chain-agnostic gate — safe without EVM imports in UIKit. */ +function isCrossNetworkFallback( + provenance: Pick +): boolean { + return provenance.resolvedViaNetworkFallback === true; +} + function visibleOnRow(scopedToNetworkId: string | undefined, rowNetworkId: string): boolean { if (scopedToNetworkId === undefined) return true; return scopedToNetworkId === rowNetworkId; @@ -54,7 +55,9 @@ async function main(): Promise { getEnsAvatar: async () => 'https://example.com/avatar.png', } as unknown as PublicClient; - const service = createEvmNameResolutionService(sepoliaConfig, boundClient, l1Client); + const service = createEvmNameResolutionService(sepoliaConfig, boundClient, l1Client, { + enableMainnetL1MissFallback: true, + }); const result = await service.resolveAddress(VITALIK); if (!result.ok) { @@ -65,11 +68,12 @@ async function main(): Promise { const { name, provenance } = result.value; console.log(`Sepolia miss-fallback: ${VITALIK} → ${name}`); console.log(` L1 getEnsName calls: ${l1Calls} (expect 1)`); - console.log(` scopedToNetworkId: ${provenance.scopedToNetworkId ?? '(absent — global)'}`); + console.log(` scopedToNetworkId: ${provenance.scopedToNetworkId ?? '(absent — global gate)'}`); + console.log(` resolvedViaNetworkFallback: ${provenance.resolvedViaNetworkFallback}`); + console.log(` queriedOnNetworkId: ${provenance.queriedOnNetworkId}`); + console.log(` resolvedOnNetworkId: ${provenance.resolvedOnNetworkId}`); + console.log(` isCrossNetworkFallback: ${isCrossNetworkFallback(provenance)}`); console.log(` isEnsProvenance: ${isEnsProvenance(provenance)} (enrichment only)`); - if (isEnsProvenance(provenance)) { - console.log(` coinType: ${provenance.coinType} (do NOT use for display gate)`); - } console.log(` show on Base row: ${visibleOnRow(provenance.scopedToNetworkId, 'base-mainnet')}`); console.log( ` show on Sepolia row: ${visibleOnRow(provenance.scopedToNetworkId, 'ethereum-sepolia')}` diff --git a/packages/adapter-evm-core/docs/name-resolution/integration-guide.md b/packages/adapter-evm-core/docs/name-resolution/integration-guide.md index 3aad82b..0c2bbb6 100644 --- a/packages/adapter-evm-core/docs/name-resolution/integration-guide.md +++ b/packages/adapter-evm-core/docs/name-resolution/integration-guide.md @@ -1,17 +1,17 @@ -# Integration Guide — ENS Name Resolution (forward SF-2 · reverse SF-3 · ENS v2 SF-5 · L1 reverse SF-1) +# Integration Guide — ENS Name Resolution (forward SF-2 · reverse SF-3 · ENS v2 SF-5 · L1 miss-fallback 003) How to wire and use the EVM name-resolution capability. A few patterns cover almost every consumer: **register** the capability into an EVM runtime, **call** forward resolution (name → address) from a consumer, **call** reverse resolution (address → name, for display), **read ENS v2 -provenance** (SF-5), **wire the optional L1 cross-chain client**, **apply the reverse miss-fallback -ladder and chain-agnostic scope gate** (002 SF-1), and **test** against a mocked client. +provenance** (SF-5), **wire the optional L1 cross-chain client**, **opt in to mainnet-L1 +miss-fallback** (003 SF-1), **apply the reverse miss-fallback ladder and chain-agnostic scope gate** +(003 SF-3), **read cross-network fallback provenance** (003 SF-2), **forward UR bound miss-fallback** +(003 SF-4), and **test** against a mocked client. -> Scope: all four delivered slices — forward (SF-2), reverse + avatar (SF-3), ENS v2 (SF-5: -> `EnsProvenance` on every forward result, observed `external`, the optional `ensL1Client` for L1 -> cross-chain resolution, and `coinType` / `scopedToNetworkId` scoping), and mainnet-L1 reverse -> miss-fallback (002 SF-1: bound-first, empty-only L1 consult, provenance scope via base -> `scopedToNetworkId`). Registration (Pattern 1) is shared — one capability instance, one bound -> client plus an optional L1 client, serves `resolveName` and `resolveAddress` alike. +> Scope: forward (SF-2), reverse + avatar (SF-3), ENS v2 (SF-5), and 003 mainnet-L1 opt-in +> miss-fallback (SF-1 gate, SF-2 triplet, SF-3 reverse + SF-4 forward ladders). Registration +> (Pattern 1) is shared — one capability instance, one bound client, optional L1 client, and an +> explicit opt-in flag serve `resolveName` and `resolveAddress` alike. --- @@ -49,7 +49,10 @@ nameResolution: (config: NetworkConfig) => { const typed = toTypedEvmNetworkConfig(config); return createNameResolution(typed, { publicClient: ensClient(typed), // bound client (D-A) — unchanged - ensL1Client: ensL1Client(typed), // SF-5 — enables the L1 cross-chain path + ensL1Client: ensL1Client(typed), // SF-5 + 003 — enables L1 paths when eligible + // enableMainnetL1MissFallback: omitted → default OFF (003 SF-1 fund-safety). + // UIKit / dapp integrators opt in explicitly when accepting cross-network namespace risk: + // enableMainnetL1MissFallback: true, }); }, ``` @@ -59,15 +62,18 @@ Notes: - For ENS-supporting networks (mainnet and most L1/L2s viem ships), `config.viemChain` carries `contracts.ensUniversalResolver`, so the **bound** client resolves mainnet-bound (`coinType` 60). - For a network whose bound chain has **no** Universal Resolver (an L2), the wired `ensL1Client` - lets `resolveName` resolve the name **chain-scoped on L1** (`coinType = toCoinType(boundChainId)`), - and the success provenance carries `scopedToNetworkId`. It also enables **reverse** miss-fallback - and direct L1 reverse (002 SF-1) — same injection, no second config knob. Omit `ensL1Client` and - that same L2-bound forward resolve returns `UNSUPPORTED_NETWORK`; reverse on non-UR chains likewise - returns `UNSUPPORTED_NETWORK` before I/O. -- Wiring `ensL1Client` is a one-time cost per capability instance (a borrowed client). Forward: the L1 - hop only happens when the bound chain has no UR. Reverse: L1 is consulted after a **definitive bound - empty** on UR-carrying non-mainnet chains, or directly when the bound chain has no UR — never on bound - gateway/transport failure, and never redundantly on mainnet-bound reverse. + lets `resolveName` resolve the name **chain-scoped on L1** (`coinType = toCoinType(boundChainId)`) + — the canonical `001` 1b path (**no** fallback triplet). With **`enableMainnetL1MissFallback: true`**, + it also enables UR-bound miss-fallback on both directions. Omit `ensL1Client` and that same L2-bound + forward resolve returns `UNSUPPORTED_NETWORK`; reverse on non-UR chains likewise returns + `UNSUPPORTED_NETWORK` before I/O. +- **`enableMainnetL1MissFallback` defaults OFF.** Shared runtime profiles (`adapter-evm/profiles/shared.ts`) + wire `ensL1Client` but do **not** pass `enableMainnetL1MissFallback: true` — safe posture preserved. + Opt-in is a separate, explicit integrator decision (UIKit sibling wires the UI affordance). +- Wiring `ensL1Client` is a one-time cost per capability instance (a borrowed client). Forward 1b: L1 + hop when bound chain has no UR. Miss-fallback: L1 only after definitive bound empty / `NAME_NOT_FOUND` + on UR-carrying non-mainnet chains when opted in — never on bound gateway/transport failure, never + redundantly on mainnet-bound. - No cleanup registration is needed — a viem `http` client holds no handle requiring teardown, and the capability borrows (does not own) **either** client. - Both v1 and v2 forward resolution rely on viem's **default** CCIP-Read handling (the Universal @@ -158,13 +164,13 @@ async function displayForAddress( const result = await cap.resolveAddress(address); if (result.ok) { // result.value.name is ALREADY forward-verified (forwardVerified === true). Render as-is. - // Chain-agnostic scope gate (002 SF-1) — see Pattern 8 before showing on a network-scoped row. + // Chain-agnostic scope gate (003 SF-3) — see Pattern 8; triplet disclaimer Pattern 9. return { kind: 'name', name: result.value.name, avatarUrl: result.value.avatarUrl }; } // Switch on the CODE. ADDRESS_NOT_FOUND = no verified name for display. // RESOLUTION_TIMEOUT / EXTERNAL_GATEWAY_ERROR = infrastructure failure — NOT an empty record; - // on UR-carrying chains the adapter did NOT fall through to L1 (002 SF-1 INV-9). + // on UR-carrying chains the adapter did NOT fall through to L1 (003 SF-3 INV-9). switch (result.error.code) { case 'ADDRESS_NOT_FOUND': return { kind: 'hex' }; case 'UNSUPPORTED_NETWORK': return { kind: 'hex' }; @@ -412,107 +418,121 @@ network-bound `resolveName`; the UI wires the active-network capability and trig --- -## Pattern 8: Reverse miss-fallback ladder + chain-agnostic scope gate (002 SF-1) +## Pattern 8: Reverse miss-fallback ladder + scope gate (003 SF-3) -On non-mainnet-bound adapters with the standard `ensL1Client` injection (Pattern 1), `resolveAddress` -follows an **Option B miss-fallback ladder**. Forward asymmetry is preserved: `resolveName` does **not** -gain miss-fallback. +Requires `ensL1Client` (Pattern 1) **and** `enableMainnetL1MissFallback: true` (003 SF-1). With opt-in +**OFF** (default), bound-empty on UR chains returns `ADDRESS_NOT_FOUND` without L1 — pre-002 safe +semantics (`SC-001`). -### Ladder (behavioral) +| Bound network | Opt-in | What happens | +|---------------|--------|--------------| +| Mainnet (UR) | any | Bound only — no L1 | +| UR non-mainnet (e.g. Sepolia) | **ON** | Bound first → **definitive empty** → one L1 consult + **triplet**; bound **failure** → typed error, **no L1** | +| UR non-mainnet | **OFF** | Bound only; empty → `ADDRESS_NOT_FOUND` | +| No UR (e.g. Base) | **ON** | L1 direct — **no** fallback triplet (`001` 1b parity; not `precededByBoundMiss`) | -| Bound network | `ensL1Client` | What happens | -|---------------|---------------|--------------| -| Mainnet (has UR) | any | Bound reverse only — no L1 hop, even on empty | -| UR-carrying non-mainnet (e.g. Sepolia) | wired | Bound reverse first → name hit: stop with bound-local provenance → bound **empty**: one L1 consult → bound **failure** (timeout/gateway): typed error, **no L1** | -| No UR (e.g. Base) | wired | L1 reverse direct (default primary, `coinType` 60) | -| No UR | absent | `UNSUPPORTED_NETWORK` (sync, before I/O) | +**Never-silent-fallback:** only definitive empty (`null`, Approach A mismatch, resolver-semantic reverts) +is L1-eligible. Bound gateway/timeout → `RESOLUTION_TIMEOUT` / `EXTERNAL_GATEWAY_ERROR` — never L1. -**Miss-fallback discipline:** L1 fires **only** on a definitive empty/no-record from bound reverse -(`null`, Approach A mismatch, resolver-semantic reverts). Gateway/transport/timeout on bound reverse -returns `RESOLUTION_TIMEOUT` or `EXTERNAL_GATEWAY_ERROR` and **never** consults L1. This is -**distinct from** forward's single-call / no v2→v1 error-fallback rule (SF-5). - -### Provenance three-row contract (D-R7 / INV-28) - -Scope is keyed on **where the name resolved**, via base `scopedToNetworkId` **only**: - -| Path | `scopedToNetworkId` | `isEnsProvenance` | Meaning for display gate | -|------|---------------------|-------------------|--------------------------| -| Bound hit on **mainnet** | **absent** | `false` | Global / mainnet identity — show on any row | -| Bound hit on **non-mainnet** UR chain (e.g. Sepolia local primary) | **present** = bound `networkId` | `false` | Network-local — hide on rows for other networks | -| **L1** hit (miss-fallback or direct) | **absent** | **`true`** (`coinType: 60`) | Global / mainnet identity — show on any row | - -**Principle II (chain-agnostic consumers):** the UIKit `AddressNameResolutionProvider` gate (and any -chain-agnostic code) **MUST** branch on `scopedToNetworkId` only — **MUST NOT** import -`isEnsProvenance`, inspect `coinType`, or narrow to `EnsProvenance` for display safety. EVM-aware -callers may read `coinType` / `external` after narrowing as enrichment only. +Show/hide: base `scopedToNetworkId` only. Cross-network disclaimer: Pattern 9 triplet only. ```ts import type { ResolutionProvenance } from '@openzeppelin/ui-types'; -/** Chain-agnostic gate — safe in UIKit without EVM imports. */ -function reverseNameVisibleOnRow( - provenance: ResolutionProvenance, - rowNetworkId: string, -): boolean { +function reverseNameVisibleOnRow(provenance: ResolutionProvenance, rowNetworkId: string): boolean { const scope = provenance.scopedToNetworkId; - // Absent scope ⇒ global / mainnet identity — show anywhere. if (scope === undefined) return true; - // Present ⇒ network-local primary — show only on matching row. return scope === rowNetworkId; } +``` -// Usage after resolveAddress success: -if (rev.ok && reverseNameVisibleOnRow(rev.value.provenance, activeNetworkId)) { - display(rev.value.name); -} +### Mocked snippet (Sepolia bound-empty → L1) + +```ts +const service = createEvmNameResolutionService( + { id: 'ethereum-sepolia', chainId: 11155111 } as never, + boundClient, + l1Client, + { enableMainnetL1MissFallback: true }, +); +// Success: scopedToNetworkId absent; triplet present — see Pattern 9 ``` -**`isEnsProvenance(reverse)` on L1 hits:** after a Sepolia miss-fallback to mainnet L1, the success -provenance is an `EnsProvenance` (`system: 'ens'`, `coinType: 60`, observed `external`) with **absent** -`scopedToNetworkId`. That is intentional adapter-internal enrichment — **not** the scope signal. A -Sepolia-local bound hit has `scopedToNetworkId === 'ethereum-sepolia'` (or your bound id) and `isEnsProvenance === -false`. Do not teach "`isEnsProvenance` ⇒ global name" — use `scopedToNetworkId` absence instead. +See [`examples/reverse-miss-fallback/`](./examples/reverse-miss-fallback/). + +--- -### Default-primary vs ENSIP-19 L2-primary (UX caveat) +## Pattern 9: Cross-network fallback provenance (003 SF-2) -L1 reverse uses viem's default `coinType` 60 (Ethereum mainnet primary). It does **not** pass -`toCoinType(boundChainId)`. An address whose only reverse record is an ENSIP-19 **L2-scoped** primary -(with no coinType-60 primary) returns `ADDRESS_NOT_FOUND` after the L1 attempt — not the L2 name. -Product copy should treat that as "no ENS display name," not a resolution bug. ENSIP-19 L2-primary -reverse is deferred product scope. +The triplet lives on base `ResolutionProvenance` in `@openzeppelin/ui-types@3.3.0` — coordinated +cross-repo contract, not adapter-invented fields: -### Mocked test snippet (Sepolia miss-fallback) +- `resolvedViaNetworkFallback: true` +- `queriedOnNetworkId` — bound `networkConfig.id` that missed first (e.g. `ethereum-sepolia`) +- `resolvedOnNetworkId` — always `ethereum-mainnet` for 003 L1 fallback + +**Emission rule (finalized):** the adapter spreads the triplet **only** when **all** hold: + +1. `enableMainnetL1MissFallback === true`, +2. L1 success followed a **real bound-empty miss** on a UR-carrying chain (`precededByBoundMiss`), +3. Adapter is not mainnet-bound. + +**Triplet absent:** bound-local hits; mainnet-bound hits; **non-UR direct L1** reverse/forward +(`001` SF-5 branch 1b); bound gateway/timeout failures. On L1 miss-fallback successes, +`scopedToNetworkId` stays **absent** (D-R7) — triplet carries cross-network context; scope gate and +disclaimer are orthogonal. ```ts -import { createEvmNameResolutionService } from '@openzeppelin/adapter-evm-core'; -import type { PublicClient } from 'viem'; +import type { ResolutionProvenance } from '@openzeppelin/ui-types'; -const boundClient = { - chain: { id: 11155111, contracts: { ensUniversalResolver: { address: '0xeeee…' } } }, - getEnsName: async () => null, // definitive empty on Sepolia - getEnsAvatar: async () => null, -} as unknown as PublicClient; +function isCrossNetworkFallback( + provenance: Pick, +): boolean { + return provenance.resolvedViaNetworkFallback === true; +} -const l1Client = { - chain: { id: 1, contracts: { ensUniversalResolver: { address: '0x…' } } }, - getEnsName: async () => 'vitalik.eth', - getEnsAvatar: async () => 'https://example.com/avatar.png', -} as unknown as PublicClient; +// After resolveAddress / resolveName success on UR bound-empty → L1: +if (isCrossNetworkFallback(provenance)) { + // disclaimer only — do NOT use triplet for show/hide (INV-25) + const queried = provenance.queriedOnNetworkId; // bound network that missed + const resolved = provenance.resolvedOnNetworkId; // ethereum-mainnet +} +``` -const service = createEvmNameResolutionService( - { id: 'ethereum-sepolia', chainId: 11155111 /* … */ } as never, - boundClient, - l1Client, -); +UIKit: prefer `@openzeppelin/ui-utils` `isCrossNetworkFallback` / `getFallbackNetworks`. **Never** +infer fallback from absent `scopedToNetworkId`, `label`, or `external`. + +--- + +## Pattern 10: Forward miss-fallback on UR bound chains (003 SF-4) + +On UR-carrying **non-mainnet** bound chains, `resolveName` is bound-UR-authoritative when opt-in is +**OFF** (default): bound `NAME_NOT_FOUND` is terminal — no L1 call (`001` SF-5 fund-safety). With +`enableMainnetL1MissFallback: true` + wired `ensL1Client`: -const result = await service.resolveAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); -// result.ok === true; result.value.name === 'vitalik.eth' -// result.value.provenance.scopedToNetworkId === undefined (global — gate shows on any row) -// isEnsProvenance(result.value.provenance) === true (enrichment only — not the gate) +1. Bound `resolveVia` runs first (`coinType` 60 on bound client). +2. Only **definitive** `NAME_NOT_FOUND` (empty record / resolver-semantic no-record, not gateway or + `UNSUPPORTED_NAME`) triggers **one** L1 `resolveVia(ensL1Client, ETH_COIN_TYPE)`. +3. L1 success spreads SF-2 triplet (`precededByBoundMiss` equivalent on forward). +4. L1 empty or failure is terminal — no bound retry. + +**Excluded from SF-4 (no forward miss-fallback, no triplet):** non-UR `001` 1b chain-scoped L1; +mainnet-bound; bound hit; bound gateway/timeout/`UNSUPPORTED_NAME`. + +```ts +const cap = createNameResolution(sepoliaConfig, { + publicClient: boundClient, + ensL1Client: l1Client, + enableMainnetL1MissFallback: true, +}); + +const result = await cap.resolveName('vitalik.eth'); +if (result.ok && result.value.provenance.resolvedViaNetworkFallback === true) { + // mainnet address after Sepolia bound miss — triplet carries queried/resolved network ids +} ``` -See [`examples/reverse-miss-fallback/`](./examples/reverse-miss-fallback/) for a runnable mock. +Forward and reverse share the same opt-in flag and the same triplet emission rule (Pattern 9). --- @@ -545,11 +565,14 @@ See [`examples/reverse-miss-fallback/`](./examples/reverse-miss-fallback/) for a **not** map it to a mechanism name — that interpretation is the consumer's (UIKit SF-6). - **Treating a `scopedToNetworkId` address as a mainnet address.** When the key is present, the address is scoped to that network; binding it to mainnet (or another chain) is a fund-safety bug. -- **Calling `isEnsProvenance` on a reverse result and using it as the scope gate.** L1 miss-fallback - successes may return `true` — that marks adapter-internal ENS enrichment (`coinType: 60`), not - "show on every row." Chain-agnostic gates use **`scopedToNetworkId` absent vs present** only - (Pattern 8). Sepolia-local names are `isEnsProvenance === false` but **scoped** — gating on - `isEnsProvenance` would wrongly show them globally. +- **Expecting miss-fallback without opt-in.** Wiring `ensL1Client` does not enable L1 consult after + bound-empty — pass `enableMainnetL1MissFallback: true` explicitly (Pattern 1 / 003 SF-1). +- **Expecting the fallback triplet on non-UR direct L1.** Canonical `001` 1b paths omit the triplet; + only UR bound-empty → L1 emits it (Pattern 9). +- **Using the fallback triplet for show/hide.** `scopedToNetworkId` gates display; triplet drives + disclaimer copy only (`resolvedViaNetworkFallback === true`). +- **Inferring fallback from absent `scopedToNetworkId`.** Mainnet-bound and L1-direct hits also lack + scope — classify fallback only via `resolvedViaNetworkFallback`. - **Assuming bound reverse timeout/gateway failure will try L1.** It will not (002 SF-1 INV-9). Surface `RESOLUTION_TIMEOUT` / `EXTERNAL_GATEWAY_ERROR` — do not treat it like `ADDRESS_NOT_FOUND`. - **Expecting ENSIP-19 L2 primary on L1 reverse.** Only coinType-60 default primary is returned; L2-only @@ -557,9 +580,10 @@ See [`examples/reverse-miss-fallback/`](./examples/reverse-miss-fallback/) for a - **Calling `isEnsProvenance` on a reverse result and expecting always `false`.** Bound hits: `false`. L1 miss-fallback/direct hits: **`true`** — enrichment only; scope still comes from absent `scopedToNetworkId`. -- **Expecting a silent fallback to a v1/on-chain result when a v2 gateway fails.** There is none — - a CCIP-Read failure is `EXTERNAL_GATEWAY_ERROR`, distinct from `NAME_NOT_FOUND`. Don't write code - that assumes a stale address comes back on gateway error. +- **Expecting forward bound miss-fallback without opt-in.** On UR chains, bound `NAME_NOT_FOUND` is + terminal when `enableMainnetL1MissFallback` is absent/false (003 SF-4 / `001` SF-5 default). +- **Expecting L1 forward consult after bound gateway error.** Only `NAME_NOT_FOUND` is eligible — + `EXTERNAL_GATEWAY_ERROR` / `RESOLUTION_TIMEOUT` are terminal (never-silent-fallback). - **Re-verifying, or distrusting, a reverse-resolved name.** `resolveAddress` already forward-verified it — `forwardVerified` is always `true` on a success. Do not build your own "is this name really theirs?" check; render `result.value.name` directly. Equally, do not treat @@ -595,4 +619,6 @@ See [`examples/reverse-miss-fallback/`](./examples/reverse-miss-fallback/) for a - [`examples/reverse-miss-fallback`](./examples/reverse-miss-fallback) — mocked Sepolia miss-fallback, scope gate, and bound-failure-no-L1 discipline (002 SF-1). - Pattern 7 (above) — re-resolve when provenance scope and the active network diverge. -- Pattern 8 (above) — reverse miss-fallback ladder + chain-agnostic scope gate. +- Pattern 8 (above) — reverse miss-fallback ladder + scope gate (opt-in gated). +- Pattern 9 (above) — cross-network fallback provenance triplet + emission rule. +- Pattern 10 (above) — forward UR bound miss-fallback (opt-in gated). diff --git a/packages/adapter-evm-core/package.json b/packages/adapter-evm-core/package.json index 4ab9356..5a7b113 100644 --- a/packages/adapter-evm-core/package.json +++ b/packages/adapter-evm-core/package.json @@ -42,7 +42,7 @@ "peerDependencies": { "@openzeppelin/ui-components": "^2.0.0", "@openzeppelin/ui-react": "^2.0.1", - "@openzeppelin/ui-types": "^3.2.0", + "@openzeppelin/ui-types": "^3.3.0", "@openzeppelin/ui-utils": "^2.0.0", "@rainbow-me/rainbowkit": "^2.0.0", "lucide-react": "^0.468.0", @@ -66,7 +66,7 @@ "devDependencies": { "@openzeppelin/ui-components": "^2.0.0", "@openzeppelin/ui-react": "^2.0.1", - "@openzeppelin/ui-types": "^3.2.0", + "@openzeppelin/ui-types": "^3.3.0", "@openzeppelin/ui-utils": "^2.0.0", "@rainbow-me/rainbowkit": "^2.0.0", "@types/lodash-es": "^4.17.12", diff --git a/packages/adapter-evm-core/src/capabilities/name-resolution.ts b/packages/adapter-evm-core/src/capabilities/name-resolution.ts index a706c43..563bcef 100644 --- a/packages/adapter-evm-core/src/capabilities/name-resolution.ts +++ b/packages/adapter-evm-core/src/capabilities/name-resolution.ts @@ -22,13 +22,25 @@ export interface CreateNameResolutionOptions { readonly publicClient: PublicClient; /** - * SF-5 — NEW, OPTIONAL. A dedicated **mainnet** viem client, used ONLY when the bound network has - * no Universal Resolver, to resolve an ENS name chain-scoped to the bound network via L1 - * (`coinType = toCoinType(boundChainId)`, D-V1). Also borrowed, never disposed (INV-21). When - * absent, an L2-bound `resolveName` returns `UNSUPPORTED_NETWORK` exactly as SF-2 does today - * (D-B preserved) — so wiring it is purely additive. + * SF-5 — OPTIONAL. A dedicated **mainnet** viem client, used for: + * - `001` SF-5 non-UR forward chain-scoped resolution (`coinType = toCoinType(boundChainId)`) + * - L1 miss-fallback on reverse (002) and forward (SF-4) **only when** + * {@link enableMainnetL1MissFallback} is explicitly `true` + * + * Also borrowed, never disposed (INV-21). Wiring `ensL1Client` does **not** imply opt-in. + * Default miss-fallback posture remains OFF (003 SF-1). */ readonly ensL1Client?: PublicClient; + + /** + * SF-1 (003) — OPTIONAL. When `true`, permits mainnet-L1 miss-fallback after a **definitive** + * bound-chain empty / NAME_NOT_FOUND-class miss on **both** `resolveAddress` and `resolveName` + * (UR-carrying bound chains). When absent or `false` (default), preserves safe posture: reverse + * does not consult L1 on bound empty; forward stays bound-UR-authoritative on bound miss. + * + * Does not relax never-silent-fallback — transport/gateway/timeout failures remain terminal. + */ + readonly enableMainnetL1MissFallback?: boolean; } /** @@ -53,7 +65,8 @@ export function createNameResolution( const service = createEvmNameResolutionService( networkConfig, options.publicClient, - options.ensL1Client + options.ensL1Client, + { enableMainnetL1MissFallback: options.enableMainnetL1MissFallback } ); return guardRuntimeCapability( diff --git a/packages/adapter-evm-core/src/index.ts b/packages/adapter-evm-core/src/index.ts index e47c333..6daf4c8 100644 --- a/packages/adapter-evm-core/src/index.ts +++ b/packages/adapter-evm-core/src/index.ts @@ -371,8 +371,13 @@ export { isValidName, normalizeName, baseEnsProvenance, + boundReverseProvenance, + composeNetworkFallbackProvenance, + MAINNET_NETWORK_ID, + networkFallbackProvenanceFields, EvmNameResolutionService, createEvmNameResolutionService, + type CreateEvmNameResolutionServiceOptions, // SF-5 ENS v2: the EnsProvenance extension type + guard + builders (exported for downstream // narrowing via `isEnsProvenance` — SC-005 — without pulling in the service). isEnsProvenance, diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/fixtures.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/fixtures.ts index 2e48edb..6597ce1 100644 --- a/packages/adapter-evm-core/src/name-resolution/__tests__/fixtures.ts +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/fixtures.ts @@ -155,6 +155,13 @@ export function makeClient(opts: MakeClientOptions = {}): MockClient { return { client, getEnsAddress, getEnsName, getEnsAvatar, ccipRequest }; } +/** + * 003 SF-1 — explicit opt-in bag for tests that expect `002` L1 miss-fallback behavior. + * Default factory posture is OFF; pass this as the fourth argument to + * `createEvmNameResolutionService` when the ladder should consult L1 after bound empty. + */ +export const ENABLE_MAINNET_L1_MISS_FALLBACK = { enableMainnetL1MissFallback: true as const }; + /** A paired bound + mainnet L1 mock for 002 SF-1 reverse miss-fallback tests. */ export interface DualReverseClients { readonly bound: MockClient; diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/helpers/fallback-provenance.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/helpers/fallback-provenance.ts new file mode 100644 index 0000000..a93953b --- /dev/null +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/helpers/fallback-provenance.ts @@ -0,0 +1,60 @@ +/** + * SF-2 test helpers — chain-agnostic fallback classifiers and triplet assertions. + * Mirrors the UIKit sibling contract (`isCrossNetworkFallback` / completeness check). + */ +import { expect } from 'vitest'; + +import type { ResolutionProvenance } from '@openzeppelin/ui-types'; + +import { MAINNET_NETWORK_ID } from '../../provenance'; + +/** Principle II / SC-004 — base-field classifier only (INV-4 / INV-19). */ +export function isCrossNetworkFallback( + provenance: Pick +): boolean { + return provenance.resolvedViaNetworkFallback === true; +} + +/** Strict triplet integrity validator for tests (INV-3). */ +export function isCompleteNetworkFallbackProvenance( + provenance: ResolutionProvenance +): provenance is ResolutionProvenance & { + readonly resolvedViaNetworkFallback: true; + readonly queriedOnNetworkId: string; + readonly resolvedOnNetworkId: string; +} { + return ( + provenance.resolvedViaNetworkFallback === true && + typeof provenance.queriedOnNetworkId === 'string' && + provenance.queriedOnNetworkId !== '' && + typeof provenance.resolvedOnNetworkId === 'string' && + provenance.resolvedOnNetworkId !== '' + ); +} + +/** Show/hide gate — unchanged 002 contract; fallback fields MUST NOT affect scope (INV-25). */ +export function chainAgnosticScope(provenance: ResolutionProvenance): 'global' | { local: string } { + if ('scopedToNetworkId' in provenance && provenance.scopedToNetworkId !== undefined) { + return { local: provenance.scopedToNetworkId }; + } + return 'global'; +} + +export function expectNoFallbackTriplet(provenance: ResolutionProvenance): void { + expect(provenance.resolvedViaNetworkFallback).toBeUndefined(); + expect(provenance.queriedOnNetworkId).toBeUndefined(); + expect(provenance.resolvedOnNetworkId).toBeUndefined(); + expect(isCrossNetworkFallback(provenance)).toBe(false); +} + +export function expectCompleteFallbackTriplet( + provenance: ResolutionProvenance, + queriedOnNetworkId: string +): void { + expect(isCrossNetworkFallback(provenance)).toBe(true); + expect(isCompleteNetworkFallbackProvenance(provenance)).toBe(true); + expect(provenance.resolvedViaNetworkFallback).toBe(true); + expect(provenance.queriedOnNetworkId).toBe(queriedOnNetworkId); + expect(provenance.resolvedOnNetworkId).toBe(MAINNET_NETWORK_ID); + expect('scopedToNetworkId' in provenance).toBe(false); +} diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/network-fallback-provenance.test.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/network-fallback-provenance.test.ts new file mode 100644 index 0000000..57870ba --- /dev/null +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/network-fallback-provenance.test.ts @@ -0,0 +1,336 @@ +/** + * 003 SF-2 · Cross-network fallback provenance — integration + contract test suite. + * + * Golden-matrix coverage for triplet emission: present on UR bound-empty → L1 (reverse + forward), + * absent on non-UR direct L1 reverse (001-1b parity), bound-local hits, and error paths. + * Organized by invariant category; every `describe` names the invariant(s) it covers. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ResolutionProvenance } from '@openzeppelin/ui-types'; + +import { + chainAgnosticScope, + expectCompleteFallbackTriplet, + expectNoFallbackTriplet, + isCrossNetworkFallback, +} from './helpers/fallback-provenance'; + +import { isEnsProvenance } from '../ens-provenance'; +import { createEvmNameResolutionService } from '../service'; +import { + ENABLE_MAINNET_L1_MISS_FALLBACK, + EVM_NETWORK_CONFIG, + L2_NETWORK_CONFIG, + makeClient, + makeDualReverseClients, + makeHttpError, + makeTimeoutError, + SEPOLIA_NETWORK_CONFIG, + VITALIK_ADDRESS, + VITALIK_NAME, +} from './fixtures'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROVENANCE_SOURCE = readFileSync(join(__dirname, '../provenance.ts'), 'utf8'); +const SERVICE_SOURCE = readFileSync(join(__dirname, '../service.ts'), 'utf8'); + +// =========================================================================== +// Request/Response Contract — golden matrix (INV-5, INV-7, INV-8, INV-15) +// =========================================================================== + +describe('resolveAddress — UR bound-empty → L1 emits triplet (INV-5, INV-7, INV-15)', () => { + it('definitive empty signals each produce complete triplet after L1 success', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveAddress(VITALIK_ADDRESS); + expect(result.ok).toBe(true); + if (!result.ok) return; + expectCompleteFallbackTriplet(result.value.provenance, SEPOLIA_NETWORK_CONFIG.id); + expect(chainAgnosticScope(result.value.provenance)).toBe('global'); + }); +}); + +describe('resolveAddress — non-UR direct L1 omits triplet (INV-15, INV-22 parity)', () => { + it('L2 direct L1 success: buildEnsProvenance only — no fallback fields', async () => { + const { client: boundClient, getEnsName: boundGetEnsName } = makeClient({ supported: false }); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { client: l1Client } = makeClient({ getEnsName: l1GetEnsName, boundChainId: 1 }); + const service = createEvmNameResolutionService( + L2_NETWORK_CONFIG, + boundClient, + l1Client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveAddress(VITALIK_ADDRESS); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(boundGetEnsName).not.toHaveBeenCalled(); + expectNoFallbackTriplet(result.value.provenance); + expect(isEnsProvenance(result.value.provenance)).toBe(true); + }); +}); + +describe('resolveName — UR bound miss → L1 emits triplet (INV-5, INV-7, SF-4 wiring)', () => { + it('Sepolia bound NAME_NOT_FOUND then L1 hit spreads complete triplet', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeDualReverseClients(); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + expectCompleteFallbackTriplet(result.value.provenance, SEPOLIA_NETWORK_CONFIG.id); + expect(chainAgnosticScope(result.value.provenance)).toBe('global'); + }); + + it('Sepolia bound hit does not emit fallback triplet', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeDualReverseClients(); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + expectNoFallbackTriplet(result.value.provenance); + }); +}); + +describe('resolveName — non-UR forward 1b omits triplet (INV-22)', () => { + it('L2 chain-scoped L1 success with opt-in ON has no fallback fields', async () => { + const boundGetEnsAddress = vi.fn(); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { client: boundClient } = makeClient({ + supported: false, + getEnsAddress: boundGetEnsAddress, + }); + const { client: l1Client } = makeClient({ getEnsAddress: l1GetEnsAddress, boundChainId: 1 }); + const service = createEvmNameResolutionService( + L2_NETWORK_CONFIG, + boundClient, + l1Client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expectNoFallbackTriplet(result.value.provenance); + expect(boundGetEnsAddress).not.toHaveBeenCalled(); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + }); +}); + +// =========================================================================== +// Error Semantics — success-only provenance (INV-9, INV-11) +// =========================================================================== + +describe('fallback triplet is success-only (INV-9)', () => { + it('reverse bound gateway failure with opt-in ON returns error without fallback keys', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockRejectedValue(makeHttpError()), + l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveAddress(VITALIK_ADDRESS); + expect(result.ok).toBe(false); + if (result.ok) return; + expect('resolvedViaNetworkFallback' in result.error).toBe(false); + expect(l1.getEnsName).not.toHaveBeenCalled(); + }); + + it('reverse bound empty + L1 empty returns ADDRESS_NOT_FOUND without provenance', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + l1GetEnsName: vi.fn().mockResolvedValue(null), + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveAddress(VITALIK_ADDRESS); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('ADDRESS_NOT_FOUND'); + }); + + it('forward bound miss + L1 NAME_NOT_FOUND terminal has no triplet on error', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(null); + const { bound, l1 } = makeDualReverseClients(); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveName('missing.eth'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NAME_NOT_FOUND'); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + }); +}); + +// =========================================================================== +// Idempotency & Retry — deterministic emission (INV-12, INV-13) +// =========================================================================== + +describe('fallback provenance allocation (INV-12, INV-13)', () => { + it('two successive UR bound-empty → L1 successes return referentially distinct provenance', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const first = await service.resolveAddress(VITALIK_ADDRESS); + const second = await service.resolveAddress(VITALIK_ADDRESS); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + expect(first.value.provenance).not.toBe(second.value.provenance); + expect(first.value.provenance).toEqual(second.value.provenance); + }); + + it('double-call under stable stubs yields identical triplet values', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const first = await service.resolveAddress(VITALIK_ADDRESS); + const second = await service.resolveAddress(VITALIK_ADDRESS); + expect(first).toEqual(second); + }); +}); + +// =========================================================================== +// Sensitive Data Handling — Principle II classifiers (INV-19, INV-20, INV-25) +// =========================================================================== + +describe('chain-agnostic fallback classification (INV-19, INV-20)', () => { + it('classifies fallback using resolvedViaNetworkFallback only — not coinType', () => { + const withTriplet: ResolutionProvenance = { + label: 'ENS', + external: false, + resolvedViaNetworkFallback: true, + queriedOnNetworkId: SEPOLIA_NETWORK_CONFIG.id, + resolvedOnNetworkId: EVM_NETWORK_CONFIG.id, + }; + const ensEnriched = { ...withTriplet, system: 'ens' as const, coinType: 60 }; + expect(isCrossNetworkFallback(withTriplet)).toBe(true); + expect(isCrossNetworkFallback(ensEnriched)).toBe(true); + }); + + it('absent scopedToNetworkId on triplet success does not imply fallback without flag (INV-20)', () => { + const mainnetHit: ResolutionProvenance = { label: 'ENS', external: false }; + expect(chainAgnosticScope(mainnetHit)).toBe('global'); + expect(isCrossNetworkFallback(mainnetHit)).toBe(false); + }); + + it('fallback triplet does not alter show/hide scope gate (INV-25)', () => { + const fallbackHit: ResolutionProvenance = { + label: 'ENS', + external: false, + resolvedViaNetworkFallback: true, + queriedOnNetworkId: SEPOLIA_NETWORK_CONFIG.id, + resolvedOnNetworkId: EVM_NETWORK_CONFIG.id, + }; + expect(chainAgnosticScope(fallbackHit)).toBe('global'); + expect(chainAgnosticScope(fallbackHit)).toBe( + chainAgnosticScope({ label: 'ENS', external: false }) + ); + }); +}); + +// =========================================================================== +// Performance, Scalability & Re-usability — DRY builder audit (INV-23) +// =========================================================================== + +describe('single DRY triplet emission site (INV-23)', () => { + it('resolvedViaNetworkFallback is assigned only inside provenance.ts builder', () => { + expect(PROVENANCE_SOURCE).toContain('resolvedViaNetworkFallback: true'); + expect(SERVICE_SOURCE).not.toMatch(/resolvedViaNetworkFallback\s*:\s*true/); + expect(SERVICE_SOURCE).toContain('composeNetworkFallbackProvenance'); + expect(SERVICE_SOURCE).toContain('precededByBoundMiss'); + }); +}); + +// =========================================================================== +// Auth Boundary — emission requires bound miss context (INV-14, INV-15) +// =========================================================================== + +describe('precededByBoundMiss threads through reverse ladder (INV-15)', () => { + it('bound transport failure with opt-in ON never reaches L1 — no triplet path', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockRejectedValue(makeTimeoutError()), + l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveAddress(VITALIK_ADDRESS); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('RESOLUTION_TIMEOUT'); + expect(l1.getEnsName).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/provenance.test.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/provenance.test.ts index c1c76ff..0300ab0 100644 --- a/packages/adapter-evm-core/src/name-resolution/__tests__/provenance.test.ts +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/provenance.test.ts @@ -1,16 +1,27 @@ /** - * SF-2 · `provenance.ts` — `baseEnsProvenance()` test suite. + * SF-2 · `provenance.ts` — provenance builders test suite. * - * Verifies INV-5 (fixed user-safe shape `{ label: 'ENS', external: false }`, fresh per call, no v1 - * network-scoping) and the label half of INV-19 (`label` is the constant `'ENS'`, never a URL / - * gateway host / keyed identifier — the property SF-4's conformance `label`-allowlist and UIKit - * INV-16 enforce). INV-13's "fresh object per call, no aliased singleton" is verified here too. + * Covers `baseEnsProvenance()` (legacy SF-2 forward v1) and 003 cross-network fallback builders + * (`networkFallbackProvenanceFields`, `composeNetworkFallbackProvenance`, `MAINNET_NETWORK_ID`). */ import { describe, expect, it } from 'vitest'; import type { ResolutionProvenance } from '@openzeppelin/ui-types'; -import { baseEnsProvenance } from '../provenance'; +import { + expectNoFallbackTriplet, + isCompleteNetworkFallbackProvenance, + isCrossNetworkFallback, +} from './helpers/fallback-provenance'; + +import { + baseEnsProvenance, + boundReverseProvenance, + composeNetworkFallbackProvenance, + MAINNET_NETWORK_ID, + networkFallbackProvenanceFields, +} from '../provenance'; +import { EVM_NETWORK_CONFIG } from './fixtures'; describe('baseEnsProvenance — fixed user-safe shape (INV-5)', () => { it('deep-equals the canonical v1 forward provenance', () => { @@ -48,3 +59,99 @@ describe('baseEnsProvenance — user-safe label (INV-19 / SF-4 allowlist)', () = expect(label).not.toMatch(/[A-Za-z0-9_-]{16,}/); // no long key-shaped token }); }); + +// =========================================================================== +// SF-2 · networkFallbackProvenanceFields (INV-3, INV-6, INV-12, INV-18, INV-23, INV-28) +// =========================================================================== + +describe('MAINNET_NETWORK_ID — canonical mainnet slug (INV-6, INV-24)', () => { + it('equals the mainnet profile networkConfig.id fixture', () => { + expect(MAINNET_NETWORK_ID).toBe(EVM_NETWORK_CONFIG.id); + }); +}); + +describe('networkFallbackProvenanceFields — triplet builder (INV-3, INV-28)', () => { + it('returns the complete canonical fallback shape', () => { + const fields = networkFallbackProvenanceFields({ + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }); + expect(fields).toEqual({ + resolvedViaNetworkFallback: true, + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }); + }); + + it('omits label, external, and scopedToNetworkId (INV-28)', () => { + const fields = networkFallbackProvenanceFields({ + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }); + expect('label' in fields).toBe(false); + expect('external' in fields).toBe(false); + expect('scopedToNetworkId' in fields).toBe(false); + }); + + it('returns a fresh object per call (INV-12)', () => { + const args = { + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }; + const a = networkFallbackProvenanceFields(args); + const b = networkFallbackProvenanceFields(args); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); +}); + +describe('composeNetworkFallbackProvenance — spread composition (INV-28)', () => { + it('preserves base label/external/scope while adding triplet keys', () => { + const base: ResolutionProvenance = { + label: 'ENS via external gateway', + external: true, + scopedToNetworkId: 'ethereum-sepolia', + }; + const composed = composeNetworkFallbackProvenance(base, { + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }); + expect(composed.label).toBe('ENS via external gateway'); + expect(composed.external).toBe(true); + expect(composed.scopedToNetworkId).toBe('ethereum-sepolia'); + expect(composed.resolvedViaNetworkFallback).toBe(true); + expect(composed.queriedOnNetworkId).toBe('ethereum-sepolia'); + expect(composed.resolvedOnNetworkId).toBe(MAINNET_NETWORK_ID); + }); +}); + +describe('boundReverseProvenance — non-fallback bound-local shape (INV-8)', () => { + it('carries scopedToNetworkId and no fallback triplet', () => { + const provenance = boundReverseProvenance('ethereum-sepolia'); + expect(provenance).toEqual({ + label: 'ENS', + external: false, + scopedToNetworkId: 'ethereum-sepolia', + }); + expectNoFallbackTriplet(provenance); + }); +}); + +describe('fallback classifiers — integrity negatives (INV-3, INV-4, INV-20)', () => { + it('orphan ids without resolvedViaNetworkFallback === true are not fallback', () => { + const orphan: ResolutionProvenance = { + label: 'ENS', + external: false, + queriedOnNetworkId: 'ethereum-sepolia', + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }; + expect(isCrossNetworkFallback(orphan)).toBe(false); + expect(isCompleteNetworkFallbackProvenance(orphan)).toBe(false); + }); + + it('mainnet-bound baseEnsProvenance (absent scope, no triplet) is not fallback', () => { + const provenance = baseEnsProvenance(); + expectNoFallbackTriplet(provenance); + expect(isCrossNetworkFallback(provenance)).toBe(false); + }); +}); diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/service.forward-l1.test.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/service.forward-l1.test.ts new file mode 100644 index 0000000..23f327d --- /dev/null +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/service.forward-l1.test.ts @@ -0,0 +1,488 @@ +/** + * 003 SF-4 · `service.ts` — opt-in-gated forward mainnet-L1 miss-fallback test suite. + * + * **SF-4 primary ON matrix** (UR bound `NAME_NOT_FOUND` → single L1 `resolveVia` + SF-2 triplet): + * spec acceptance scenarios 1–3, never-silent-fallback on bound gateway/timeout (KEY INV-10), + * `NAME_NOT_FOUND`-only eligibility (INV-11), non-UR 001-1b exclusion (INV-9), I/O budget spies. + * + * Opt-in OFF forward contract (`SC-001`, INV-2) is owned by `service.mainnet-l1-opt-in.test.ts` + * (SF-1). SF-2 triplet integration rows also appear in `network-fallback-provenance.test.ts`. + * + * All L1 miss-fallback scenarios require `ENABLE_MAINNET_L1_MISS_FALLBACK` (default OFF elsewhere). + * + * Organized by invariant category. Every `describe` names the invariant(s) it covers. + */ +import { describe, expect, it, vi } from 'vitest'; + +import type { ResolutionResult, ResolvedAddress } from '@openzeppelin/ui-types'; + +import { + chainAgnosticScope, + expectCompleteFallbackTriplet, + expectNoFallbackTriplet, + isCrossNetworkFallback, +} from './helpers/fallback-provenance'; + +import { isEnsProvenance } from '../ens-provenance'; +import { createEvmNameResolutionService } from '../service'; +import { + ENABLE_MAINNET_L1_MISS_FALLBACK, + EVM_NETWORK_CONFIG, + L2_NETWORK_CONFIG, + makeClient, + makeDecodedRevert, + makeDualReverseClients, + makeHttpError, + makeTimeoutError, + SEPOLIA_NETWORK_CONFIG, + VITALIK_ADDRESS, +} from './fixtures'; + +function expectError(result: ResolutionResult) { + if (result.ok) { + throw new Error(`expected { ok: false } but got success: ${JSON.stringify(result.value)}`); + } + return result.error; +} + +function expectValue(result: ResolutionResult): ResolvedAddress { + if (!result.ok) { + throw new Error(`expected { ok: true } but got error: ${JSON.stringify(result.error)}`); + } + return result.value; +} + +function makeForwardDualClients( + boundGetEnsAddress: ReturnType, + l1GetEnsAddress: ReturnType +) { + const { bound, l1 } = makeDualReverseClients(); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + return { bound, l1, boundGetEnsAddress, l1GetEnsAddress }; +} + +// =========================================================================== +// Request/Response Contract — spec acceptance + ladder totality (INV-1, INV-6) +// =========================================================================== + +describe('resolveName — SF-4 forward miss-fallback ladder (INV-1, INV-6)', () => { + it('acceptance 1: Sepolia UR + opt-in ON — bound NAME_NOT_FOUND then L1 hit', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveName('vitalik.eth')); + + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + expect(value.address).toBe(VITALIK_ADDRESS); + expectCompleteFallbackTriplet(value.provenance, SEPOLIA_NETWORK_CONFIG.id); + }); + + it('acceptance 2: bound hit short-circuits — zero L1 I/O regardless of opt-in', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveName('vitalik.eth')); + + expect(value.address).toBe(VITALIK_ADDRESS); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + expectNoFallbackTriplet(value.provenance); + }); + + it('acceptance 3: non-UR L2 + opt-in ON — canonical 001-1b L1 path, no fallback triplet', async () => { + const boundGetEnsAddress = vi.fn(); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { client: boundClient } = makeClient({ + supported: false, + getEnsAddress: boundGetEnsAddress, + }); + const { client: l1Client } = makeClient({ getEnsAddress: l1GetEnsAddress, boundChainId: 1 }); + const service = createEvmNameResolutionService( + L2_NETWORK_CONFIG, + boundClient, + l1Client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveName('vitalik.eth')); + + expect(boundGetEnsAddress).not.toHaveBeenCalled(); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + expectNoFallbackTriplet(value.provenance); + expect(isEnsProvenance(value.provenance)).toBe(true); + }); +}); + +// =========================================================================== +// Request/Response Contract — provenance three-row (INV-3, INV-5, INV-7, INV-8) +// =========================================================================== + +describe('resolveName — D-R7 forward provenance three-row (INV-5, INV-7, INV-8)', () => { + it('Sepolia miss-fallback success: absent scopedToNetworkId + complete triplet', async () => { + const { bound, l1 } = makeForwardDualClients( + vi.fn().mockResolvedValue(null), + vi.fn().mockResolvedValue(VITALIK_ADDRESS) + ); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveName('vitalik.eth')); + + expect(chainAgnosticScope(value.provenance)).toBe('global'); + expect('scopedToNetworkId' in value.provenance).toBe(false); + expectCompleteFallbackTriplet(value.provenance, SEPOLIA_NETWORK_CONFIG.id); + expect(isEnsProvenance(value.provenance)).toBe(true); + if (isEnsProvenance(value.provenance)) expect(value.provenance.coinType).toBe(60); + }); + + it('Sepolia bound-local hit: no triplet; L1 never consulted', async () => { + const localAddress = '0x1111111111111111111111111111111111111111'; + const { bound, l1, l1GetEnsAddress } = makeForwardDualClients( + vi.fn().mockResolvedValue(localAddress), + vi.fn().mockResolvedValue(VITALIK_ADDRESS) + ); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveName('local.sepolia.eth')); + + expect(value.address).toBe(localAddress); + expectNoFallbackTriplet(value.provenance); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); + + it('mainnet-bound miss: gate false — bound terminal, L1 untouched', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn(), + boundChainId: 1, + }); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + const service = createEvmNameResolutionService( + EVM_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expect(expectError(await service.resolveName('missing.eth')).code).toBe('NAME_NOT_FOUND'); + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Request/Response Contract — definitive empty signals (INV-1, INV-11) +// =========================================================================== + +describe('resolveName — forward NAME_NOT_FOUND eligibility breadth (INV-1, INV-11)', () => { + const ELIGIBLE_SIGNALS = [ + ['null', vi.fn().mockResolvedValue(null)], + ['ResolverNotFound', vi.fn().mockRejectedValue(makeDecodedRevert('ResolverNotFound'))], + ['ResolverNotContract', vi.fn().mockRejectedValue(makeDecodedRevert('ResolverNotContract'))], + ['ResolverError', vi.fn().mockRejectedValue(makeDecodedRevert('ResolverError'))], + ] as const; + + it.each(ELIGIBLE_SIGNALS)( + 'bound %s on Sepolia + opt-in ON → one L1 getEnsAddress then L1 success', + async (_label, boundGetEnsAddress) => { + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expectValue(await service.resolveName('vitalik.eth')); + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + } + ); + + it('bound UnsupportedResolverProfile + opt-in ON → UNSUPPORTED_NAME, zero L1 calls', async () => { + const boundGetEnsAddress = vi + .fn() + .mockRejectedValue(makeDecodedRevert('UnsupportedResolverProfile')); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const error = expectError(await service.resolveName('noaddr.eth')); + expect(error.code).toBe('UNSUPPORTED_NAME'); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Error Semantics — KEY never-silent-fallback (INV-10) +// =========================================================================== + +describe('resolveName — KEY: bound failure NEVER falls through to L1 (INV-10)', () => { + it.each([ + ['RESOLUTION_TIMEOUT', makeTimeoutError()], + ['EXTERNAL_GATEWAY_ERROR via HttpRequestError', makeHttpError()], + ])( + 'acceptance 3: bound %s → typed error; L1 getEnsAddress receives ZERO calls', + async (_label, thrown) => { + const boundGetEnsAddress = vi.fn().mockRejectedValue(thrown); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const error = expectError(await service.resolveName('vitalik.eth')); + expect(error.code).not.toBe('NAME_NOT_FOUND'); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + } + ); +}); + +// =========================================================================== +// Error Semantics — L1 terminal discipline (INV-12, INV-13) +// =========================================================================== + +describe('resolveName — L1 terminal outcomes (INV-12, INV-13)', () => { + it('bound miss + L1 empty → NAME_NOT_FOUND (no third tier)', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(null); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expect(expectError(await service.resolveName('missing.eth')).code).toBe('NAME_NOT_FOUND'); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + }); + + it('bound miss + L1 gateway failure → typed error, never silent address', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockRejectedValue(makeTimeoutError()); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const error = expectError(await service.resolveName('vitalik.eth')); + expect(error.code).toBe('RESOLUTION_TIMEOUT'); + expect(error.code).not.toBe('NAME_NOT_FOUND'); + }); + + it('bound miss + L1 invalid address bytes → NAME_NOT_FOUND (M1), no triplet', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue('0xdead'); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveName('evil.eth'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NAME_NOT_FOUND'); + }); +}); + +// =========================================================================== +// Error Semantics — strict:true on both clients (INV-14) +// =========================================================================== + +describe('resolveName — strict:true on bound and L1 getEnsAddress (INV-14)', () => { + it('both attempts pass strict:true on miss-fallback path', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + await service.resolveName('vitalik.eth'); + + expect(boundGetEnsAddress.mock.calls[0][0]).toMatchObject({ strict: true }); + expect(l1GetEnsAddress.mock.calls[0][0]).toMatchObject({ strict: true }); + }); +}); + +// =========================================================================== +// Idempotency & Retry — deterministic emission (INV-17, INV-19) +// =========================================================================== + +describe('resolveName — deterministic forward L1-fallback outcomes (INV-17, INV-19)', () => { + it('double-call under stable stubs yields identical triplet values', async () => { + const { bound, l1 } = makeForwardDualClients( + vi.fn().mockResolvedValue(null), + vi.fn().mockResolvedValue(VITALIK_ADDRESS) + ); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const first = await service.resolveName('vitalik.eth'); + const second = await service.resolveName('vitalik.eth'); + expect(first).toEqual(second); + }); +}); + +// =========================================================================== +// Side-Effect Ordering — I/O sequence (INV-23) +// =========================================================================== + +describe('resolveName — bound → L1 I/O ordering (INV-23)', () => { + it('eligible bound miss consults L1 only after bound attempt completes', async () => { + const callOrder: string[] = []; + const boundGetEnsAddress = vi.fn().mockImplementation(async () => { + callOrder.push('bound'); + return null; + }); + const l1GetEnsAddress = vi.fn().mockImplementation(async () => { + callOrder.push('l1'); + return VITALIK_ADDRESS; + }); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expectValue(await service.resolveName('vitalik.eth')); + expect(callOrder).toEqual(['bound', 'l1']); + }); + + it('malformed name + opt-in ON → zero getEnsAddress calls (INV-24)', async () => { + const boundGetEnsAddress = vi.fn(); + const l1GetEnsAddress = vi.fn(); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expectError(await service.resolveName(VITALIK_ADDRESS)); + expect(boundGetEnsAddress).not.toHaveBeenCalled(); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Resource Limits — bounded work spy counts (INV-26, INV-27) +// =========================================================================== + +describe('resolveName — forward I/O budget (INV-26)', () => { + it('opt-in ON eligible miss: bound=1, L1=1', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expectValue(await service.resolveName('vitalik.eth')); + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + }); + + it('opt-in ON bound gateway failure: bound=1, L1=0', async () => { + const boundGetEnsAddress = vi.fn().mockRejectedValue(makeHttpError()); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeForwardDualClients(boundGetEnsAddress, l1GetEnsAddress); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expectError(await service.resolveName('vitalik.eth')); + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Sensitive Data Handling — Principle II classifiers (INV-29, INV-30) +// =========================================================================== + +describe('resolveName — chain-agnostic fallback classification (INV-29, INV-30)', () => { + it('L1 miss-fallback success classifies via resolvedViaNetworkFallback only', async () => { + const { bound, l1 } = makeForwardDualClients( + vi.fn().mockResolvedValue(null), + vi.fn().mockResolvedValue(VITALIK_ADDRESS) + ); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveName('vitalik.eth')); + expect(isCrossNetworkFallback(value.provenance)).toBe(true); + expect(chainAgnosticScope(value.provenance)).toBe('global'); + }); + + it('mainnet-bound forward hit without triplet does not imply cross-network fallback', async () => { + const { client } = makeClient({ boundChainId: 1 }); + const service = createEvmNameResolutionService(EVM_NETWORK_CONFIG, client); + + const value = expectValue(await service.resolveName('vitalik.eth')); + expect(isCrossNetworkFallback(value.provenance)).toBe(false); + expectNoFallbackTriplet(value.provenance); + }); +}); diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/service.mainnet-l1-opt-in.test.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/service.mainnet-l1-opt-in.test.ts new file mode 100644 index 0000000..86383ac --- /dev/null +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/service.mainnet-l1-opt-in.test.ts @@ -0,0 +1,488 @@ +/** + * 003 SF-1 · Consumer opt-in configuration seam — test suite. + * + * Verifies `enableMainnetL1MissFallback` (default OFF) gates mainnet-L1 miss-fallback on both + * directions through `mayConsultL1ForMissFallback()`. Organized by invariant category; every + * `describe` names the invariant(s) it covers. SC-001 (KEY INV-6) is pinned with hard L1 spies. + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ResolutionResult, ResolvedName } from '@openzeppelin/ui-types'; + +import { createNameResolution } from '../../capabilities/name-resolution'; +import { createEvmNameResolutionService } from '../service'; +import { + ENABLE_MAINNET_L1_MISS_FALLBACK, + EVM_NETWORK_CONFIG, + L2_NETWORK_CONFIG, + makeClient, + makeDecodedRevert, + makeDualReverseClients, + makeHttpError, + makeTimeoutError, + SEPOLIA_NETWORK_CONFIG, + SEVEN_CODE_SET, + VITALIK_ADDRESS, + VITALIK_NAME, +} from './fixtures'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SERVICE_SOURCE = readFileSync(join(__dirname, '../service.ts'), 'utf8'); +const SHARED_PROFILE_SOURCE = readFileSync( + join(__dirname, '../../../../adapter-evm/src/profiles/shared.ts'), + 'utf8' +); + +function expectError(result: ResolutionResult) { + if (result.ok) { + throw new Error(`expected { ok: false } but got success: ${JSON.stringify(result.value)}`); + } + return result.error; +} + +// =========================================================================== +// Request/Response Contract — options shape + factory threading (INV-1, INV-20, INV-21) +// =========================================================================== + +describe('CreateNameResolutionOptions — additive opt-in field (INV-1, INV-20, INV-21)', () => { + it('createNameResolution accepts enableMainnetL1MissFallback without changing capability methods', () => { + const { client } = makeClient(); + const capability = createNameResolution(SEPOLIA_NETWORK_CONFIG, { + publicClient: client, + enableMainnetL1MissFallback: true, + }); + + expect(typeof capability.isValidName).toBe('function'); + expect(typeof capability.resolveName).toBe('function'); + expect(typeof capability.resolveAddress).toBe('function'); + }); + + it('createEvmNameResolutionService three-arg call sites compile unchanged (options bag optional)', () => { + const { client } = makeClient(); + expect(() => createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, client)).not.toThrow(); + expect(() => + createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, client, makeClient().client) + ).not.toThrow(); + }); + + it('createNameResolution threads opt-in to reverse L1 when explicitly true', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), + }); + const capability = createNameResolution(SEPOLIA_NETWORK_CONFIG, { + publicClient: bound.client, + ensL1Client: l1.client, + enableMainnetL1MissFallback: true, + }); + + const result = await capability.resolveAddress!(VITALIK_ADDRESS); + expect(result.ok).toBe(true); + expect(l1.getEnsName).toHaveBeenCalledTimes(1); + }); +}); + +// =========================================================================== +// Request/Response Contract — strict enablement (INV-2, INV-9) +// =========================================================================== + +describe('enableMainnetL1MissFallback — strict === true enablement (INV-2, INV-9)', () => { + const TRUTHY_NON_BOOLEANS = [ + ['undefined (omitted)', undefined], + ['false', false], + ['null (JS boundary)', null], + ['numeric 1', 1], + ['string "true"', 'true'], + ] as const; + + it.each(TRUTHY_NON_BOOLEANS)( + 'opt-in %s → zero L1 getEnsName on bound empty (fail-safe OFF)', + async (_label, optInValue) => { + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + l1GetEnsName, + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + { enableMainnetL1MissFallback: optInValue as boolean | undefined } + ); + + const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(error.code).toBe('ADDRESS_NOT_FOUND'); + expect(l1GetEnsName).not.toHaveBeenCalled(); + } + ); + + it('only explicit true permits L1 miss-fallback after bound empty', async () => { + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + l1GetEnsName, + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveAddress(VITALIK_ADDRESS); + expect(result.ok).toBe(true); + expect(l1GetEnsName).toHaveBeenCalledTimes(1); + }); + + it('construct never throws for junk opt-in at the JS boundary', () => { + const { bound, l1 } = makeDualReverseClients(); + expect(() => + createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client, { + enableMainnetL1MissFallback: null as unknown as boolean, + }) + ).not.toThrow(); + }); +}); + +// =========================================================================== +// Request/Response Contract — wiring ≠ opt-in (INV-3) +// =========================================================================== + +describe('ensL1Client wiring does NOT imply opt-in (INV-3)', () => { + it('SC-001 reverse: wired L1 + default OFF → ADDRESS_NOT_FOUND, L1 getEnsName count 0', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(error.code).toBe('ADDRESS_NOT_FOUND'); + expect(boundGetEnsName).toHaveBeenCalledTimes(1); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); + + it('SC-001 forward: wired L1 + default OFF → NAME_NOT_FOUND, L1 getEnsAddress count 0', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeDualReverseClients(); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NAME_NOT_FOUND'); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Request/Response Contract — immutability (INV-4, INV-11) +// =========================================================================== + +describe('opt-in frozen at construct time (INV-4, INV-11)', () => { + it('repeated resolveAddress calls under stable stubs are deterministic with opt-in OFF', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + const first = await service.resolveAddress(VITALIK_ADDRESS); + const second = await service.resolveAddress(VITALIK_ADDRESS); + + expect(first).toEqual(second); + expect(boundGetEnsName).toHaveBeenCalledTimes(2); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Error Semantics — central gate predicate (INV-5) +// =========================================================================== + +describe('mayConsultL1ForMissFallback — sole miss-fallback gate (INV-5)', () => { + it('reverse ladder uses mayConsultL1ForMissFallback instead of bare ensL1Client && !isMainnetBound()', () => { + expect(SERVICE_SOURCE).toContain('mayConsultL1ForMissFallback()'); + expect(SERVICE_SOURCE).not.toMatch(/ensL1Client\s*&&\s*!this\.isMainnetBound\(\)/); + }); +}); + +// =========================================================================== +// Error Semantics — KEY default-OFF zero L1 I/O (INV-6) +// =========================================================================== + +describe('KEY SC-001 — opt-in OFF ⇒ zero L1 miss-fallback I/O (INV-6)', () => { + it('reverse bound empty: exactly one bound getEnsName, zero L1 calls', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(boundGetEnsName).toHaveBeenCalledTimes(1); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); + + it.each([ + ['null', vi.fn().mockResolvedValue(null)], + [ + 'ReverseAddressMismatch', + vi.fn().mockRejectedValue(makeDecodedRevert('ReverseAddressMismatch')), + ], + ])('reverse bound %s signal with opt-in OFF → no L1 consult', async (_label, boundGetEnsName) => { + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Error Semantics — non-UR terminal (INV-7) +// =========================================================================== + +describe('non-UR reverse with gate false → UNSUPPORTED_NETWORK (INV-7)', () => { + it('opt-in OFF + ensL1Client wired on non-UR → UNSUPPORTED_NETWORK, zero getEnsName', async () => { + const { client: boundClient, getEnsName: boundGetEnsName } = makeClient({ supported: false }); + const { client: l1Client, getEnsName: l1GetEnsName } = makeClient({ boundChainId: 1 }); + const service = createEvmNameResolutionService(L2_NETWORK_CONFIG, boundClient, l1Client); + + const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(error.code).toBe('UNSUPPORTED_NETWORK'); + expect(boundGetEnsName).not.toHaveBeenCalled(); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Error Semantics — closed error union (INV-8, INV-19) +// =========================================================================== + +describe('no new error codes or opt-in leakage (INV-8, INV-19)', () => { + it('opt-in OFF terminals use existing seven-code union only', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(SEVEN_CODE_SET.has(error.code)).toBe(true); + expect(JSON.stringify(error)).not.toContain('enableMainnetL1MissFallback'); + expect(JSON.stringify(error)).not.toContain('optIn'); + }); +}); + +// =========================================================================== +// Idempotency & Retry — never-silent-fallback (INV-10) +// =========================================================================== + +describe('never-silent-fallback preserved regardless of opt-in (INV-10)', () => { + it.each([ + ['opt-in OFF', undefined], + ['opt-in ON', ENABLE_MAINNET_L1_MISS_FALLBACK], + ])( + 'bound gateway failure with %s → typed error; L1 getEnsName count 0', + async (_label, optIn) => { + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockRejectedValue(makeHttpError()), + l1GetEnsName, + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + optIn + ); + + const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(error.code).not.toBe('ADDRESS_NOT_FOUND'); + expect(SEVEN_CODE_SET.has(error.code)).toBe(true); + expect(l1GetEnsName).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['opt-in OFF', undefined], + ['opt-in ON', ENABLE_MAINNET_L1_MISS_FALLBACK], + ])('bound timeout with %s → RESOLUTION_TIMEOUT; L1 never consulted', async (_label, optIn) => { + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockRejectedValue(makeTimeoutError()), + l1GetEnsName, + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + optIn + ); + + const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(error.code).toBe('RESOLUTION_TIMEOUT'); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Auth Boundary — no implicit runtime enablement (INV-12) +// =========================================================================== + +describe('adapter-evm shared profile must not implicit-enable (INV-12)', () => { + it('shared.ts createNameResolution call sites omit enableMainnetL1MissFallback: true', () => { + expect(SHARED_PROFILE_SOURCE).not.toMatch(/enableMainnetL1MissFallback\s*:\s*true/); + }); +}); + +// =========================================================================== +// Side-Effect Ordering — gate before L1 I/O (INV-14, INV-15, INV-18) +// =========================================================================== + +describe('resolveAddress I/O ordering with opt-in OFF (INV-14, INV-18)', () => { + it('bound empty + OFF → exactly one bound getEnsName, zero L1 I/O', async () => { + const order: string[] = []; + const boundGetEnsName = vi.fn(async () => { + order.push('bound-name'); + return null; + }); + const l1GetEnsName = vi.fn(async () => { + order.push('l1-name'); + return VITALIK_NAME; + }); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + expectError(await service.resolveAddress(VITALIK_ADDRESS)); + expect(order).toEqual(['bound-name']); + expect(l1GetEnsName).not.toHaveBeenCalled(); + }); +}); + +describe('bound-local success never consults L1 regardless of opt-in (INV-15)', () => { + it('bound hit + opt-in ON → L1 getEnsName count 0', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue('local.sepolia.eth'), + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + await service.resolveAddress(VITALIK_ADDRESS); + expect(l1.getEnsName).not.toHaveBeenCalled(); + }); +}); + +// =========================================================================== +// Resource Limits — bounded work when OFF (INV-18) +// =========================================================================== + +describe('opt-in OFF removes L1 tier from miss-fallback paths (INV-18)', () => { + it('UR-carrying bound empty performs at most one getEnsName total', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + await service.resolveAddress(VITALIK_ADDRESS); + expect(boundGetEnsName).toHaveBeenCalledTimes(1); + expect(l1GetEnsName).toHaveBeenCalledTimes(0); + }); +}); + +// =========================================================================== +// Performance, Scalability & Re-usability — per-instance DI (INV-20, INV-22, INV-23, INV-24) +// =========================================================================== + +describe('per-capability-instance opt-in via DI (INV-20)', () => { + it('two services in one process — one ON, one OFF — independent gate behavior', async () => { + const boundGetEnsName = vi.fn().mockResolvedValue(null); + const l1GetEnsNameOn = vi.fn().mockResolvedValue(VITALIK_NAME); + const l1GetEnsNameOff = vi.fn().mockResolvedValue(VITALIK_NAME); + const onClients = makeDualReverseClients({ boundGetEnsName, l1GetEnsName: l1GetEnsNameOn }); + const offClients = makeDualReverseClients({ boundGetEnsName, l1GetEnsName: l1GetEnsNameOff }); + + const onService = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + onClients.bound.client, + onClients.l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + const offService = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + offClients.bound.client, + offClients.l1.client + ); + + expect((await onService.resolveAddress(VITALIK_ADDRESS)).ok).toBe(true); + expect((await offService.resolveAddress(VITALIK_ADDRESS)).ok).toBe(false); + expect(l1GetEnsNameOn).toHaveBeenCalledTimes(1); + expect(l1GetEnsNameOff).not.toHaveBeenCalled(); + }); +}); + +describe('non-UR forward chain-scoped L1 remains outside opt-in gate (INV-22)', () => { + it('L2 + ensL1Client + opt-in OFF still resolves forward via L1 when record exists', async () => { + const boundGetEnsAddress = vi.fn(); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { client: boundClient } = makeClient({ + supported: false, + getEnsAddress: boundGetEnsAddress, + }); + const { client: l1Client } = makeClient({ getEnsAddress: l1GetEnsAddress, boundChainId: 1 }); + const service = createEvmNameResolutionService(L2_NETWORK_CONFIG, boundClient, l1Client); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.address).toBe(VITALIK_ADDRESS); + expect(boundGetEnsAddress).not.toHaveBeenCalled(); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + }); +}); + +describe('forward bound-UR miss stays terminal in SF-1 (INV-23)', () => { + it('Sepolia UR + mainnet-only name + opt-in OFF → NAME_NOT_FOUND without L1 getEnsAddress', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeDualReverseClients(); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NAME_NOT_FOUND'); + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).not.toHaveBeenCalled(); + }); +}); + +describe('mainnet-bound fence — opt-in inert on mainnet (INV-24)', () => { + it('mainnet-bound + opt-in true + injected L1 → gate false; L1 getEnsName count 0 on empty', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + boundChainId: 1, + }); + const service = createEvmNameResolutionService( + EVM_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + expect(expectError(await service.resolveAddress(VITALIK_ADDRESS)).code).toBe( + 'ADDRESS_NOT_FOUND' + ); + expect(l1.getEnsName).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapter-evm-core/src/name-resolution/__tests__/service.reverse-l1.test.ts b/packages/adapter-evm-core/src/name-resolution/__tests__/service.reverse-l1.test.ts index 7f2d92a..e7f2017 100644 --- a/packages/adapter-evm-core/src/name-resolution/__tests__/service.reverse-l1.test.ts +++ b/packages/adapter-evm-core/src/name-resolution/__tests__/service.reverse-l1.test.ts @@ -1,21 +1,33 @@ /** - * 002 SF-1 · `service.ts` — Option B miss-fallback reverse ladder (mainnet L1) test suite. + * 002 SF-1 / 003 SF-3 · `service.ts` — Option B miss-fallback reverse ladder test suite. * - * Extends the `001` SF-3 baseline in `service.reverse.test.ts` with the Specify Revision 1 ladder: - * bound-first on UR-carrying chains (Sepolia first-class), empty-only miss-fallback to gated L1, - * non-UR + L1 direct, never-silent-fallback on bound transport/gateway failure (KEY INV-9), and - * D-R7 / Principle II provenance via base `scopedToNetworkId` only (INV-5, INV-28). + * **SF-3 primary ON matrix** (opt-in gated 002 fidelity + SF-2 triplet on bound-empty → L1): + * bound-first on UR-carrying chains, empty-only miss-fallback to gated L1, non-UR direct L1 + * (triplet absent — `precededByBoundMiss` false), never-silent-fallback on bound failures (KEY + * INV-9), D-R7 scope + SF-2 fallback triplet via `helpers/fallback-provenance.ts`. + * + * Extends the `001` SF-3 baseline in `service.reverse.test.ts`. Opt-in OFF reverse contract lives + * in `service.mainnet-l1-opt-in.test.ts` (SF-1 ownership, SF-3 INV-2 cross-reference). + * + * All L1 miss-fallback scenarios require `ENABLE_MAINNET_L1_MISS_FALLBACK` (default OFF elsewhere). * * Organized by invariant category. Every `describe` names the invariant(s) it covers. */ import { describe, expect, it, vi } from 'vitest'; -import type { ResolutionProvenance, ResolutionResult, ResolvedName } from '@openzeppelin/ui-types'; +import type { ResolutionResult, ResolvedName } from '@openzeppelin/ui-types'; + +import { + chainAgnosticScope, + expectCompleteFallbackTriplet, + expectNoFallbackTriplet, +} from './helpers/fallback-provenance'; import { isEnsProvenance } from '../ens-provenance'; import { createEvmNameResolutionService } from '../service'; import { AVATAR_URL, + ENABLE_MAINNET_L1_MISS_FALLBACK, EVM_NETWORK_CONFIG, L2_NETWORK_CONFIG, makeClient, @@ -42,16 +54,48 @@ function expectValue(result: ResolutionResult): ResolvedName { return result.value; } -/** - * Chain-agnostic display-safety gate (INV-28): consumers MUST discriminate scope using base - * `scopedToNetworkId` only — never `isEnsProvenance` / `coinType`. - */ -function chainAgnosticScope(provenance: ResolutionProvenance): 'global' | { local: string } { - if ('scopedToNetworkId' in provenance && provenance.scopedToNetworkId !== undefined) { - return { local: provenance.scopedToNetworkId }; - } - return 'global'; -} +// =========================================================================== +// SF-2 cross-network fallback triplet — bound-miss-only emission (D-S3-1) +// =========================================================================== + +describe('resolveAddress — SF-2 fallback triplet (bound-empty miss only)', () => { + it('UR bound-empty → L1 success emits complete fallback triplet', async () => { + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn().mockResolvedValue(null), + l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), + }); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); + + expectCompleteFallbackTriplet(value.provenance, SEPOLIA_NETWORK_CONFIG.id); + expect('scopedToNetworkId' in value.provenance).toBe(false); + }); + + it('non-UR direct L1 success omits fallback triplet (forward 001-1b parity)', async () => { + const { client: boundClient, getEnsName: boundGetEnsName } = makeClient({ supported: false }); + const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); + const { client: l1Client } = makeClient({ getEnsName: l1GetEnsName, boundChainId: 1 }); + const service = createEvmNameResolutionService( + L2_NETWORK_CONFIG, + boundClient, + l1Client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); + + expect(boundGetEnsName).not.toHaveBeenCalled(); + expectNoFallbackTriplet(value.provenance); + expect(chainAgnosticScope(value.provenance)).toBe('global'); + expect(isEnsProvenance(value.provenance)).toBe(true); + }); +}); // =========================================================================== // Request/Response Contract — provenance three-row (INV-5, INV-28) @@ -63,13 +107,19 @@ describe('resolveAddress — D-R7 provenance three-row (INV-5, INV-28)', () => { boundGetEnsName: vi.fn().mockResolvedValue(null), l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); expect(value.name).toBe(VITALIK_NAME); expect(chainAgnosticScope(value.provenance)).toBe('global'); expect('scopedToNetworkId' in value.provenance).toBe(false); + expectCompleteFallbackTriplet(value.provenance, SEPOLIA_NETWORK_CONFIG.id); // Adapter-internal enrichment (must NOT be used for display-safety gating): expect(isEnsProvenance(value.provenance)).toBe(true); if (isEnsProvenance(value.provenance)) expect(value.provenance.coinType).toBe(60); @@ -87,6 +137,7 @@ describe('resolveAddress — D-R7 provenance three-row (INV-5, INV-28)', () => { expect(value.name).toBe(localName); expect(chainAgnosticScope(value.provenance)).toEqual({ local: SEPOLIA_NETWORK_CONFIG.id }); expect(isEnsProvenance(value.provenance)).toBe(false); + expectNoFallbackTriplet(value.provenance); expect(l1.getEnsName).not.toHaveBeenCalled(); }); @@ -101,6 +152,7 @@ describe('resolveAddress — D-R7 provenance three-row (INV-5, INV-28)', () => { expect(value.provenance).toEqual({ label: 'ENS', external: false }); expect(chainAgnosticScope(value.provenance)).toBe('global'); + expectNoFallbackTriplet(value.provenance); expect(l1.getEnsName).not.toHaveBeenCalled(); }); }); @@ -114,7 +166,12 @@ describe('resolveAddress — Option B ladder selection (INV-6)', () => { const boundGetEnsName = vi.fn().mockResolvedValue(null); const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); @@ -139,13 +196,19 @@ describe('resolveAddress — Option B ladder selection (INV-6)', () => { const { client: boundClient, getEnsName: boundGetEnsName } = makeClient({ supported: false }); const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); const { client: l1Client } = makeClient({ getEnsName: l1GetEnsName, boundChainId: 1 }); - const service = createEvmNameResolutionService(L2_NETWORK_CONFIG, boundClient, l1Client); + const service = createEvmNameResolutionService( + L2_NETWORK_CONFIG, + boundClient, + l1Client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); expect(boundGetEnsName).not.toHaveBeenCalled(); expect(l1GetEnsName).toHaveBeenCalledTimes(1); expect(chainAgnosticScope(value.provenance)).toBe('global'); + expectNoFallbackTriplet(value.provenance); expect(isEnsProvenance(value.provenance)).toBe(true); }); @@ -200,7 +263,8 @@ describe('resolveAddress — definitive-empty triggers exactly one L1 consult (I const service = createEvmNameResolutionService( SEPOLIA_NETWORK_CONFIG, bound.client, - l1.client + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK ); expectValue(await service.resolveAddress(VITALIK_ADDRESS)); @@ -228,7 +292,8 @@ describe('resolveAddress — KEY: bound failure NEVER falls through to L1 (INV-9 const service = createEvmNameResolutionService( SEPOLIA_NETWORK_CONFIG, bound.client, - l1.client + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK ); const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); @@ -249,7 +314,12 @@ describe('resolveAddress — L1 terminal outcomes (INV-10, INV-11)', () => { boundGetEnsName: vi.fn().mockResolvedValue(null), l1GetEnsName: vi.fn().mockResolvedValue(null), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); expect(expectError(await service.resolveAddress(VITALIK_ADDRESS)).code).toBe( 'ADDRESS_NOT_FOUND' @@ -261,7 +331,12 @@ describe('resolveAddress — L1 terminal outcomes (INV-10, INV-11)', () => { boundGetEnsName: vi.fn().mockRejectedValue(makeDecodedRevert('ReverseAddressMismatch')), l1GetEnsName: vi.fn().mockResolvedValue(VITALIK_NAME), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); expect(value.name).toBe(VITALIK_NAME); @@ -273,7 +348,12 @@ describe('resolveAddress — L1 terminal outcomes (INV-10, INV-11)', () => { boundGetEnsName: vi.fn().mockResolvedValue(null), l1GetEnsName: vi.fn().mockRejectedValue(makeDecodedRevert('ReverseAddressMismatch')), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); expect(expectError(await service.resolveAddress(VITALIK_ADDRESS)).code).toBe( 'ADDRESS_NOT_FOUND' @@ -286,7 +366,12 @@ describe('resolveAddress — L1 terminal outcomes (INV-10, INV-11)', () => { boundGetEnsName: vi.fn().mockResolvedValue(null), l1GetEnsName: vi.fn().mockRejectedValue(makeTimeoutError()), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); const error = expectError(await service.resolveAddress(VITALIK_ADDRESS)); expect(error.code).toBe('RESOLUTION_TIMEOUT'); @@ -303,7 +388,12 @@ describe('resolveAddress — strict:true on bound and L1 getEnsName (INV-13)', ( const boundGetEnsName = vi.fn().mockResolvedValue(null); const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); await service.resolveAddress(VITALIK_ADDRESS); @@ -340,7 +430,12 @@ describe('resolveAddress — I/O ordering and avatar client affinity (INV-18, IN return AVATAR_URL; }), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); const value = expectValue(await service.resolveAddress(VITALIK_ADDRESS)); @@ -361,7 +456,12 @@ describe('resolveAddress — I/O ordering and avatar client affinity (INV-18, IN return VITALIK_NAME; }), }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); await service.resolveAddress(VITALIK_ADDRESS); expect(order).toEqual(['bound-name']); @@ -377,7 +477,12 @@ describe('resolveAddress — bounded work per call (INV-20)', () => { const boundGetEnsName = vi.fn().mockResolvedValue(null); const l1GetEnsName = vi.fn().mockResolvedValue(VITALIK_NAME); const { bound, l1 } = makeDualReverseClients({ boundGetEnsName, l1GetEnsName }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); await service.resolveAddress(VITALIK_ADDRESS); expect(boundGetEnsName).toHaveBeenCalledTimes(1); @@ -392,7 +497,12 @@ describe('resolveAddress — L1 default primary only, no bound coinType (INV-27) boundGetEnsName: vi.fn().mockResolvedValue(null), l1GetEnsName, }); - const service = createEvmNameResolutionService(SEPOLIA_NETWORK_CONFIG, bound.client, l1.client); + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); await service.resolveAddress(VITALIK_ADDRESS); @@ -402,11 +512,11 @@ describe('resolveAddress — L1 default primary only, no bound coinType (INV-27) }); // =========================================================================== -// Performance, Scalability & Re-usability — forward asymmetry spot-check (INV-26) +// Performance, Scalability & Re-usability — forward asymmetry OFF/ON (INV-37) // =========================================================================== -describe('resolveName — forward asymmetry: no reverse miss-fallback on forward (INV-26)', () => { - it('Sepolia-bound forward with L1 wired does NOT miss-fall back on NAME_NOT_FOUND', async () => { +describe('resolveName — forward asymmetry: opt-in OFF vs ON (INV-37)', () => { + it('default OFF: Sepolia-bound forward with L1 wired does NOT miss-fall back on NAME_NOT_FOUND', async () => { const boundGetEnsAddress = vi.fn().mockResolvedValue(null); const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); const { bound, l1 } = makeDualReverseClients({ @@ -424,4 +534,30 @@ describe('resolveName — forward asymmetry: no reverse miss-fallback on forward expect(result.error.code).toBe('NAME_NOT_FOUND'); expect(l1GetEnsAddress).not.toHaveBeenCalled(); }); + + it('opt-in ON: Sepolia-bound forward miss-falls back to L1 (SF-4 parity with reverse)', async () => { + const boundGetEnsAddress = vi.fn().mockResolvedValue(null); + const l1GetEnsAddress = vi.fn().mockResolvedValue(VITALIK_ADDRESS); + const { bound, l1 } = makeDualReverseClients({ + boundGetEnsName: vi.fn(), + l1GetEnsName: vi.fn(), + }); + bound.client.getEnsAddress = boundGetEnsAddress; + l1.client.getEnsAddress = l1GetEnsAddress; + + const service = createEvmNameResolutionService( + SEPOLIA_NETWORK_CONFIG, + bound.client, + l1.client, + ENABLE_MAINNET_L1_MISS_FALLBACK + ); + + const result = await service.resolveName('vitalik.eth'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.address).toBe(VITALIK_ADDRESS); + expect(boundGetEnsAddress).toHaveBeenCalledTimes(1); + expect(l1GetEnsAddress).toHaveBeenCalledTimes(1); + expectCompleteFallbackTriplet(result.value.provenance, SEPOLIA_NETWORK_CONFIG.id); + }); }); diff --git a/packages/adapter-evm-core/src/name-resolution/index.ts b/packages/adapter-evm-core/src/name-resolution/index.ts index 89c572d..8255709 100644 --- a/packages/adapter-evm-core/src/name-resolution/index.ts +++ b/packages/adapter-evm-core/src/name-resolution/index.ts @@ -29,5 +29,15 @@ export { type EnsProvenance, } from './ens-provenance'; export { isValidName, normalizeName } from './name-validation'; -export { baseEnsProvenance } from './provenance'; -export { EvmNameResolutionService, createEvmNameResolutionService } from './service'; +export { + baseEnsProvenance, + boundReverseProvenance, + composeNetworkFallbackProvenance, + MAINNET_NETWORK_ID, + networkFallbackProvenanceFields, +} from './provenance'; +export { + EvmNameResolutionService, + createEvmNameResolutionService, + type CreateEvmNameResolutionServiceOptions, +} from './service'; diff --git a/packages/adapter-evm-core/src/name-resolution/provenance.ts b/packages/adapter-evm-core/src/name-resolution/provenance.ts index fc672f1..b286a0d 100644 --- a/packages/adapter-evm-core/src/name-resolution/provenance.ts +++ b/packages/adapter-evm-core/src/name-resolution/provenance.ts @@ -10,6 +10,51 @@ import type { ResolutionProvenance } from '@openzeppelin/ui-types'; +/** + * Canonical mainnet repo network id — MUST match `ethereumMainnet.id` in + * `@openzeppelin/adapter-evm` (`packages/adapter-evm/src/networks/mainnet.ts`). + * Single source for `resolvedOnNetworkId` on 003 L1 miss-fallback successes (INV-6 / INV-24). + */ +export const MAINNET_NETWORK_ID = 'ethereum-mainnet' as const; + +/** + * The three base fallback fields adapters spread onto any L1 miss-fallback success provenance. + * Fresh object per call (INV-12). Does not set `label`, `external`, or `scopedToNetworkId` (INV-28). + * + * @param args.queriedOnNetworkId — bound adapter `networkConfig.id` that missed first (INV-7). + * @param args.resolvedOnNetworkId — network where the record was found; 003 always {@link MAINNET_NETWORK_ID}. + */ +export function networkFallbackProvenanceFields(args: { + readonly queriedOnNetworkId: string; + readonly resolvedOnNetworkId: string; +}): Pick< + ResolutionProvenance, + 'resolvedViaNetworkFallback' | 'queriedOnNetworkId' | 'resolvedOnNetworkId' +> { + return { + resolvedViaNetworkFallback: true, + queriedOnNetworkId: args.queriedOnNetworkId, + resolvedOnNetworkId: args.resolvedOnNetworkId, + }; +} + +/** + * Spread fallback triplet onto an existing success provenance (forward SF-4 / reverse L1). + * Preserves `label`, `external`, and `scopedToNetworkId` from the base object (INV-28). + */ +export function composeNetworkFallbackProvenance( + provenance: ResolutionProvenance, + args: { + readonly queriedOnNetworkId: string; + readonly resolvedOnNetworkId: string; + } +): ResolutionProvenance { + return { + ...provenance, + ...networkFallbackProvenanceFields(args), + }; +} + /** * Base provenance for a v1 forward resolution: a freshly-allocated `{ label: 'ENS', external: false }` * on every call (INV-5). diff --git a/packages/adapter-evm-core/src/name-resolution/service.ts b/packages/adapter-evm-core/src/name-resolution/service.ts index 211c010..860034d 100644 --- a/packages/adapter-evm-core/src/name-resolution/service.ts +++ b/packages/adapter-evm-core/src/name-resolution/service.ts @@ -59,7 +59,12 @@ import { unsupportedNetwork, } from './error-mapping'; import { isValidName as isValidEnsName, normalizeName } from './name-validation'; -import { baseEnsProvenance, boundReverseProvenance } from './provenance'; +import { + baseEnsProvenance, + boundReverseProvenance, + composeNetworkFallbackProvenance, + MAINNET_NETWORK_ID, +} from './provenance'; /** Which reverse client / provenance policy a single `attemptReverse` executes (002 SF-1). */ type ReverseAttemptKind = 'bound' | 'l1'; @@ -144,19 +149,26 @@ function describeNormalizeFailure(error: unknown): string { * repeated calls converge and concurrent calls never interfere. */ export class EvmNameResolutionService { + /** + * Frozen at construct time — only `=== true` enables miss-fallback (INV-2 / 003 SF-1). + */ + private readonly enableMainnetL1MissFallback: boolean; + /** * @param networkConfig - The bound (read-only) network config. * @param publicClient - The bound per-network viem client (D-A). Borrowed, never disposed (INV-21). - * @param ensL1Client - SF-5, OPTIONAL. A dedicated **mainnet** viem client, used ONLY when the - * bound network has no Universal Resolver, to resolve chain-scoped to the bound network via L1 - * (`coinType = toCoinType(boundChainId)`, D-V1). Also borrowed, never disposed. When absent, an - * L2-bound `resolveName` returns `UNSUPPORTED_NETWORK` exactly as SF-2 does today (D-B preserved). + * @param ensL1Client - SF-5, OPTIONAL. Mainnet client for non-UR forward chain-scoped resolution + * and for L1 miss-fallback when {@link enableMainnetL1MissFallback} is explicitly `true`. + * @param enableMainnetL1MissFallback - 003 SF-1 opt-in; normalized to strict `true` only (INV-2). */ constructor( private readonly networkConfig: TypedEvmNetworkConfig, private readonly publicClient: PublicClient, - private readonly ensL1Client?: PublicClient - ) {} + private readonly ensL1Client?: PublicClient, + enableMainnetL1MissFallback?: boolean + ) { + this.enableMainnetL1MissFallback = enableMainnetL1MissFallback === true; // INV-2, INV-9 + } /** * Synchronous ENSIP-15 shape check (INV-3/INV-4). No I/O; delegates to the client-free @@ -179,6 +191,8 @@ export class EvmNameResolutionService { * 2. shape gate fails → `UNSUPPORTED_NAME` * 3. normalize throws → `UNSUPPORTED_NAME` (D-D backstop) * 4–5. delegated to {@link resolveVia}: the one `getEnsAddress` call + ordered catch. + * 6. 003 SF-4 (UR bound only): bound `NAME_NOT_FOUND` + opt-in ON → single L1 `resolveVia` + + * SF-2 fallback triplet on success; bound gateway/`UNSUPPORTED_NAME` → terminal (INV-10). * * The selection ladder runs **before** the shape/normalize gates so an unsupported network wins * over a malformed name (SF-2 INV-12 precedence, preserved verbatim: a bad name on an unsupported @@ -196,13 +210,13 @@ export class EvmNameResolutionService { // (1b) the mainnet-L1 `ensL1Client` path is the CANONICAL path ONLY for chains WITHOUT their own // UR (L2s resolved via CCIP-Read + chain-scoped `coinType`) — it is NOT a fallback for a // name that (1a) failed to find. - // Consequence (intended): a testnet/chain with its own UR returns NAME_NOT_FOUND for a mainnet-only - // name — it never silently falls back to mainnet L1. This preserves bound-network semantics and - // namespace honesty: resolving a mainnet name against a bound chain and returning a mainnet address - // would be a cross-namespace answer the caller never asked for (a fund-safety hazard). + // Consequence (default OFF): a testnet/chain with its own UR returns NAME_NOT_FOUND for a + // mainnet-only name — it never silently falls back to mainnet L1 unless the integrator opted in + // via `enableMainnetL1MissFallback: true` (003 SF-1 / SF-4 implements the forward ladder). + const selectedBoundBranch = this.supportsEns(); let client: PublicClient; let coinType: bigint; - if (this.supportsEns()) { + if (selectedBoundBranch) { client = this.publicClient; // bound client carries a Universal Resolver (mainnet-bound) coinType = ETH_COIN_TYPE; // ETH / mainnet — unscoped } else if (this.ensL1Client) { @@ -235,7 +249,39 @@ export class EvmNameResolutionService { } // (4–5) The unified success routine — same for both client selections (INV-3). - return this.resolveVia(client, coinType, name, normalized); + const result = await this.resolveVia(client, coinType, name, normalized); + + // 003 SF-4: UR-bound definitive NAME_NOT_FOUND → single L1 resolveVia when opted in (INV-1). + if ( + selectedBoundBranch && + this.isForwardBoundMissEligibleForL1(result) && + this.mayConsultL1ForMissFallback() + ) { + const l1Result = await this.resolveVia(this.ensL1Client!, ETH_COIN_TYPE, name, normalized); + if (!l1Result.ok) { + return l1Result; // INV-12 — L1 terminal; no bound retry + } + return { + ok: true, + value: { + ...l1Result.value, + provenance: composeNetworkFallbackProvenance(l1Result.value.provenance, { + queriedOnNetworkId: this.networkConfig.id, + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }), + }, + }; + } + + return result; + } + + /** + * True iff the bound-tier forward attempt is a definitive empty / no-record miss eligible for L1 + * consult — NOT transport failure, NOT UNSUPPORTED_NAME (SF-4 INV-11 / D-S4-1). + */ + private isForwardBoundMissEligibleForL1(result: ResolutionResult): boolean { + return !result.ok && result.error.code === 'NAME_NOT_FOUND'; } /** @@ -348,22 +394,28 @@ export class EvmNameResolutionService { } /** - * Reverse resolution: address → name (002 SF-1 / Option B miss-fallback). Returns a discriminated - * {@link ResolutionResult}; **never throws for an expected failure** (INV-7). The sole sanctioned - * throw is `RuntimeDisposedError`, raised by the factory's guard proxy *before* this body runs. + * Reverse resolution: address → name (002 SF-1 / 003 SF-3 Option B miss-fallback). Returns a + * discriminated {@link ResolutionResult}; **never throws for an expected failure** (INV-7). The + * sole sanctioned throw is `RuntimeDisposedError`, raised by the factory's guard proxy *before* + * this body runs. * - * Option B ladder (Specify Revision 1 / Design D-R1–D-R10): + * 003 SF-3 ladder (inherits 002 Option B; L1 tiers gated by SF-1 `mayConsultL1ForMissFallback()`): * * 0. use-after-dispose → `RuntimeDisposedError` (guard proxy) * 1. malformed address → `ADDRESS_NOT_FOUND` (sync, before I/O) * 2. `supportsEns()` → bound `attemptReverse` first * - success → bound provenance per D-R7; stop (no L1) * - failure → typed error; stop (**no** miss-fallback — INV-9) - * - empty + `ensL1Client` + not mainnet-bound → L1 `attemptReverse` - * - empty + (no L1 or mainnet-bound) → `ADDRESS_NOT_FOUND` - * 3. else `ensL1Client` + not mainnet-bound → L1 direct + * - empty + `mayConsultL1ForMissFallback()` → L1 `attemptReverse` (003 SF-1) + * - empty + gate false → `ADDRESS_NOT_FOUND` + * 3. else `mayConsultL1ForMissFallback()` → L1 direct (non-UR) * 4. else → `UNSUPPORTED_NETWORK` (sync, before I/O) * + * L1 success after bound-empty miss: `buildEnsProvenance` + SF-2 fallback triplet + * (`precededByBoundMiss`). Non-UR direct L1: `buildEnsProvenance` only (001-1b parity). + * `scopedToNetworkId` absent on all L1 hits (002 D-R7). Opt-in OFF: bound-empty / non-UR + * terminate without L1 (SF-1 SC-001). + * * Bound and L1 attempts use `strict: true`, Approach A suppress-on-mismatch, observing clients * for truthful `viaGateway`, and selected-client avatar affinity (INV-18). * @@ -386,16 +438,16 @@ export class EvmNameResolutionService { if (boundOutcome.kind === 'failure') { return boundOutcome.result; } - // Definitive bound empty — miss-fallback to L1 only when wired and not mainnet-bound (INV-22). - if (this.ensL1Client && !this.isMainnetBound()) { - return this.finishL1Attempt(address); + // Definitive bound empty — L1 miss-fallback only when integrator opted in (INV-5 / INV-6). + if (this.mayConsultL1ForMissFallback()) { + return this.finishL1Attempt(address, true); } return { ok: false, error: addressNotFound(address) }; } - // (3) Non-UR + L1 wired → L1 direct (mainnet-bound never reaches here with L1 — INV-22). - if (this.ensL1Client && !this.isMainnetBound()) { - return this.finishL1Attempt(address); + // (3) Non-UR + opted-in L1 → L1 direct (INV-7) — canonical path, not bound miss-fallback. + if (this.mayConsultL1ForMissFallback()) { + return this.finishL1Attempt(address, false); } // (4) No UR and no L1 client — pre-I/O unsupported (INV-21). @@ -405,9 +457,20 @@ export class EvmNameResolutionService { /** * Terminal handling for an L1 reverse attempt (direct or miss-fallback). No further client exists * after L1 — empty and failure both terminate here (INV-10). + * + * @param precededByBoundMiss - When true, bound UR tier returned definitive empty before this L1 + * consult — SF-2 triplet is emitted on success. False for non-UR direct L1 (001-1b parity). */ - private async finishL1Attempt(address: string): Promise> { - const l1Outcome = await this.attemptReverse(this.ensL1Client!, address, 'l1'); + private async finishL1Attempt( + address: string, + precededByBoundMiss: boolean + ): Promise> { + const l1Outcome = await this.attemptReverse( + this.ensL1Client!, + address, + 'l1', + precededByBoundMiss + ); if (l1Outcome.kind === 'success') { return { ok: true, value: l1Outcome.value }; } @@ -425,7 +488,8 @@ export class EvmNameResolutionService { private async attemptReverse( client: PublicClient, address: string, - kind: ReverseAttemptKind + kind: ReverseAttemptKind, + precededByBoundMiss = false ): Promise { let sawOffchain = false; const callClient = deriveObservingClient(client, () => { @@ -468,7 +532,7 @@ export class EvmNameResolutionService { } const avatarUrl = await this.tryGetAvatar(client, name); - const provenance = + const baseProvenance = kind === 'l1' ? buildEnsProvenance({ external: sawOffchain, @@ -478,6 +542,16 @@ export class EvmNameResolutionService { : this.isMainnetBound() ? baseEnsProvenance() : boundReverseProvenance(this.networkConfig.id); + // SF-3 INV-3 / D-S3-1: SF-2 triplet only when L1 follows a real bound-empty miss + // (`precededByBoundMiss`). Non-UR direct L1 omits triplet (forward 001-1b parity). + // `scopedToNetworkId` stays absent on all L1 hits (INV-5 / D-R7). + const provenance = + kind === 'l1' && precededByBoundMiss + ? composeNetworkFallbackProvenance(baseProvenance, { + queriedOnNetworkId: this.networkConfig.id, + resolvedOnNetworkId: MAINNET_NETWORK_ID, + }) + : baseProvenance; return { kind: 'success', @@ -491,6 +565,19 @@ export class EvmNameResolutionService { }; } + /** + * Authoritative miss-fallback eligibility for both directions (003 SF-1 INV-5). + * L1 consult is permitted only when opt-in is strictly true, L1 client is wired, and the adapter + * is not mainnet-bound (INV-24). + */ + private mayConsultL1ForMissFallback(): boolean { + return ( + this.enableMainnetL1MissFallback === true && // INV-2 + this.ensL1Client !== undefined && // INV-3 + !this.isMainnetBound() // INV-24 + ); + } + /** True when the bound chain is Ethereum mainnet — drives miss-fallback fence and D-R7 scope (INV-22). */ private isMainnetBound(): boolean { return this.networkConfig.chainId === mainnet.id; @@ -553,10 +640,20 @@ export class EvmNameResolutionService { * (D-A / INV-25). `ensL1Client` is optional: when omitted the service resolves mainnet-bound exactly * as SF-2 does, and an L2-bound resolve returns `UNSUPPORTED_NETWORK` (D-B preserved). */ +export interface CreateEvmNameResolutionServiceOptions { + readonly enableMainnetL1MissFallback?: boolean; +} + export function createEvmNameResolutionService( networkConfig: TypedEvmNetworkConfig, publicClient: PublicClient, - ensL1Client?: PublicClient + ensL1Client?: PublicClient, + options?: CreateEvmNameResolutionServiceOptions ): EvmNameResolutionService { - return new EvmNameResolutionService(networkConfig, publicClient, ensL1Client); + return new EvmNameResolutionService( + networkConfig, + publicClient, + ensL1Client, + options?.enableMainnetL1MissFallback + ); } diff --git a/packages/adapter-evm/package.json b/packages/adapter-evm/package.json index 601ca6f..d9a881a 100644 --- a/packages/adapter-evm/package.json +++ b/packages/adapter-evm/package.json @@ -60,7 +60,7 @@ "@openzeppelin/adapter-runtime-utils": "workspace:*", "@openzeppelin/ui-components": "^2.0.0", "@openzeppelin/ui-react": "^2.0.1", - "@openzeppelin/ui-types": "^3.2.0", + "@openzeppelin/ui-types": "^3.3.0", "@openzeppelin/ui-utils": "^2.0.0", "@rainbow-me/rainbowkit": "^2.2.8", "@tanstack/react-query": "^5.84.1", @@ -83,7 +83,7 @@ "peerDependencies": { "@openzeppelin/ui-components": "^2.0.0", "@openzeppelin/ui-react": "^2.0.1", - "@openzeppelin/ui-types": "^3.2.0", + "@openzeppelin/ui-types": "^3.3.0", "@openzeppelin/ui-utils": "^2.0.0", "@rainbow-me/rainbowkit": "^2.2.8", "@tanstack/react-query": "^5.74.7", diff --git a/packages/adapter-evm/src/__tests__/profile-runtime.test.ts b/packages/adapter-evm/src/__tests__/profile-runtime.test.ts index 3a85463..ea25be2 100644 --- a/packages/adapter-evm/src/__tests__/profile-runtime.test.ts +++ b/packages/adapter-evm/src/__tests__/profile-runtime.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createNameResolution } from '../capabilities'; import { ethereumSepolia } from '../networks'; import { createRuntime } from '../profiles/shared'; @@ -181,4 +182,26 @@ describe('EVM profile runtimes', () => { expect(queryLoadContractSpy).toHaveBeenCalledTimes(1); expect(contractLoadingLoadSpy).toHaveBeenCalledWith({ contractAddress: '0x1234' }); }, 30000); + + it('threads enableMainnetL1MissFallback from CreateRuntimeOptions into createNameResolution', () => { + vi.mocked(createNameResolution).mockClear(); + + createRuntime('viewer', ethereumSepolia, { + nameResolution: { enableMainnetL1MissFallback: true }, + }); + + expect(createNameResolution).toHaveBeenCalledWith( + ethereumSepolia, + expect.objectContaining({ enableMainnetL1MissFallback: true }) + ); + }); + + it('omits enableMainnetL1MissFallback when runtime options do not opt in', () => { + vi.mocked(createNameResolution).mockClear(); + + createRuntime('viewer', ethereumSepolia); + + const [, options] = vi.mocked(createNameResolution).mock.calls.at(-1) ?? []; + expect(options).not.toHaveProperty('enableMainnetL1MissFallback'); + }); }); diff --git a/packages/adapter-evm/src/profiles/shared.ts b/packages/adapter-evm/src/profiles/shared.ts index e876172..b81e272 100644 --- a/packages/adapter-evm/src/profiles/shared.ts +++ b/packages/adapter-evm/src/profiles/shared.ts @@ -12,6 +12,7 @@ import { } from '@openzeppelin/adapter-runtime-utils'; import type { CapabilityFactoryMap, + CreateRuntimeOptions, EcosystemRuntime, ExecutionConfig, NetworkConfig, @@ -88,6 +89,29 @@ function ensL1Client(config: TypedEvmNetworkConfig) { return createEvmPublicClient(resolveMainnetRpcUrl(config), mainnet); } +/** + * Shared `createNameResolution` options for profile factories (003 SF-1). + * + * Static {@link capabilityFactories} omit runtime options — miss-fallback stays OFF (INV-12). + * {@link createRuntime} threads `CreateRuntimeOptions.nameResolution.enableMainnetL1MissFallback` + * when strictly `=== true` (INV-2). + */ +function buildNameResolutionCreateOptions( + config: TypedEvmNetworkConfig, + runtimeOptions?: CreateRuntimeOptions +) { + const enableMainnetL1MissFallback = runtimeOptions?.nameResolution?.enableMainnetL1MissFallback; + + return { + publicClient: ensClient(config), + // ensL1Client is only useful for L2→L1 cross-chain; on mainnet-bound the bound client already + // has the Universal Resolver, so building a second mainnet client is waste-only. + // Obs: hand-rolled chainId:1 whose viemChain lacks ensUniversalResolver now → UNSUPPORTED_NETWORK (was L1 path); unreachable for shipped configs (viem `mainnet` defines the UR). + ...(config.chainId === mainnet.id ? {} : { ensL1Client: ensL1Client(config) }), // SF-5 / D-V1 + ...(enableMainnetL1MissFallback === true ? { enableMainnetL1MissFallback } : {}), + }; +} + function bridgeSignAndBroadcast( execution: ReturnType ): ( @@ -120,16 +144,11 @@ export const capabilityFactories: CapabilityFactoryMap = { wallet: (config: NetworkConfig) => createWallet(toTypedEvmNetworkConfig(config)), uiKit: (config: NetworkConfig) => createUiKit(toTypedEvmNetworkConfig(config)), relayer: (config: NetworkConfig) => createRelayer(toTypedEvmNetworkConfig(config)), - nameResolution: (config: NetworkConfig) => { - const typed = toTypedEvmNetworkConfig(config); - // ensL1Client is only useful for L2→L1 cross-chain; on mainnet-bound the bound client already - // has the Universal Resolver, so building a second mainnet client is waste-only. - // Obs: hand-rolled chainId:1 whose viemChain lacks ensUniversalResolver now → UNSUPPORTED_NETWORK (was L1 path); unreachable for shipped configs (viem `mainnet` defines the UR). - return createNameResolution(typed, { - publicClient: ensClient(typed), - ...(typed.chainId === mainnet.id ? {} : { ensL1Client: ensL1Client(typed) }), // SF-5 / D-V1 - }); - }, + nameResolution: (config: NetworkConfig) => + createNameResolution( + toTypedEvmNetworkConfig(config), + buildNameResolutionCreateOptions(toTypedEvmNetworkConfig(config)) + ), accessControl: (config: NetworkConfig) => { const typedConfig = toTypedEvmNetworkConfig(config); const execution = createExecution(typedConfig); @@ -143,7 +162,10 @@ export const capabilityFactories: CapabilityFactoryMap = { }, }; -function createRuntimeCapabilityFactories(config: TypedEvmNetworkConfig): CapabilityFactoryMap { +function createRuntimeCapabilityFactories( + config: TypedEvmNetworkConfig, + runtimeOptions?: CreateRuntimeOptions +): CapabilityFactoryMap { return createLazyRuntimeCapabilityFactories(config, { addressing: () => createAddressing(), explorer: () => createExplorer(config), @@ -161,12 +183,7 @@ function createRuntimeCapabilityFactories(config: TypedEvmNetworkConfig): Capabi uiKit: () => createUiKit(config), relayer: () => createRelayer(config), nameResolution: () => - createNameResolution(config, { - publicClient: ensClient(config), - // ensL1Client is only useful for L2→L1 cross-chain; skip on mainnet-bound (waste-only). - // Obs: hand-rolled chainId:1 whose viemChain lacks ensUniversalResolver now → UNSUPPORTED_NETWORK (was L1 path); unreachable for shipped configs (viem `mainnet` defines the UR). - ...(config.chainId === mainnet.id ? {} : { ensL1Client: ensL1Client(config) }), // SF-5 / D-V1 - }), + createNameResolution(config, buildNameResolutionCreateOptions(config, runtimeOptions)), accessControl: (_runtimeConfig, getCapability) => createAccessControl(config, { signAndBroadcast: bridgeSignAndBroadcast( @@ -179,7 +196,12 @@ function createRuntimeCapabilityFactories(config: TypedEvmNetworkConfig): Capabi export function createRuntime( profile: ProfileName, config: TypedEvmNetworkConfig, - options?: { uiKit?: string } + options?: CreateRuntimeOptions ): EcosystemRuntime { - return createCoreRuntime(profile, config, createRuntimeCapabilityFactories(config), options); + return createCoreRuntime( + profile, + config, + createRuntimeCapabilityFactories(config, options), + options + ); } diff --git a/packages/adapter-evm/test/sf-5-published-release.test.ts b/packages/adapter-evm/test/sf-5-published-release.test.ts new file mode 100644 index 0000000..661f309 --- /dev/null +++ b/packages/adapter-evm/test/sf-5-published-release.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment node +/** + * SF-5 · Published release via `@openzeppelin/adapter-evm` — release-correctness probes. + * + * Verifies the 003 terminal release contract (INV-R1, SC-005, V-1–V-7): dual-package changeset, + * ui-types ^3.3.0 floor, and 003 delta bundled into the npm tarball — not runtime behavior + * (covered by SF-1–SF-4 suites). + */ +import { execSync } from 'node:child_process'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const ADAPTER_EVM_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const ADAPTER_EVM_CORE_ROOT = resolve(ADAPTER_EVM_ROOT, '../adapter-evm-core'); +const REPO_ROOT = resolve(ADAPTER_EVM_ROOT, '../..'); +const CHANGESET_PATH = resolve(REPO_ROOT, '.changeset/ens-mainnet-l1-opt-in-fallback.md'); + +const UI_TYPES_FLOOR = '^3.3.0'; + +/** Design V-1–V-7 markers that must appear in shipped adapter-evm dist JS. */ +const BUNDLED_DELTA_MARKERS = [ + { id: 'V-1', pattern: 'enableMainnetL1MissFallback', label: 'SF-1 opt-in seam bundled' }, + { id: 'V-2', pattern: 'mayConsultL1ForMissFallback', label: 'SF-1 gate + SF-3/4 ladder gating' }, + { id: 'V-3', pattern: 'resolvedViaNetworkFallback', label: 'SF-2 triplet discriminant' }, + { id: 'V-4', pattern: 'queriedOnNetworkId', label: 'SF-2 triplet queried network' }, + { id: 'V-5', pattern: 'resolvedOnNetworkId', label: 'SF-2 triplet resolved network' }, + { id: 'V-6', pattern: 'networkFallbackProvenanceFields', label: 'SF-2 provenance builder' }, + { id: 'V-7', pattern: 'ethereum-mainnet', label: 'SF-2 MAINNET_NETWORK_ID literal' }, +] as const; + +type PackageManifest = { + peerDependencies?: Record; + devDependencies?: Record; +}; + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, 'utf8')) as T; +} + +function parseChangesetFrontmatter(content: string): Record { + const match = /^---\n([\s\S]*?)\n---/m.exec(content); + if (!match) { + throw new Error( + 'INV-R1: changeset frontmatter missing — release-trap guard cannot be verified' + ); + } + const entries: Record = {}; + for (const line of match[1].split('\n')) { + const parsed = /^"([^"]+)":\s*(\S+)\s*$/.exec(line.trim()); + if (parsed) { + entries[parsed[1]] = parsed[2]; + } + } + return entries; +} + +function collectBundledJs(distDir: string): string { + const bundleFiles = readdirSync(distDir).filter( + (name) => name.endsWith('.mjs') || name.endsWith('.cjs') + ); + if (bundleFiles.length === 0) { + throw new Error(`BUNDLED-DELTA: no .mjs/.cjs files under ${distDir}`); + } + return bundleFiles.map((name) => readFileSync(join(distDir, name), 'utf8')).join('\n'); +} + +function assertMarkersPresent(bundle: string, context: string): void { + for (const { id, pattern, label } of BUNDLED_DELTA_MARKERS) { + expect( + bundle.includes(pattern), + `${id} (${label}): expected "${pattern}" in ${context} — 003 delta not bundled (002 release-trap variant)` + ).toBe(true); + } +} + +function assertUiTypesFloor(manifest: PackageManifest, packageLabel: string): void { + for (const slot of ['peerDependencies', 'devDependencies'] as const) { + const range = manifest[slot]?.['@openzeppelin/ui-types']; + expect( + range, + `FLOOR: ${packageLabel} ${slot} must declare @openzeppelin/ui-types (triplet type fields)` + ).toBe(UI_TYPES_FLOOR); + } +} + +describe('SF-5 published release correctness', () => { + describe('INV-R1 · RELEASE-TRAP GUARD — dual-package changeset', () => { + it('lists @openzeppelin/adapter-evm with a semver bump (public npm target)', () => { + const content = readFileSync(CHANGESET_PATH, 'utf8'); + const frontmatter = parseChangesetFrontmatter(content); + expect( + frontmatter['@openzeppelin/adapter-evm'], + 'INV-R1: adapter-evm must be bumped — core-only changeset leaves npm consumers on stale 2.2.0 (002 trap)' + ).toBeTruthy(); + }); + + it('lists @openzeppelin/adapter-evm-core with a semver bump (private bundled source)', () => { + const content = readFileSync(CHANGESET_PATH, 'utf8'); + const frontmatter = parseChangesetFrontmatter(content); + expect( + frontmatter['@openzeppelin/adapter-evm-core'], + 'INV-R1: adapter-evm-core must be bumped in lockstep — workspace truth must match bundled bits' + ).toBeTruthy(); + }); + + it('does not ship a core-only changeset (adapter-evm absent)', () => { + const content = readFileSync(CHANGESET_PATH, 'utf8'); + const frontmatter = parseChangesetFrontmatter(content); + const publicBump = frontmatter['@openzeppelin/adapter-evm']; + const coreBump = frontmatter['@openzeppelin/adapter-evm-core']; + expect( + publicBump && coreBump, + `INV-R1: both packages required; got adapter-evm=${publicBump ?? 'MISSING'}, adapter-evm-core=${coreBump ?? 'MISSING'}` + ).toBeTruthy(); + }); + }); + + describe('FLOOR CORRECTNESS — @openzeppelin/ui-types ^3.3.0', () => { + it('adapter-evm declares ui-types ^3.3.0 in peerDependencies and devDependencies', () => { + const manifest = readJson(resolve(ADAPTER_EVM_ROOT, 'package.json')); + assertUiTypesFloor(manifest, '@openzeppelin/adapter-evm'); + }); + + it('adapter-evm-core declares ui-types ^3.3.0 in peerDependencies and devDependencies', () => { + const manifest = readJson(resolve(ADAPTER_EVM_CORE_ROOT, 'package.json')); + assertUiTypesFloor(manifest, '@openzeppelin/adapter-evm-core'); + }); + }); + + describe('SC-005 · BUNDLED-DELTA in published tarball', () => { + let tarballDistBundle: string; + let workspaceDistBundle: string; + + beforeAll(() => { + execSync('pnpm run build', { cwd: ADAPTER_EVM_ROOT, stdio: 'pipe', encoding: 'utf8' }); + + workspaceDistBundle = collectBundledJs(resolve(ADAPTER_EVM_ROOT, 'dist')); + + const extractRoot = mkdtempSync(join(tmpdir(), 'sf5-adapter-evm-pack-')); + let tarballPath = ''; + try { + const tarballName = execSync('npm pack --silent', { + cwd: ADAPTER_EVM_ROOT, + encoding: 'utf8', + }).trim(); + tarballPath = resolve(ADAPTER_EVM_ROOT, tarballName); + execSync(`tar -xzf ${JSON.stringify(tarballPath)}`, { cwd: extractRoot, stdio: 'pipe' }); + tarballDistBundle = collectBundledJs(join(extractRoot, 'package', 'dist')); + } finally { + rmSync(extractRoot, { recursive: true, force: true }); + if (tarballPath) { + rmSync(tarballPath, { force: true }); + } + } + }, 120_000); + + it('workspace dist contains V-1–V-7 markers after build', () => { + assertMarkersPresent(workspaceDistBundle, 'packages/adapter-evm/dist'); + }); + + it('npm pack tarball dist contains V-1–V-7 markers (consumer install path)', () => { + assertMarkersPresent(tarballDistBundle, 'npm pack tarball package/dist'); + }); + + it('threads enableMainnetL1MissFallback through createRuntime wiring in tarball', () => { + expect( + tarballDistBundle.includes('enableMainnetL1MissFallback'), + 'SC-005: opt-in miss-fallback must be bundled into published JS via createRuntime profile wiring' + ).toBe(true); + }); + + it('emits cross-network provenance triplet fields in tarball', () => { + for (const field of [ + 'resolvedViaNetworkFallback', + 'queriedOnNetworkId', + 'resolvedOnNetworkId', + ] as const) { + expect( + tarballDistBundle.includes(field), + `SC-005: provenance triplet field "${field}" must ship in npm tarball dist` + ).toBe(true); + } + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9c1a29..846815a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ importers: version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.7.0(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) commitizen: specifier: ^4.3.1 version: 4.3.1(@types/node@25.9.1)(typescript@5.9.3) @@ -121,7 +121,7 @@ importers: version: 0.19.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(synckit@0.11.12)(typescript@5.9.3) vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) packages/adapter-evm: dependencies: @@ -157,8 +157,8 @@ importers: specifier: ^2.0.1 version: 2.0.1(@babel/runtime@7.29.2)(@tanstack/react-query@5.91.2(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) '@openzeppelin/ui-types': - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^3.3.0 + version: 3.3.0 '@openzeppelin/ui-utils': specifier: ^2.0.0 version: 2.0.0 @@ -209,7 +209,7 @@ importers: version: 2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) wagmi: specifier: ^2.16.1 version: 2.19.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.1(@babel/core@7.29.7)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.4)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.91.2)(@tanstack/react-query@5.91.2(react@19.2.4))(@types/react@19.2.14)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.1(@babel/core@7.29.7)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) @@ -242,8 +242,8 @@ importers: specifier: ^2.0.1 version: 2.0.1(@babel/runtime@7.29.2)(@tanstack/react-query@5.91.2(react@18.3.1))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@openzeppelin/ui-types': - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^3.3.0 + version: 3.3.0 '@openzeppelin/ui-utils': specifier: ^2.0.0 version: 2.0.0 @@ -279,7 +279,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) wagmi: specifier: ^2.16.1 version: 2.19.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.84.1(@babel/core@7.29.7)(@types/react@19.2.14)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.91.2)(@tanstack/react-query@5.91.2(react@18.3.1))(@types/react@19.2.14)(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.84.1(@babel/core@7.29.7)(@types/react@19.2.14)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.44.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) @@ -382,7 +382,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@26.1.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) packages/adapter-polkadot: dependencies: @@ -461,7 +461,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) packages/adapter-runtime-utils: devDependencies: @@ -476,7 +476,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) packages/adapter-solana: dependencies: @@ -534,7 +534,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@26.1.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) packages/adapter-stellar: dependencies: @@ -601,7 +601,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@26.1.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) packages/adapters-vite: devDependencies: @@ -631,10 +631,10 @@ importers: version: 5.9.3 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) vitest: specifier: ^4.1.0 - version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + version: 4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) packages: @@ -718,6 +718,10 @@ packages: resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -773,8 +777,8 @@ packages: peerDependencies: '@babel/core': ^7.29.6 - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.29.6 @@ -847,6 +851,10 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -1689,6 +1697,9 @@ packages: '@openzeppelin/ui-types@3.2.0': resolution: {integrity: sha512-884c+a8k/EM86wNuknwUUjWvWXTwlW7twjUoNpkFtOD1fzKvP8/T//m4Jzw8TMGUA9PbpLVVOsyffNfCz2gLbA==} + '@openzeppelin/ui-types@3.3.0': + resolution: {integrity: sha512-O2CVNs4oc4EJFOzZkye6OJNlhSWHpqrrlVhzh7cVU+uye+cVvwEXoLbtJ8cti0JoNb6RKTKH+DPvfRg5r5R+Aw==} + '@openzeppelin/ui-utils@2.0.0': resolution: {integrity: sha512-nOFe2H8Yeg/gGX64NDHDPg4icPCCLKo9Y4S0LJFOmucFJ7o7VhG/WYf+lXRxCgUgkShd8ctTB0hDg/vDVnWrkA==} @@ -2636,8 +2647,8 @@ packages: resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} '@sinclair/typebox@0.33.22': resolution: {integrity: sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==} @@ -3739,6 +3750,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/object-inspect@1.13.0': resolution: {integrity: sha512-lwGTVESDDV+XsQ1pH4UifpJ1f7OtXzQ6QBOX2Afq2bM/T3oOt8hF6exJMjjIjtEWeAN2YAo25J7HxWh97CCz9w==} @@ -4302,6 +4316,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -4592,12 +4611,15 @@ packages: blakejs@1.2.1: resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} - bn.js@4.12.3: - resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + borsh@0.7.0: resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} @@ -6444,6 +6466,10 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -7071,8 +7097,8 @@ packages: node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} - node-addon-api@8.7.0: - resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + node-addon-api@8.9.0: + resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} engines: {node: ^18 || ^20 || >= 21} node-exports-info@1.6.0: @@ -7368,6 +7394,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -7453,8 +7483,13 @@ packages: preact@10.29.0: resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} - preact@10.29.2: - resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -7978,6 +8013,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -8030,8 +8070,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} side-channel-list@1.0.0: @@ -8313,8 +8353,8 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - terser@5.47.1: - resolution: {integrity: sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==} + terser@5.49.0: + resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} engines: {node: '>=10'} hasBin: true @@ -8357,6 +8397,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -8549,6 +8593,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unfetch@4.2.0: resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} @@ -8986,6 +9033,18 @@ packages: utf-8-validate: optional: true + ws@7.5.12: + resolution: {integrity: sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -9053,6 +9112,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -9249,6 +9312,8 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.29.7': {} @@ -9275,77 +9340,77 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': dependencies: @@ -9359,6 +9424,8 @@ snapshots: '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -9649,16 +9716,17 @@ snapshots: '@coinbase/wallet-sdk@3.9.3': dependencies: - bn.js: 5.2.3 + bn.js: 5.2.5 buffer: 6.0.3 clsx: 1.2.1 eth-block-tracker: 7.1.0 eth-json-rpc-filters: 6.0.1 eventemitter3: 5.0.4 keccak: 3.0.4 - preact: 10.29.2 + preact: 10.29.7 sha.js: 2.4.12 transitivePeerDependencies: + - preact-render-to-string - supports-color '@coinbase/wallet-sdk@4.3.0': @@ -10186,7 +10254,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.2 + js-yaml: 3.15.0 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -10199,21 +10267,21 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 26.1.1 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.9.1 + '@types/node': 26.1.1 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 '@jest/schemas@29.6.3': dependencies: - '@sinclair/typebox': 0.27.10 + '@sinclair/typebox': 0.27.12 '@jest/transform@29.7.0': dependencies: @@ -10240,7 +10308,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.9.1 + '@types/node': 26.1.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -10479,7 +10547,7 @@ snapshots: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.13 debug: 4.4.3 - semver: 7.8.0 + semver: 7.8.5 superstruct: 1.0.4 transitivePeerDependencies: - supports-color @@ -10979,6 +11047,8 @@ snapshots: '@openzeppelin/ui-types@3.2.0': {} + '@openzeppelin/ui-types@3.3.0': {} + '@openzeppelin/ui-utils@2.0.0': dependencies: '@openzeppelin/ui-types': 2.0.0 @@ -12091,8 +12161,8 @@ snapshots: hermes-parser: 0.32.0 invariant: 2.2.4 nullthrows: 1.1.1 - tinyglobby: 0.2.16 - yargs: 17.7.2 + tinyglobby: 0.2.17 + yargs: 17.7.3 '@react-native/community-cli-plugin@0.84.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)': dependencies: @@ -12102,7 +12172,7 @@ snapshots: metro: 0.83.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) metro-config: 0.83.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) metro-core: 0.83.7 - semver: 7.8.0 + semver: 7.8.5 transitivePeerDependencies: - bufferutil - supports-color @@ -12115,9 +12185,9 @@ snapshots: debug: 4.4.3 invariant: 2.2.4 metro: 0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6) - metro-config: 0.83.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) + metro-config: 0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-core: 0.83.7 - semver: 7.8.0 + semver: 7.8.5 transitivePeerDependencies: - bufferutil - supports-color @@ -12146,7 +12216,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10) + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - supports-color @@ -12166,7 +12236,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -12883,7 +12953,7 @@ snapshots: '@simple-libs/stream-utils@1.2.0': optional: true - '@sinclair/typebox@0.27.10': {} + '@sinclair/typebox@0.27.12': {} '@sinclair/typebox@0.33.22': {} @@ -14407,7 +14477,7 @@ snapshots: bitcoin-ops: 1.4.1 blake-hash: 2.0.0 blakejs: 1.2.1 - bn.js: 5.2.3 + bn.js: 5.2.5 bs58: 6.0.0 bs58check: 4.0.0 create-hmac: 1.1.7 @@ -14488,7 +14558,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 '@types/hast@2.3.10': dependencies: @@ -14534,6 +14604,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + '@types/object-inspect@1.13.0': {} '@types/prismjs@1.26.6': {} @@ -14771,7 +14845,7 @@ snapshots: dependencies: '@vanilla-extract/css': 1.17.3 - '@vitejs/plugin-react@4.7.0(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -14779,7 +14853,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -14792,13 +14866,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) + + '@vitest/mocker@4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -14858,6 +14940,7 @@ snapshots: - db0 - encoding - ioredis + - preact-render-to-string - react - supports-color - uploadthing @@ -14898,6 +14981,7 @@ snapshots: - db0 - encoding - ioredis + - preact-render-to-string - react - supports-color - uploadthing @@ -14948,6 +15032,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react - react-native - supports-color @@ -15001,6 +15086,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react - react-native - supports-color @@ -16844,6 +16930,8 @@ snapshots: acorn@8.16.0: {} + acorn@8.17.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -17047,7 +17135,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 5.2.1 @@ -17073,7 +17161,7 @@ snapshots: '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) @@ -17174,10 +17262,12 @@ snapshots: blakejs@1.2.1: {} - bn.js@4.12.3: {} + bn.js@4.12.5: {} bn.js@5.2.3: {} + bn.js@5.2.5: {} + borsh@0.7.0: dependencies: bn.js: 5.2.3 @@ -17334,7 +17424,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -17343,7 +17433,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -17799,7 +17889,7 @@ snapshots: elliptic@6.6.1: dependencies: - bn.js: 4.12.3 + bn.js: 4.12.5 brorand: 1.1.0 hash.js: 1.1.7 hmac-drbg: 1.0.1 @@ -18300,6 +18390,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + feaxios@0.0.23: dependencies: is-retry-allowed: 3.0.0 @@ -19140,7 +19234,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 26.1.1 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -19150,7 +19244,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.9.1 + '@types/node': 26.1.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -19177,7 +19271,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 26.1.1 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -19185,7 +19279,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.9.1 + '@types/node': 26.1.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -19202,7 +19296,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.9.1 + '@types/node': 26.1.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -19224,6 +19318,11 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -19698,6 +19797,22 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true + + metro-config@0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-cache: 0.83.7 + metro-core: 0.83.7 + metro-runtime: 0.83.7 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate metro-core@0.83.7: dependencies: @@ -19722,7 +19837,7 @@ snapshots: metro-minify-terser@0.83.7: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.47.1 + terser: 5.49.0 metro-resolver@0.83.7: dependencies: @@ -19730,7 +19845,7 @@ snapshots: metro-runtime@0.83.7: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 metro-source-map@0.83.7: @@ -19788,6 +19903,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true metro-transform-worker@0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: @@ -19848,12 +19964,13 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10) - yargs: 17.7.2 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@5.0.10) + yargs: 17.7.3 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate + optional: true metro@0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: @@ -19880,7 +19997,7 @@ snapshots: metro-babel-transformer: 0.83.7 metro-cache: 0.83.7 metro-cache-key: 0.83.7 - metro-config: 0.83.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) + metro-config: 0.83.7(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-core: 0.83.7 metro-file-map: 0.83.7 metro-resolver: 0.83.7 @@ -19894,8 +20011,8 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) - yargs: 17.7.2 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yargs: 17.7.3 transitivePeerDependencies: - bufferutil - supports-color @@ -20057,7 +20174,7 @@ snapshots: node-addon-api@5.1.0: {} - node-addon-api@8.7.0: {} + node-addon-api@8.9.0: {} node-exports-info@1.6.0: dependencies: @@ -20373,6 +20490,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + pidtree@0.6.0: {} pify@3.0.0: {} @@ -20462,7 +20581,7 @@ snapshots: preact@10.29.0: {} - preact@10.29.2: {} + preact@10.29.7: {} prelude-ls@1.2.1: {} @@ -20599,8 +20718,8 @@ snapshots: react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: - shell-quote: 1.8.3 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10) + shell-quote: 1.10.0 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -20608,8 +20727,8 @@ snapshots: react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: - shell-quote: 1.8.3 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + shell-quote: 1.10.0 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -20671,12 +20790,12 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.8.0 + semver: 7.8.5 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10) - yargs: 17.7.2 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@5.0.10) + yargs: 17.7.3 optionalDependencies: '@types/react': 19.2.14 transitivePeerDependencies: @@ -20720,12 +20839,12 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.8.0 + semver: 7.8.5 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@5.0.10) - yargs: 17.7.2 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@5.0.10) + yargs: 17.7.3 optionalDependencies: '@types/react': 19.2.14 transitivePeerDependencies: @@ -20769,12 +20888,12 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.8.0 + semver: 7.8.5 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) - yargs: 17.7.2 + ws: 7.5.12(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yargs: 17.7.3 optionalDependencies: '@types/react': 19.2.14 transitivePeerDependencies: @@ -21218,6 +21337,8 @@ snapshots: semver@7.8.0: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -21294,7 +21415,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.3: {} + shell-quote@1.10.0: {} side-channel-list@1.0.0: dependencies: @@ -21596,10 +21717,10 @@ snapshots: term-size@2.2.1: {} - terser@5.47.1: + terser@5.49.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.17.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -21624,7 +21745,7 @@ snapshots: tiny-secp256k1@1.1.7: dependencies: bindings: 1.5.0 - bn.js: 4.12.3 + bn.js: 4.12.5 create-hmac: 1.1.7 elliptic: 6.6.1 nan: 2.26.2 @@ -21643,6 +21764,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinyrainbow@3.1.0: {} tldts-core@6.1.86: {} @@ -21828,6 +21954,8 @@ snapshots: undici-types@7.24.6: {} + undici-types@8.3.0: {} + unfetch@4.2.0: {} unicorn-magic@0.1.0: {} @@ -21938,7 +22066,7 @@ snapshots: usb@2.17.0: dependencies: '@types/w3c-web-usb': 1.0.13 - node-addon-api: 8.7.0 + node-addon-api: 8.9.0 node-gyp-build: 4.8.4 use-callback-ref@1.3.3(@types/react@19.2.14)(react@18.3.1): @@ -22068,7 +22196,7 @@ snapshots: - utf-8-validate - zod - vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0): + vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -22079,16 +22207,33 @@ snapshots: '@types/node': 25.9.1 fsevents: 2.3.3 jiti: 2.6.1 - terser: 5.47.1 + terser: 5.49.0 yaml: 2.9.0 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)): + vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.49.0 + yaml: 2.9.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + vitest@4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -22105,7 +22250,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 @@ -22113,10 +22258,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)): + vitest@4.1.8(@types/node@25.9.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -22133,7 +22278,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.47.1)(yaml@2.9.0) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.9.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 @@ -22141,6 +22286,62 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@types/node@26.1.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - msw + + vitest@4.1.8(@types/node@26.1.1)(jsdom@26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@26.1.1)(jiti@2.6.1)(terser@5.49.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + jsdom: 26.1.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - msw + vlq@1.0.1: {} w3c-xmlserializer@5.0.0: @@ -22186,6 +22387,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react-native - supports-color - uploadthing @@ -22231,6 +22433,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react-native - supports-color - uploadthing @@ -22370,6 +22573,17 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 6.0.6 + ws@7.5.12(bufferutil@4.1.0)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 5.0.10 + optional: true + + ws@7.5.12(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@5.0.10): optionalDependencies: bufferutil: 4.1.0 @@ -22445,6 +22659,16 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/vitest.shared.config.ts b/vitest.shared.config.ts index 88a6603..4b563e7 100644 --- a/vitest.shared.config.ts +++ b/vitest.shared.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from 'vitest/config'; export const sharedVitestConfig = defineConfig({ plugins: [react()], + resolve: { + dedupe: ['@openzeppelin/ui-types', '@openzeppelin/ui-renderer'], + }, optimizeDeps: { include: ['@openzeppelin/ui-renderer', '@openzeppelin/ui-types'], }, From eb6bbaca299078dcdc00ea6fc680335b6c86957d Mon Sep 17 00:00:00 2001 From: Aleksandr Pasevin Date: Fri, 17 Jul 2026 13:42:16 +0100 Subject: [PATCH 2/2] fix(adapter-evm): address PR #55 review comments - Use execFileSync(tar, [...]) instead of a shell-interpolated command in the SF-5 release test, resolving the CodeQL shell-command alert. - Compute the typed EVM config once in the nameResolution capability factory instead of calling toTypedEvmNetworkConfig twice. Co-authored-by: Cursor --- packages/adapter-evm/src/profiles/shared.ts | 9 ++++----- packages/adapter-evm/test/sf-5-published-release.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/adapter-evm/src/profiles/shared.ts b/packages/adapter-evm/src/profiles/shared.ts index b81e272..7c9519a 100644 --- a/packages/adapter-evm/src/profiles/shared.ts +++ b/packages/adapter-evm/src/profiles/shared.ts @@ -144,11 +144,10 @@ export const capabilityFactories: CapabilityFactoryMap = { wallet: (config: NetworkConfig) => createWallet(toTypedEvmNetworkConfig(config)), uiKit: (config: NetworkConfig) => createUiKit(toTypedEvmNetworkConfig(config)), relayer: (config: NetworkConfig) => createRelayer(toTypedEvmNetworkConfig(config)), - nameResolution: (config: NetworkConfig) => - createNameResolution( - toTypedEvmNetworkConfig(config), - buildNameResolutionCreateOptions(toTypedEvmNetworkConfig(config)) - ), + nameResolution: (config: NetworkConfig) => { + const typedConfig = toTypedEvmNetworkConfig(config); + return createNameResolution(typedConfig, buildNameResolutionCreateOptions(typedConfig)); + }, accessControl: (config: NetworkConfig) => { const typedConfig = toTypedEvmNetworkConfig(config); const execution = createExecution(typedConfig); diff --git a/packages/adapter-evm/test/sf-5-published-release.test.ts b/packages/adapter-evm/test/sf-5-published-release.test.ts index 661f309..37e9b63 100644 --- a/packages/adapter-evm/test/sf-5-published-release.test.ts +++ b/packages/adapter-evm/test/sf-5-published-release.test.ts @@ -6,7 +6,7 @@ * ui-types ^3.3.0 floor, and 003 delta bundled into the npm tarball — not runtime behavior * (covered by SF-1–SF-4 suites). */ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -147,7 +147,7 @@ describe('SF-5 published release correctness', () => { encoding: 'utf8', }).trim(); tarballPath = resolve(ADAPTER_EVM_ROOT, tarballName); - execSync(`tar -xzf ${JSON.stringify(tarballPath)}`, { cwd: extractRoot, stdio: 'pipe' }); + execFileSync('tar', ['-xzf', tarballPath], { cwd: extractRoot, stdio: 'pipe' }); tarballDistBundle = collectBundledJs(join(extractRoot, 'package', 'dist')); } finally { rmSync(extractRoot, { recursive: true, force: true });