diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a469c9ef..29246f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,9 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Feature-branch supersession is useful, but main's long native network + # proof must be allowed to finish even if another merge lands meanwhile. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: test: @@ -131,9 +133,171 @@ jobs: FREEDOM_IPFS_NATIVE_SMOKE_LIVE: '0' FREEDOM_IPFS_NATIVE_SMOKE_ISSUE_102: '1' - # Settings navigation and persistence are shipped control surfaces. Exercise - # their real renderer/webview integration so malformed sidebar markup and - # broken section transitions cannot pass unit-only CI. + # Load every native Myotis addon target that release packaging requires. + # The live network proof below remains one representative architecture per + # shipped OS; this fast matrix closes the packaging gap for Intel macOS and + # ARM64 Linux without multiplying the expensive cold-sync workload. + myotis-addon-load: + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + addon: myotis-bin/mac-arm64/myotis-node.node + - os: macos-15-intel + target: darwin-x64 + addon: myotis-bin/mac-x64/myotis-node.node + - os: ubuntu-latest + target: linux-x64 + addon: myotis-bin/linux-x64/myotis-node.node + - os: ubuntu-24.04-arm + target: linux-arm64 + addon: myotis-bin/linux-arm64/myotis-node.node + - os: windows-latest + target: win32-x64 + addon: myotis-bin/win-x64/myotis-node.node + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Download this runner's Myotis addon + run: npm run myotis:download + env: + GITHUB_TOKEN: ${{ github.token }} + MYOTIS_DOWNLOAD_TARGET: ${{ matrix.target }} + + - name: Load addon and verify ABI + run: node -e "const addon=require('./'+process.env.MYOTIS_ADDON); const abi=addon.init(); if(abi!==22) throw new Error('Expected ABI 22, got '+abi); console.log(process.platform+'-'+process.arch+' ABI '+abi);" + env: + MYOTIS_ADDON: ${{ matrix.addon }} + + # Real Myotis release-addon + Freedom resolver E2E on every operating + # system we ship. One production-app launch cold-syncs over libp2p/devp2p, + # proves direct verified Ethereum + Gnosis reads, then exercises ENS plus + # WNS/GNS through Freedom's Myotis resolver tier. Avoid a raw-smoke launch + # followed by an immediate app restart: persisted peer backoff can leave the + # second client temporarily unable to reacquire a usable snap peer even + # though the first launch just served verified reads. + # + # Cold sync can take 30-40 minutes. Cache restore/save are split so a timed + # out first attempt still preserves its cleanly-stopped partial sync; a + # re-run continues from that snapshot instead of starting from zero. + myotis-native-e2e: + # A feature branch with an open PR otherwise launches this 30-40 minute + # live-network matrix twice (push + pull_request), doubling cost and exposure + # to transient peer selection. Keep PR/manual gates and post-merge coverage on main. + if: github.event_name != 'push' || github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + addon: myotis-bin/mac-arm64/myotis-node.node + - os: ubuntu-latest + target: linux-x64 + addon: myotis-bin/linux-x64/myotis-node.node + - os: windows-latest + target: win32-x64 + addon: myotis-bin/win-x64/myotis-node.node + runs-on: ${{ matrix.os }} + timeout-minutes: 190 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Linux native USB build dependency + if: runner.os == 'Linux' + uses: ./.github/actions/install-linux-native-deps + + - name: Install dependencies + run: npm ci + + - name: Install Playwright system dependencies + if: runner.os == 'Linux' + run: npx playwright install-deps + + - name: Download Myotis release addons + run: npm run myotis:download + env: + GITHUB_TOKEN: ${{ github.token }} + MYOTIS_DOWNLOAD_TARGET: ${{ matrix.target }} + + - name: Assert native-addon target + run: node -e "const actual=process.platform+'-'+process.arch; const expected=process.env.EXPECTED_TARGET; if(actual!==expected) throw new Error('expected '+expected+', got '+actual); console.log(actual);" + env: + EXPECTED_TARGET: ${{ matrix.target }} + + - name: Restore Myotis sync snapshot + id: myotis-cache + uses: actions/cache/restore@v4 + with: + path: ${{ runner.temp }}/myotis-data + key: myotis-${{ hashFiles('scripts/fetch-myotis.js') }}-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + myotis-${{ hashFiles('scripts/fetch-myotis.js') }}-${{ matrix.target }}- + + - name: Download IPFS native addon + run: npm run ipfs:download + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Run Freedom Myotis resolver E2E + shell: bash + env: + MYOTIS_DATA_DIR: ${{ runner.temp }}/myotis-data + MYOTIS_NODE_PATH: ${{ github.workspace }}/${{ matrix.addon }} + MYOTIS_E2E_READY_TIMEOUT_MIN: '75' + FREEDOM_TEST_HIDE_WINDOW: '1' + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + xvfb-run -a npm run test:e2e:live -- test-e2e/live/myotis-ens.spec.js + else + npm run test:e2e:live -- test-e2e/live/myotis-ens.spec.js + fi + + - name: Save Myotis sync snapshot + if: always() + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/myotis-data + key: myotis-${{ hashFiles('scripts/fetch-myotis.js') }}-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Upload Playwright traces and screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: myotis-native-e2e-${{ matrix.target }}-traces + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + retention-days: 14 + + # Settings navigation, persistence, and chain policy are shipped control + # surfaces. Exercise their real renderer/webview integration so malformed + # sidebar markup, broken transitions, and Myotis policy regressions cannot + # pass unit-only CI. e2e-settings: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index 9d187e1d..bc9ed064 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ tmp/ *.swp .env ant-bin/ +myotis-bin/ ant-data/ assets/adblock/ ipfs-bin/ diff --git a/README.md b/README.md index b9ab7876..a8225220 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ [![License: MPL-2.0](https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg)](https://opensource.org/licenses/MPL-2.0) [![Platform](https://img.shields.io/badge/platform-macOS%20|%20Linux%20|%20Windows-lightgrey)](https://github.com/solardev-xyz/freedom-browser/releases) -Freedom is a browser for the decentralized web, with Swarm, IPFS, Radicle, ENS, and Tezos Domains as first-class protocols. -It ships with integrated Swarm, IPFS, and Radicle nodes, enabling direct peer-to-peer network access without relying on centralized HTTP gateways. Radicle is available on macOS and Linux; the Windows build ships without Radicle until official Windows binaries are published upstream. +Freedom is a browser for the decentralized web, with Swarm, IPFS, onchain applications, Radicle, ENS, and Tezos Domains as first-class protocols. +It ships with integrated Swarm, IPFS, Radicle, and experimental Myotis nodes, enabling direct peer-to-peer network access without relying on centralized HTTP gateways. Radicle is available on macOS and Linux; the Windows build ships without Radicle until official Windows binaries are published upstream. --- @@ -24,6 +24,7 @@ It ships with integrated Swarm, IPFS, and Radicle nodes, enabling direct peer-to npm run ant:download npm run ipfs:download npm run radicle:download + npm run myotis:download ``` 4. **Launch the app:** @@ -32,15 +33,34 @@ It ships with integrated Swarm, IPFS, and Radicle nodes, enabling direct peer-to npm start ``` -5. Swarm and IPFS nodes start automatically by default. To use `rad://`, first enable **Settings → Experimental → Enable Radicle integration (Beta)**. Enter a Swarm hash, IPFS CID, Radicle ID, `bzz://` URL, `ipfs://` URL, `rad://` URL, or `.eth`/`.box`/`.wei`/`.gwei`/`.tez` domain in the address bar. +5. Swarm and IPFS nodes start automatically by default. Myotis is an opt-in embedded Ethereum light client under **Settings → Automatic Startup**. To use `rad://`, first enable **Settings → Experimental → Enable Radicle integration (Beta)**. Enter a Swarm hash, IPFS CID, onchain app contract (`web3://:`), Radicle ID, `bzz://` URL, `ipfs://` URL, `rad://` URL, or `.eth`/`.box`/`.wei`/`.gwei`/`.tez` domain in the address bar. --- ## Architecture -Freedom Browser is an Electron application. Protocol logic lives in the main process; the renderer is a modular UI layer that talks to it over IPC (channels defined in `src/shared/ipc-channels.js`). The main process manages node lifecycles (`ant-manager.js`, `ipfs-manager.js`, `radicle-manager.js`, `tor-manager.js`), URL rewriting (`request-rewriter.js`), and persistent data (settings, bookmarks, history). A central `service-registry.js` tracks node endpoints, modes, and status, and broadcasts state to all windows — both node managers and the request rewriter read from it. +Freedom Browser is an Electron application. Protocol logic lives in the main process; the renderer is a modular UI layer that talks to it over IPC (channels defined in `src/shared/ipc-channels.js`). The main process manages node lifecycles (`ant-manager.js`, `ipfs-manager.js`, `myotis/myotis-manager.js`, `radicle-manager.js`, `tor-manager.js`), URL rewriting (`request-rewriter.js`), and persistent data (settings, bookmarks, history). A central `service-registry.js` tracks node endpoints, modes, and status, and broadcasts state to all windows — both node managers and the request rewriter read from it. -When a user enters a `bzz://`, `ipfs://`, `ipns://`, `rad://`, `.onion`, or ENS URL, the main process either dispatches to a custom protocol handler (`bzz`, `ipfs`, `ipns`) that proxies to the local node, rewrites the URL to the active gateway URL via the registry (`rad`), or routes `.onion` hosts through the active profile's Tor SOCKS5 endpoint. `rad://` handling is gated by the Radicle integration setting, and `.onion` routing is gated by the Tor integration setting. `bzz://` navigation is additionally gated by a cold-start probe (see next section). `ipfs://` / `ipns://` navigation goes straight to the native IPFS protocol handler, so no renderer warm-up probe is needed. +When a user enters a `bzz://`, `ipfs://`, `ipns://`, `web3://`, `rad://`, `.onion`, or ENS URL, the main process either dispatches to a custom protocol handler (`bzz`, `ipfs`, `ipns`, `web3`), rewrites the URL to the active gateway URL via the registry (`rad`), or routes `.onion` hosts through the active profile's Tor SOCKS5 endpoint. `rad://` handling is gated by the Radicle integration setting, and `.onion` routing is gated by the Tor integration setting. `bzz://` navigation is additionally gated by a cold-start probe (see next section). `ipfs://` / `ipns://` navigation goes straight to the native IPFS protocol handler, while `web3://` reads an ERC-8244 HTML document from the selected chain without a gateway. + +--- + +## Contract-hosted Applications (ERC-8244) + +Freedom has native support for the draft ERC-8244 `html()` interface. Enter the ERC-4804-style form `web3://:/` in the address bar; omitting `:` defaults to Ethereum mainnet. Freedom keeps that standard form in browser chrome, history, bookmarks, copying, and permission prompts while the webview navigates to a Chromium-safe, chain-scoped origin internally: + +```text +web3://0x00000095643CFfA7D9fae407a84dfCB6406456c6 +→ web3://0x00000095643cffa7d9fae407a84dfcb6406456c6.eip155-1/ +``` + +The `.eip155-` suffix is invisible browser plumbing, not an extra DNS or gateway dependency. A bare all-hex `0x…` standard-scheme host is rejected by Chromium as an oversized IPv4 literal, while using the chain as a URL port triggers unsafe-port rules and excludes large chain IDs. The internal hostname gives each contract-and-chain pair a distinct web-storage origin; page scripts and DevTools therefore see that real internal origin, while Freedom's user-facing surfaces reverse-map it to the standard URL. + +The `web3:` protocol handler calls selector `0x33c34ac3` (`html()`) through the same capability-aware chain-data router used by the wallet: Myotis when available, then Colibri, RPC quorum, and direct RPC fallback according to network policy. It ABI-decodes the returned UTF-8 string and serves those bytes unchanged as `text/html`; paths, queries, and fragments remain available to the app as client-side routes. Reads have a 30-second browser deadline and an 8 MiB decoded-document limit. + +Contract HTML runs in a context-isolated webview with a response-enforced sandbox and default-deny content policy. Inline scripts/styles and embedded `data:`/`blob:` media work, but ambient network connections, external frames, workers, objects, scripted top-level redirects, and popups are blocked. Genuine user link clicks are handed back to Freedom's navigation chrome. The app receives the existing EIP-1193/EIP-6963 wallet provider; reads and approvals are pinned to the chain encoded in its origin, and `wallet_switchEthereumChain` cannot silently move it to another chain. Private windows can render the document but continue to omit wallet providers. + +This first slice resolves contracts directly. Registry fallback and upgrade-policy discovery described as optional extensions in the ERC are not inferred by the browser; an upgrade-aware resolver contract can expose its own `html()` result. --- @@ -176,29 +196,24 @@ Don't hardcode `http://localhost:8080` — Freedom no longer exposes a desktop I ## Features -### Triple Node Architecture +### Integrated Node Architecture -Freedom runs Swarm, IPFS, Radicle, and Tor nodes, giving you access to decentralized and onion networks from a single interface. +Freedom runs Swarm, IPFS, Radicle, Tor, and an experimental Myotis Ethereum light client from a single interface. -| | Swarm | IPFS | Radicle | Tor (.onion) | -| -------------------- | -------------- | ------------------------------------- | ------------------------------ | ----------------------------- | -| **Protocol** | `bzz://` | `ipfs://`, `ipns://` | `rad://` | `http(s)://*.onion` | -| **Node Software** | Ant (antd, bee-compatible) | freedom-ipfs native | radicle-node + radicle-httpd | Arti SOCKS5 proxy | -| **Hash Format** | 64 or 128-char hex (encrypted refs supported) | CIDv0 (`Qm...`) or CIDv1 (`bafy...`) | Repository ID (`z...`) | Onion service hostname | -| **Managed Gateway Port** | 11633+ | internal native handler | 18780+ | n/a | -| **Managed API Port** | 11633+ | internal native handler | 18780+ | n/a | -| **Managed P2P Port** | 12633+ | internal native handler | 18776+ | n/a | -| **Managed SOCKS Port** | n/a | n/a | n/a | 19150+ | -| **Route Prefix** | `/bzz/{hash}/` | `/ipfs/{cid}/`, `/ipns/{name}/` | `/api/v1/repos/{rid}/` | SOCKS5 for `.onion` hosts | -| **Data Directory** | `/ant-data/` | `/ipfs-data/freedom-ipfs/` | profile-scoped short Radicle home | `/tor-data/` | -| **Binary Directory** | `ant-bin/` | `native/freedom-ipfs-node/` | `radicle-bin/` | `arti-bin/` | +| | Swarm | IPFS | Myotis | Radicle | Tor (.onion) | +| -------------------- | -------------- | ------------------------------------- | ------------------------------ | ------------------------------ | ----------------------------- | +| **Protocol role** | `bzz://` | `ipfs://`, `ipns://` | Verified Ethereum/Gnosis reads and transaction broadcast | `rad://` | `http(s)://*.onion` | +| **Node Software** | Ant (antd, bee-compatible) | freedom-ipfs native | Myotis native addon | radicle-node + radicle-httpd | Arti SOCKS5 proxy | +| **Managed ports** | API 11633+, P2P 12633+ | none; embedded native handler | none; embedded native client | HTTP 18780+, P2P 18776+ | SOCKS 19150+ | +| **Data Directory** | `/ant-data/` | `/ipfs-data/freedom-ipfs/` | `/myotis/` (Ethereum) and `/myotis/gnosis/` | profile-scoped short Radicle home | `/tor-data/` | +| **Binary Directory** | `ant-bin/` | `native/freedom-ipfs-node/` | `myotis-bin/` | `radicle-bin/` | `arti-bin/` | ### Smart Node Connection Freedom manages nodes per browser profile: -1. **Independent Managed Nodes**: By default, each profile starts its own Ant, native IPFS, Radicle, and Arti data directories. Ant, Radicle, and Tor use profile-specific non-default ports; IPFS uses the embedded native handler without loopback API or gateway ports. -2. **Explicit External Nodes**: Profiles can opt into external Swarm/Radicle endpoints or an external Tor SOCKS5 endpoint in profile settings. External node identity, storage, and circuit state are shared outside that profile. IPFS always uses the embedded `freedom-ipfs` native node. +1. **Independent Managed Nodes**: Every profile owns separate Ant, native IPFS, Myotis, Radicle, and Arti data. Ant, Radicle, and Tor use profile-specific non-default ports; IPFS and Myotis are embedded native clients without loopback API or gateway ports. +2. **Explicit External Nodes**: Profiles can opt into external Swarm/Radicle endpoints or an external Tor SOCKS5 endpoint in profile settings. External node identity, storage, and circuit state are shared outside that profile. IPFS and Myotis always use their embedded native clients. 3. **Port Conflict Handling**: If a managed Ant, Radicle, or Tor profile port is busy, Freedom picks a free profile port and persists the reassignment. 4. **Visual Feedback**: The Nodes panel and profile settings show whether a node is managed, external/shared, or disabled. @@ -225,6 +240,16 @@ launching can use `open -n -a Freedom --args --profile=`. - **Native Transport**: Uses the embedded `freedom-ipfs` native addon instead of a loopback Kubo process. - **Live Diagnostics**: View native gateway stats and request progress while IPFS/IPNS pages load. +### Integrated Myotis Ethereum Light Client (Experimental) + +- **Per-profile clients**: Each profile has independent Ethereum and Gnosis runtimes and state, and never reuses a separately installed Myotis application. +- **Native transport**: Runs in-process through the Myotis native addon, with no HTTP API or managed port. +- **Independent controls**: Ethereum and Gnosis have separate autostart, runtime toggle, sync status, peer count, and finalized-block controls. +- **Chain data routing**: Wallet balances, transaction preparation, signed-transaction broadcast, and compatible dapp reads prefer Myotis, then Colibri verification, RPC quorum, and direct RPC according to each chain's settings. Unsupported methods transparently continue to the next source. +- **Gnosis verification**: Both Myotis and Colibri are available as verified Gnosis sources; Colibri keeps separate verifier state per chain. +- **Verified ENS reads**: Once synced, ENS content, address, and forward-verified reverse records are resolved against finalized Ethereum state before Freedom falls back to its configured verification method. ERC-3668/CCIP-Read records use their declared gateway only to retrieve the callback payload; Myotis verifies the callback against the same chain state. +- **WNS/GNS adapters**: Freedom executes the existing WNS and GNS NameNFT calls through Myotis's local EVM path. Myotis v0.1.7 serves generic contract calls from the beacon optimistic head without a finalized verdict, so the UI labels those answers unverified rather than overstating their trust. + ### Integrated Radicle Node (macOS & Linux) - **Two-Process Architecture**: Manages both `radicle-node` (P2P network) and `radicle-httpd` (HTTP API) as a coordinated pair. @@ -260,6 +285,8 @@ The address bar also provides **autocomplete suggestions** from browsing history - **Automatic Resolution**: `.eth`, `.box`, `.wei`, and `.gwei` domains resolve to their Swarm, IPFS, or IPNS content. `.eth` and `.box` use ENS; `.wei` uses Wei Name Service (WNS); `.gwei` uses Gwei Name Service (GNS). - **CCIP-Read Support**: `.box` domains resolve via offchain CCIP-Read (EIP-3668) through 3dns.xyz. +- **Ordered Resolution Policy**: Settings → Name Resolution presents Myotis, Colibri, RPC quorum, and direct RPC as one ordered list. Methods can be enabled, disabled, and reprioritized per profile. By default Freedom tries Myotis, then Colibri, then RPC quorum; direct RPC is disabled. +- **Verified-Answer Preference**: An unverified result can be held provisionally while later enabled methods try to produce a verified answer. If none succeeds, Freedom uses the provisional result and applies the configured warning behavior. - **Protocol Detection**: Automatically detects and routes to Swarm (`bzz://`), IPFS (`ipfs://`), or IPNS (`ipns://`) content. - **Transport-Aware Address Bar**: After resolution, the address bar shows the resolved transport with the name as the host — e.g. `vitalik.eth` resolves and displays as `ipfs://vitalik.eth`, a Swarm-backed `mysite.eth` displays as `bzz://mysite.eth`, a WNS-backed `alice.wei` displays as `ipfs://alice.wei`, and a GNS-backed `apoorv.gwei` displays as `ipfs://apoorv.gwei`. The legacy `ens://` form is still accepted as input (and stored bookmarks keep working) but is no longer the canonical display. - **Typed Scheme Is an Assertion**: Typing `bzz://name.eth`, `ipfs://name.eth`, `ipns://name.eth`, or the equivalent `.wei`/`.gwei` forms only resolves if the contenthash matches the typed transport. Mismatches surface as a "resolves to X, not Y" message rather than silently switching transports — same rule the `bzz://` protocol handler enforces for subresource fetches. Bare names and the legacy `ens://` form make no assertion and accept any supported transport. @@ -314,7 +341,7 @@ The address bar also provides **autocomplete suggestions** from browsing history ### Bookmarks - **Address Bar Star**: Click the star icon to bookmark or unbookmark the current page. -- **Supported Protocols**: Bookmark any `bzz://`, `ipfs://`, `ipns://`, `rad://`, `http://`, or `https://` URL. +- **Supported Protocols**: Bookmark any `bzz://`, `ipfs://`, `ipns://`, `web3://`, `rad://`, `http://`, or `https://` URL. - **Named Bookmarks**: Name and edit bookmarks via modal or right-click. - **Bookmarks Bar**: Quick access below the toolbar, with an overflow menu when bookmarks don't fit. Always visible on the new tab page; toggle visibility on other pages with `Cmd+Shift+B` / `Ctrl+Shift+B` (persisted across sessions). @@ -329,7 +356,7 @@ The address bar also provides **autocomplete suggestions** from browsing history - **Ephemeral by construction**: Every private window runs its webviews on a unique in-memory session (`private-` partition, never written to disk). Cookies, logins, caches, and site data evaporate when the window closes. - **No local traces**: Nothing browsed in a private window is written to history, the favicon cache, or address-bar autocomplete. Downloads still work, but their entries are kept in memory only — never written to the profile's download database, visible only inside the private window, and gone when it closes (saved files stay on disk). Site-permission decisions made in a private window last only as long as the window — never remembered, even if you tick "remember". - **Wallet disabled**: Your identity and wallet are persistent by design, so they are unavailable in private windows — pages see no `window.ethereum` / `window.swarm` / `window.radicle` (nothing announces via EIP-6963), and x402 pay-per-request interception is off. Use a normal window for anything wallet-related. -- **Decentralized protocols still work**: `bzz://`, `ipfs://`, `ipns://`, and ENS names resolve and load through the shared local nodes. Publishing (which records publish history) is unavailable from private windows. +- **Decentralized protocols still work**: `bzz://`, `ipfs://`, `ipns://`, `web3://`, and ENS names resolve and load through the shared local nodes/chain-data router. Publishing (which records publish history) and wallet providers are unavailable from private windows. - **What private windows do NOT protect**: This is local privacy, not anonymity. Websites you sign in to still know it's you; your network operator can still see your traffic; Swarm/IPFS/Radicle peers still see your nodes' requests; and your IP address remains visible to every site and peer. The private new-tab page spells this out. ### Downloads @@ -380,11 +407,11 @@ Access built-in browser pages using the `freedom://` protocol: ### Settings & UI - **Theme**: Light, Dark, or System (follows OS preference). -- **Node Auto-start**: Toggle whether Swarm and IPFS nodes start automatically at launch (enabled by default). +- **Node Auto-start**: Toggle whether Swarm, IPFS, and experimental Myotis start automatically at launch. Myotis remains off by default. - **Site Permissions**: When a site asks to use your camera, microphone, notifications, clipboard, location, or MIDI devices, a prompt appears under the address bar (Allow / Block, with "Remember for this site"). Remembered decisions are listed under Settings → Site Permissions with per-permission, per-site, and remove-all revocation; sites with granted permissions show an indicator icon in the address bar with quick revoke. - **Experimental**: Enable Radicle integration (Beta) and set `Start Radicle node when Freedom opens`. - **Auto-Updates**: Toggle automatic update checks (enabled by default). -- **Protocol Icons**: Address bar shows Swarm (hexagon), IPFS (cube), Radicle (seedling), or HTTP (globe) icon based on current protocol. +- **Protocol Icons**: Address bar shows Swarm (hexagon), IPFS (cube), onchain app (Ethereum diamond), Radicle (seedling), or HTTP (globe) icon based on current protocol. - **Hamburger Menu**: Access browser features (New Tab, New Window, History, Zoom, Print, Developer Tools, Settings, About). ### Error Handling @@ -404,10 +431,11 @@ Freedom automatically manages node connections per profile. The default profile' - **Swarm Ant**: `http://127.0.0.1:11633` - **IPFS**: embedded native `freedom-ipfs` handler; no desktop loopback gateway/API port is started +- **Myotis**: embedded native Ethereum light client; no desktop loopback API or managed port is started - **Radicle httpd**: `http://127.0.0.1:18780` - **Tor Arti SOCKS5**: `127.0.0.1:19150` -Named profiles use the next profile slot for Ant, Radicle, and Tor (`11634`, `18781`, `19151`, and so on). The ecosystem default Swarm/Radicle/Tor ports (`1633`, `8780`, `9150`) are treated as external/system-node endpoints, not Freedom-managed defaults. IPFS is native-only and does not expose or reuse Kubo API/gateway ports. +Named profiles use the next profile slot for Ant, Radicle, and Tor (`11634`, `18781`, `19151`, and so on). The ecosystem default Swarm/Radicle/Tor ports (`1633`, `8780`, `9150`) are treated as external/system-node endpoints, not Freedom-managed defaults. IPFS and Myotis are native-only and do not expose or reuse external daemon ports. If Freedom detects a compatible Swarm, Radicle, or Tor daemon on an ecosystem default port while that protocol is starting, it asks whether that profile should use the existing external node or keep an independent managed node. This check runs both during profile startup and when a managed node is started manually from the Nodes menu. @@ -422,11 +450,11 @@ npm start ### External Protocol Links And Profiles -Inside Freedom, `bzz://`, `ipfs://`, `ipns://`, `rad://`, and `.onion` URLs always resolve through the active profile's node settings and storage. OS-level protocol launches from other apps are a v1 limitation: they are not profile-aware and should not be used when a link must open in a specific profile. Open the target profile first and paste or navigate to the URL inside that window. +Inside Freedom, `bzz://`, `ipfs://`, `ipns://`, `web3://`, `rad://`, and `.onion` URLs always resolve through the active profile's node/network settings and storage. OS-level protocol launches from other apps are a v1 limitation: they are not profile-aware and should not be used when a link must open in a specific profile. Open the target profile first and paste or navigate to the URL inside that window. ### Ethereum Name Resolution -ENS, WNS, and GNS domains are resolved using Ethereum JSON-RPC. ENS uses the ENS Universal Resolver; WNS reads the Wei Name Service contract directly; GNS reads the Gwei Name Service contract directly. The browser tries multiple public RPC providers in sequence (see `src/main/ens-resolver.js` for the current list). You can prepend your own endpoint by setting the `ETH_RPC` environment variable. +ENS, WNS, and GNS domains share an ordered, per-profile resolution policy. Myotis resolves through the embedded P2P light client, Colibri verifies remote proofs locally, RPC quorum requires byte-identical responses from independent providers, and direct RPC trusts one configured endpoint. ENS uses the ENS Universal Resolver; WNS reads the Wei Name Service contract directly; GNS reads the Gwei Name Service contract directly. Configure method order under **Settings → Name Resolution** and RPC endpoints under **Settings → Chains**. **Recommended: Helios Light Client** @@ -558,12 +586,12 @@ the Tor Project's pure-Rust Tor client. It is **off by default** and gated behin | Directory | Contents | | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `src/main/` | Electron main process — node managers (Ant, IPFS, Radicle), ENS resolver, IPC, settings, history, bookmarks, auto-updater | +| `src/main/` | Electron main process — node managers (Ant, IPFS, Myotis, Radicle), ENS resolver, IPC, settings, history, bookmarks, auto-updater | | `src/renderer/` | UI — tabs, navigation, address bar, menus, context menus, bookmarks bar, debug console, settings modal | | `src/renderer/pages/` | Internal pages (home, history, error, links, protocol-test, rad-browser) | | `src/shared/` | Constants shared between main and renderer | | `config/` | Ant config template, default bookmarks, macOS entitlements | -| `scripts/` | Build and setup helpers (binary downloads, Ant/IPFS/Radicle init) | +| `scripts/` | Build and setup helpers (binary/addon downloads, Ant/IPFS/Myotis/Radicle init) | | `assets/` | App icons | --- @@ -582,7 +610,7 @@ npm test The suite covers most of `src/main/` and `src/renderer/lib/` — see `src/**/*.test.js` for the full list. Notable areas include: -- **Networking & protocols**: `bzz-protocol`, `swarm-probe`, `swarm-service`, `swarm-provider-ipc`, `request-rewriter`, `ens-resolver`, `ipfs-manager`, `radicle-manager`, `ant-manager`, `service-registry` +- **Networking & protocols**: `bzz-protocol`, `swarm-probe`, `swarm-service`, `swarm-provider-ipc`, `request-rewriter`, `ens-resolver`, `ipfs-manager`, `myotis-manager`, `radicle-manager`, `ant-manager`, `service-registry` - **Renderer navigation & UI**: `navigation`, `navigation-utils`, `tabs`, `tabs-ui`, `bookmarks-ui`, `autocomplete`, `menus`, `page-context-menu`, `settings-ui`, `wallet/*` - **Identity, vault & wallet**: `identity/derivation`, `identity/vault`, `identity/formats`, `wallet/dapp-permissions`, `wallet/transaction-service` - **Parsing & utilities**: `url-utils`, `cid-utils`, `origin-utils`, `ethereum-uri`, `page-urls`, `brand` @@ -594,7 +622,7 @@ Two Playwright projects live under `test-e2e/`. The harness suite is run manuall | Suite | Command | Files | What it does | | --- | --- | --- | --- | -| `harness` | `npm run test:e2e` | `test-e2e/*.spec.js` | Launches Electron with `FREEDOM_TEST_MODE=1`. The in-process harness in `src/main/test-harness.js` stubs Ant/IPFS startup, ENS resolution, the Swarm probe, and the `bzz:` / `ipfs:` / `ipns:` protocol handlers, so specs are fast (~15 s end-to-end), deterministic, and require no network or downloaded binaries. Covers address-bar normalisation, tabs, bookmarks, settings persistence, and the error-page flow. | +| `harness` | `npm run test:e2e` | `test-e2e/*.spec.js` | Launches Electron with `FREEDOM_TEST_MODE=1`. The in-process harness in `src/main/test-harness.js` stubs Ant/IPFS startup, ENS resolution, the Swarm probe, and the `bzz:` / `ipfs:` / `ipns:` / `web3:` protocol handlers, so specs are fast (~15 s end-to-end), deterministic, and require no network or downloaded binaries. Covers address-bar normalisation, tabs, bookmarks, settings persistence, and the error-page flow. | | `live` | `npm run test:e2e:live` | `test-e2e/live/*.spec.js` | Launches Electron without the harness — actual Ant + native IPFS startup, live ENS resolution, real `bzz://` / `ipfs://` protocol handlers. The live smoke waits for Swarm peers and for native IPFS to report running, then navigates to `meinhard.eth` (Swarm) and `vitalik.eth` (IPFS). `npm run test:e2e:tor` runs the Tor-only live smoke against real Arti and a live `.onion` service. Requires the matching binary download/build first; missing binaries/addons skip before Electron launches. | `npm run test:e2e:tor` sets `FREEDOM_LIVE_E2E_DISABLE_DEFAULT_NODES=1`, which makes the live fixtures seed settings that keep Ant / IPFS / Radicle / Tor from autostarting, so the Tor smoke doesn't pay for (or inherit the flakiness of) the other nodes booting. Both suites use a per-run temp `userData` directory (`FREEDOM_TEST_USER_DATA`) so they never touch your real settings, bookmarks, or history. Sequential runs only (`workers: 1`) — Electron + protocol-scheme registration and Ant port detection don't tolerate parallel app instances. @@ -799,7 +827,7 @@ npm run start:test-updater - **Context Isolation**: Uses `contextIsolation: true` and `nodeIntegration: false`. - **Remote Module Disabled**: The remote module is not available. - **Minimal API Surface**: Only necessary IPC methods are exposed to the renderer. The `freedomAPI` (history, bookmarks, etc.) is restricted to internal `freedom://` pages — external websites cannot call it. -- **Local Nodes**: Ant, IPFS, Radicle, and Tor run locally when their integrations are enabled; no external services required for basic operation. +- **Local Nodes**: Ant, IPFS, Myotis, Radicle, and Tor run locally when their integrations are enabled; no external services are required for their native operation. - **Permission Handling**: Web permissions are deny-by-default with per-site prompts for camera, microphone, notifications, clipboard reading, location, and MIDI. Decisions marked "Remember for this site" persist per profile (`permissions.json`, reviewable under Settings → Site Permissions); unremembered decisions last for the session. Pointer lock and fullscreen remain auto-allowed for better UX in Swarm/IPFS apps; everything else (HID, screen capture, …) is denied. Location grants expose the API but may not resolve reliably — Electron lacks Chromium's network location service. - **Public RPC Fallback**: ENS resolution uses public RPCs by default. For trustless verification, use a local Helios client. diff --git a/jest.config.js b/jest.config.js index a0f57a89..95c72a1b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,6 +28,6 @@ module.exports = { '^.+\\.js$': 'babel-jest', }, transformIgnorePatterns: [ - '/node_modules/(?!(@scure|@noble|micro-key-producer|micro-packed)/)', + '/node_modules/(?!(@scure|@noble|micro-key-producer|micro-packed|@openlv|websocket-mqtt|ts-pattern)/)', ], }; diff --git a/package-lock.json b/package-lock.json index ca580b2f..e035930f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,9 @@ "@ledgerhq/hw-app-eth": "^7.8.8", "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", + "@openlv/core": "^0.0.2", + "@openlv/session": "^0.0.3", + "@safe-global/protocol-kit": "^8.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", @@ -34,14 +37,17 @@ "@babel/preset-env": "^8.0.2", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", + "aedes": "^0.51.3", "babel-jest": "^30.4.1", "dotenv-cli": "^11.0.0", "electron": "^43.0.0", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^10.4.0", "globals": "^17.6.0", "jest": "^30.4.2", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "ws": "^8.21.0" }, "engines": { "node": ">=18" @@ -1469,6 +1475,16 @@ "@babel/core": "^8.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", @@ -1842,6 +1858,448 @@ "node": ">=18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3829,11 +4287,125 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@openlv/core": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@openlv/core/-/core-0.0.2.tgz", + "integrity": "sha512-Q7ISpgQX8YkKw25hs76SgDjsIosFkiA/28P/p9b+MvpnN8cymCJDauWA31SSNSyMOrsOS/iMK4I5VHCcwwC5RA==", + "license": "LGPL-3.0-only", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@openlv/core/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@openlv/core/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@openlv/session": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/session/-/session-0.0.3.tgz", + "integrity": "sha512-s5Q6BIYAXEnmGuOIW3IQYbY1qW9YS5R5ytgwEjSYWJHiJCOVBu8gcNCQ9NIZ9wI6XLlprKFSdHkw6pzP7pAx4g==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "@openlv/signaling": "0.0.3", + "@openlv/transport": "0.0.3", + "eventemitter3": "^5.0.1", + "globals": "^16.2.0", + "ts-pattern": "^5" + } + }, + "node_modules/@openlv/session/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openlv/signaling": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/signaling/-/signaling-0.0.3.tgz", + "integrity": "sha512-U2WBRN05DjC6keeTWG7p2WAcm71XDkHjqC8GMpU6LeCcmoqAHIO/rfLcRZxpr3E+BImSblLVe6nwuImAN/v5tg==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "eventemitter3": "^5", + "globals": "^16", + "gun": "^0.2020.1241", + "ts-pattern": "^5", + "websocket-mqtt": "0.0.7" + } + }, + "node_modules/@openlv/signaling/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@openlv/transport": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@openlv/transport/-/transport-0.0.3.tgz", + "integrity": "sha512-nqQnYk/jATPx25zIHFG409sVlNRrDYjxrqXFkmr+zmSlbBSq29Blk/XzwK/7xCcaiDf4tZu+hnf2V0b2YU2IuQ==", + "license": "LGPL-3.0-only", + "dependencies": { + "@openlv/core": "0.0.2", + "eventemitter3": "^5.0.1", + "globals": "^16.2.0", + "ts-pattern": "^5" + } + }, + "node_modules/@openlv/transport/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -3845,7 +4417,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -3858,7 +4430,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -3868,7 +4440,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.7.0", @@ -3990,6 +4562,68 @@ "integrity": "sha512-Er3Q8q0/2OcCJPQYJOPLmCuqO0wu7cav3SPtpjlxSbjFi1x+A1pZkkLD6c9q2rGEkGW/tkrRzfrhNMt8VQjzXg==", "license": "MPL-2.0" }, + "node_modules/@safe-global/protocol-kit": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@safe-global/protocol-kit/-/protocol-kit-8.0.3.tgz", + "integrity": "sha512-XKtpfalQQXzfcLq3BNcbCfqJt6ScdL6AZNsefxVSEujrPSY19K+GWmX3yHskbPE8Z9pI07OtJOOpypfLhsFr8Q==", + "license": "MIT", + "dependencies": { + "@safe-global/safe-deployments": "^1.37.59", + "@safe-global/safe-modules-deployments": "^3.0.7", + "@safe-global/types-kit": "^4.0.1", + "abitype": "^1.2.3", + "semver": "^7.8.0", + "viem": "^2.52.2" + }, + "optionalDependencies": { + "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13" + } + }, + "node_modules/@safe-global/protocol-kit/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@safe-global/safe-deployments": { + "version": "1.37.59", + "resolved": "https://registry.npmjs.org/@safe-global/safe-deployments/-/safe-deployments-1.37.59.tgz", + "integrity": "sha512-y1eAviyDJARMqwXctqXylsBGqNkeFIq/q2XJlmkVZ9vSwfQBP31a33sUZkbmwwJuomv0/Yrl04YNDQ2C8CaGWw==", + "license": "MIT", + "dependencies": { + "semver": "^7.6.2" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.16.0" + } + }, + "node_modules/@safe-global/safe-modules-deployments": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@safe-global/safe-modules-deployments/-/safe-modules-deployments-3.0.7.tgz", + "integrity": "sha512-XTloEuDvKBQJCKoySg1Y+NMdVFJWxwhH2fSaziT8R4vIbuEyQYWW9vj4z92+hHPXCBBh0cyi/MBXW+HYjYroew==", + "license": "MIT" + }, + "node_modules/@safe-global/types-kit": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@safe-global/types-kit/-/types-kit-4.0.1.tgz", + "integrity": "sha512-zmIYyAH9mcBcqHszPgcfNjOJYuPSvWCcc/f8zeznh7N1HSA7jEoFErO06O4QDfwAAS4aEyoyjhPyfHbVYRheZg==", + "license": "MIT", + "dependencies": { + "abitype": "^1.2.3" + } + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -4490,6 +5124,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -4911,6 +5555,19 @@ } } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -4934,6 +5591,88 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/aedes": { + "version": "0.51.3", + "resolved": "https://registry.npmjs.org/aedes/-/aedes-0.51.3.tgz", + "integrity": "sha512-aQfiI9w3RbqnowNCdcGMmCtxBFXN9bhJFcuZm24U5/NU06V3MCl42jWK2GUnu8rOypR2Ahi/aEcgq3w7CMcycg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "aedes-persistence": "^9.1.2", + "end-of-stream": "^1.4.4", + "fastfall": "^1.5.1", + "fastparallel": "^2.4.1", + "fastseries": "^2.0.0", + "hyperid": "^3.2.0", + "mqemitter": "^6.0.0", + "mqtt-packet": "^9.0.0", + "retimer": "^4.0.0", + "reusify": "^1.0.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/aedes" + } + }, + "node_modules/aedes-packet": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/aedes-packet/-/aedes-packet-3.0.0.tgz", + "integrity": "sha512-swASey0BxGs4/npZGWoiVDmnEyPvVFIRY6l2LVKL4rbiW8IhcIGDLfnb20Qo8U20itXlitAKPQ3MVTEbOGG5ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mqtt-packet": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes-packet/node_modules/mqtt-packet": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-7.1.2.tgz", + "integrity": "sha512-FFZbcZ2omsf4c5TxEQfcX9hI+JzDpDKPT46OmeIBpVA7+t32ey25UNqlqNXTmeZOr5BLsSIERpQQLsFWJS94SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.2", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/aedes-persistence": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/aedes-persistence/-/aedes-persistence-9.1.2.tgz", + "integrity": "sha512-2Wlr5pwIK0eQOkiTwb8ZF6C20s8UPUlnsJ4kXYePZ3JlQl0NbBA176mzM8wY294BJ5wybpNc9P5XEQxqadRNcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aedes-packet": "^3.0.0", + "qlobber": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/aedes/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/aes-js": { "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", @@ -5309,7 +6048,7 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -7126,6 +7865,48 @@ "license": "MIT", "optional": true }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7510,6 +8291,16 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -7614,6 +8405,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -7631,6 +8436,37 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastseries": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", + "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==", + "dev": true, + "license": "ISC" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8114,6 +8950,42 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gun": { + "version": "0.2020.1241", + "resolved": "https://registry.npmjs.org/gun/-/gun-0.2020.1241.tgz", + "integrity": "sha512-rmGqLuJj4fAuZ/0lddCvXHbENPkEnBOBYpq+kXHrwQ5RdNtQ5p0Io99lD1qUXMFmtwNacQ/iqo3VTmjmMyAYZg==", + "license": "(Zlib OR MIT OR Apache-2.0)", + "dependencies": { + "ws": "^7.2.1" + }, + "engines": { + "node": ">=0.8.4" + }, + "optionalDependencies": { + "@peculiar/webcrypto": "^1.1.1" + } + }, + "node_modules/gun/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "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 + } + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8308,6 +9180,29 @@ "node": ">=10.17.0" } }, + "node_modules/hyperid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hyperid/-/hyperid-3.3.0.tgz", + "integrity": "sha512-7qhCVT4MJIoEsNcbhglhdmBKb09QtcmJNiIQGq7js/Khf5FtQQ9bzcAuloeqBeee7XD7JqDeve9KNlQya5tSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "uuid": "^8.3.2", + "uuid-parse": "^1.1.0" + } + }, + "node_modules/hyperid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -10756,6 +11651,97 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mqemitter": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/mqemitter/-/mqemitter-6.0.2.tgz", + "integrity": "sha512-8RGlznQx/Nb1xC3xKUFXHWov7pn7JdH++YVwlr6SLT6k3ft1h+ImGqZdVudbdKruFckIq9wheq9s4hgCivJDow==", + "dev": true, + "license": "ISC", + "dependencies": { + "fastparallel": "^2.4.1", + "qlobber": "^8.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mqemitter/node_modules/qlobber": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-8.0.1.tgz", + "integrity": "sha512-O+Wd1chXj5YE1DwmD+ae0bXiSLehmnS3czlC1R9FL/Nt/3q8uMS1bIHmg2lJfCoiimCxClWM8AAuJrF0EvNiog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt-packet/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/mqtt-packet/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/mqtt-packet/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11599,6 +12585,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -11691,7 +12687,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -11701,12 +12697,22 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.0.0" } }, + "node_modules/qlobber": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/qlobber/-/qlobber-7.0.1.tgz", + "integrity": "sha512-FsFg9lMuMEFNKmTO9nV7tlyPhx8BmskPPjH2akWycuYVTtWaVwhW5yCHLJQ6Q+3mvw5cFX2vMfW2l9z2SiYAbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -12056,6 +13062,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retimer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retimer/-/retimer-4.0.0.tgz", + "integrity": "sha512-fZIVtvbOsQsxNSDhpdPOX4lx5Ss2ni+S72AUBitARpFhtA3UzrAjQ6gDtypB2/+l7L+1VQgAgpvAKY66mElH0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "worker-timers": "^7.0.75" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -12065,6 +13081,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -12748,6 +13775,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -13037,6 +14070,13 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/uuid-parse": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/uuid-parse/-/uuid-parse-1.1.0.tgz", + "integrity": "sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13138,7 +14178,7 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.7.0", @@ -13148,6 +14188,12 @@ "tslib": "^2.8.1" } }, + "node_modules/websocket-mqtt": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/websocket-mqtt/-/websocket-mqtt-0.0.7.tgz", + "integrity": "sha512-+33fT52HJ3IlJdybH/em5TNmswwxcDCSyubernwVCaXKf7/RCEg95Lr2U51Cgev1/dqi9dUgOyozhUK/09e4IA==", + "license": "LGPL-3.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13180,6 +14226,43 @@ "node": ">=0.10.0" } }, + "node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13281,6 +14364,16 @@ "node": ">=8.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index facb838d..c863540d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "freedom-browser", "version": "0.8.1-dev", "license": "MPL-2.0", - "description": "Freedom – A browser for the decentralized web, with Swarm, IPFS, and ENS as first-class protocols.", + "description": "Freedom – A browser for the decentralized web, with Swarm, IPFS, onchain apps, and ENS as first-class protocols.", "author": { "name": "Freedom Team", "email": "browser@freedom.baby" @@ -37,10 +37,12 @@ "dist:mac:notary-status": "dotenv -- node scripts/macos-notary.js status", "dist:mac:notary-log": "dotenv -- node scripts/macos-notary.js log", "dist:mac:staple-notary": "dotenv -- node scripts/macos-notary.js staple", - "dist:linux:arm64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/arm64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run dist -- --linux --arm64\"", - "dist:linux:x64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/amd64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run dist -- --linux --x64\"", + "dist:linux:arm64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/arm64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run myotis:download && npm run dist -- --linux --arm64\"", + "dist:linux:x64:docker": "docker run --rm -v \"$PWD\":/app -v /app/node_modules -v \"${ELECTRON_CACHE:-$HOME/Library/Caches/electron}\":/root/.cache/electron -w /app -e USE_SYSTEM_FPM=true --platform linux/amd64 node:24-trixie bash -c \"apt-get update && apt-get install -y ruby ruby-dev build-essential xz-utils && gem install fpm --no-document && npm ci && npm run radicle:download && npm run ipfs:download && npm run myotis:download && npm run dist -- --linux --x64\"", "adblock:download": "node scripts/fetch-adblock-lists.js", "ant:download": "node scripts/fetch-ant.js", + "myotis:download": "node scripts/fetch-myotis.js", + "myotis:smoke": "node scripts/smoke-myotis.js", "ant:init": "node scripts/init-ant.js", "ant:start": "echo 'ant:start is a legacy alias for system-ant:start (repo-root ant-data on ecosystem port 1633).'; npm run system-ant:start", "ant:stop": "echo 'ant:stop is a legacy alias for system-ant:stop.'; npm run system-ant:stop", @@ -64,6 +66,9 @@ "radicle:reset": "rm -rf radicle-data && echo 'Radicle data reset.'", "identity:reset": "rm -rf ant-data ipfs-data radicle-data identity-data && echo 'All identity data reset (ant, ipfs, radicle, vault).'", "dapp:reset": "node scripts/reset-dapp-permissions.js", + "vendor:openlv": "node scripts/bundle-openlv.js", + "bridge:serve": "node scripts/serve-bridge.js", + "openlv:ios-harness": "node scripts/openlv-ios-harness.js", "serve:updates": "node scripts/serve-updates.js", "start:test-updater": "ENABLE_DEV_UPDATER=true npm start", "postinstall": "electron-builder install-app-deps" @@ -178,6 +183,13 @@ "freedom_ipfs_native.node" ] }, + { + "from": "myotis-bin/${os}-${arch}/", + "to": "myotis-node", + "filter": [ + "myotis-node.node" + ] + }, { "from": "assets/", "to": "assets", @@ -203,14 +215,17 @@ "@babel/preset-env": "^8.0.2", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", + "aedes": "^0.51.3", "babel-jest": "^30.4.1", "dotenv-cli": "^11.0.0", "electron": "^43.0.0", "electron-builder": "^26.8.1", + "esbuild": "^0.28.1", "eslint": "^10.4.0", "globals": "^17.6.0", "jest": "^30.4.2", - "prettier": "^3.8.3" + "prettier": "^3.8.3", + "ws": "^8.21.0" }, "dependencies": { "@adraffy/ens-normalize": "^1.11.1", @@ -221,6 +236,9 @@ "@ledgerhq/hw-app-eth": "^7.8.8", "@ledgerhq/hw-transport-node-hid": "^6.33.5", "@metamask/browser-passworder": "^6.0.0", + "@openlv/core": "^0.0.2", + "@openlv/session": "^0.0.3", + "@safe-global/protocol-kit": "^8.0.3", "@scure/bip39": "^2.2.0", "@x402/core": "^2.12.0", "@x402/evm": "^2.12.0", diff --git a/scripts/build.js b/scripts/build.js index ac9eceb9..b11095af 100755 --- a/scripts/build.js +++ b/scripts/build.js @@ -58,6 +58,24 @@ if (archs.length === 0) { else archs.push('arm64', 'x64'); // Linux defaults to both } +// Distributables must not ship the interim remote-signing bridge origin +// (personal test deployment — see the pre-merge checklist on PR #159). +// Override for local experiments only: FREEDOM_ALLOW_INTERIM_BRIDGE=1. +if (dist && process.env.FREEDOM_ALLOW_INTERIM_BRIDGE !== '1') { + const remoteSession = fs.readFileSync( + path.join(__dirname, '..', 'src', 'renderer', 'lib', 'wallet', 'remote-session.js'), + 'utf8' + ); + if (remoteSession.includes('florianglatz.eth.limo')) { + console.error( + 'Error: BRIDGE_ORIGIN in src/renderer/lib/wallet/remote-session.js still points at the ' + + 'interim test deployment. Deploy freedom-bridge to the production origin and update the ' + + 'constant before building a distributable (FREEDOM_ALLOW_INTERIM_BRIDGE=1 to override locally).' + ); + process.exit(1); + } +} + // 1. Check binaries for the target platform/arch const checkArgs = [`--${platform}`, ...archs.map((a) => `--${a}`)].join(' '); console.log(`\n→ Checking binaries: npm run check-binaries -- ${checkArgs}\n`); diff --git a/scripts/bundle-openlv.js b/scripts/bundle-openlv.js new file mode 100644 index 00000000..d8d898b3 --- /dev/null +++ b/scripts/bundle-openlv.js @@ -0,0 +1,126 @@ +/** + * Bundle the @openlv/* packages (LGPL-3.0) into a single ES module for the + * chrome renderer, which loads plain `type="module"` scripts and cannot + * resolve bare npm specifiers. + * + * The output (src/renderer/vendor/openlv.esm.js) is committed like the + * other vendor files; re-run this script after bumping the @openlv + * dependencies: + * + * node scripts/bundle-openlv.js + * + * Keeping the LGPL code in one clearly-labelled, regenerable file (rather + * than mixed into app bundles) is also what makes it trivially replaceable, + * as the LGPL asks. + * + * `connectSession` is deliberately left out so tree-shaking drops the + * gundb/ntfy signaling backends it would drag in via dynamic imports — + * the client role (bridge page) instead calls `createSession` with the + * decoded URI parameters and the explicit mqtt layer, which is the same + * thing minus the dynamic protocol lookup. + * + * A second flavor, openlv.iife.js (`window.OpenLV`), is built for freedom + * mobile's hidden-WKWebView engine: WKWebView refuses ES-module imports + * from file:// pages (opaque origin + CORS), but classic scripts load + * fine, and file:// stays a secure context so `crypto.subtle` works. + * + * The bridge page (solardev-xyz/freedom-bridge) and freedom mobile + * (solardev-xyz/freedom-browser-ios) vendor their own copies; when + * their sibling checkouts are present they are refreshed automatically + * from this one build, so the repos can't drift. + */ + +const fs = require('fs'); +const path = require('path'); + +const esbuild = require('esbuild'); + +const openlvVersion = require( + path.join(__dirname, '..', 'node_modules', '@openlv', 'session', 'package.json') +).version; + +const ENTRY = ` +export { createSession, SESSION_STATE } from '@openlv/session'; +export { encodeConnectionURL, decodeConnectionURL, OPENLV_PROTOCOL_VERSION } from '@openlv/core'; +export { mqtt } from '@openlv/signaling/mqtt'; +export { webrtc } from '@openlv/transport/webrtc'; +`; + +const banner = (filename) => `/*! + * ${filename} — bundled from @openlv/session@${openlvVersion} and its + * dependencies (https://github.com/v3xlabs/open-lavatory, LGPL-3.0-only). + * Generated by scripts/bundle-openlv.js — do not edit by hand. + */`; + +// The same surface serves both roles: freedom's renderer hosts sessions +// (shows the QR); the bridge page joins them (client role — createSession +// with the URI's `h` parameter takes that path). Built once, copied — +// the copies can never drift. +const ESM_OUTPUT = path.join(__dirname, '..', 'src', 'renderer', 'vendor', 'openlv.esm.js'); +// Intermediate only — the iife flavor's sole home is the iOS repo. +const IIFE_OUTPUT = path.join(require('os').tmpdir(), 'openlv.iife.js'); + +// Sibling checkouts vendoring their own copies — synced when present +// (dev machines), reported otherwise (CI). +const SIBLING_COPIES = [ + { + from: ESM_OUTPUT, + to: path.join(__dirname, '..', '..', 'freedom-bridge', 'openlv.esm.js'), + }, + { + from: IIFE_OUTPUT, + to: path.join( + __dirname, '..', '..', 'nodes', 'swarm-mobile-ios', + 'Freedom', 'Freedom', 'Wallet', 'OpenLV', 'openlv.iife.js', + ), + }, +]; + +async function main() { + const shared = { + stdin: { + contents: ENTRY, + resolveDir: path.join(__dirname, '..'), + sourcefile: 'openlv-entry.js', + }, + bundle: true, + platform: 'browser', + target: 'es2022', + legalComments: 'inline', + minify: true, + }; + + await esbuild.build({ + ...shared, + format: 'esm', + outfile: ESM_OUTPUT, + banner: { js: banner('openlv.esm.js') }, + }); + + await esbuild.build({ + ...shared, + format: 'iife', + globalName: 'OpenLV', + outfile: IIFE_OUTPUT, + banner: { js: banner('openlv.iife.js') }, + }); + + const outputs = [ESM_OUTPUT]; + for (const { from, to } of SIBLING_COPIES) { + if (fs.existsSync(path.dirname(to))) { + fs.copyFileSync(from, to); + outputs.push(to); + } else { + console.log(`sibling checkout not found — copy ${from} to ${to} by hand`); + } + } + + for (const file of outputs) { + console.log(`Wrote ${file} (${(fs.statSync(file).size / 1024).toFixed(0)} KiB)`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/check-binaries.js b/scripts/check-binaries.js index 99d0b83b..2fe9e027 100644 --- a/scripts/check-binaries.js +++ b/scripts/check-binaries.js @@ -12,6 +12,11 @@ const FREEDOM_IPFS_NATIVE_PREBUILDS_DIR = path.join( const FREEDOM_IPFS_NATIVE_ADDON = 'freedom_ipfs_native.node'; const RADICLE_BIN_DIR = path.join(__dirname, '..', 'radicle-bin'); const ARTI_BIN_DIR = path.join(__dirname, '..', 'arti-bin'); +const MYOTIS_BIN_DIR = path.join(__dirname, '..', 'myotis-bin'); +// Targets the Myotis release publishes addons for (see scripts/fetch-myotis.js). +// Anything else (e.g. win-arm64) is skipped with a notice — the app degrades +// gracefully to Colibri/quorum when the addon is absent. +const MYOTIS_SUPPORTED = new Set(['mac-x64', 'mac-arm64', 'linux-x64', 'linux-arm64', 'win-x64']); function getPlatformArch() { const args = process.argv.slice(2); @@ -105,6 +110,18 @@ function checkBinaries(platforms) { missing.push(`freedom-ipfs native addon for ${platformDir}: ${freedomIpfsAddonPath}`); } + // Myotis: required where the release publishes an addon; electron-builder + // would otherwise silently skip the missing extraResources dir and ship a + // build with the feature permanently unavailable. + if (MYOTIS_SUPPORTED.has(platformDir)) { + const myotisAddonPath = path.join(MYOTIS_BIN_DIR, platformDir, 'myotis-node.node'); + if (!fs.existsSync(myotisAddonPath)) { + missing.push(`myotis-node addon for ${platformDir}: ${myotisAddonPath}`); + } + } else { + console.log(` (myotis-node: no addon published for ${platformDir} — skipping)`); + } + // Radicle: no official Windows binaries yet — skip check for win targets if (os !== 'win') { const nodePath = path.join(RADICLE_BIN_DIR, platformDir, 'radicle-node'); @@ -158,6 +175,7 @@ function main() { console.error(' npm run ant:download'); console.error(' npm run ipfs:download'); console.error(' npm run radicle:download'); + console.error(' npm run myotis:download'); console.error(' npm run adblock:download\n'); process.exit(1); } diff --git a/scripts/fetch-myotis.js b/scripts/fetch-myotis.js new file mode 100644 index 00000000..0006217f --- /dev/null +++ b/scripts/fetch-myotis.js @@ -0,0 +1,252 @@ +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const crypto = require('crypto'); + +// Fetches the Myotis Node addon — the fully-P2P Ethereum light client +// (biafra23/myotis) as a prebuilt napi-rs binding for the current platform. +// Runs invisibly inside Freedom like the ant/IPFS nodes; see +// src/main/myotis/myotis-manager.js. Installs into `myotis-bin/`. +const OUTPUT_DIR = path.join(__dirname, '..', 'myotis-bin'); +const MYOTIS_REPO = process.env.MYOTIS_REPO || 'biafra23/myotis'; +// The known-good Myotis release this app version is built and tested against. +// Bump deliberately (with a live e2e run) — do NOT float on `latest`. The +// engine ABI the addon reports must match myotis-manager's EXPECTED_ABI. +const PINNED_RELEASE_TAG = 'v0.1.7'; +// In-repo trust root for the pinned release: sha256 of its +// myotis-node.SHA256SUMS asset, recorded at pin time. The sums file comes +// from the same GitHub release as the addons, so without this pin a +// compromised release could swap binaries *and* checksums together. Update +// alongside PINNED_RELEASE_TAG on every deliberate bump. +const PINNED_SHA256SUMS_DIGEST = + '458743f281a7886e953a32ccef599bc253781e278c12cfe05a5addc23aa2569a'; +const MYOTIS_RELEASE_TAG = process.env.MYOTIS_RELEASE_TAG || PINNED_RELEASE_TAG; + +// Every target the release publishes, installed in one run (fetch-ant.js +// convention — cross-target dist builds and the Docker recipes then need no +// --target flags). Dir names are electron-builder's ${os}-${arch}. Windows +// ARM64 is deliberately absent: no upstream artifact, and check-binaries.js +// skips it — the app degrades to Colibri/quorum there. +const TARGETS = [ + { runtime: 'darwin-arm64', dir: 'mac-arm64', asset: 'myotis-node.darwin-arm64.node' }, + { runtime: 'darwin-x64', dir: 'mac-x64', asset: 'myotis-node.darwin-x64.node' }, + { runtime: 'linux-x64', dir: 'linux-x64', asset: 'myotis-node.linux-x64-gnu.node' }, + { runtime: 'linux-arm64', dir: 'linux-arm64', asset: 'myotis-node.linux-arm64-gnu.node' }, + { runtime: 'win32-x64', dir: 'win-x64', asset: 'myotis-node.win32-x64-msvc.node' }, +]; + +const REQUEST_TIMEOUT_MS = 60000; + +function httpsGetJson(pathName) { + return new Promise((resolve, reject) => { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const headers = { + 'User-Agent': 'Freedom-Updater', + Accept: 'application/vnd.github+json', + }; + if (token) headers.Authorization = `Bearer ${token}`; + https + .get({ hostname: 'api.github.com', path: pathName, headers }, (res) => { + let data = ''; + res.on('error', reject); + res.on('data', (c) => (data += c)); + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to fetch ${pathName}: ${res.statusCode}`)); + return; + } + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(new Error(`Invalid JSON from ${pathName}: ${err.message}`)); + } + }); + }) + .on('error', reject); + }); +} + +function downloadFileOnce(url, dest, redirectCount = 0) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + let settled = false; + const fail = (err) => { + if (settled) return; + settled = true; + file.close(); + fs.unlink(dest, () => reject(err)); + }; + const req = https + .get(url, { headers: { 'User-Agent': 'Freedom-Updater' } }, (response) => { + response.on('error', fail); + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + if (redirectCount >= 5) { + fail(new Error(`Too many redirects while downloading ${url}`)); + return; + } + let location; + try { + location = new URL(response.headers.location, url); + } catch { + fail(new Error(`Invalid redirect while downloading ${url}`)); + return; + } + if (location.protocol !== 'https:') { + fail(new Error(`Refusing non-HTTPS redirect while downloading ${url}`)); + return; + } + // The redirected request owns completion from here. Ignore any late + // error emitted by the response we are deliberately draining. + settled = true; + response.resume(); + file.close(); + fs.unlink(dest, () => { + downloadFileOnce(location.href, dest, redirectCount + 1).then(resolve).catch(reject); + }); + return; + } + if (response.statusCode !== 200) { + fail(new Error(`HTTP ${response.statusCode} for ${url}`)); + return; + } + response.pipe(file); + file.on('finish', () => file.close(() => { + if (settled) return; + settled = true; + resolve(); + })); + file.on('error', fail); + }) + .on('error', fail); + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`Download timed out after ${REQUEST_TIMEOUT_MS}ms: ${url}`)); + }); + }); +} + +async function withRetries(label, fn) { + const maxAttempts = 4; + let lastErr; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt < maxAttempts) { + const delayMs = 1000 * attempt; + console.warn(`${label} attempt ${attempt} failed (${err.message}); retrying in ${delayMs}ms...`); + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } + throw lastErr; +} + +function sha256Bytes(bytes) { + const hash = crypto.createHash('sha256'); + hash.update(bytes); + return hash.digest('hex'); +} + +function sha256File(filePath) { + return sha256Bytes(fs.readFileSync(filePath)); +} + +// `sha256sum`-style lines: `␠␠` or ` *`. +function parseChecksums(text) { + const map = {}; + for (const line of text.split('\n')) { + const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/); + if (match) map[path.basename(match[2].trim())] = match[1].toLowerCase(); + } + return map; +} + +async function main() { + try { + const requestedTarget = process.env.MYOTIS_DOWNLOAD_TARGET || ''; + const targets = requestedTarget + ? TARGETS.filter((target) => target.runtime === requestedTarget) + : TARGETS; + if (!targets.length) { + throw new Error(`Unknown MYOTIS_DOWNLOAD_TARGET: ${requestedTarget}`); + } + console.log(`Fetching Myotis release info from ${MYOTIS_REPO} @ ${MYOTIS_RELEASE_TAG}...`); + const releasePath = + MYOTIS_RELEASE_TAG === 'latest' + ? `/repos/${MYOTIS_REPO}/releases/latest` + : `/repos/${MYOTIS_REPO}/releases/tags/${MYOTIS_RELEASE_TAG}`; + const release = await withRetries('Release fetch', () => httpsGetJson(releasePath)); + console.log(`Myotis version: ${release.tag_name}`); + const assets = release.assets || []; + + if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + // Checksums first — a Myotis release always ships myotis-node.SHA256SUMS. + const sumsAsset = assets.find((a) => a.name === 'myotis-node.SHA256SUMS'); + if (!sumsAsset) { + throw new Error( + `Release ${release.tag_name} has no myotis-node.SHA256SUMS — refusing to install unverified addon.` + ); + } + const sumsPath = path.join(OUTPUT_DIR, 'myotis-node.SHA256SUMS'); + await withRetries('SHA256SUMS download', () => + downloadFileOnce(sumsAsset.browser_download_url, sumsPath) + ); + + // Anchor to the in-repo trust root (pinned tag only — a tag override is a + // local-testing escape hatch with no committed digest). + let checksums; + if (MYOTIS_RELEASE_TAG === PINNED_RELEASE_TAG) { + const sumsBytes = fs.readFileSync(sumsPath); + const sumsDigest = sha256Bytes(sumsBytes); + if (sumsDigest !== PINNED_SHA256SUMS_DIGEST) { + throw new Error( + `myotis-node.SHA256SUMS for ${PINNED_RELEASE_TAG} does not match the in-repo pinned digest ` + + `(expected ${PINNED_SHA256SUMS_DIGEST}, got ${sumsDigest}). ` + + 'The release assets may have been re-published or tampered with — refusing to install.' + ); + } + console.log('Verified myotis-node.SHA256SUMS against the in-repo pinned digest'); + checksums = parseChecksums(sumsBytes.toString('utf8')); + } else { + checksums = parseChecksums(fs.readFileSync(sumsPath, 'utf8')); + } + + // Stable install name under an electron-builder-style ${os}-${arch} dir — + // packaging copies myotis-bin/-/ into resources/myotis-node/, + // and myotis-manager loads the dev path directly. + for (const target of targets) { + const asset = assets.find((a) => a.name === target.asset); + if (!asset) { + throw new Error( + `Release ${release.tag_name} has no asset ${target.asset} — refusing to produce an incomplete install.` + ); + } + const platformDir = path.join(OUTPUT_DIR, target.dir); + if (!fs.existsSync(platformDir)) fs.mkdirSync(platformDir, { recursive: true }); + const destPath = path.join(platformDir, 'myotis-node.node'); + await withRetries('Addon download', () => + downloadFileOnce(asset.browser_download_url, destPath) + ); + + const expected = checksums[target.asset]; + if (!expected) { + throw new Error(`${target.asset} missing from myotis-node.SHA256SUMS`); + } + const actual = sha256File(destPath); + if (actual !== expected) { + fs.unlinkSync(destPath); + throw new Error( + `Checksum mismatch for ${target.asset}: expected ${expected}, got ${actual} — deleted.` + ); + } + console.log(`Verified and installed ${target.asset} → ${destPath}`); + } + } catch (err) { + console.error(`fetch-myotis failed: ${err.message}`); + process.exit(1); + } +} + +main(); diff --git a/scripts/openlv-ios-harness.js b/scripts/openlv-ios-harness.js new file mode 100644 index 00000000..0391d637 --- /dev/null +++ b/scripts/openlv-ios-harness.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Cross-stack test host for the freedom-mobile (iOS) openlv wallet + * endpoint. Plays the "freedom desktop" role over the real openlv + * stack: a local MQTT broker for signaling plus a headless Chromium + * page running the same vendored `openlv.esm.js` the renderer uses + * (Node has no WebRTC, so the host session lives in a browser context, + * exactly like the remote-signing E2E). + * + * The iOS simulator shares the Mac's loopback, so the XCTest suite + * reaches both the control endpoints and the broker at 127.0.0.1. + * + * Control surface (http://127.0.0.1:8798): + * GET /uri → {uri} — openlv:// URI of the current host session + * GET /state → {phase, uri, exchanges: [{method, response}], error} + * GET /reset → reloads the host page, starting a fresh session + * (each XCTest consumes one session; call this first) + * + * Once the wallet endpoint links up, the host sends the same sequence a + * desktop signing job produces — eth_requestAccounts, the + * wallet_switchEthereumChain pre-flight, then personal_sign against the + * connected account — and records every response for the XCTest to + * assert on. + * + * Run from the repo root: `npm run openlv:ios-harness` + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('@playwright/test'); +const { startLocalMqttBroker } = require('../test/helpers/local-mqtt-broker'); +const { WEBRTC_LOCAL_SWITCH } = require('../test/helpers/webrtc'); + +const PORT = Number(process.env.OPENLV_HARNESS_PORT) || 8798; + +const MESSAGE = 'freedom openlv ios harness'; + +function buildHostPage(signalingUrl) { + return ` +`; +} + +async function main() { + const broker = await startLocalMqttBroker(); + let state; + const resetState = () => { + state = { phase: 'starting', uri: null, exchanges: [], error: null }; + }; + resetState(); + let page = null; + + const esmBundle = fs.readFileSync( + path.join(__dirname, '..', 'src', 'renderer', 'vendor', 'openlv.esm.js') + ); + const hostPage = buildHostPage(broker.url); + + const server = http.createServer((req, res) => { + const respond = (status, type, body) => { + res.writeHead(status, { 'content-type': type }); + res.end(body); + }; + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + switch (url.pathname) { + case '/uri': + return respond(200, 'application/json', JSON.stringify({ uri: state.uri })); + case '/state': + return respond(200, 'application/json', JSON.stringify(state)); + // /reset[?mode=tx] — fresh host session; mode picks the request + // sequence the host sends (default: personal_sign; tx: broadcast). + case '/reset': { + if (!page) return respond(503, 'text/plain', 'host page not up yet'); + const mode = url.searchParams.get('mode'); + resetState(); + page + .goto(`http://127.0.0.1:${PORT}/${mode ? `?mode=${encodeURIComponent(mode)}` : ''}`) + .then(() => respond(200, 'application/json', '{"ok":true}')) + .catch((err) => respond(500, 'text/plain', String(err))); + return undefined; + } + case '/': + return respond(200, 'text/html', hostPage); + case '/openlv.esm.js': + return respond(200, 'text/javascript', esmBundle); + default: + return respond(404, 'text/plain', 'not found'); + } + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + + // Raw loopback ICE candidates instead of mDNS names, so the WebKit + // peer can pair against them. + const browser = await chromium.launch({ args: [WEBRTC_LOCAL_SWITCH] }); + page = await browser.newPage(); + page.on('console', (message) => console.log('[host page]', message.text())); + await page.exposeFunction('__report', (updateJson) => { + const update = JSON.parse(updateJson); + if (update.exchange) { + state.exchanges.push(update.exchange); + } else { + Object.assign(state, update); + } + console.log('[harness]', updateJson.slice(0, 300)); + }); + await page.goto(`http://127.0.0.1:${PORT}/`); + + console.log(`[harness] control surface at http://127.0.0.1:${PORT} (uri, state)`); + console.log(`[harness] signaling broker at ${broker.url}`); + + const shutdown = async () => { + await browser.close().catch(() => {}); + await broker.close().catch(() => {}); + server.close(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.error('[harness] failed to start:', err); + process.exit(1); +}); diff --git a/scripts/serve-bridge.js b/scripts/serve-bridge.js new file mode 100644 index 00000000..1d6d4302 --- /dev/null +++ b/scripts/serve-bridge.js @@ -0,0 +1,55 @@ +/** + * Serve the wallet bridge page on a local port — for development and + * the remote-signing E2E test. Static files only. + * + * The page lives in its own repo (solardev-xyz/freedom-bridge, deployed + * independently to Swarm); this expects a sibling checkout, overridable + * via FREEDOM_BRIDGE_DIR. + * + * node scripts/serve-bridge.js [port] (default 8797) + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const BRIDGE_DIR = + process.env.FREEDOM_BRIDGE_DIR || path.join(__dirname, '..', '..', 'freedom-bridge'); + +/** False when the sibling checkout is missing — callers skip or error. */ +function bridgeAvailable() { + return fs.existsSync(path.join(BRIDGE_DIR, 'index.html')); +} +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', +}; + +function createBridgeServer() { + if (!bridgeAvailable()) { + throw new Error( + `freedom-bridge checkout not found at ${BRIDGE_DIR} — clone ` + + 'github.com/solardev-xyz/freedom-bridge next to this repo, or set FREEDOM_BRIDGE_DIR' + ); + } + return http.createServer((req, res) => { + const urlPath = new URL(req.url, 'http://localhost').pathname; + const rel = urlPath === '/' ? 'index.html' : urlPath.slice(1); + const file = path.join(BRIDGE_DIR, rel); + if (!file.startsWith(BRIDGE_DIR) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + res.writeHead(404).end('not found'); + return; + } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' }); + fs.createReadStream(file).pipe(res); + }); +} + +module.exports = { createBridgeServer, bridgeAvailable }; + +if (require.main === module) { + const port = Number(process.argv[2]) || 8797; + createBridgeServer().listen(port, '127.0.0.1', () => { + console.log(`Bridge page at http://127.0.0.1:${port}/`); + }); +} diff --git a/scripts/smoke-myotis.js b/scripts/smoke-myotis.js new file mode 100644 index 00000000..6ab3f4d3 --- /dev/null +++ b/scripts/smoke-myotis.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Cross-platform live smoke for the exact addon Freedom ships. This is kept +// separate from the Playwright spec so CI can cache a cleanly-stopped sync +// data directory even when the first cold run needs another attempt. +const EXPECTED_ABI = 22; +const VITALIK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const network = process.env.MYOTIS_NETWORK || 'mainnet'; +const POLL_INTERVAL_MS = 5000; +const QUERY_TIMEOUT_MS = 120000; + +const TARGET_DIRS = { + 'darwin-arm64': 'mac-arm64', + 'darwin-x64': 'mac-x64', + 'linux-arm64': 'linux-arm64', + 'linux-x64': 'linux-x64', + 'win32-x64': 'win-x64', +}; + +const target = `${process.platform}-${process.arch}`; +const targetDir = TARGET_DIRS[target]; +if (!targetDir) { + console.error(`No Myotis release addon is expected for ${target}`); + process.exit(1); +} + +const addonPath = path.resolve( + process.env.MYOTIS_NODE_PATH || + path.join(__dirname, '..', 'myotis-bin', targetDir, 'myotis-node.node') +); +const dataDir = path.resolve( + process.env.MYOTIS_DATA_DIR || path.join(os.tmpdir(), `freedom-myotis-smoke-${target}`) +); +const syncTimeoutMinutes = Number(process.env.MYOTIS_SMOKE_TIMEOUT_MIN) || 75; +const syncTimeoutMs = syncTimeoutMinutes * 60 * 1000; +const syncStallMinutes = Number(process.env.MYOTIS_SMOKE_STALL_MIN) || 10; +const syncStallMs = syncStallMinutes * 60 * 1000; +const queryAttempts = Number(process.env.MYOTIS_SMOKE_QUERY_ATTEMPTS) || 8; +const readRecoveryAttempts = Number(process.env.MYOTIS_SMOKE_RECOVERY_ATTEMPTS) || 3; +const syncRecoveryAttempts = Number(process.env.MYOTIS_SMOKE_SYNC_RECOVERY_ATTEMPTS) || 3; + +let addon = null; +let handle = -1; +let stopped = false; +const startedAt = Date.now(); + +function elapsed() { + return `${((Date.now() - startedAt) / 1000).toFixed(1)}s`; +} + +function log(...args) { + console.log(`[myotis-smoke ${elapsed()}]`, ...args); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withTimeout(promise, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${QUERY_TIMEOUT_MS}ms`)), + QUERY_TIMEOUT_MS + ); + }), + ]).finally(() => clearTimeout(timer)); +} + +function drainLogs() { + if (!addon) return; + const batch = addon.drainLogs(100); + for (const line of batch.split('\n')) { + if (/ERROR|WARN/.test(line)) console.log(` [myotis-engine] ${line}`); + } +} + +function stop() { + if (stopped) return; + stopped = true; + if (addon && handle >= 1) { + try { + addon.stop(handle); + log('stopped cleanly'); + } catch (err) { + console.error(`Myotis stop failed: ${err.message}`); + } + } + handle = -1; +} + +process.once('SIGINT', () => { + stop(); + process.exit(130); +}); +process.once('SIGTERM', () => { + stop(); + process.exit(143); +}); + +async function verifiedReads() { + let lastError = null; + for (let attempt = 1; attempt <= queryAttempts; attempt++) { + try { + if (network === 'gnosis') { + const account = JSON.parse( + await withTimeout( + addon.requestAccountJson(handle, VITALIK_ADDRESS), + 'Gnosis account read' + ) + ); + log(`verified read attempt ${attempt}:`, JSON.stringify({ account })); + if (account.error) throw new Error(`account read: ${account.error}`); + if (account.peerProofValid !== true || account.beaconChainVerified !== true) { + throw new Error(`account was not chain verified: ${JSON.stringify(account)}`); + } + if (account.balanceWei == null || account.nonce == null) { + throw new Error(`account response incomplete: ${JSON.stringify(account)}`); + } + return; + } + const address = JSON.parse( + await withTimeout( + addon.ensRecordJson( + handle, + JSON.stringify({ method: 'addr', name: 'vitalik.eth', root: 'finalized' }) + ), + 'ENS address read' + ) + ); + const contenthash = JSON.parse( + await withTimeout( + addon.ensRecordJson( + handle, + JSON.stringify({ method: 'contenthash', name: 'vitalik.eth', root: 'finalized' }) + ), + 'ENS contenthash read' + ) + ); + const reverse = JSON.parse( + await withTimeout( + addon.ensRecordJson( + handle, + JSON.stringify({ + method: 'reverse', + addressHex: VITALIK_ADDRESS, + root: 'finalized', + }) + ), + 'ENS reverse read' + ) + ); + log(`verified read attempt ${attempt}:`, JSON.stringify({ address, contenthash, reverse })); + + if (address.error) throw new Error(`address read: ${address.error}`); + if (address.status !== 'ok' || address.verified !== true) { + throw new Error(`address was not finalized-root verified: ${JSON.stringify(address)}`); + } + if (address.addressHex?.toLowerCase() !== VITALIK_ADDRESS.toLowerCase()) { + throw new Error(`address mismatch: ${address.addressHex || JSON.stringify(address)}`); + } + if (contenthash.error) throw new Error(`contenthash read: ${contenthash.error}`); + if (contenthash.status !== 'ok' || !contenthash.dataHex) { + throw new Error(`contenthash not resolved: ${JSON.stringify(contenthash)}`); + } + if (contenthash.verified !== true) { + throw new Error( + `contenthash was not finalized-root verified: ${JSON.stringify(contenthash)}` + ); + } + if (reverse.error) throw new Error(`reverse read: ${reverse.error}`); + if (reverse.status !== 'ok' || reverse.verified !== true) { + throw new Error(`reverse was not finalized-root verified: ${JSON.stringify(reverse)}`); + } + if (reverse.name?.toLowerCase() !== 'vitalik.eth') { + throw new Error(`reverse mismatch: ${reverse.name || JSON.stringify(reverse)}`); + } + return; + } catch (err) { + lastError = err; + drainLogs(); + if (attempt < queryAttempts) { + log(`verified read attempt ${attempt} failed (${err.message}); retrying in 15s`); + await delay(15000); + } + } + } + throw lastError || new Error('verified reads failed'); +} + +async function waitUntilReady(deadline) { + let lastSummary = ''; + let lastPeriod = -1; + let lastProgressAt = Date.now(); + let recoveries = 0; + for (;;) { + const status = JSON.parse(addon.statusJson(handle)); + const summary = + `beacon=${status.beaconState} peers=${status.peerCount} snapPeers=${status.snapPeers} ` + + `period=${status.currentPeriod}/${status.targetPeriod} ` + + `el=${status.elReaderAvailable} hunting=${status.elHunting}`; + if (summary !== lastSummary) { + log(summary); + lastSummary = summary; + } + drainLogs(); + + if (status.currentPeriod > lastPeriod) { + lastPeriod = status.currentPeriod; + lastProgressAt = Date.now(); + } + + const ready = + status.beaconState === 'SYNCED' && + status.elReaderAvailable === true && + status.elHunting !== true && + status.snapPeers > 0; + if (ready) return; + if (Date.now() >= deadline) { + throw new Error( + `Myotis did not become ready within ${syncTimeoutMinutes} minutes; last status: ` + + JSON.stringify(status) + ); + } + if ( + Date.now() - lastProgressAt >= syncStallMs && + recoveries < syncRecoveryAttempts && + (status.lcHunting === true || status.peerCount === 0) + ) { + recoveries += 1; + await recoverPeerPool( + `beacon catch-up stalled at period ${status.currentPeriod} for ${syncStallMinutes} minutes ` + + `(recovery ${recoveries}/${syncRecoveryAttempts})` + ); + lastProgressAt = Date.now(); + lastSummary = ''; + continue; + } + await delay(POLL_INTERVAL_MS); + } +} + +async function recoverPeerPool(reason) { + log(`${reason}; recreating the native handle from the warm snapshot`); + addon.stop(handle); + handle = -1; + await delay(1000); + handle = addon.create(network, dataDir); + if (handle < 1) throw new Error(`Myotis recreate failed with handle ${handle}`); + if (!addon.start(handle)) throw new Error('Myotis restart failed during peer-pool recovery'); +} + +async function main() { + if (!fs.existsSync(addonPath)) throw new Error(`Myotis addon not found: ${addonPath}`); + fs.mkdirSync(dataDir, { recursive: true }); + + addon = require(addonPath); + const abi = addon.init(); + if (abi !== EXPECTED_ABI) { + throw new Error(`Myotis ABI mismatch: expected ${EXPECTED_ABI}, got ${abi}`); + } + log(`loaded ${target} addon (ABI ${abi}) from ${addonPath}`); + + handle = addon.create(network, dataDir); + if (handle < 1) throw new Error(`Myotis create failed with handle ${handle}`); + if (!addon.start(handle)) throw new Error('Myotis start failed'); + log(`started handle ${handle}; data dir ${dataDir}`); + + const deadline = Date.now() + syncTimeoutMs; + for (let recovery = 0; recovery <= readRecoveryAttempts; recovery++) { + await waitUntilReady(deadline); + log(`node ready; performing verified ${network} reads`); + try { + await verifiedReads(); + log(`PASS: released addon started and served verified ${network} reads`); + return; + } catch (err) { + if (recovery >= readRecoveryAttempts) throw err; + log(`verified reads failed after peer-pool attempt ${recovery + 1}: ${err.message}`); + await recoverPeerPool('verified reads exhausted the current execution peer pool'); + } + } +} + +main() + .catch((err) => { + console.error(`myotis smoke failed: ${err.stack || err.message}`); + process.exitCode = 1; + }) + .finally(stop); diff --git a/src/main/__tests__/integration/v08-upgrade.test.js b/src/main/__tests__/integration/v08-upgrade.test.js index d5a4f40a..ab7ebdeb 100644 --- a/src/main/__tests__/integration/v08-upgrade.test.js +++ b/src/main/__tests__/integration/v08-upgrade.test.js @@ -218,7 +218,13 @@ describe('v0.8.0 upgrade path (end-to-end, in place)', () => { const networkConfig = JSON.parse( fs.readFileSync(path.join(userDataDir, 'network-config.json'), 'utf-8') ); - expect(networkConfig.networks['1']).toEqual({ verification: { primary: 'direct' } }); + expect(networkConfig.networks['1']).toEqual({ + verification: { + primary: 'direct', + order: ['myotis', 'direct', 'quorum'], + preferVerified: false, + }, + }); expect(networkConfig.endpointSources['migrated-eth-custom']).toEqual({ role: 'rpc', keyed: false, diff --git a/src/main/ens-resolver.js b/src/main/ens-resolver.js index 736e382d..d458e80a 100644 --- a/src/main/ens-resolver.js +++ b/src/main/ens-resolver.js @@ -6,6 +6,7 @@ const IPC = require('../shared/ipc-channels'); const { cidV1BytesToBase32 } = require('../shared/cid-utils'); const registry = require('./networks/network-registry'); const { prefetchGatewayUrl, NOOP_HANDLE: NOOP_PREFETCH } = require('./ens-prefetch'); +const myotisManager = require('./myotis/myotis-manager'); const { capCache } = require('./cache-utils'); const { runWithPrivateLogContext, @@ -44,6 +45,7 @@ const NAME_NFT_ABI = [ 'function addr(bytes32 node) view returns (address)', 'function reverseResolve(address addr) view returns (string)', ]; +const NAME_NFT_INTERFACE = new ethers.Interface(NAME_NFT_ABI); const NAME_SYSTEMS = { ens: { id: 'ens', label: 'ENS' }, @@ -74,6 +76,14 @@ const CONTENTHASH_SELECTOR = '0xbc1c58d1'; // bytes4(keccak256("addr(bytes32)")) const ADDR_SELECTOR = '0x3b3b57de'; +// Myotis's native ENS API returns ERC-3668 OffchainLookup envelopes to the +// host. Freedom drives one bounded gateway round and re-enters the engine so +// the callback executes against the same beacon-anchored state root. +const MYOTIS_CCIP_MAX_ROUNDS = 1; +const MYOTIS_CCIP_TIMEOUT_MS = 15000; +const MYOTIS_CCIP_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const CCIP_HTTP_ERROR_SELECTOR = 'ca7a4e75'; + // SLIP-0044 coin type for Ethereum mainnet, used by UR.reverse. const ETH_COIN_TYPE = 60n; @@ -95,8 +105,7 @@ const SWARM_CONTENTHASH_RE = /^0xe40101fa011b20(?[0-9a-f]{64})$/; // --------------------------------------------------------------------------- // Session-shuffled public-RPC pool with per-provider quarantine. Backs the -// `consensusResolve` primitive defined later; the one remaining caller of -// the older `getWorkingProvider` path is reverse resolution. +// quorum strategy for forward and reverse resolution. // --------------------------------------------------------------------------- // Per-provider sticky failure with exponential cooldown. In-memory only, @@ -215,11 +224,27 @@ const ANCHOR_SAFETY_DEPTH = { latest: 8, 'latest-32': 32, finalized: 0 }; // to the single-source unverified path instead of claiming "verified". const MIN_QUORUM_PROVIDERS = 3; +// Every JSON-RPC endpoint used in this module serves Ethereum Mainnet. Tell +// ethers that up front so its short-lived providers do not start an +// independent eth_chainId detection loop. A failed or cancelled quorum leg +// otherwise leaves ethers printing its generic "failed to detect network" +// retry message until teardown catches up. +const ETHEREUM_MAINNET_CHAIN_ID = 1; +const EPHEMERAL_PROVIDER_OPTIONS = { staticNetwork: true }; + +function createEthereumProvider(url) { + return new ethers.JsonRpcProvider( + url, + ETHEREUM_MAINNET_CHAIN_ID, + EPHEMERAL_PROVIDER_OPTIONS, + ); +} + // Create a short-lived provider for a single scoped operation, wrap the // caller's work in withTimeout, and guarantee the provider is destroyed // on both success and timeout. `fn` receives the bound provider. async function withEphemeralProvider(url, timeoutMs, fn) { - const provider = new ethers.JsonRpcProvider(url); + const provider = createEthereumProvider(url); const cleanup = () => { try { provider.destroy(); } catch { /* already torn down */ } }; try { return await withTimeout(fn(provider), timeoutMs, cleanup); @@ -420,15 +445,11 @@ function withTimeout(promise, ms, onTimeout) { }); } -let cachedProvider = null; -let cachedProviderUrl = null; - // Outcome-specific TTLs. Indexed by trust.level; the default fallback -// applies to legacy code paths (e.g. reverse resolution) that don't carry -// a trust field. Verified/user-configured outcomes are stable enough for -// 15min; unverified answers expire in 60s so transient public-RPC noise -// doesn't pin the user-facing result for long; conflict outcomes are -// negative-cached for 10s purely to avoid re-entry storms on repeated +// applies to results that don't carry a trust field. Verified/user-configured +// outcomes are stable enough for 15min; unverified answers expire in 60s so +// transient public-RPC noise doesn't pin the user-facing result for long; +// conflict outcomes are negative-cached for 10s purely to avoid re-entry storms on repeated // navigation attempts during an active lie. const TTL_BY_LEVEL = { verified: 15 * 60 * 1000, @@ -461,82 +482,33 @@ const ensAddressCache = new Map(); // Address (lowercased 0x) → { result, expiresAt } for reverse lookups. const ensReverseCache = new Map(); +let resolutionLifecycleEpoch = 0; + +// Any Ethereum Myotis lifecycle boundary invalidates both cached and in-flight +// policy decisions. Ready transitions let the preferred local tier overtake a +// fallback answer; unavailable/stopping transitions prevent stale local reads +// from being cached after shutdown. +myotisManager.onAvailabilityTransition((event) => { + if (event?.chainId != null && Number(event.chainId) !== 1) return; + const swept = ensResultCache.size + ensAddressCache.size + ensReverseCache.size; + resolutionLifecycleEpoch += 1; + ensResultCache.clear(); + ensAddressCache.clear(); + ensReverseCache.clear(); + inFlightResolves.clear(); + log.info( + `[ens] myotis ${event?.ready ? 'ready' : 'unavailable'} reason=${event?.reason || 'unknown'} ` + + `epoch=${event?.epoch ?? 'unknown'} swept=${swept} cached resolution result(s)` + ); +}); -// Get a working provider, trying each in sequence with fallback -async function getWorkingProvider() { - // If the cached provider's URL no longer matches the current settings, invalidate it - if (cachedProvider && cachedProviderUrl) { - const providers = getEffectiveRpcEndpoints(); - if (providers[0] !== cachedProviderUrl) { - log.info(`[ens] Settings changed, invalidating cached provider: ${cachedProviderUrl}`); - cachedProvider.destroy(); - cachedProvider = null; - cachedProviderUrl = null; - } - } - - // Return cached provider if still working - if (cachedProvider && cachedProviderUrl) { - try { - await cachedProvider.getBlockNumber(); - log.info(`[ens] Reusing cached provider: ${cachedProviderUrl}`); - return cachedProvider; - } catch { - log.warn(`[ens] Cached provider ${cachedProviderUrl} failed, trying fallbacks...`); - cachedProvider.destroy(); - cachedProvider = null; - cachedProviderUrl = null; - } - } - - // Try each provider in sequence - const providers = getEffectiveRpcEndpoints(); - const total = providers.length; - for (let i = 0; i < total; i++) { - const rpcUrl = providers[i]; - const providerNum = `${i + 1}/${total}`; - let provider; - try { - log.info(`[ens] Trying provider ${providerNum}: ${rpcUrl}`); - provider = new ethers.JsonRpcProvider(rpcUrl); - await provider.getBlockNumber(); // Health check - log.info(`[ens] Using provider ${providerNum}: ${rpcUrl}`); - cachedProvider = provider; - cachedProviderUrl = rpcUrl; - return provider; - } catch (err) { - log.warn(`[ens] Provider ${providerNum} failed: ${err.message}`); - if (provider) { - provider.destroy(); - } - } - } - - throw new Error('All RPC providers failed. Check your network connection.'); -} - -// Drop the cached single-provider used by getWorkingProvider. Cheap reset -// for the legacy retry loop — keeps quorum-path state (shuffled order, -// quarantine memory, pinned block anchor) intact, so a transient flake -// during reverse resolution doesn't make the next quorum wave pay an -// extra anchor RTT. -function dropCachedProvider() { - if (cachedProvider) { - log.info(`[ens] Invalidating cached provider: ${cachedProviderUrl}`); - cachedProvider.destroy(); - cachedProvider = null; - cachedProviderUrl = null; - } -} - -// Full reset: drop the legacy cached provider, wipe the quorum pool -// (shuffled order, quarantine, pinned block), AND flush the per-name -// resolution caches. External callers use this after a settings edit: +// Full reset: wipe the quorum pool (shuffled order, quarantine, pinned block), +// AND flush the per-name resolution caches. External callers use this after +// a settings edit: // the resolution caches store each name's trust level, which is derived // from the verification method — so a method change must drop them or // stale results keep their old trust until their TTL expires. function invalidateCachedProvider() { - dropCachedProvider(); invalidateProviderPool(); clearEnsResolutionCaches(); registry.invalidate(); @@ -571,9 +543,6 @@ function isProviderError(err) { return false; } -// Maximum retries for provider errors during resolution -const MAX_RESOLUTION_RETRIES = 3; - // Canonical UR custom errors we classify. ethers v6 surfaces the 4-byte // selector via err.data on CALL_EXCEPTION; some wrappers (JSON-RPC // proxies) expose it under err.info.error.data instead — check both. @@ -594,6 +563,40 @@ function getRevertData(err) { return typeof data === 'string' && data.length >= 10 ? data : null; } +// Keep provider diagnostics useful without dumping full transaction calldata, +// endpoint URLs, or arbitrarily large upstream messages into the application +// log. ethers' `shortMessage` is preferred because its full CALL_EXCEPTION +// message includes the complete Universal Resolver request. +function sanitizeErrorDetail(value, maxLength = 240) { + if (value == null || value === '') return ''; + const cleaned = String(value) + .replace(/https?:\/\/[^\s"'<>]+/gi, '') + .replace(/0x[0-9a-fA-F]{66,}/g, (hex) => + `${hex.slice(0, 10)}…(${Math.floor((hex.length - 2) / 2)} bytes)` + ) + .replace(/\s+/g, ' ') + .trim(); + return cleaned.length <= maxLength + ? cleaned + : `${cleaned.slice(0, maxLength - 1)}…`; +} + +function formatResolutionErrorForLog(err) { + const nested = err?.info?.error; + const message = sanitizeErrorDetail( + err?.shortMessage || err?.reason || err?.message || String(err) + ); + const fields = [`error=${JSON.stringify(message || 'unknown error')}`]; + if (err?.code != null) fields.push(`code=${sanitizeErrorDetail(err.code, 40)}`); + if (nested?.code != null) fields.push(`rpcCode=${sanitizeErrorDetail(nested.code, 40)}`); + if (nested?.message) { + fields.push(`rpcMessage=${JSON.stringify(sanitizeErrorDetail(nested.message))}`); + } + const revertData = getRevertData(err); + fields.push(`revert=${revertData ? revertData.slice(0, 10) : 'none'}`); + return fields.join(' '); +} + function urErrorSelector(err) { const data = getRevertData(err); return data ? data.slice(0, 10).toLowerCase() : null; @@ -723,6 +726,89 @@ async function universalResolverReverse(provider, addressBytes, overrides = {}) return { name }; } +// Reverse calls need to participate in the same byte-agreement machinery as +// forward records. Encode every semantic UR outcome as deterministic bytes so +// quorum can compare successful names, empty records, mismatches, and verified +// contract errors without collapsing them into the forward resolver's generic +// NO_CONTENTHASH bucket. +const REVERSE_OUTCOME = Object.freeze({ + noRecord: 0, + name: 1, + mismatch: 2, + error: 3, +}); + +function encodeReverseOutcome(status, detail = '') { + return ethers.AbiCoder.defaultAbiCoder().encode(['uint8', 'string'], [status, detail]); +} + +function decodeReverseOutcome(resolvedData) { + const [status, detail] = ethers.AbiCoder.defaultAbiCoder().decode( + ['uint8', 'string'], + resolvedData + ); + return { status: Number(status), detail: String(detail || '') }; +} + +async function universalResolverReverseCall(provider, normalizedAddress, _callData, overrides = {}) { + try { + const { name } = await universalResolverReverse( + provider, + ethers.getBytes(normalizedAddress), + overrides + ); + return { + resolvedData: encodeReverseOutcome( + name ? REVERSE_OUTCOME.name : REVERSE_OUTCOME.noRecord, + name || '' + ), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } catch (err) { + if (isProviderError(err)) throw err; + if (isResolverNotFoundError(err)) { + return { + resolvedData: encodeReverseOutcome(REVERSE_OUTCOME.noRecord), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } + if (isReverseAddressMismatchError(err)) { + return { + resolvedData: encodeReverseOutcome( + REVERSE_OUTCOME.mismatch, + decodeReverseMismatchClaimedName(err) || '' + ), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } + return { + resolvedData: encodeReverseOutcome(REVERSE_OUTCOME.error, err.message), + resolverAddress: UNIVERSAL_RESOLVER_ADDRESS, + }; + } +} + +// NameNFT reverseResolve(address) adapter. `name` is a synthetic value with +// the target system's suffix, allowing the existing NameNFT routing helper to +// select the correct contract while quorum compares ABI-identical strings. +async function nameNftReverseResolverCall(provider, name, callData, overrides = {}) { + const nameSystem = nameSystemForName(name); + if (!nameSystem.contractAddress) { + throw new Error(`No NameNFT contract configured for ${nameSystem.label}`); + } + const [address] = NAME_NFT_INTERFACE.decodeFunctionData('reverseResolve', callData); + const registryContract = new ethers.Contract( + nameSystem.contractAddress, + NAME_NFT_ABI, + provider + ); + const claimedName = await registryContract.reverseResolve(address, overrides); + return { + resolvedData: ethers.AbiCoder.defaultAbiCoder().encode(['string'], [claimedName || '']), + resolverAddress: nameSystem.contractAddress, + }; +} + // --------------------------------------------------------------------------- // Consensus resolution: hedged-quorum over K public RPCs at a shared pinned // block. Detects a lying RPC by requiring M byte-identical responses; @@ -765,7 +851,7 @@ async function runQuorumLeg( }; if (cancelToken) cancelToken.cleanups.add(cleanup); try { - provider = new ethers.JsonRpcProvider(url); + provider = createEthereumProvider(url); const urCall = callResolver(provider, name, callData, { blockTag: blockHash }); const result = await withTimeout(urCall, timeoutMs, cleanup); markProviderSuccess(url); @@ -951,6 +1037,243 @@ function classifyNoAgreement({ results }) { return { kind: 'conflict' }; } +async function readMyotisCcipBody(response) { + const declared = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(declared) && declared > MYOTIS_CCIP_MAX_RESPONSE_BYTES) { + throw new Error(`response exceeds ${MYOTIS_CCIP_MAX_RESPONSE_BYTES} bytes`); + } + + if (!response.body?.getReader) { + const body = await response.text(); + if (Buffer.byteLength(body) > MYOTIS_CCIP_MAX_RESPONSE_BYTES) { + throw new Error(`response exceeds ${MYOTIS_CCIP_MAX_RESPONSE_BYTES} bytes`); + } + return body; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let body = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MYOTIS_CCIP_MAX_RESPONSE_BYTES) { + try { await reader.cancel(); } catch { /* best-effort response teardown */ } + throw new Error(`response exceeds ${MYOTIS_CCIP_MAX_RESPONSE_BYTES} bytes`); + } + body += decoder.decode(value, { stream: true }); + } + return body + decoder.decode(); +} + +function containsCcipHttpError(dataHex) { + const bare = String(dataHex).replace(/^0x/i, '').toLowerCase(); + for (let i = 0; i + CCIP_HTTP_ERROR_SELECTOR.length <= bare.length; i += 2) { + if (bare.slice(i, i + CCIP_HTTP_ERROR_SELECTOR.length) === CCIP_HTTP_ERROR_SELECTOR) { + return true; + } + } + return false; +} + +async function fetchMyotisCcipResponse(rec) { + const senderHex = rec.senderHex; + const callDataHex = rec.callDataHex; + const urls = Array.isArray(rec.urls) ? rec.urls.filter((url) => typeof url === 'string') : []; + if (!senderHex || !callDataHex || urls.length === 0) { + throw new Error('CCIP-Read OffchainLookup did not include an actionable gateway tuple'); + } + + const reasons = []; + for (const template of urls) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), MYOTIS_CCIP_TIMEOUT_MS); + try { + const useGet = template.includes('{data}'); + const url = template.replaceAll('{sender}', senderHex).replaceAll('{data}', callDataHex); + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error(`unsupported gateway protocol ${parsed.protocol}`); + } + const response = await fetch(url, { + method: useGet ? 'GET' : 'POST', + headers: useGet + ? { Accept: 'application/json' } + : { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: useGet ? undefined : JSON.stringify({ sender: senderHex, data: callDataHex }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await readMyotisCcipBody(response); + let payload; + try { + payload = JSON.parse(body); + } catch { + throw new Error('response is not valid JSON'); + } + const dataHex = typeof payload?.data === 'string' && /^0x[0-9a-fA-F]*$/.test(payload.data) + ? payload.data + : null; + if (!dataHex) throw new Error('response has no hex data field'); + if ((dataHex.length - 2) % 2 !== 0) throw new Error('response data has odd-length hex'); + if (containsCcipHttpError(dataHex)) throw new Error('gateway returned HttpError'); + return dataHex; + } catch (err) { + reasons.push(`${template.slice(0, 200)}: ${err.message}`); + } finally { + clearTimeout(timeout); + } + } + throw new Error(`CCIP-Read gateway failed: ${reasons.join('; ')}`); +} + +// Drive the host half of ERC-3668 for the native engine. Myotis performs the +// resolver walk and verified callback execution; Freedom only fetches the +// gateway payload carried by the on-chain OffchainLookup tuple. One round is +// allowed, matching the upstream host driver and preventing recursive fetches. +async function resolveMyotisEnsRecord(params) { + const original = { ...params, root: params.root || 'auto' }; + let rec = await myotisManager.resolveEnsRecord(original); + if (rec.error) throw new Error(rec.error); + + for (let round = 0; rec.status === 'offchain'; round++) { + if (round >= MYOTIS_CCIP_MAX_ROUNDS) { + throw new Error(`CCIP-Read recursion exceeds ${MYOTIS_CCIP_MAX_ROUNDS} round`); + } + const responseHex = await fetchMyotisCcipResponse(rec); + rec = await myotisManager.resolveEnsRecord({ + ...original, + method: 'ccipCallback', + queryMethod: original.method, + senderHex: rec.senderHex, + callbackFunctionHex: rec.callbackFunctionHex, + responseHex, + extraDataHex: rec.extraDataHex, + wrapped: rec.wrapped === true, + finalized: rec.verified === true, + }); + if (rec.error) throw new Error(rec.error); + } + return rec; +} + +// The specialized ENS record API can pin to finalized state and reports that +// fact explicitly. Re-encode its decoded values to the raw ABI return shape +// shared by Colibri and RPC quorum so downstream decoders remain unchanged. +async function tryMyotisEnsPath(name, callData, nameSystem) { + const selector = String(callData).slice(0, 10).toLowerCase(); + const method = + selector === CONTENTHASH_SELECTOR + ? 'contenthash' + : selector === ADDR_SELECTOR + ? 'addr' + : null; + if (!method) return null; + + const rec = await resolveMyotisEnsRecord({ method, name }); + const trust = buildMyotisTrust(rec, nameSystem); + if (rec.status === 'noRecord') { + return { + outcome: 'data', + resolvedData: + method === 'contenthash' + ? ethers.AbiCoder.defaultAbiCoder().encode(['bytes'], ['0x']) + : ethers.AbiCoder.defaultAbiCoder().encode(['address'], [ethers.ZeroAddress]), + resolverAddress: null, + trust, + block: rec.blockNumber ?? null, + }; + } + if (rec.status !== 'ok') { + throw new Error(`unexpected ${method} record shape: ${JSON.stringify(rec).slice(0, 200)}`); + } + const value = method === 'contenthash' ? rec.dataHex : rec.addressHex; + if (!value) { + throw new Error(`missing ${method} value: ${JSON.stringify(rec).slice(0, 200)}`); + } + return { + outcome: 'data', + resolvedData: ethers.AbiCoder.defaultAbiCoder().encode( + [method === 'contenthash' ? 'bytes' : 'address'], + [value] + ), + resolverAddress: null, + trust, + block: rec.blockNumber ?? null, + }; +} + +// WNS and GNS are separate NameNFT contracts, not ENS registries. Myotis's +// generic verified eth_call can execute their existing calldata locally. The +// current v0.1.7 addon pins generic calls to the beacon optimistic head. Every +// state fetch is MPT-verified against that root and the root is authenticated +// by the light client's sync committee; `ok` therefore means cryptographically +// verified, while the trust metadata still states that it is not finalized. +async function tryMyotisContractPath(callData, nameSystem) { + const rec = await myotisManager.ethCall({ + to: nameSystem.contractAddress, + data: callData, + block: 'latest', + }); + if (rec.error) throw new Error(rec.error); + if (rec.status !== 'ok' || typeof rec.resultHex !== 'string') { + throw new Error(`Myotis contract call unavailable: ${rec.reason || rec.status || 'unknown'}`); + } + const status = myotisManager.getStatus() || {}; + const blockNumber = status.optimisticBlockNumber ?? null; + const trust = buildMyotisTrust({ verified: false, blockNumber }, nameSystem); + return { + outcome: 'data', + resolvedData: rec.resultHex, + resolverAddress: nameSystem.contractAddress, + trust, + block: blockNumber, + }; +} + +// Returns null only while the node cannot serve or when the requested selector +// is outside Freedom's current content/address surface. Real read failures +// throw so the orchestrator logs the handoff to the configured fallback. +async function tryMyotisPath(name, callData, nameSystem) { + if (!myotisManager.isReady()) return null; + const epoch = myotisManager.getAvailabilityEpoch(); + const result = nameSystem.contractAddress + ? await tryMyotisContractPath(callData, nameSystem) + : await tryMyotisEnsPath(name, callData, nameSystem); + if (epoch !== myotisManager.getAvailabilityEpoch() || !myotisManager.isReady()) { + const err = new Error('Myotis availability changed during verified read'); + err.code = 'MYOTIS_LIFECYCLE_CHANGED'; + throw err; + } + return result; +} + +// `verified` on the engine result is a FINALITY flag: true means the query ran +// against the beacon-finalized root; false means the sync-committee-attested +// optimistic root. Both paths cryptographically verify state and fail closed. +// Keep the distinction in `finality` and proof copy instead of mislabelling an +// authenticated optimistic result as an unverified RPC response. This matches +// Colibri, whose verified proof also targets a recent sync-committee head. +function buildMyotisTrust(rec, nameSystem = NAME_SYSTEMS.ens) { + const finalized = rec.verified === true; + return { + level: 'verified', + system: nameSystem.id, + method: 'myotis', + finality: finalized ? 'finalized' : 'optimistic', + proof: finalized + ? 'P2P light client (beacon-finalized, sync-committee proof)' + : 'P2P light client (optimistic beacon root — attested, not finalized)', + block: rec.blockNumber ?? null, + agreed: ['myotis-p2p'], + dissented: [], + queried: ['myotis-p2p'], + quorum: { k: 1, m: 1, achieved: true }, + }; +} + // Colibri primary path: a single cryptographically-verified eth_call against // the Universal Resolver via @corpus-core/colibri-stateless. The verifier // runs the EVM locally against proven storage, so a successful return is @@ -960,7 +1283,7 @@ function classifyNoAgreement({ results }) { // block N lives in block N+1). // // Throws on proof-verification failure / network error / prover outage so -// the orchestrator can fall through to the always-on quorum fallback. +// the orchestrator can fall through to the next configured method. async function tryColibriPath( name, callData, @@ -1041,7 +1364,7 @@ function buildColibriTrust(proverHost, nameSystem = NAME_SYSTEMS.ens) { // honest trust basis: true when the endpoint is a user-added source // (trust='user-configured'), false when `direct` was chosen without a // custom endpoint so it's just an unverified builtin public RPC. On any -// failure, return null so the caller falls back to the public quorum path. +// failure, return null so the caller can try the next configured method. // Pinned block is fetched from the same RPC — we don't want to send // user-node requests to public RPCs behind their back. async function tryDirectResolve( @@ -1186,50 +1509,11 @@ function getDirectRpcCandidate(chainId) { // { outcome: 'not_found', reason, trust, block } // { outcome: 'conflict', groups, trust, block } // Throws when there are no providers or both waves all-errored. -async function consensusResolve(normalizedName, callData, kind = 'content', options = {}) { +async function resolveViaQuorum(normalizedName, callData, kind = 'content', options = {}) { const network = registry.getNetwork(1); - const strategy = network.verification.primary; const callResolver = options.callResolver || universalResolverCall; const nameSystem = options.nameSystem || NAME_SYSTEMS.ens; - // Colibri primary: cryptographic verification via the sync committee - // (or zk sync proof). On verification failure or network/prover error - // we log loudly and fall through to the quorum path below — fallback to - // quorum is structural and always-on. Loud-fallback is load-bearing: a - // silent fall-through would hide both prover health regressions and the - // (rare) "active attack" signal. - if (strategy === 'colibri') { - try { - return await tryColibriPath(normalizedName, callData, callResolver, nameSystem); - } catch (err) { - log.warn( - `[ens] colibri-fallback name=${nameForLog(normalizedName)} kind=${kind} ` + - `error=${err.message}` - ); - } - } - - // Direct strategy: a single trusted endpoint (typically the user's own - // node). Try it first, fall back to public quorum on any failure so a - // misbehaving own-node still resolves. User-added sources sort ahead of - // builtins, so directUrl is the user's own endpoint when one exists — - // only then is the answer honestly 'user-configured'; with no custom - // endpoint added, `direct` is just an unverified builtin public RPC. - if (strategy === 'direct') { - const direct = getDirectRpcCandidate(1); - if (direct.url) { - const directResult = await tryDirectResolve( - direct.url, - normalizedName, - callData, - direct.userConfigured, - callResolver, - nameSystem, - ); - if (directResult) return directResult; - } - } - const { quorum } = network; const desiredK = Math.max(1, Math.min(Number(quorum.k) || 3, 9)); const desiredM = Math.max(1, Math.min(Number(quorum.m) || 2, desiredK)); @@ -1442,6 +1726,189 @@ async function consensusResolve(normalizedName, callData, kind = 'content', opti }; } +const RESOLUTION_METHOD_IDS = new Set(['myotis', 'colibri', 'quorum', 'direct']); + +function resolutionPolicy(network) { + const verification = network?.verification || {}; + const configured = Array.isArray(verification.order) + ? verification.order.filter((method, index, all) => + RESOLUTION_METHOD_IDS.has(method) && all.indexOf(method) === index + ) + : []; + if (configured.length > 0) { + return { + order: configured, + preferVerified: verification.preferVerified === true, + }; + } + + // Legacy network-config.json files only have `primary`. Preserve their old + // behavior until the user saves the new policy: Myotis first when enabled, + // the selected remote strategy next, and quorum as Colibri/direct fallback. + const primary = ['colibri', 'quorum', 'direct'].includes(verification.primary) + ? verification.primary + : 'colibri'; + return { + order: [...new Set(['myotis', primary, ...(primary === 'quorum' ? [] : ['quorum'])])], + preferVerified: false, + }; +} + +function isProvisionalResolution(result) { + return result?.trust?.level === 'unverified'; +} + +function unavailableResolutionReason(method) { + if (method === 'myotis') { + return myotisManager.isEnabled() ? 'not-ready' : 'disabled'; + } + if (method === 'direct') return 'not-configured'; + return 'unavailable'; +} + +function logForwardMethodOutcome({ + method, normalizedName, kind, result, action, reason, durationMs, +}) { + const outcome = result?.outcome ? String(result.outcome).toUpperCase() : 'SKIP'; + const trust = result?.trust?.level || 'none'; + log.info( + `[ens] method=${method} name=${nameForLog(normalizedName)} kind=${kind} outcome=${outcome} ` + + `trust=${trust} action=${action} reason=${reason || 'none'} durationMs=${durationMs}` + ); +} + +// Execute exactly one configured method. Keeping this separate from the +// fallback loop is important for reverse records: a WNS/GNS reverse claim +// must be forward-verified through the same source that produced the claim. +async function resolveWithMethod(method, normalizedName, callData, kind, options = {}) { + const callResolver = options.callResolver || universalResolverCall; + const nameSystem = options.nameSystem || NAME_SYSTEMS.ens; + + if (method === 'myotis') { + if (!myotisManager.isEnabled()) return null; + return tryMyotisPath(normalizedName, callData, nameSystem); + } + if (method === 'colibri') { + return tryColibriPath(normalizedName, callData, callResolver, nameSystem); + } + if (method === 'direct') { + const direct = getDirectRpcCandidate(1); + if (!direct.url) return null; + return tryDirectResolve( + direct.url, + normalizedName, + callData, + direct.userConfigured, + callResolver, + nameSystem, + ); + } + if (method === 'quorum') { + return resolveViaQuorum(normalizedName, callData, kind, options); + } + return null; +} + +// Execute the user's unified resolution order. An unverified answer can be +// retained provisionally while later methods get a chance to produce a +// verified result; conflicts and user-configured/verified answers always stop. +async function consensusResolve(normalizedName, callData, kind = 'content', options = {}) { + const network = registry.getNetwork(1); + const callResolver = options.callResolver || universalResolverCall; + const nameSystem = options.nameSystem || NAME_SYSTEMS.ens; + const { order, preferVerified } = resolutionPolicy(network); + let provisional = null; + let provisionalMethod = null; + let lastError = null; + + log.info( + `[ens] policy name=${nameForLog(normalizedName)} kind=${kind} order=[${order.join(',')}] ` + + `preferVerified=${preferVerified}` + ); + + for (const method of order) { + const startedAt = Date.now(); + let result; + try { + result = await resolveWithMethod(method, normalizedName, callData, kind, { + ...options, + callResolver, + nameSystem, + }); + } catch (err) { + if (err?.code === 'MYOTIS_LIFECYCLE_CHANGED') throw err; + lastError = err; + log.warn( + `[ens] ${method}-fallback name=${nameForLog(normalizedName)} kind=${kind} ` + + formatResolutionErrorForLog(err) + ); + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result: { outcome: 'error' }, + action: 'continue', + reason: err?.code || 'error', + durationMs: Date.now() - startedAt, + }); + continue; + } + + if (!result) { + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result, + action: 'continue', + reason: unavailableResolutionReason(method), + durationMs: Date.now() - startedAt, + }); + continue; + } + if (preferVerified && isProvisionalResolution(result)) { + if (!provisional) { + provisional = result; + provisionalMethod = method; + } + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result, + action: 'continue', + reason: 'prefer-verified', + durationMs: Date.now() - startedAt, + }); + continue; + } + logForwardMethodOutcome({ + method, + normalizedName, + kind, + result, + action: 'accept', + durationMs: Date.now() - startedAt, + }); + return result; + } + + if (provisional) { + logForwardMethodOutcome({ + method: provisionalMethod, + normalizedName, + kind, + result: provisional, + action: 'accept', + reason: 'fallback-exhausted', + durationMs: 0, + }); + return provisional; + } + if (lastError) throw lastError; + throw new Error(`No enabled name-resolution method could resolve ${normalizedName}`); +} + async function resolveEnsContent(name) { return resolveWithCache(name, ensResultCache, doResolveEnsContent, 'content'); } @@ -1634,10 +2101,9 @@ async function resolveEnsAddress(name) { // selectors and need independent caches. const inFlightResolves = new Map(); -// Shared validation + cache wrapper for the content-hash and addr lookup -// paths. The consensusResolve primitive handles provider-error escalation -// internally via its second-wave logic, so no outer retry loop is needed -// for the new path. Legacy reverse-resolution uses its own retry below. +// Shared validation + cache wrapper for content-hash, addr, and reverse +// lookups. Each strategy handles provider fallback internally, so this layer +// only normalizes, caches, and deduplicates concurrent work. // // Normalization goes through @adraffy/ens-normalize (UTS-46 / ENSIP-15), // not a bare .toLowerCase(). That's correct for unicode ENS names @@ -1652,6 +2118,7 @@ const inFlightResolves = new Map(); // path (the bzz protocol handler now resolves on every subresource // request, so a busy page can fan out dozens of cache hits per page load). const PURE_ASCII_HOST = /^[a-z0-9-.]+$/; +const MAX_LIFECYCLE_RESTARTS = 3; function fastNormalize(trimmed) { const lowered = trimmed.toLowerCase(); @@ -1668,7 +2135,7 @@ async function resolveWithCache(name, cache, doResolve, label) { const cached = cache.get(normalized); if (cached && Date.now() < cached.expiresAt) { - log.debug(`[ens] ${label} cache hit for ${normalized}`); + log.debug(`[ens] ${label} cache hit for ${nameForLog(normalized)}`); return cached.result; } @@ -1679,39 +2146,48 @@ async function resolveWithCache(name, cache, doResolve, label) { return existing; } - // consensusResolve (content/addr paths) handles provider-error escalation - // internally via its second-wave logic, so the outer retry loop only runs - // for the legacy reverse-resolution path. - const needsLegacyRetry = label === 'reverse'; - - const promise = (async () => { - if (!needsLegacyRetry) return doResolve(normalized); - - let lastError; - for (let attempt = 1; attempt <= MAX_RESOLUTION_RETRIES; attempt++) { - try { - return await doResolve(normalized); - } catch (err) { - lastError = err; - if (isProviderError(err) && attempt < MAX_RESOLUTION_RETRIES) { - log.warn( - `[ens] ${label} provider error on attempt ${attempt}/${MAX_RESOLUTION_RETRIES}: ${err.message}` - ); - dropCachedProvider(); - continue; - } - throw err; - } + let promise; + promise = resolveAcrossStableLifecycle(normalized, cache, doResolve, label).finally(() => { + // A Myotis transition clears the map so a fresh request can begin. Do not + // let the older promise's finally handler delete that replacement entry. + if (inFlightResolves.get(dedupKey) === promise) { + inFlightResolves.delete(dedupKey); } - throw lastError; - })().finally(() => { - inFlightResolves.delete(dedupKey); }); inFlightResolves.set(dedupKey, promise); return promise; } +async function resolveAcrossStableLifecycle(normalized, cache, doResolve, label) { + for (let attempt = 1; attempt <= MAX_LIFECYCLE_RESTARTS; attempt++) { + const epoch = resolutionLifecycleEpoch; + let result; + try { + result = await doResolve(normalized); + } catch (err) { + if (epoch === resolutionLifecycleEpoch) throw err; + log.info( + `[ens] ${label} lifecycle changed during failed resolution for ${nameForLog(normalized)}; ` + + `restarting (${attempt}/${MAX_LIFECYCLE_RESTARTS})` + ); + continue; + } + + if (epoch === resolutionLifecycleEpoch) return result; + + // doResolve helpers cache before returning. Remove only this stale result; + // a newer concurrent request may already have populated the same key. + const cached = cache.get(normalized); + if (cached?.result === result) cache.delete(normalized); + log.info( + `[ens] ${label} lifecycle changed during resolution for ${nameForLog(normalized)}; ` + + `discarding stale result and restarting (${attempt}/${MAX_LIFECYCLE_RESTARTS})` + ); + } + throw new Error(`Myotis availability changed repeatedly while resolving ${normalized}`); +} + async function doResolveEnsAddress(normalized) { const node = ethers.namehash(normalized); const callData = ADDR_SELECTOR + node.slice(2); @@ -1847,7 +2323,7 @@ async function resolveEnsReverse(address) { } // Colibri reverse path: cryptographically-verified `ur.reverse`. Returns -// the same result shape as the legacy path, plus a `trust` object so the +// the common reverse-result shape plus a `trust` object so the // renderer can surface a "verified" indicator. ReverseAddressMismatch // surfaces as UNVERIFIED — the proof was valid but the contract reverted, // which is the spoofed-reverse-record signal. @@ -1895,155 +2371,354 @@ function unverifiedReverseResult(normalizedAddress, nameSystem, claimedName, det }; } -async function verifyContractBackedReverseName(normalizedAddress, nameSystem, claimedName) { - if (!claimedName) return null; - const claimedSystem = nameSystemForName(claimedName); - if (claimedSystem.id !== nameSystem.id) { - return unverifiedReverseResult( +async function readMyotisReverse(normalizedAddress) { + const ensRec = await resolveMyotisEnsRecord({ + method: 'reverse', + addressHex: normalizedAddress, + }); + const ensTrust = buildMyotisTrust(ensRec, NAME_SYSTEMS.ens); + if (ensRec.status === 'ok' && ensRec.name) { + return { + success: true, + address: normalizedAddress, + name: ensRec.name, + system: 'ens', + trust: ensTrust, + }; + } + if (ensRec.status !== 'noRecord') { + throw new Error(`unexpected reverse record shape: ${JSON.stringify(ensRec).slice(0, 200)}`); + } + + // WNS/GNS reverse records live on their NameNFT contracts. Query each over + // Myotis and forward-check any claim through the same local verified-call + // path before presenting it as a name. + let firstUnverified = null; + let lastTrust = ensTrust; + for (const nameSystem of CONTRACT_BACKED_REVERSE_SYSTEMS) { + const reverseCall = NAME_NFT_INTERFACE.encodeFunctionData('reverseResolve', [ + normalizedAddress, + ]); + const reverseOutcome = await tryMyotisContractPath(reverseCall, nameSystem); + lastTrust = reverseOutcome.trust; + let claimedName; + try { + [claimedName] = NAME_NFT_INTERFACE.decodeFunctionResult( + 'reverseResolve', + reverseOutcome.resolvedData + ); + } catch (err) { + throw new Error(`invalid ${nameSystem.label} reverse response: ${err.message}`, { + cause: err, + }); + } + if (!claimedName) continue; + + const claimedSystem = nameSystemForName(claimedName); + if (claimedSystem.id !== nameSystem.id) { + const invalid = unverifiedReverseResult( + normalizedAddress, + nameSystem, + claimedName, + `Reverse record for ${normalizedAddress} claims a non-${nameSystem.label} name`, + reverseOutcome.trust + ); + if (!firstUnverified) firstUnverified = invalid; + continue; + } + + let forwardOutcome; + try { + const normalizedClaim = fastNormalize(String(claimedName).trim()); + const forwardCall = ADDR_SELECTOR + ethers.namehash(normalizedClaim).slice(2); + forwardOutcome = await tryMyotisContractPath(forwardCall, nameSystem); + const [forwardAddress] = ethers.AbiCoder.defaultAbiCoder().decode( + ['address'], + forwardOutcome.resolvedData + ); + if (String(forwardAddress).toLowerCase() === normalizedAddress) { + return { + success: true, + address: normalizedAddress, + name: normalizedClaim, + system: nameSystem.id, + trust: forwardOutcome.trust, + }; + } + } catch (err) { + log.info( + `[${nameSystem.id}] myotis forward verification failed for ${nameForLog(normalizedAddress)}: ${err.message}` + ); + } + + const invalid = unverifiedReverseResult( normalizedAddress, nameSystem, claimedName, - `Reverse record for ${normalizedAddress} claims a non-${nameSystem.label} name` + `Reverse record for ${normalizedAddress} does not forward-verify`, + forwardOutcome?.trust || reverseOutcome.trust ); + if (!firstUnverified) firstUnverified = invalid; } - let forwardResult; - try { - forwardResult = await resolveEnsAddress(claimedName); - } catch (err) { + return firstUnverified || { + ...noReverseResult(normalizedAddress), + trust: lastTrust, + }; +} + +async function tryMyotisReverse(normalizedAddress) { + if (!myotisManager.isReady()) return null; + const epoch = myotisManager.getAvailabilityEpoch(); + const result = await readMyotisReverse(normalizedAddress); + if (epoch !== myotisManager.getAvailabilityEpoch() || !myotisManager.isReady()) { + const err = new Error('Myotis availability changed during verified reverse read'); + err.code = 'MYOTIS_LIFECYCLE_CHANGED'; + throw err; + } + return result; +} + +function reverseConflictResult(normalizedAddress, nameSystem, outcome) { + return { + success: false, + address: normalizedAddress, + system: nameSystem.id, + reason: 'CONFLICT', + error: `Providers disagreed about the reverse record for ${normalizedAddress}`, + trust: outcome.trust, + groups: outcome.groups, + }; +} + +async function resolveEnsReverseWithMethod(method, normalizedAddress) { + if (method === 'colibri') return tryColibriReverse(normalizedAddress); + + const outcome = await resolveWithMethod(method, normalizedAddress, '0x', 'reverse-ens', { + callResolver: universalResolverReverseCall, + nameSystem: NAME_SYSTEMS.ens, + }); + if (!outcome) return null; + if (outcome.outcome === 'conflict') { + return reverseConflictResult(normalizedAddress, NAME_SYSTEMS.ens, outcome); + } + if (outcome.outcome === 'not_found') { + return { ...noReverseResult(normalizedAddress), trust: outcome.trust }; + } + + const decoded = decodeReverseOutcome(outcome.resolvedData); + if (decoded.status === REVERSE_OUTCOME.name) { + return { + success: true, + address: normalizedAddress, + name: decoded.detail, + system: 'ens', + trust: outcome.trust, + }; + } + if (decoded.status === REVERSE_OUTCOME.mismatch) { return unverifiedReverseResult( normalizedAddress, - nameSystem, - claimedName, - `Reverse record for ${normalizedAddress} could not be forward-verified: ${err.message}` + NAME_SYSTEMS.ens, + decoded.detail || null, + `Reverse record for ${normalizedAddress} does not forward-verify`, + outcome.trust ); } - - const forwardAddress = String(forwardResult?.address || '').toLowerCase(); - if (forwardResult?.success && forwardAddress === normalizedAddress) { + if (decoded.status === REVERSE_OUTCOME.error) { return { - success: true, + success: false, address: normalizedAddress, - name: forwardResult.name || claimedName, - system: nameSystem.id, - trust: forwardResult.trust, + system: 'ens', + reason: 'RESOLUTION_ERROR', + error: decoded.detail, + trust: outcome.trust, }; } - - return unverifiedReverseResult( - normalizedAddress, - nameSystem, - claimedName, - `Reverse record for ${normalizedAddress} does not forward-verify`, - forwardResult?.trust - ); + return { ...noReverseResult(normalizedAddress), trust: outcome.trust }; } -async function withContractBackedReverseFallback(normalizedAddress, ensResult) { +async function resolveContractBackedReverseWithMethod(method, normalizedAddress, ensResult) { if (ensResult?.reason !== 'NO_REVERSE') return ensResult; - let provider; - try { - provider = await getWorkingProvider(); - // Return the first forward-verified name across systems. A claim that - // doesn't forward-verify shouldn't stop us from checking the next system - // (an address can have a stale/spoofed .wei record but a valid .gwei - // primary), so keep the first unverified claim only as a fallback so its - // warning still surfaces when no system verifies. - let firstUnverified = null; - for (const nameSystem of CONTRACT_BACKED_REVERSE_SYSTEMS) { - try { - const registryContract = new ethers.Contract( - nameSystem.contractAddress, - NAME_NFT_ABI, - provider + let firstUnverified = null; + let lastTrust = ensResult.trust; + for (const nameSystem of CONTRACT_BACKED_REVERSE_SYSTEMS) { + const reverseCall = NAME_NFT_INTERFACE.encodeFunctionData('reverseResolve', [ + normalizedAddress, + ]); + const syntheticName = `reverse${nameSystem.suffix}`; + const reverseOutcome = await resolveWithMethod( + method, + syntheticName, + reverseCall, + `reverse-${nameSystem.id}`, + { callResolver: nameNftReverseResolverCall, nameSystem } + ); + if (!reverseOutcome) return null; + lastTrust = reverseOutcome.trust || lastTrust; + if (reverseOutcome.outcome === 'conflict') { + return reverseConflictResult(normalizedAddress, nameSystem, reverseOutcome); + } + if (reverseOutcome.outcome === 'not_found') continue; + + let claimedName; + try { + [claimedName] = ethers.AbiCoder.defaultAbiCoder().decode( + ['string'], + reverseOutcome.resolvedData + ); + } catch (err) { + throw new Error(`invalid ${nameSystem.label} reverse response: ${err.message}`, { + cause: err, + }); + } + if (!claimedName) continue; + + const claimedSystem = nameSystemForName(claimedName); + if (claimedSystem.id !== nameSystem.id) { + firstUnverified ||= unverifiedReverseResult( + normalizedAddress, + nameSystem, + claimedName, + `Reverse record for ${normalizedAddress} claims a non-${nameSystem.label} name`, + reverseOutcome.trust + ); + continue; + } + + let forwardOutcome; + try { + const normalizedClaim = fastNormalize(String(claimedName).trim()); + const forwardCall = ADDR_SELECTOR + ethers.namehash(normalizedClaim).slice(2); + forwardOutcome = await resolveWithMethod( + method, + normalizedClaim, + forwardCall, + `reverse-forward-${nameSystem.id}`, + { callResolver: nameNftResolverCall, nameSystem } + ); + if (forwardOutcome?.outcome === 'data') { + const [forwardAddress] = ethers.AbiCoder.defaultAbiCoder().decode( + ['address'], + forwardOutcome.resolvedData ); - const name = await registryContract.reverseResolve(normalizedAddress); - if (!name) continue; - const verified = await verifyContractBackedReverseName(normalizedAddress, nameSystem, name); - if (verified?.success) return verified; - if (!firstUnverified) firstUnverified = verified; - } catch (err) { - if (isProviderError(err)) throw err; - log.info(`[${nameSystem.id}] reverse failed for ${nameForLog(normalizedAddress)}: ${err.message}`); + if (String(forwardAddress).toLowerCase() === normalizedAddress) { + return { + success: true, + address: normalizedAddress, + name: normalizedClaim, + system: nameSystem.id, + trust: forwardOutcome.trust, + }; + } } + } catch (err) { + log.info( + `[${nameSystem.id}] ${method} forward verification failed for ${nameForLog(normalizedAddress)}: ${err.message}` + ); } - return firstUnverified || ensResult; - } catch (err) { - if (isProviderError(err)) throw err; - log.info( - `[ens] contract-backed reverse fallback failed for ${nameForLog(normalizedAddress)}: ${err.message}` + + firstUnverified ||= unverifiedReverseResult( + normalizedAddress, + nameSystem, + claimedName, + `Reverse record for ${normalizedAddress} does not forward-verify`, + forwardOutcome?.trust || reverseOutcome.trust ); - return ensResult; } + + return firstUnverified || { + ...noReverseResult(normalizedAddress), + trust: lastTrust, + }; +} + +function isProvisionalReverse(result) { + return result?.trust?.level === 'unverified' || result?.reason === 'UNVERIFIED'; +} + +function logReverseMethodOutcome(method, normalizedAddress, result, action, reason = '') { + const outcome = result?.success ? 'RESOLVED' : result?.reason || 'UNAVAILABLE'; + const system = outcome === 'NO_REVERSE' + ? 'ens,wns,gns' + : result?.system || result?.trust?.system || 'none'; + const trust = result?.trust?.level || 'none'; + log.info( + `[ens] reverse method=${method} address=${nameForLog(normalizedAddress)} outcome=${outcome} ` + + `system=${system} trust=${trust} action=${action}${reason ? ` reason=${reason}` : ''}` + ); } async function doResolveEnsReverse(normalizedAddress) { - const strategy = registry.getNetwork(1).verification.primary; + const network = registry.getNetwork(1); + const { order, preferVerified } = resolutionPolicy(network); + let provisional = null; + let provisionalMethod = null; + let lastError = null; + + log.info( + `[ens] reverse policy address=${nameForLog(normalizedAddress)} order=[${order.join(',')}] ` + + `preferVerified=${preferVerified}` + ); - if (strategy === 'colibri') { + for (const method of order) { + let result = null; + let unavailableReason = 'unavailable'; try { - const ensResult = await tryColibriReverse(normalizedAddress); - return cacheReverseResult( - normalizedAddress, - await withContractBackedReverseFallback(normalizedAddress, ensResult) - ); + if (method === 'myotis') { + if (myotisManager.isEnabled()) { + result = await tryMyotisReverse(normalizedAddress); + if (!result) unavailableReason = 'not-ready'; + } else { + unavailableReason = 'disabled'; + } + } else { + const ensResult = await resolveEnsReverseWithMethod(method, normalizedAddress); + result = await resolveContractBackedReverseWithMethod( + method, + normalizedAddress, + ensResult + ); + } } catch (err) { + if (err?.code === 'MYOTIS_LIFECYCLE_CHANGED') throw err; + lastError = err; log.warn( - `[ens] colibri-fallback reverse address=${nameForLog(normalizedAddress)} error=${err.message}` + `[ens] reverse method=${method} address=${nameForLog(normalizedAddress)} outcome=ERROR ` + + `action=continue error=${err.message}` ); + continue; } - } - - const provider = await getWorkingProvider(); - const ur = new ethers.Contract(UNIVERSAL_RESOLVER_ADDRESS, UR_ABI, provider); - const addrBytes = ethers.getBytes(normalizedAddress); - let claimedName; - try { - const [name] = await ur.reverse(addrBytes, ETH_COIN_TYPE, { enableCcipRead: true }); - claimedName = name; - } catch (err) { - if (isProviderError(err)) throw err; - if (isResolverNotFoundError(err)) { - return cacheReverseResult( - normalizedAddress, - await withContractBackedReverseFallback(normalizedAddress, noReverseResult(normalizedAddress)) - ); + if (!result) { + logReverseMethodOutcome(method, normalizedAddress, result, 'continue', unavailableReason); + continue; } - if (isReverseAddressMismatchError(err)) { - return cacheReverseResult(normalizedAddress, { - success: false, - address: normalizedAddress, - system: 'ens', - reason: 'UNVERIFIED', - claimedName: decodeReverseMismatchClaimedName(err), - error: `Reverse record for ${normalizedAddress} does not forward-verify`, - }); + if (preferVerified && isProvisionalReverse(result)) { + if (!provisional) { + provisional = result; + provisionalMethod = method; + } + logReverseMethodOutcome(method, normalizedAddress, result, 'continue', 'prefer-verified'); + continue; } - log.info(`[ens] UR reverse failed for ${nameForLog(normalizedAddress)}: ${err.message}`); - return cacheReverseResult(normalizedAddress, { - success: false, - address: normalizedAddress, - system: 'ens', - reason: 'RESOLUTION_ERROR', - error: err.message, - }); + logReverseMethodOutcome(method, normalizedAddress, result, 'accept'); + return cacheReverseResult(normalizedAddress, result); } - if (!claimedName) { - return cacheReverseResult( + if (provisional) { + logReverseMethodOutcome( + provisionalMethod, normalizedAddress, - await withContractBackedReverseFallback(normalizedAddress, noReverseResult(normalizedAddress)) + provisional, + 'accept', + 'no-better-result' ); + return cacheReverseResult(normalizedAddress, provisional); } - - return cacheReverseResult(normalizedAddress, { - success: true, - address: normalizedAddress, - name: claimedName, - system: 'ens', - }); + if (lastError) throw lastError; + throw new Error(`No enabled name-resolution method could reverse-resolve ${normalizedAddress}`); } function noReverseResult(normalizedAddress) { @@ -2051,7 +2726,7 @@ function noReverseResult(normalizedAddress) { success: false, address: normalizedAddress, reason: 'NO_REVERSE', - error: `No primary ENS name set for ${normalizedAddress}`, + error: `No primary name set for ${normalizedAddress}`, }; } @@ -2157,6 +2832,7 @@ function invalidateEnsContent(name) { // after a settings edit (equivalent to waiting out the TTLs); tests also // call it directly to share ENS names across cases without cross-pollution. function clearEnsResolutionCaches() { + resolutionLifecycleEpoch += 1; ensResultCache.clear(); ensAddressCache.clear(); ensReverseCache.clear(); diff --git a/src/main/ens-resolver.test.js b/src/main/ens-resolver.test.js index 6d315be5..9834c83d 100644 --- a/src/main/ens-resolver.test.js +++ b/src/main/ens-resolver.test.js @@ -84,7 +84,13 @@ jest.mock('./networks/network-registry', () => { return { chainId: 1, name: 'Ethereum', - verification: { primary }, + verification: { + primary, + ...(Array.isArray(s.ensResolutionOrder) ? { order: s.ensResolutionOrder } : {}), + ...(typeof s.ensPreferVerified === 'boolean' + ? { preferVerified: s.ensPreferVerified } + : {}), + }, quorum: { k: s.ensQuorumK ?? 3, m: s.ensQuorumM ?? 2, @@ -132,6 +138,28 @@ jest.mock('./ens/colibri-resolver', () => ({ resolveReverseViaColibri: (...args) => mockResolveReverseViaColibri(...args), })); +// Myotis manager (experimental P2P light-client tier). Defaults to disabled +// so every pre-existing test sees the resolver exactly as before; the +// myotis-path suite flips these per test. +const mockMyotisIsEnabled = jest.fn(() => false); +const mockMyotisIsReady = jest.fn(() => false); +const mockMyotisResolveEnsRecord = jest.fn(); +const mockMyotisEthCall = jest.fn(); +const mockMyotisGetStatus = jest.fn(() => ({ optimisticBlockNumber: 23456800 })); +const mockMyotisGetAvailabilityEpoch = jest.fn(() => 0); +// Captures the resolver's module-load registration so tests can fire either +// availability direction and exercise cache/in-flight lifecycle behavior. +const mockMyotisAvailabilityListeners = []; +jest.mock('./myotis/myotis-manager', () => ({ + isEnabled: (...args) => mockMyotisIsEnabled(...args), + isReady: (...args) => mockMyotisIsReady(...args), + resolveEnsRecord: (...args) => mockMyotisResolveEnsRecord(...args), + ethCall: (...args) => mockMyotisEthCall(...args), + getStatus: (...args) => mockMyotisGetStatus(...args), + getAvailabilityEpoch: (...args) => mockMyotisGetAvailabilityEpoch(...args), + onAvailabilityTransition: (cb) => mockMyotisAvailabilityListeners.push(cb), +})); + // Mock ethers with controllable provider and resolver behavior. // `mockUrResolve` is shared across all Contract instances — this is fine // for tests that use `mockResolvedValue(X)` (every quorum leg returns X @@ -169,6 +197,7 @@ let mockProviderAnchorMap = null; const WNS_ADDRESS = '0x0000000000696760e15f265e828db644a0c242eb'; const GNS_ADDRESS = '0x9d51d507bc7264d4fe8ad1cf7fe191933a0a81d6'; +const ADDR_SELECTOR = '0x3b3b57de'; jest.mock('ethers', () => { const actual = jest.requireActual('ethers').ethers; @@ -244,6 +273,7 @@ jest.mock('ethers', () => { decodeBase58: actual.decodeBase58, getBytes: actual.getBytes, ZeroAddress: actual.ZeroAddress, + Interface: actual.Interface, }, }; }); @@ -261,6 +291,7 @@ const { } = require('./ens-resolver'); const { ipcMain } = require('electron'); const IPC = require('../shared/ipc-channels'); +const resolverLog = require('./logger'); // Fake block anchor — stable hash so consensus legs querying the same // block get deterministic agreement. @@ -268,6 +299,7 @@ const FAKE_BLOCK = { number: 12345678, hash: '0xabcdef00000000000000000000000000 beforeEach(() => { jest.clearAllMocks(); + mockMyotisGetAvailabilityEpoch.mockImplementation(() => 0); invalidateCachedProvider(); lastProviderUrl = null; mockProviderRouteMap = null; @@ -283,6 +315,10 @@ beforeEach(() => { mockGnsContenthash.mockResolvedValue('0x'); mockGnsAddr.mockResolvedValue('0x0000000000000000000000000000000000000000'); mockGnsReverseResolve.mockResolvedValue(''); + mockResolveViaColibri.mockResolvedValue({ + resolvedData: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['']), + resolverAddress: WNS_ADDRESS, + }); mockLoadSettings.mockReturnValue({ enableEnsCustomRpc: false, ensRpcUrl: '', @@ -613,6 +649,21 @@ describe('ens-resolver', () => { // Default test settings: K=3, matching TEST_PROVIDERS.length. expect(mockUrResolve).toHaveBeenCalledTimes(3); }); + + test('pins every short-lived RPC provider to Ethereum Mainnet', async () => { + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + await resolveEnsContent('static-network.eth'); + + expect(ethers.JsonRpcProvider).toHaveBeenCalled(); + for (const call of ethers.JsonRpcProvider.mock.calls) { + expect(call).toEqual([ + expect.any(String), + 1, + { staticNetwork: true }, + ]); + } + }); }); describe('custom RPC URL', () => { @@ -851,12 +902,41 @@ describe('ens-resolver', () => { const result = await resolveEnsReverse(input); - expect(result).toEqual({ + expect(result).toMatchObject({ success: true, address: input.toLowerCase(), name: 'verified1.eth', system: 'ens', + trust: { + level: 'verified', + system: 'ens', + quorum: { k: 3, m: 2, achieved: true }, + }, }); + expect(mockUrReverse).toHaveBeenCalledTimes(3); + }); + + test('surfaces conflicting reverse names when the RPC quorum disagrees', async () => { + const input = addr('1014'); + mockUrReverse + .mockResolvedValueOnce(['alice.eth', RESOLVER, RESOLVER]) + .mockResolvedValueOnce(['bob.eth', RESOLVER, RESOLVER]) + .mockResolvedValueOnce(['carol.eth', RESOLVER, RESOLVER]); + + const result = await resolveEnsReverse(input); + + expect(result).toMatchObject({ + success: false, + address: input.toLowerCase(), + system: 'ens', + reason: 'CONFLICT', + trust: { + level: 'conflict', + quorum: { k: 3, m: 2, achieved: false }, + }, + }); + expect(result.groups).toHaveLength(3); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('falls back to WNS reverse when ENS has no primary name', async () => { @@ -874,8 +954,8 @@ describe('ens-resolver', () => { system: 'wns', }); expect(result.trust).toMatchObject({ level: 'verified', system: 'wns' }); - expect(mockUrReverse).toHaveBeenCalledTimes(1); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockWnsAddr).toHaveBeenCalledTimes(3); }); @@ -895,9 +975,9 @@ describe('ens-resolver', () => { system: 'gns', }); expect(result.trust).toMatchObject({ level: 'verified', system: 'gns' }); - expect(mockUrReverse).toHaveBeenCalledTimes(1); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); - expect(mockGnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); + expect(mockGnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockGnsAddr).toHaveBeenCalledTimes(3); }); @@ -918,7 +998,7 @@ describe('ens-resolver', () => { }); expect(result.name).toBeUndefined(); expect(result.trust).toMatchObject({ level: 'verified', system: 'wns' }); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockWnsAddr).toHaveBeenCalledTimes(3); }); @@ -940,7 +1020,7 @@ describe('ens-resolver', () => { }); expect(result.name).toBeUndefined(); expect(result.trust).toMatchObject({ level: 'verified', system: 'gns' }); - expect(mockGnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockGnsReverseResolve).toHaveBeenCalledTimes(3); expect(mockGnsAddr).toHaveBeenCalledTimes(3); }); @@ -955,12 +1035,12 @@ describe('ens-resolver', () => { try { await resolveEnsReverse(input); await resolveEnsReverse(input); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(1); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(3); now.mockReturnValue(1_061_000); await resolveEnsReverse(input); - expect(mockWnsReverseResolve).toHaveBeenCalledTimes(2); + expect(mockWnsReverseResolve).toHaveBeenCalledTimes(6); } finally { now.mockRestore(); } @@ -1026,13 +1106,13 @@ describe('ens-resolver', () => { mockUrReverse .mockRejectedValueOnce(providerError) - .mockResolvedValueOnce(['retry-reverse.eth', RESOLVER, RESOLVER]); + .mockResolvedValue(['retry-reverse.eth', RESOLVER, RESOLVER]); const result = await resolveEnsReverse(input); expect(result.success).toBe(true); expect(result.name).toBe('retry-reverse.eth'); - expect(mockUrReverse).toHaveBeenCalledTimes(2); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('caches successful verified results', async () => { @@ -1042,7 +1122,7 @@ describe('ens-resolver', () => { await resolveEnsReverse(input); await resolveEnsReverse(input); - expect(mockUrReverse).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('caches NO_REVERSE negative results too', async () => { @@ -1052,7 +1132,7 @@ describe('ens-resolver', () => { await resolveEnsReverse(input); await resolveEnsReverse(input); - expect(mockUrReverse).toHaveBeenCalledTimes(1); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); test('normalizes input address to lowercase for caching', async () => { @@ -1063,7 +1143,28 @@ describe('ens-resolver', () => { await resolveEnsReverse(input.toLowerCase()); // Second call hits the cache keyed on lowercase form. + expect(mockUrReverse).toHaveBeenCalledTimes(3); + }); + + test('direct reverse resolution only queries the selected custom RPC', async () => { + const input = addr('1013'); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + enableEnsCustomRpc: true, + ensRpcUrl: 'https://user-rpc.example.com', + ensResolutionOrder: ['direct'], + }); + mockUrReverse.mockResolvedValue(['direct.eth', RESOLVER, RESOLVER]); + + const result = await resolveEnsReverse(input); + + expect(result).toMatchObject({ + success: true, + name: 'direct.eth', + trust: { level: 'user-configured' }, + }); expect(mockUrReverse).toHaveBeenCalledTimes(1); + expect(mockGetBlockNumber).toHaveBeenCalledTimes(1); }); }); @@ -1175,6 +1276,802 @@ describe('ens-resolver', () => { // exist in the legacy single-provider flow: conflict, degraded K=1 // unverified, user-configured fast-path labelling, block pinning. // -------------------------------------------------------------------- + describe('experimental myotis path', () => { + const IPFS_V0 = 'QmW81r84Aihiqqi2Jw6nM1LnpeMfRCenRxtjwHNkXVkZYa'; + + const myotisUp = () => { + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => true); + }; + + afterEach(() => { + mockMyotisIsEnabled.mockImplementation(() => false); + mockMyotisIsReady.mockImplementation(() => false); + }); + + test('serves verified contenthash from the local P2P node without touching RPC or colibri', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const result = await resolveEnsContent('myotis-ok.eth'); + + expect(result).toMatchObject({ + type: 'ok', + codec: 'ipfs-ns', + protocol: 'ipfs', + uri: `ipfs://${IPFS_V0}`, + }); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + block: 23456789, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledWith({ + method: 'contenthash', + name: 'myotis-ok.eth', + root: 'auto', + }); + expect(mockUrResolve).not.toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + }); + + test('serves verified ENS addr records through the same Myotis tier', async () => { + myotisUp(); + const address = '0x1111111111111111111111111111111111111111'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + addressHex: address, + }); + + const result = await resolveEnsAddress('myotis-addr.eth'); + + expect(result).toMatchObject({ + success: true, + name: 'myotis-addr.eth', + address, + trust: { level: 'verified', method: 'myotis', block: 23456789 }, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledWith({ + method: 'addr', + name: 'myotis-addr.eth', + root: 'auto', + }); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('serves forward-verified ENS reverse records through Myotis', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001201'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456789, + name: 'reverse-myotis.eth', + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: true, + address, + name: 'reverse-myotis.eth', + system: 'ens', + trust: { level: 'verified', method: 'myotis' }, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledWith({ + method: 'reverse', + addressHex: address, + root: 'auto', + }); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + + test('accepts an optimistic beacon-verified reverse answer without duplicate fallback', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001210'; + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + name: 'optimistic.eth', + }); + mockResolveReverseViaColibri.mockResolvedValue({ name: 'verified.eth' }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: true, + name: 'optimistic.eth', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(mockResolveReverseViaColibri).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + + test('accepts a complete optimistic Myotis reverse miss without repeating ENS remotely', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001211'; + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: false, + blockNumber: 23456791, + }); + mockMyotisEthCall.mockResolvedValue({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['']), + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: false, + reason: 'NO_REVERSE', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledTimes(1); + expect(mockMyotisEthCall).toHaveBeenCalledTimes(2); + expect(mockResolveReverseViaColibri).not.toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + + test('optimistic answers are verified while remaining explicitly non-finalized', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const result = await resolveEnsContent('myotis-peerhead.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + finality: 'optimistic', + }); + }); + + test('does not repeat an optimistic Myotis answer through Colibri', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + const result = await resolveEnsContent('prefer-verified.eth'); + + expect(mockMyotisResolveEnsRecord).toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + finality: 'optimistic', + }); + }); + + test('honors custom method order and does not invoke lower-priority methods after success', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['colibri', 'myotis', 'quorum'], + ensPreferVerified: true, + }); + const [resolvedData, resolverAddress] = urReturnsBytes(ipfsContenthashFor(IPFS_V0)); + mockResolveViaColibri.mockResolvedValue({ resolvedData, resolverAddress }); + + const result = await resolveEnsContent('colibri-first.eth'); + + expect(result.trust).toMatchObject({ level: 'verified', method: 'colibri' }); + expect(mockMyotisResolveEnsRecord).not.toHaveBeenCalled(); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('does not consult a later method after an optimistic verified answer', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionMethod: 'colibri', + ensResolutionOrder: ['myotis', 'colibri'], + ensPreferVerified: true, + }); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: false, + blockNumber: 23456790, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + const result = await resolveEnsContent('provisional-fallback.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust).toMatchObject({ + level: 'verified', + method: 'myotis', + finality: 'optimistic', + }); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('excludes disabled methods from resolution entirely', async () => { + myotisUp(); + mockLoadSettings.mockReturnValue({ + ...mockLoadSettings(), + ensResolutionOrder: ['quorum'], + ensPreferVerified: true, + }); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + const result = await resolveEnsContent('quorum-only.eth'); + + expect(result.type).toBe('ok'); + expect(mockMyotisResolveEnsRecord).not.toHaveBeenCalled(); + expect(mockResolveViaColibri).not.toHaveBeenCalled(); + expect(mockUrResolve).toHaveBeenCalled(); + }); + + test('verified absence maps to EMPTY_CONTENTHASH', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: true, + blockNumber: 23456791, + }); + + const result = await resolveEnsContent('myotis-norecord.eth'); + + expect(result).toMatchObject({ type: 'not_found', reason: 'EMPTY_CONTENTHASH' }); + expect(result.trust).toMatchObject({ level: 'verified', method: 'myotis' }); + expect(mockUrResolve).not.toHaveBeenCalled(); + }); + + test('node not synced yet: silent fall-through to the quorum path', async () => { + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => false); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + const infoSpy = jest.spyOn(resolverLog, 'info').mockImplementation(() => {}); + + const result = await resolveEnsContent('myotis-notready.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(mockMyotisResolveEnsRecord).not.toHaveBeenCalled(); + expect(mockUrResolve).toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith( + '[ens] policy name=myotis-notready.eth kind=content ' + + 'order=[myotis,quorum] preferVerified=false' + ); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /^\[ens\] method=myotis name=myotis-notready\.eth kind=content outcome=SKIP trust=none action=continue reason=not-ready durationMs=\d+$/ + ) + ); + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /^\[ens\] method=quorum name=myotis-notready\.eth kind=content outcome=DATA trust=verified action=accept reason=none durationMs=\d+$/ + ) + ); + infoSpy.mockRestore(); + }); + + test('discards a Myotis read interrupted by shutdown and resolves once through Colibri', async () => { + let ready = true; + let epoch = 1; + let finishMyotis; + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => ready); + mockMyotisGetAvailabilityEpoch.mockImplementation(() => epoch); + mockMyotisResolveEnsRecord.mockImplementation(() => new Promise((resolve) => { + finishMyotis = resolve; + })); + withColibri({ + ensResolutionOrder: ['myotis', 'colibri'], + ensPreferVerified: true, + }); + const [resolvedData, resolverAddress] = urReturnsBytes(ipfsContenthashFor(IPFS_V0)); + mockResolveViaColibri.mockResolvedValue({ resolvedData, resolverAddress }); + + const pending = resolveEnsContent('shutdown-race.eth'); + await Promise.resolve(); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledTimes(1); + + ready = false; + epoch = 2; + for (const cb of mockMyotisAvailabilityListeners) { + cb({ chainId: 1, ready: false, reason: 'stopping', epoch }); + } + finishMyotis({ + status: 'ok', + verified: true, + blockNumber: 23456801, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const result = await pending; + + expect(result).toMatchObject({ + type: 'ok', + uri: `ipfs://${IPFS_V0}`, + trust: { level: 'verified', method: 'colibri' }, + }); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(1); + }); + + test('unavailable transition evicts a cached Myotis answer before the next lookup', async () => { + let ready = true; + let epoch = 1; + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => ready); + mockMyotisGetAvailabilityEpoch.mockImplementation(() => epoch); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'ok', + verified: true, + blockNumber: 23456802, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + const first = await resolveEnsContent('shutdown-cache.eth'); + expect(first.trust.method).toBe('myotis'); + + ready = false; + epoch = 2; + for (const cb of mockMyotisAvailabilityListeners) { + cb({ chainId: 1, ready: false, reason: 'stopping', epoch }); + } + withColibri({ + ensResolutionOrder: ['myotis', 'colibri'], + ensPreferVerified: true, + }); + const [resolvedData, resolverAddress] = urReturnsBytes(ipfsContenthashFor(IPFS_V0)); + mockResolveViaColibri.mockResolvedValue({ resolvedData, resolverAddress }); + + const second = await resolveEnsContent('shutdown-cache.eth'); + + expect(second.trust.method).toBe('colibri'); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(1); + }); + + test('engine failure falls through to the quorum path', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockRejectedValue(new Error('no snap peer')); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + const result = await resolveEnsContent('myotis-enginefail.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(mockUrResolve).toHaveBeenCalled(); + }); + + test('malformed CCIP offchain envelopes fall back to the configured resolver', async () => { + myotisUp(); + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'offchain', + verified: true, + blockNumber: 23456792, + }); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + const result = await resolveEnsContent('myotis-offchain.eth'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(mockUrResolve).toHaveBeenCalled(); + }); + + test('completes a CCIP-Read gateway round and verifies the callback in Myotis', async () => { + myotisUp(); + const originalFetch = global.fetch; + const gatewayData = '0xabcdef'; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue(JSON.stringify({ data: gatewayData })), + }); + mockMyotisResolveEnsRecord + .mockResolvedValueOnce({ + status: 'offchain', + verified: true, + blockNumber: 23456792, + senderHex: '0x1111111111111111111111111111111111111111', + urls: ['https://ccip.example/{sender}/{data}.json'], + callDataHex: '0x1234', + callbackFunctionHex: '0xaabbccdd', + extraDataHex: '0x5678', + wrapped: true, + }) + .mockResolvedValueOnce({ + status: 'ok', + verified: true, + blockNumber: 23456792, + dataHex: ipfsContenthashFor(IPFS_V0), + }); + + try { + const result = await resolveEnsContent('myotis-ccip.box'); + + expect(result).toMatchObject({ + type: 'ok', + uri: `ipfs://${IPFS_V0}`, + trust: { level: 'verified', method: 'myotis' }, + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://ccip.example/0x1111111111111111111111111111111111111111/0x1234.json', + expect.objectContaining({ method: 'GET' }) + ); + expect(mockMyotisResolveEnsRecord).toHaveBeenNthCalledWith(2, { + method: 'ccipCallback', + name: 'myotis-ccip.box', + root: 'auto', + queryMethod: 'contenthash', + senderHex: '0x1111111111111111111111111111111111111111', + callbackFunctionHex: '0xaabbccdd', + responseHex: gatewayData, + extraDataHex: '0x5678', + wrapped: true, + finalized: true, + }); + expect(mockUrResolve).not.toHaveBeenCalled(); + } finally { + global.fetch = originalFetch; + } + }); + + test('uses POST CCIP gateways and tries the next URL after a bad response', async () => { + myotisUp(); + const originalFetch = global.fetch; + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue('{"notData":"0x"}'), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue('{"data":"0xcafe"}'), + }); + mockMyotisResolveEnsRecord + .mockResolvedValueOnce({ + status: 'offchain', + verified: false, + blockNumber: 23456793, + senderHex: '0x2222222222222222222222222222222222222222', + urls: ['https://bad.example/query', 'https://good.example/query'], + callDataHex: '0xbeef', + callbackFunctionHex: '0x01020304', + extraDataHex: '0x', + wrapped: false, + }) + .mockResolvedValueOnce({ + status: 'noRecord', + verified: false, + blockNumber: 23456793, + }); + + try { + const result = await resolveEnsContent('myotis-ccip-post.box'); + + expect(result).toMatchObject({ + type: 'not_found', + reason: 'EMPTY_CONTENTHASH', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch.mock.calls[1][1]).toMatchObject({ + method: 'POST', + body: JSON.stringify({ + sender: '0x2222222222222222222222222222222222222222', + data: '0xbeef', + }), + }); + } finally { + global.fetch = originalFetch; + } + }); + + test('reads the top-level CCIP data field instead of matching JSON string contents', async () => { + myotisUp(); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue( + JSON.stringify({ message: 'ignore "data": "0x00"', data: '0xcafe' }) + ), + }); + mockMyotisResolveEnsRecord + .mockResolvedValueOnce({ + status: 'offchain', + verified: true, + blockNumber: 23456793, + senderHex: '0x2222222222222222222222222222222222222222', + urls: ['https://ccip.example/query'], + callDataHex: '0xbeef', + callbackFunctionHex: '0x01020304', + extraDataHex: '0x', + wrapped: false, + }) + .mockResolvedValueOnce({ + status: 'noRecord', + verified: true, + blockNumber: 23456793, + }); + + try { + await resolveEnsContent('myotis-ccip-json.box'); + expect(mockMyotisResolveEnsRecord).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ responseHex: '0xcafe' }) + ); + } finally { + global.fetch = originalFetch; + } + }); + + test('caps recursive CCIP-Read at one gateway round and falls back safely', async () => { + myotisUp(); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: jest.fn(() => null) }, + text: jest.fn().mockResolvedValue('{"data":"0xcafe"}'), + }); + const offchain = { + status: 'offchain', + verified: true, + blockNumber: 23456793, + senderHex: '0x2222222222222222222222222222222222222222', + urls: ['https://recursive.example/{data}'], + callDataHex: '0xbeef', + callbackFunctionHex: '0x01020304', + extraDataHex: '0x', + wrapped: false, + }; + mockMyotisResolveEnsRecord.mockResolvedValue(offchain); + mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + + try { + const result = await resolveEnsContent('myotis-recursive.box'); + + expect(result.type).toBe('ok'); + expect(result.trust.method).not.toBe('myotis'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockMyotisResolveEnsRecord).toHaveBeenCalledTimes(2); + expect(mockUrResolve).toHaveBeenCalled(); + } finally { + global.fetch = originalFetch; + } + }); + + test('ready transition sweeps cached content, address, and reverse fallback answers', async () => { + // 1. Node syncing: RPC paths serve and cache every lookup kind. + mockMyotisIsEnabled.mockImplementation(() => true); + mockMyotisIsReady.mockImplementation(() => false); + const address = '0x0000000000000000000000000000000000001203'; + mockUrResolve.mockImplementation((_name, callData) => + String(callData).startsWith(ADDR_SELECTOR) + ? urReturnsAddress(address) + : urReturnsBytes(ipfsContenthashFor(IPFS_V0)) + ); + mockUrReverse.mockResolvedValue(['myotis-overtake-reverse.eth']); + const firstContent = await resolveEnsContent('myotis-overtake.eth'); + const firstAddress = await resolveEnsAddress('myotis-overtake-addr.eth'); + const firstReverse = await resolveEnsReverse(address); + expect(firstContent.trust.method).not.toBe('myotis'); + expect(firstAddress.trust.method).not.toBe('myotis'); + expect(firstReverse.trust).toMatchObject({ + level: 'verified', + quorum: { k: 3, m: 2, achieved: true }, + }); + + // 2. Node becomes ready — but the cached fallback answers still win… + mockMyotisIsReady.mockImplementation(() => true); + mockMyotisResolveEnsRecord.mockImplementation(async (params) => { + if (params.method === 'contenthash') { + return { + status: 'ok', + verified: true, + blockNumber: 23456799, + dataHex: ipfsContenthashFor(IPFS_V0), + }; + } + if (params.method === 'addr') { + return { status: 'ok', verified: true, blockNumber: 23456799, addressHex: address }; + } + return { + status: 'ok', + verified: true, + blockNumber: 23456799, + name: 'myotis-overtake-reverse.eth', + }; + }); + expect((await resolveEnsContent('myotis-overtake.eth')).trust.method).not.toBe('myotis'); + expect((await resolveEnsAddress('myotis-overtake-addr.eth')).trust.method).not.toBe('myotis'); + expect((await resolveEnsReverse(address)).trust.method).not.toBe('myotis'); + + // 3. …until the ready transition sweeps all three caches. + expect(mockMyotisAvailabilityListeners.length).toBeGreaterThan(0); + for (const cb of mockMyotisAvailabilityListeners) { + cb({ chainId: 1, ready: true, reason: 'ready', epoch: 1 }); + } + expect((await resolveEnsContent('myotis-overtake.eth')).trust).toMatchObject({ + level: 'verified', + method: 'myotis', + }); + expect((await resolveEnsAddress('myotis-overtake-addr.eth')).trust).toMatchObject({ + level: 'verified', + method: 'myotis', + }); + expect((await resolveEnsReverse(address)).trust).toMatchObject({ + level: 'verified', + method: 'myotis', + }); + }); + + test('resolves WNS content through Myotis generic verified eth_call', async () => { + myotisUp(); + mockMyotisEthCall.mockResolvedValue({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode( + ['bytes'], + [ipfsContenthashFor(IPFS_V0)] + ), + }); + + const result = await resolveEnsContent('myotis-wns.wei'); + + expect(result).toMatchObject({ + type: 'ok', + system: 'wns', + uri: `ipfs://${IPFS_V0}`, + trust: { + level: 'verified', + method: 'myotis', + system: 'wns', + finality: 'optimistic', + }, + }); + expect(mockMyotisEthCall.mock.calls[0][0].to.toLowerCase()).toBe(WNS_ADDRESS); + expect(mockMyotisEthCall.mock.calls[0][0].block).toBe('latest'); + expect(mockWnsContenthash).not.toHaveBeenCalled(); + }); + + test('resolves GNS addr records through Myotis generic verified eth_call', async () => { + myotisUp(); + const address = '0x3333333333333333333333333333333333333333'; + mockMyotisEthCall.mockResolvedValue({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['address'], [address]), + }); + + const result = await resolveEnsAddress('myotis-gns.gwei'); + + expect(result).toMatchObject({ + success: true, + system: 'gns', + address, + trust: { + level: 'verified', + method: 'myotis', + system: 'gns', + finality: 'optimistic', + }, + }); + expect(mockMyotisEthCall.mock.calls[0][0].to.toLowerCase()).toBe(GNS_ADDRESS); + expect(mockMyotisEthCall.mock.calls[0][0].block).toBe('latest'); + expect(mockGnsAddr).not.toHaveBeenCalled(); + }); + + test('forward-verifies WNS reverse claims through Myotis contract calls', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001202'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: true, + blockNumber: 23456794, + }); + mockMyotisEthCall + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['alice.wei']), + }) + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['address'], [address]), + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: true, + name: 'alice.wei', + system: 'wns', + trust: { + level: 'verified', + method: 'myotis', + system: 'wns', + finality: 'optimistic', + }, + }); + expect(mockMyotisEthCall).toHaveBeenCalledTimes(2); + expect(mockUrReverse).not.toHaveBeenCalled(); + expect(mockWnsReverseResolve).not.toHaveBeenCalled(); + }); + + test('rejects a WNS reverse claim that does not forward-resolve through Myotis', async () => { + myotisUp(); + const address = '0x0000000000000000000000000000000000001204'; + mockMyotisResolveEnsRecord.mockResolvedValue({ + status: 'noRecord', + verified: true, + blockNumber: 23456795, + }); + mockMyotisEthCall + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['spoof.wei']), + }) + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode( + ['address'], + ['0x9999999999999999999999999999999999999999'] + ), + }) + .mockResolvedValueOnce({ + status: 'ok', + resultHex: actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['']), + }); + + const result = await resolveEnsReverse(address); + + expect(result).toMatchObject({ + success: false, + reason: 'UNVERIFIED', + claimedName: 'spoof.wei', + system: 'wns', + trust: { level: 'verified', method: 'myotis', finality: 'optimistic' }, + }); + expect(mockUrReverse).not.toHaveBeenCalled(); + }); + }); + describe('consensus quorum', () => { const IPFS_HASH = 'QmW81r84Aihiqqi2Jw6nM1LnpeMfRCenRxtjwHNkXVkZYa'; @@ -2188,12 +3085,16 @@ describe('ens-resolver', () => { test('CALL_EXCEPTION without revert data falls through to public RPC quorum', async () => { withColibri(); - const err = Object.assign(new Error('missing response from Colibri prover'), { + const err = Object.assign(new Error( + 'missing revert data transaction={ data: "0x' + 'ab'.repeat(200) + '" }' + ), { code: 'CALL_EXCEPTION', - info: { error: { code: -32603, message: 'no response' } }, + shortMessage: 'missing revert data', + info: { error: { code: -32603, message: 'no response from prover' } }, }); mockResolveViaColibri.mockRejectedValue(err); mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); + const warnSpy = jest.spyOn(resolverLog, 'warn').mockImplementation(() => {}); const result = await resolveEnsContent('prover-down.eth'); @@ -2202,6 +3103,13 @@ describe('ens-resolver', () => { expect(mockUrResolve).toHaveBeenCalled(); expect(result.trust.method).not.toBe('colibri'); expect(result.trust.quorum).toEqual({ k: 3, m: 2, achieved: true }); + expect(warnSpy).toHaveBeenCalledWith( + '[ens] colibri-fallback name=prover-down.eth kind=content ' + + 'error="missing revert data" code=CALL_EXCEPTION rpcCode=-32603 ' + + 'rpcMessage="no response from prover" revert=none' + ); + expect(warnSpy.mock.calls.flat().join(' ')).not.toContain('abababababababab'); + warnSpy.mockRestore(); }); test('non-revert error falls through to the quorum path by default', async () => { @@ -2219,7 +3127,7 @@ describe('ens-resolver', () => { expect(result.trust.quorum).toEqual({ k: 3, m: 2, achieved: true }); }); - test('default ensResolutionMethod=quorum leaves the legacy path untouched (regression)', async () => { + test('default ensResolutionMethod=quorum bypasses Colibri (regression)', async () => { // Don't call withColibri — defaults to 'quorum'. mockUrResolve.mockResolvedValue(urReturnsBytes(ipfsContenthashFor(IPFS_V0))); @@ -2252,7 +3160,7 @@ describe('ens-resolver', () => { }); test('no primary set surfaces as NO_REVERSE with trust attached', async () => { - withColibri(); + withColibri({ ensResolutionOrder: ['colibri'] }); mockResolveReverseViaColibri.mockResolvedValue({ name: '' }); const result = await resolveEnsReverse(ADDR); @@ -2260,6 +3168,41 @@ describe('ens-resolver', () => { expect(result.success).toBe(false); expect(result.reason).toBe('NO_REVERSE'); expect(result.trust.method).toBe('colibri'); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(2); + expect(mockResolveViaColibri.mock.calls.map(([name]) => name)).toEqual([ + 'reverse.wei', + 'reverse.gwei', + ]); + expect(mockGetBlockNumber).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + expect(mockWnsReverseResolve).not.toHaveBeenCalled(); + expect(mockGnsReverseResolve).not.toHaveBeenCalled(); + }); + + test('resolves and forward-verifies WNS through Colibri without public RPC', async () => { + withColibri(); + mockResolveReverseViaColibri.mockResolvedValue({ name: '' }); + mockResolveViaColibri.mockImplementation(async (name) => { + const resolvedData = name === 'reverse.wei' + ? actualEthers.AbiCoder.defaultAbiCoder().encode(['string'], ['alice.wei']) + : actualEthers.AbiCoder.defaultAbiCoder().encode(['address'], [ADDR]); + return { resolvedData, resolverAddress: WNS_ADDRESS }; + }); + + const result = await resolveEnsReverse(ADDR); + + expect(result).toMatchObject({ + success: true, + address: ADDR, + name: 'alice.wei', + system: 'wns', + trust: { level: 'verified', method: 'colibri', system: 'wns' }, + }); + expect(mockResolveViaColibri).toHaveBeenCalledTimes(2); + expect(mockGetBlockNumber).not.toHaveBeenCalled(); + expect(mockUrReverse).not.toHaveBeenCalled(); + expect(mockWnsReverseResolve).not.toHaveBeenCalled(); + expect(mockWnsAddr).not.toHaveBeenCalled(); }); test('ResolverNotFound surfaces as NO_REVERSE (no record at all)', async () => { @@ -2294,6 +3237,43 @@ describe('ens-resolver', () => { expect(result.trust.level).toBe('verified'); }); + test('logs why an unverified Colibri reverse result continues to quorum', async () => { + withColibri({ + ensResolutionOrder: ['myotis', 'colibri', 'quorum'], + ensPreferVerified: true, + }); + const infoSpy = jest.spyOn(resolverLog, 'info').mockImplementation(() => {}); + const err = Object.assign(new Error('ReverseAddressMismatch'), { + data: '0xef9c03ce', + }); + mockResolveReverseViaColibri.mockRejectedValue(err); + mockUrReverse.mockResolvedValue(['']); + + try { + const result = await resolveEnsReverse(ADDR); + + expect(result).toMatchObject({ success: false, reason: 'NO_REVERSE' }); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse policy address=${ADDR} order=[myotis,colibri,quorum] ` + + 'preferVerified=true' + ); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse method=myotis address=${ADDR} outcome=UNAVAILABLE ` + + 'system=none trust=none action=continue reason=disabled' + ); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse method=colibri address=${ADDR} outcome=UNVERIFIED ` + + 'system=ens trust=verified action=continue reason=prefer-verified' + ); + expect(infoSpy).toHaveBeenCalledWith( + `[ens] reverse method=quorum address=${ADDR} outcome=NO_REVERSE ` + + 'system=ens,wns,gns trust=verified action=accept' + ); + } finally { + infoSpy.mockRestore(); + } + }); + test('UNVERIFIED carries the decoded claimedName from revert data', async () => { withColibri(); // ReverseAddressMismatch(string,bytes) — claimed name + address bytes. @@ -2325,7 +3305,7 @@ describe('ens-resolver', () => { expect(result.claimedName).toBeNull(); }); - test('non-revert error falls through to the legacy path by default', async () => { + test('non-revert error falls through to the configured quorum path', async () => { withColibri(); mockResolveReverseViaColibri.mockRejectedValue(new Error('prover unreachable')); mockUrReverse.mockResolvedValue(['legacy.eth']); @@ -2334,20 +3314,26 @@ describe('ens-resolver', () => { expect(result.success).toBe(true); expect(result.name).toBe('legacy.eth'); - // No trust field on the legacy path — additive design. - expect(result.trust).toBeUndefined(); - expect(mockUrReverse).toHaveBeenCalled(); + expect(result.trust).toMatchObject({ + level: 'verified', + quorum: { k: 3, m: 2, achieved: true }, + }); + expect(mockUrReverse).toHaveBeenCalledTimes(3); }); - test('default ensResolutionMethod=quorum leaves the legacy reverse path untouched', async () => { + test('default ensResolutionMethod=quorum queries the configured provider quorum', async () => { // Don't call withColibri — defaults to 'quorum'. mockUrReverse.mockResolvedValue(['legacy.eth']); const result = await resolveEnsReverse(ADDR); expect(mockResolveReverseViaColibri).not.toHaveBeenCalled(); - expect(mockUrReverse).toHaveBeenCalled(); + expect(mockUrReverse).toHaveBeenCalledTimes(3); expect(result.name).toBe('legacy.eth'); + expect(result.trust).toMatchObject({ + level: 'verified', + quorum: { k: 3, m: 2, achieved: true }, + }); }); test('invalid address rejects before either path is hit', async () => { diff --git a/src/main/ens/colibri-resolver.js b/src/main/ens/colibri-resolver.js index 0ab0aad6..080119bf 100644 --- a/src/main/ens/colibri-resolver.js +++ b/src/main/ens/colibri-resolver.js @@ -12,16 +12,15 @@ const { universalResolverCall, universalResolverReverse, hostOf } = require('../ // to the prover); pinning rather than exposing as a toggle keeps the // threat model legible. const PRIVACY_MODE = 'basic'; -const CHAIN_ID = 1; const MAX_LATEST_AGE_SECONDS = 60; -let cachedClient = null; -let cachedClientKey = null; -let cachedProvider = null; -let inFlightBuild = null; +const clients = new Map(); +const inFlightBuilds = new Map(); +const clientReferences = new Map(); +const retiredClients = new Set(); let storageRegistration = null; let storageRegistered = false; -let buildGeneration = 0; +const buildGenerations = new Map(); // Disk-backed storage adapter for Colibri's verifier state (sync committee // pubkeys, current head witness, etc — keys like "states_1" / "sync_1_"). @@ -66,14 +65,141 @@ function destroyClient(client) { } } -async function buildClient({ key, proverUrl, zkProof, generation }) { +function retainClient(client) { + clientReferences.set(client, (clientReferences.get(client) || 0) + 1); +} + +function releaseClient(client) { + const remaining = (clientReferences.get(client) || 1) - 1; + if (remaining > 0) { + clientReferences.set(client, remaining); + return; + } + clientReferences.delete(client); + if (retiredClients.delete(client)) destroyClient(client); +} + +function retireClient(client) { + if (!client) return; + if ((clientReferences.get(client) || 0) > 0) { + retiredClients.add(client); + return; + } + destroyClient(client); +} + +// Obtain a live client + its provider with a reference already held, closing +// the use-after-destroy window: retainClient only ran inside the old +// useClient, i.e. after `await getClient` resolved, and in that microtask gap a +// concurrent request's failure path could release the last ref and destroy the +// very client this caller was about to use. Here we retain optimistically and +// then re-validate that the retained client is still the cached one (and not +// retired); if it was swapped/evicted during the gap, release and re-acquire. +// The caller releases in a finally. +async function acquireClient(chainId) { + const id = Number(chainId); + for (;;) { + const client = await getClient(id); + retainClient(client); + const cached = clients.get(id); + if (cached?.client === client && !retiredClients.has(client)) { + return { client, provider: cached.provider }; + } + // Evicted or rebuilt during the acquire gap — drop our ref and retry. + releaseClient(client); + } +} + +function colibriRevertData(err) { + const data = err?.data || err?.info?.error?.data || ''; + return typeof data === 'string' && data.length >= 10 ? data : null; +} + +function retryableColibriError(err) { + if (!err) return false; + if (err.code === 'CALL_EXCEPTION') return colibriRevertData(err) === null; + if (['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT'].includes(err.code)) return true; + if (err.info?.error?.code === -32603) return true; + return /ECONN|ENOTFOUND|ETIMEDOUT|fetch failed|network|no response|timeout/i + .test(err.shortMessage || err.message || ''); +} + +function sanitizeColibriErrorDetail(value, maxLength = 200) { + if (value == null || value === '') return ''; + const cleaned = String(value) + .replace(/https?:\/\/[^\s"'<>]+/gi, '') + .replace(/0x[0-9a-fA-F]{66,}/g, (hex) => + `${hex.slice(0, 10)}…(${Math.floor((hex.length - 2) / 2)} bytes)` + ) + .replace(/\s+/g, ' ') + .trim(); + return cleaned.length <= maxLength + ? cleaned + : `${cleaned.slice(0, maxLength - 1)}…`; +} + +function colibriErrorForLog(err) { + const nested = err?.info?.error; + const message = sanitizeColibriErrorDetail( + err?.shortMessage || err?.reason || err?.message || String(err) + ); + const fields = [`error=${JSON.stringify(message || 'unknown error')}`]; + if (err?.code != null) fields.push(`code=${sanitizeColibriErrorDetail(err.code, 40)}`); + if (nested?.code != null) fields.push(`rpcCode=${sanitizeColibriErrorDetail(nested.code, 40)}`); + if (nested?.message) { + fields.push(`rpcMessage=${JSON.stringify(sanitizeColibriErrorDetail(nested.message))}`); + } + fields.push(`revert=${colibriRevertData(err)?.slice(0, 10) || 'none'}`); + return fields.join(' '); +} + +// Evict only the client that actually failed. If another concurrent request +// or a settings change has already installed a replacement, leave it intact. +function evictFailedClient(chainId, failedClient) { + const cached = clients.get(chainId); + if (cached?.client !== failedClient) return; + clients.delete(chainId); + retireClient(failedClient); +} + +// Retry exactly once after rebuilding the in-memory verifier. This recovers +// from a stale runtime after sleep or a transient prover/network failure while +// preserving fail-closed behavior: proof failures and reverts carrying actual +// EVM revert data are never retried or reclassified. +async function withColibriClientRetry(chainId, operation) { + const id = Number(chainId); + const first = await acquireClient(id); + try { + return await operation(first); + } catch (err) { + if (!retryableColibriError(err)) throw err; + log.warn( + `[colibri] chain ${id} request failed; rebuilding client and retrying once ` + + colibriErrorForLog(err) + ); + evictFailedClient(id, first.client); + const retry = await acquireClient(id); + try { + return await operation(retry); + } finally { + releaseClient(retry.client); + } + } finally { + // Always releases first's acquire ref — on success, on a rethrown + // non-retryable error, and after the retry path above. Dropping the last + // ref on an evicted/retired client is what finally destroys it. + releaseClient(first.client); + } +} + +async function buildClient({ chainId, key, proverUrl, zkProof, generation }) { // Storage adapter is registered exactly once per process: on the very // first construction. Later settings-change rebuilds reuse it — the // adapter is keyless and the Colibri runtime expects a single global. await ensureStorageRegistered(); const client = new Colibri({ - chainId: CHAIN_ID, + chainId, prover: [proverUrl], zk_proof: zkProof, privacy_mode: PRIVACY_MODE, @@ -81,17 +207,19 @@ async function buildClient({ key, proverUrl, zkProof, generation }) { max_latest_age_seconds: MAX_LATEST_AGE_SECONDS, }); - if (generation !== buildGeneration) { + if (generation !== buildGenerations.get(chainId)) { destroyClient(client); - return getClient(); + return getClient(chainId); } - const previousClient = cachedClient; - cachedClient = client; - cachedClientKey = key; - cachedProvider = new ethers.BrowserProvider(client); - destroyClient(previousClient); - log.info(`[ens-colibri] client ready (prover=${hostOf(proverUrl)}, zk=${zkProof})`); + const previousClient = clients.get(chainId)?.client; + // Co-locate the provider with its client in one entry so acquireClient + // captures the {client, provider} pair atomically — a separate providers + // map can return undefined mid-rebuild or a provider from another + // generation. + clients.set(chainId, { client, key, provider: new ethers.BrowserProvider(client) }); + retireClient(previousClient); + log.info(`[colibri] chain ${chainId} client ready (prover=${hostOf(proverUrl)}, zk=${zkProof})`); return client; } @@ -101,25 +229,32 @@ async function buildClient({ key, proverUrl, zkProof, generation }) { // on first use, not module load. `inFlightBuild` collapses concurrent // first-call lookups onto a single construction. The generation counter // prevents a slower old-settings build from replacing a newer client. -async function getClient() { - const [proverUrl] = registry.getEndpoints(CHAIN_ID, 'prover'); +async function getClient(chainId = 1) { + const id = Number(chainId); + const [proverUrl] = registry.getEndpoints(id, 'prover'); if (!proverUrl) { - throw new Error(`No Colibri prover configured for chain ${CHAIN_ID}`); + throw new Error(`No Colibri prover configured for chain ${id}`); } - const zkProof = registry.getNetwork(CHAIN_ID).zkProof !== false; + const zkProof = registry.getNetwork(id)?.zkProof !== false; const key = `${proverUrl}|${zkProof}`; - if (cachedClient && cachedClientKey === key) { - if (inFlightBuild && inFlightBuild.key !== key) buildGeneration += 1; - return cachedClient; + const cached = clients.get(id); + const inFlight = inFlightBuilds.get(id); + if (cached?.client && cached.key === key) { + if (inFlight && inFlight.key !== key) { + buildGenerations.set(id, (buildGenerations.get(id) || 0) + 1); + } + return cached.client; } - if (inFlightBuild && inFlightBuild.key === key) return inFlightBuild.promise; + if (inFlight && inFlight.key === key) return inFlight.promise; - const generation = buildGeneration + 1; - buildGeneration = generation; - const promise = buildClient({ key, proverUrl, zkProof, generation }); - inFlightBuild = { key, promise, generation }; + const generation = (buildGenerations.get(id) || 0) + 1; + buildGenerations.set(id, generation); + const promise = buildClient({ chainId: id, key, proverUrl, zkProof, generation }); + inFlightBuilds.set(id, { key, promise, generation }); try { return await promise; } - finally { if (inFlightBuild && inFlightBuild.promise === promise) inFlightBuild = null; } + finally { + if (inFlightBuilds.get(id)?.promise === promise) inFlightBuilds.delete(id); + } } // Drop-in for what a single `consensusResolve` leg does today, but the @@ -128,8 +263,9 @@ async function getClient() { // pins to head − 1 by construction (sync committee signatures for block N // live in block N+1). async function resolveCallViaColibri(name, callData, callResolver = universalResolverCall) { - await getClient(); - return callResolver(cachedProvider, name, callData); + return withColibriClientRetry(1, ({ provider }) => + callResolver(provider, name, callData) + ); } async function resolveViaColibri(name, callData) { @@ -141,24 +277,39 @@ async function resolveViaColibri(name, callData) { // Throws on revert (UR's ResolverNotFound / ReverseAddressMismatch) or // network/verification failure — the orchestrator classifies. async function resolveReverseViaColibri(addressBytes) { - await getClient(); - return universalResolverReverse(cachedProvider, addressBytes); + return withColibriClientRetry(1, ({ provider }) => + universalResolverReverse(provider, addressBytes) + ); +} + +async function requestViaColibri(chainId, method, params = []) { + return withColibriClientRetry(chainId, ({ client }) => + client.request({ method, params }) + ); } function clearColibriClientForTest() { - destroyClient(cachedClient); - cachedClient = null; - cachedClientKey = null; - cachedProvider = null; - inFlightBuild = null; + for (const { client } of clients.values()) retireClient(client); + for (const chainId of new Set([ + ...clients.keys(), + ...inFlightBuilds.keys(), + ...buildGenerations.keys(), + ])) { + buildGenerations.set(chainId, (buildGenerations.get(chainId) || 0) + 1); + } + clients.clear(); + inFlightBuilds.clear(); + for (const client of retiredClients) destroyClient(client); + clientReferences.clear(); + retiredClients.clear(); storageRegistration = null; storageRegistered = false; - buildGeneration += 1; } module.exports = { resolveCallViaColibri, resolveViaColibri, resolveReverseViaColibri, + requestViaColibri, clearColibriClientForTest, }; diff --git a/src/main/ens/colibri-resolver.test.js b/src/main/ens/colibri-resolver.test.js index e5a09347..1196f61d 100644 --- a/src/main/ens/colibri-resolver.test.js +++ b/src/main/ens/colibri-resolver.test.js @@ -3,6 +3,13 @@ jest.mock('electron', () => ({ app: { getPath: (...args) => mockGetPath(...args) }, })); +const mockLogInfo = jest.fn(); +const mockLogWarn = jest.fn(); +jest.mock('../logger', () => ({ + info: (...args) => mockLogInfo(...args), + warn: (...args) => mockLogWarn(...args), +})); + const mockMkdirSync = jest.fn(); const mockReadFileSync = jest.fn(); const mockWriteFileSync = jest.fn(); @@ -25,6 +32,7 @@ jest.mock('@corpus-core/colibri-stateless', () => { mockColibriCtor(config); this.config = config; this.destroy = jest.fn(); + this.request = jest.fn().mockResolvedValue('0x2a'); mockClientInstances.push(this); } static register_storage(storage) { return mockRegisterStorage(storage); } @@ -68,6 +76,7 @@ jest.mock('../ens-resolver', () => ({ const { resolveViaColibri, resolveReverseViaColibri, + requestViaColibri, clearColibriClientForTest, } = require('./colibri-resolver'); @@ -226,6 +235,75 @@ describe('resolveViaColibri', () => { const err = new Error('proof verification failed'); mockUniversalResolverCall.mockRejectedValue(err); await expect(resolveViaColibri('a.eth', '0x')).rejects.toBe(err); + expect(mockColibriCtor).toHaveBeenCalledTimes(1); + }); + + test('rebuilds once and retries a CALL_EXCEPTION without revert data', async () => { + const err = Object.assign(new Error('full ethers message with 0x' + 'ab'.repeat(200)), { + code: 'CALL_EXCEPTION', + shortMessage: 'missing revert data', + info: { error: { code: -32603, message: 'prover returned no response' } }, + }); + const recovered = { resolvedData: '0xfeed', resolverAddress: '0x1234' }; + mockUniversalResolverCall + .mockRejectedValueOnce(err) + .mockResolvedValueOnce(recovered); + + await expect(resolveViaColibri('retry.eth', '0x')).resolves.toEqual(recovered); + + expect(mockUniversalResolverCall).toHaveBeenCalledTimes(2); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + expect(mockClientInstances[0].destroy).toHaveBeenCalledTimes(1); + expect(mockLogWarn).toHaveBeenCalledWith( + '[colibri] chain 1 request failed; rebuilding client and retrying once ' + + 'error="missing revert data" code=CALL_EXCEPTION rpcCode=-32603 ' + + 'rpcMessage="prover returned no response" revert=none' + ); + }); + + test('bounds a retryable failure to one rebuild', async () => { + const err = Object.assign(new Error('request timed out'), { code: 'TIMEOUT' }); + mockUniversalResolverCall.mockRejectedValue(err); + + await expect(resolveViaColibri('still-down.eth', '0x')).rejects.toBe(err); + + expect(mockUniversalResolverCall).toHaveBeenCalledTimes(2); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + }); + + test('does not destroy a failed shared client while a sibling request still uses it', async () => { + let rejectFirst; + let resolveSibling; + mockUniversalResolverCall + .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject; })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveSibling = resolve; })) + .mockResolvedValueOnce({ resolvedData: '0xrecovered' }); + + const first = resolveViaColibri('first.eth', '0x'); + const sibling = resolveViaColibri('sibling.eth', '0x'); + while (mockUniversalResolverCall.mock.calls.length < 2) await Promise.resolve(); + const sharedClient = mockClientInstances[0]; + + rejectFirst(Object.assign(new Error('network unavailable'), { code: 'NETWORK_ERROR' })); + await expect(first).resolves.toEqual({ resolvedData: '0xrecovered' }); + expect(sharedClient.destroy).not.toHaveBeenCalled(); + + resolveSibling({ resolvedData: '0xsibling' }); + await expect(sibling).resolves.toEqual({ resolvedData: '0xsibling' }); + expect(sharedClient.destroy).toHaveBeenCalledTimes(1); + }); + + test('does not retry an EVM revert carrying verified revert data', async () => { + const err = Object.assign(new Error('execution reverted'), { + code: 'CALL_EXCEPTION', + data: '0xdeadbeef', + }); + mockUniversalResolverCall.mockRejectedValue(err); + + await expect(resolveViaColibri('reverted.eth', '0x')).rejects.toBe(err); + + expect(mockUniversalResolverCall).toHaveBeenCalledTimes(1); + expect(mockColibriCtor).toHaveBeenCalledTimes(1); }); }); @@ -256,6 +334,39 @@ describe('resolveReverseViaColibri', () => { }); }); +describe('requestViaColibri', () => { + test('creates and reuses an independent Gnosis client', async () => { + mockLoadSettings.mockReturnValue({ ...DEFAULTS }); + await expect( + requestViaColibri(100, 'eth_getBalance', ['0xabc', 'latest']) + ).resolves.toBe('0x2a'); + const gnosisClient = mockClientInstances[0]; + expect(mockColibriCtor).toHaveBeenCalledWith(expect.objectContaining({ chainId: 100 })); + expect(gnosisClient.request).toHaveBeenCalledWith({ + method: 'eth_getBalance', + params: ['0xabc', 'latest'], + }); + + await requestViaColibri(1, 'eth_blockNumber'); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + }); + + test('rebuilds the affected chain client once after a network failure', async () => { + await requestViaColibri(100, 'eth_blockNumber'); + const firstClient = mockClientInstances[0]; + firstClient.request + .mockRejectedValueOnce(Object.assign(new Error('network unavailable'), { + code: 'NETWORK_ERROR', + })); + + await expect(requestViaColibri(100, 'eth_blockNumber')).resolves.toBe('0x2a'); + + expect(firstClient.destroy).toHaveBeenCalledTimes(1); + expect(mockColibriCtor).toHaveBeenCalledTimes(2); + expect(mockClientInstances[1].request).toHaveBeenCalledTimes(1); + }); +}); + describe('disk storage adapter', () => { // Captured from the register_storage call after triggering construction. // No public export — the integration assertion (passed to register_storage) diff --git a/src/main/identity-manager.js b/src/main/identity-manager.js index 5b56dd4f..4162ebb4 100644 --- a/src/main/identity-manager.js +++ b/src/main/identity-manager.js @@ -286,7 +286,7 @@ async function getUserWalletKey(walletIndex) { throw new Error(`Wallet with index ${walletIndex} does not exist`); } if (record.type !== WALLET_TYPES.MNEMONIC) { - throw new Error('Hardware wallet accounts have no derivable private key'); + throw new Error('This account has no derivable private key — the key never leaves its device'); } const identity = await loadIdentityModule(); @@ -793,8 +793,32 @@ async function exportMnemonic() { const WALLET_TYPES = { MNEMONIC: 'mnemonic', LEDGER: 'ledger', + REMOTE: 'remote', // phone / other device signing over openlv + SAFE: 'safe', // Safe smart account owned by other wallet records }; +/** User-facing labels for non-mnemonic account types (auto-names, error text). */ +const DEVICE_LABELS = { + [WALLET_TYPES.LEDGER]: 'Ledger', + [WALLET_TYPES.REMOTE]: 'Phone', + [WALLET_TYPES.SAFE]: 'Safe', +}; + +/** Type-specific record fields to expose through the record seams. */ +function extraRecordFields(record) { + const fields = {}; + if (record.path) { + fields.path = record.path; + } + if (record.type === WALLET_TYPES.SAFE) { + fields.owners = record.owners; + fields.threshold = record.threshold; + fields.saltNonce = record.saltNonce; + fields.deployed = record.deployed || {}; + } + return fields; +} + /** * Hardware accounts are allocated from a disjoint, never-reused slice of * the wallet index space, starting here. @@ -886,7 +910,7 @@ function getWalletRecord(walletIndex, meta = getVaultMeta()) { name: record.name, address, type: record.type || WALLET_TYPES.MNEMONIC, - ...(record.path ? { path: record.path } : {}), + ...extraRecordFields(record), }; } @@ -931,9 +955,9 @@ async function getDerivedWallets() { const type = wallet.type || WALLET_TYPES.MNEMONIC; let address = null; - if (type === WALLET_TYPES.LEDGER) { - // Hardware accounts: the address was read from the device when the - // account was added; there is nothing to derive locally. + if (type !== WALLET_TYPES.MNEMONIC) { + // Device accounts (Ledger, phone): the address was read from the + // device when the account was added; nothing to derive locally. address = wallet.address || null; } else if (mnemonic) { // Derive address from mnemonic @@ -953,7 +977,7 @@ async function getDerivedWallets() { name: wallet.name, address, type, - ...(wallet.path ? { path: wallet.path } : {}), + ...extraRecordFields(wallet), }); } @@ -961,25 +985,23 @@ async function getDerivedWallets() { } /** - * Add a Ledger hardware-wallet account to the wallet list. + * Add a device account (Ledger, phone) to the wallet list. * - * The address comes from the device during account discovery and is + * The address comes from the device when the account is added and is * persisted — it can never be re-derived locally. Does not require the * vault to be unlocked (no mnemonic involved), only that a vault exists * so there is a wallet list to add to. * - * @param {string} name - Display name ('' → auto "Ledger N") - * @param {string} address - Checksummed address read from the device - * @param {string} path - Derivation path in device format (e.g. "44'/60'/0'/0/0") - * @returns {Promise<{index: number, name: string, address: string, type: string, path: string}>} + * @param {string} type - WALLET_TYPES.LEDGER or WALLET_TYPES.REMOTE + * @param {string} name - Display name ('' → auto "