Skip to content

feat(onchain): add native contract-hosted apps - #192

Open
flotob wants to merge 88 commits into
mainfrom
codex/onchain-apps
Open

feat(onchain): add native contract-hosted apps#192
flotob wants to merge 88 commits into
mainfrom
codex/onchain-apps

Conversation

@flotob

@flotob flotob commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Preview status

Important

This is a shareable draft preview, not the eventual merge branch.

It is stacked on the pending foundation work in #159 (feature/openlv), #160 (feature/safe-accounts), and #181 (feat/myotis-spike). Those branches may still change during review. After they merge, the onchain-app commit will be transplanted onto fresh main so the final PR contains only the focused feature diff.

What this adds

Freedom can now load draft ERC-8244 contract-hosted applications directly from Ethereum-compatible chains.

Users can enter the friendly form:

web3://<contract>:<chainId>/

The chain ID is optional and defaults to Ethereum mainnet. Freedom canonicalizes the address to a Chromium-safe, chain-scoped origin:

web3://<contract>.eip155-<chainId>/

The main process calls the contract's read-only html() function (0x33c34ac3) through Freedom's chain-data router, ABI-decodes the returned UTF-8 document, and serves it under that stable web3: origin. The app therefore does not depend on an HTTP gateway or a page-selected RPC endpoint.

zSwap validation

This was tested against the live zSwap v0.1 root:

web3://0x00000095643CFfA7D9fae407a84dfCB6406456c6

Its live html() result decoded to a 240,945-byte HTML document and rendered successfully in Freedom. The current zSwap document uses inline application code and no remote scripts, fetch/XHR calls, or WebSockets, so it is compatible with Freedom's isolated onchain-app policy.

Security and wallet behavior

  • The response uses a default-deny CSP and Permissions Policy.
  • Ambient network access, frames, workers, plugins, base URL changes, scripted top-level navigation, and page-created windows are denied.
  • Documents have an 8 MiB decoded response limit and a 30-second load deadline.
  • Every contract + chain pair gets a separate origin and wallet permission key.
  • EIP-1193 reads, transaction requests, and signing use Freedom's existing provider bridge.
  • The provider is pinned to the chain encoded in the app URL.
  • wallet_switchEthereumChain cannot move an onchain app away from its declared chain.
  • Global wallet-chain changes do not emit contradictory chainChanged events into pinned apps.
  • Private windows can render onchain HTML, while the wallet provider remains unavailable.

Browser integration

  • Address-bar parsing and canonicalization
  • Native web3: protocol registration in regular and private sessions
  • Protocol indicator, bookmarks, and history integration
  • Trusted link routing through browser chrome
  • Documentation and an Electron smoke fixture

Current scope

This first slice resolves direct contracts exposing html(). Upgrade registries and resolver discovery are intentionally deferred; they can be added later without changing the origin or security model.

ERC-8244 is currently a draft proposal:

Validation

  • npm run lint — passed
  • npm test — 177 suites passed, 3 skipped; 3,387 tests passed, 10 skipped
  • npm run test:e2e — 69 passed, 2 platform-specific skipped; 0 failed
  • npx playwright test --project=harness test-e2e/onchain-apps.spec.js — passed
  • Private title and dweb-request persistence guards — passed on macOS
  • Live zSwap html() read and document inspection — passed
  • Staged diff/credential audit — passed
  • git diff --check — passed

The earlier macOS main.log test-path failures were fixed on main by b0dbfa8e and merged into this preview branch. The full Electron harness is now green.

Developer impact

The implementation deliberately builds on the pending Myotis, Safe Accounts, and OpenLV foundations instead of duplicating their chain routing, account, or signing work. The final cleanup after those PRs merge should be a transplant/rebase operation, not a redesign of this feature.

flotob and others added 30 commits July 8, 2026 23:08
Sign with a wallet on your phone over the Open Lavatory protocol
(openlv.sh): main publishes signing jobs to a renderer-hosted openlv
session (P2P WebRTC, end-to-end encrypted, per-request QR); the phone's
answer is verified against the account record before use.

- remote signer backend behind the getSigner seam (type 'remote'):
  personal_sign / eth_signTypedData_v4 with recover-and-compare
  verification (REMOTE_WRONG_ACCOUNT), stable REMOTE_* error codes
  mirroring the LEDGER_* registry, EIP-1193 rejection mapping
- optional Signer.sendTransaction capability: phones broadcast
  themselves; transaction-service prefers it and best-effort verifies
  the reported tx's sender on-chain; coded device errors now pass
  through its catch unwrapped
- renderer session broker (one openlv session per job, dual-purpose QR
  URL with the session secret in the fragment) + preload IPC bridge
  that only accepts responses from the chrome renderer
- shared signing-utils for EIP-712 wire-payload construction (was
  duplicated across vault/ledger, now also used by remote)
- vendored @openlv bundle (LGPL-3.0, regenerable via npm run
  vendor:openlv), lazy-loaded off the renderer boot path
- integration test: real @openlv stack, both roles, against an in-test
  MQTT broker — handshake, encryption, signing round-trips, and a
  relay-sees-only-ciphertext assertion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add remote (phone) accounts to the wallet list and the sidebar flow to
connect one: show an openlv QR, receive the phone's accounts over the
encrypted P2P session (eth_requestAccounts), pick and name one, and
switch to it. Works with the vault locked — the key stays on the phone.

- identity-manager: addDeviceWallet generalizes the Ledger add path
  (shared validation/dedup/auto-naming via DEVICE_LABELS);
  addRemoteWallet + wallet:add-remote-wallet IPC; device-neutral
  key-guard messages
- broker connectPhone(): local discovery job reusing the session/
  teardown/cancel plumbing via a per-job respond seam
- Connect Phone subscreen with QR/status/retry, account picker, and
  success step; wallet selector gains the entry point and per-type
  badges (Ledger/Phone)
- shared device-account picker + inline-error helpers +
  activateAddedWallet extracted; connect-ledger migrated to them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a phone account signs (dapp tx/sign, manual send, x402 payment),
a QR panel overlays the approval flow: scan with the phone, live
connection status, cancel, and a fresh-QR retry that supersedes the
session without dropping the pending request.

- wallet-utils: accountType/isDeviceAccount/deviceLabel generalize the
  ledger-only helpers; signingButtonLabel and the unlock-gate bypass
  (renamed bypassUnlockGateForDevice) now cover phone accounts, so all
  four approval flows skip the vault gate and label buttons per device
- broker: job events carry kind ('signing'|'connect'); retryJob mints a
  new QR for a live job with staleness-guarded attempts; aborted
  attempts release their session immediately (Promise.race) instead of
  parking on the SDK's long response timeout
- send/x402 copy adapts to the device type (Ledger vs phone)
- shared PHASE_STATUS_TEXT + generateScannableQr used by both QR UIs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The other half of the dual-purpose QR: phones without an openlv-native
wallet open the bridge page (static, in-repo under bridge/, no backend)
inside their wallet app's browser. It joins the session from the URL
fragment — never sent to any server — and forwards freedom's requests
to the wallet's window.ethereum behind a method allowlist, so the
wallet's own confirmation UI still gates every signature.

- bridge/: self-contained page + the same openlv bundle the renderer
  vendors (built once, copied; npm run bridge:serve for local dev)
- FREEDOM_OPENLV_SIGNALING env override (preload nodeConfig → broker)
  so tests pin the signaling relay to a local broker
- Playwright E2E: real Electron app with a pre-seeded locked vault,
  bridge page in a plain Chromium as the "phone" (fake window.ethereum
  signing with a test key in Node) — connects a phone account through
  the sidebar UI and round-trips a verified personal_sign through the
  remote signer, QR panel included, all offline (local MQTT + local
  WebRTC)
- shared test/helpers/local-mqtt-broker.js for the jest protocol test
  and the E2E fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
freedom.florianglatz.eth.limo hosts the bridge page for smoke testing;
final hostname still TBD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phone-side dapp sessions default to Ethereum mainnet regardless of the
wallet's selected network, so remote sendTransaction landed on the
wrong chain (found in the Gnosis smoke test). The chainId field inside
eth_sendTransaction params is ignored by wallets — the chain is session
state, switched explicitly like any dapp does.

- remote signer ships an EIP-3085 chain descriptor with each
  eth_sendTransaction job (name/currency/explorer from the chain
  registry; public https RPC endpoints only — a user's local node
  config never leaves the machine)
- broker pre-flights wallet_switchEthereumChain over the same session
  (same QR); unknown chain (4902) → wallet_addEthereumChain with the
  descriptor, then switch again; a declined switch fails the job before
  the tx is ever sent
- QR panel shows a "confirm the network switch" status; bridge page
  allowlists the two wallet_* methods

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The freedom mobile wallet endpoint (swarm-mobile-ios, WP-R4a) runs the
vendored openlv SDK inside a hidden WKWebView, and WKWebView refuses
ES-module imports from file:// pages while the SDK hard-requires a
secure context for crypto.subtle — so bundle-openlv.js now also emits
bridge/openlv.iife.js (window.OpenLV, classic script) and refreshes the
sibling iOS checkout's vendored copy when present.

npm run openlv:ios-harness plays the desktop host role for the iOS
XCTest suite: local aedes MQTT broker + a headless Chromium page
running the same SDK (Node has no WebRTC) + an HTTP control surface on
127.0.0.1:8798, sending the signing-job sequence (eth_requestAccounts,
wallet_switchEthereumChain pre-flight, personal_sign) and recording the
responses. The Chromium mDNS ICE workaround moves to
test/helpers/webrtc.js, shared with the remote-signing E2E fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each XCTest consumes one host session; GET /reset reloads the Chromium
host page so the next test gets a fresh session and a clean exchange
log instead of a consumed one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two findings from the freedom-mobile smoke test:

Freedom's signing jobs never send eth_requestAccounts (the account was
captured in an earlier connect session), and strict wallets — freedom
mobile's own in-tab provider included — reject signing from an origin
that never connected ("Connect first … isn't authorized"). The bridge
now connects lazily before the first signing request; lenient wallets
treat the extra connect as a no-op.

The page also offers "Open in Freedom app" via the freedom:// custom
scheme, so scanning the QR with the plain camera can hand the session
to the native endpoint today, before the universal-link claim on this
origin is deployed. On phones without the app the tap is a no-op and
every other path stays available.

Redeploy bridge/ to Swarm to take effect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/reset?mode=tx makes the host send the desktop signing-job shape for a
transaction (eth_requestAccounts → chain-switch pre-flight →
eth_sendTransaction) instead of personal_sign, so the iOS suite can
prove the wallet endpoint's self-built transaction actually mines on a
local anvil chain — not just that it was signed and broadcast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bridge page is a standalone static site deployed independently to
Swarm on its own cadence — it now lives in
github.com/solardev-xyz/freedom-bridge. Everything here that touched
bridge/ points at a sibling checkout instead: serve-bridge.js (and
through it the remote-signing E2E) resolves ../freedom-bridge with a
FREEDOM_BRIDGE_DIR override, the iOS harness reads the esm bundle from
the renderer vendor copy (byte-identical), and bundle-openlv.js syncs
both sibling repos' vendored bundles from its one build so nothing can
drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serve-bridge.js threw at require time when the sibling checkout was
missing, which failed the whole `npm run test:e2e` sweep for anyone
without it. The availability check moves behind bridgeAvailable() and
the spec skips itself with a clone hint instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The require was one directory short (resolved to src/test/... instead
of the repo-root test/helpers/), so the suite failed to load — the CI
red on PR #159. Six directories up, six dots of shame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The openlv mqtt signaling layer uses the browser WebSocket global,
which Node only ships from v22 — CI runs Node 20, so the suite failed
with "WebSocket is not defined" there while passing on dev machines.
Polyfill from `ws`, which the local broker helper already depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Safe account has no key of its own: its owners are existing wallet
records, and a SafeTx is EIP-712 typed data, so every signer backend
(vault, Ledger, phone) co-signs through the ordinary getSigner seam.
src/main/wallet/safe/safe-executor.js orchestrates the rest over
@safe-global/protocol-kit (local SDK only — never the hosted Safe
Transaction Service): counterfactual address prediction, deployment
through the canonical safe-deployments factory (asserted, never a
user-supplied one), SafeTx build/hash returning plain-JSON shapes,
sequential recover-verified owner-signature collection, and
execTransaction submitted by an executor EOA that pays the gas.

The record's original init params (owners, threshold, saltNonce) are
the reproducibility anchor: buildSafeTransaction re-derives the CREATE2
address from them and refuses on mismatch, and the anvil-fork
integration test (skips without anvil/network) proves the same params
deploy to the same address on forked Gnosis AND Base, with retroactive
deployment claiming funds sent to the address before it existed.

getSigner(safeIndex) now throws — a Safe is an account, not a signer.
provider-manager grows getEip1193Provider(chainId) (protocol-kit needs
a raw request interface FallbackProvider cannot give) sharing the RPC
pool and cache invalidation; transaction-service grows toFeeFields so
callers stop re-deriving the eip1559/legacy branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Safe is now a first-class wallet record (type 'safe') whose owners
are existing accounts, created from the account dropdown with the two
shipped presets — backup (1 of 2) and resilient (2 of 3); 2-of-2 is
deliberately not offered since losing either device would brick the
funds. The record freezes the init params (owners, threshold,
saltNonce) that make the CREATE2 address reproducible; identity-manager
enforces the presets, refuses Safes owning Safes, and blocks deleting
an account while a Safe references it as owner.

Creation is free: safe-service predicts the counterfactual address and
stores the record, so the account can receive immediately. "Needs
funds" is a first-class blocking state, not an error — the status card
under Send/Receive quotes the one-time Gnosis activation (deployment
tx built once, gas + executor balance checked in parallel) and either
offers Activate, or blocks with "fund <executor> with ≥ X xDAI", or
explains that no owner can pay. Activation reuses the quoted
deployment tx, waits for confirmation, and only then marks the record
deployed (chain state also self-heals the record).

Until the multi-owner signing flow ships, Safe accounts are
receive-only: Send is disabled with an explanation and dApp connect
skips them (EIP-1271 comes later). The shared account picker grew
label/multi-select support so the owner list, Ledger, and phone
screens render from one component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ence

A deployed Safe can now send: the send flow branches into a Safe path
where main builds the SafeTx, collects owner signatures one device at a
time (vault instantly, Ledger tap, phone QR — all through the existing
signer seam), and submits execTransaction through the executor EOA. The
pending view shows a per-owner signature checklist driven by progress
events streamed from main; the review screen names who pays the fee
instead of quoting gas that can only be known after signing.

Every collected signature is persisted the moment it exists (interim
JSON, one pending SafeTx per Safe — a single slot sidesteps the nonce
replacement swamp). A rejection, an unreachable phone, a failed
broadcast, or an app restart never loses signatures: the status card
shows "transaction awaiting signatures (1 of 2)" with continue/discard,
and resume skips owners who already signed.

Safe transactions land in payment history: safe-send rows say from =
safe address with the executor and safeTxHash in metadata (tx-recorder
gained a fromAddress override), and activation deploys record as
safe-deploy. The payments page knows both kinds.

Also fixes a stale-capability bug: the renderer's record snapshot now
syncs deployment truth from main when the status card refreshes, so the
Send button enables right after activation instead of after a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
startSafeSend passed the safe record straight into the executor layer,
so protocol-kit received the owners as wallet INDEXES and viem died on
getAddress(0) ("Address \"0\" is invalid") the moment a safe send was
confirmed. The activation path resolved them correctly; the send
orchestrator now does the same, and initPredictedKit fails loudly on
non-address owners so the index/address mixup can never reach the
address parser again.

The unit test had asserted the wrong shape against a mocked builder —
fixed, and the anvil-fork suite gained an un-mocked regression test
that walks the real record → build → collect(2/3) → execTransaction
path and checks the funds arrive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New Playwright spec drives the actual app end to end: create a 1-of-2
Safe in the wizard, hit the needs-funds blocking state, fund the
executor on an anvil fork of Gnosis (the registry's user config points
every Gnosis RPC at it, builtin endpoints removed), activate through
the canonical factory, watch Send enable, and send 0.5 xDAI out of the
Safe — recipient balance verified on-chain. Skips cleanly without
anvil/network, like the jest fork suite. The fork runs with
--block-time so confirmation behaves like the real chain.

Two findings it caught immediately, both fixed:
- activating with a locked vault failed silently to the console and
  reset the button — the status card now surfaces the error inline;
- the Safe send path skipped gas estimation and with it the only check
  that an asset was selected — validateAmount now owns that check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field testing a 2/3 Safe showed the flaw: signature collection ran as a
pipeline that marched through owners in record order and declared
"Transaction Failed" the moment the Ledger wasn't plugged in — for a
task that is fundamentally asynchronous and belongs to the user, not
the app.

Collecting signatures is now a board the user drives. Confirming a send
creates and persists the SafeTx, silently adds the free signatures
(mnemonic owners, vault unlocked — kept, that's what made 1-of-2 feel
instant), and opens a dedicated "Collect signatures" subscreen: what is
being sent and to whom, "1 of 2 signatures — any 2 of the 3 owners can
sign", when it started, and one row per owner with its own action
(Sign with Ledger / Show QR code) that the user taps when the device is
actually in hand. A failed attempt is a row state with main's error
message; a rejection is a decision — the row quietly returns to
waiting. The board is leaveable: the account card shows what's waiting
("Sending 0.5 xDAI to 0x12…cd — 1 of 2 signatures, started 2 days
ago") and re-opens it. While the board is open, an unplugged Ledger row
flips to "Ledger detected — sign now" the moment it's connected. The
phone QR overlay now says it's approving your own multi-owner
transaction instead of dApp copy.

Main's API turned granular and defensive: start (build + free
signatures only — devices are never cold-called), sign-one-owner
(per-safe mutual exclusion; a signature landing after its transaction
was discarded is dropped by a safeTxHash identity check), and execute
as its own idempotent step — auto-run by the board at threshold,
nonce-guarded so a broadcast that secretly landed (or an
app.safe.global execution) flips the transaction to a truthful terminal
"superseded" state instead of retry-looping, with an executor-can't-pay
banner and signatures that survive every failure. The progress event
stream is gone; the board renders from returned state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pending SafeTxs move out of the account status card into a wallet-wide
"Unfinished transactions" row above Recent payments, with a count badge
across ALL Safe accounts. It opens an overview subscreen (one entry per
waiting transaction: summary, which Safe, signature progress, age) and
each entry opens its signing board — with a single pending transaction
the row jumps straight to the board. The status card is back to
activation states only.

Also fixes the raw-wei summary ("Sending 2000000000000000 to …") that
pending transactions created before the presentation fields existed
rendered with: the summary now formats atomic amounts with the stored
decimals and falls back to xDAI for native sends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: tapping "Show QR code" on the signing board failed within
a split second with "Phone signing failed. Try again." A new E2E — a
phone owner co-signing a 2/3 SafeTx through the real bridge page over a
local relay — passes, which isolated the failure to the environment:
the default signaling relay (test.mosquitto.org's public TEST broker)
currently accepts TLS and instantly hangs up the MQTT websocket,
reproducible with a raw probe.

The session broker now resolves its relay per session: an explicit
override (env) is used as-is, otherwise the public candidates
(mosquitto, EMQX, HiveMQ) are probed with a single websocket handshake
and the first reachable one wins (cached for a minute). The chosen
relay rides inside the QR, so the phone always joins the same one —
and relays only ever carry ciphertext. Non-Error job failures also stop
collapsing into the generic registry message, so the next environmental
failure names itself.

The new safe-phone E2E stays: it covers the signing board's QR row end
to end (vault free signature + phone co-signature + execution, funds
verified on the fork).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field report: after collecting both signatures the board said "Vault is
locked / The signatures are kept — you can try again" — the vault had
auto-locked while the phone signature was being gathered, and executing
needs the executor's vault key. A locked vault is a step in the flow,
not an error: the safe IPC handlers now tag vault-locked failures with
a stable code, and the signing board (execute AND per-owner sign) plus
the activation card respond by opening the standard vault-unlock
screen and retrying the step after a successful unlock. Cancelling the
unlock leaves an actionable notice instead of a dead end.

The safe-accounts E2E now exercises this path for real: it clicks
Activate with a locked vault, unlocks through the UI, and waits on the
Send button (the deployed-truth barrier — the status card sits inside
the identity view, which the unlock screen hides, so a card-hidden
assertion passes prematurely). The phone E2E tolerates the card
quoting before or after the executor funding lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dApp's personal_sign / eth_signTypedData_v4 against a Safe account is
answered with owner signatures over the SafeMessage EIP-712 envelope;
completeSafeMessage returns the sorted concatenated bytes the dApp
verifies via isValidSignature on the Safe.

- safe/safe-messages.js: in-memory sessions (a dApp request is a live
  promise that dies with its page); same-hash restart resumes with the
  collected signatures, a different hash replaces the dead session.
  Digests are computed with ethers over normalized input — protocol-kit's
  hashSafeMessage would UTF-8-hash hex personal_sign payloads, diverging
  from what EOA signers and verifying dApps compute.
- safe/signature-collection.js: the owner-collection machinery (in-flight
  lock, free-signature sweep, per-owner ceremony with identity re-check)
  extracted from safe-transactions over a store adapter; sends and
  message sessions share one lock per Safe.
- safe/errors.js: the SAFE_* code registry.
- IPC wallet:safe-message-start/-sign/-state/-cancel/-complete (lazy,
  VAULT_LOCKED-tagged) + window.wallet.safeMessage*.
- Fork test: collected signatures pass isValidSignature(bytes32,bytes)
  on the real deployed contract for personal_sign AND typed data; a
  tampered digest is refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- dapp-connect lists DEPLOYED safes (receive-only stay hidden), defaults
  to the active account, and says upfront that a multi-owner account
  signs as a smart contract some apps can't verify (same caveat on the
  sign approval screen).
- Message signing routes through the signing board's new 'message' mode:
  free vault signatures that meet the threshold answer instantly (no
  board), otherwise the board collects per-owner and completion hands
  the combined signature back and closes — closing/cancelling rejects
  the dApp with 4001. Opening the board over a live message session
  settles that session first.
- dApp eth_sendTransaction starts a pending SafeTx and hands over to the
  board; the fee row names who pays (getSafeStatus executor — also used
  by the send review now); the dApp promise resolves with the execution
  hash via wallet:safe-executed and rejects on wallet:safe-discarded.
  Parking the board keeps the dApp waiting — its transaction IS pending.
- Gnosis-only guards on both paths (an unverifiable signature or a
  never-executable SafeTx beats a confusing failure later); safes are
  never auto-approved for transactions.
- Board openers accept the caller's fresh state, dropping the redundant
  re-fetch + free-sweep IPC on every dApp interaction.
- SafeMessage phone signatures get their own QR copy (context
  'safe-message').
- NEW E2E safe-dapp.spec.js: a real bzz:// dApp page connects the Safe
  through the webview provider bridge, personal_sign verifies via
  isValidSignature on the anvil Gnosis fork, and a send comes back as an
  execution hash with the balance moved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without an injected signaling override the broker probes the public
MQTT brokers with a real WebSocket — the unit suite silently depended
on broker reachability. The harness now injects a fixed relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Distributable builds now fail while BRIDGE_ORIGIN still points at the
interim test deployment, so the pre-merge checklist item on #159 is
enforced rather than remembered. FREEDOM_ALLOW_INTERIM_BRIDGE=1
overrides for local experiments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A SafeMessage session was keyed only by Safe index and hash: a second
dApp tab requesting the identical digest resumed (and could complete)
another site's session, and a different digest silently replaced a
still-live request while its board and promise stayed attached to the
old ceremony.

Sessions now carry an unguessable token (crypto.randomUUID) plus the
requester identity {origin, webContentsId}, threaded from the dApp
provider through preload/IPC:

- sign/complete/cancel require the token; state queries with a foreign
  token render as "nothing open" for that caller
- the identical request only resumes for the SAME page (origin AND
  webContents); any other request is refused with SAFE_MESSAGE_EXISTS
  while a live session exists — no silent replacement
- sessions are dropped when the requesting webContents navigates or is
  destroyed (a dead page's leftover no longer blocks, and its
  signatures cannot linger for whatever loads next)
- the session slots live in a new dependency-light message-sessions
  module so lifecycle owners can force-drop without the signing stack

Fixes PR #160 review finding P1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
safe-pending.json entries and live SafeMessage sessions are keyed by
wallet index. Deleting a Safe left them behind; since new wallet
indexes are assigned max+1, deleting the highest-index Safe let the
next account inherit — or be blocked by — the dead Safe's half-signed
SafeTx and session.

deleteDerivedWallet now discards the pending SafeTx entry and
force-drops the message session (unhooking its webContents listeners)
before the record is removed, via two dependency-light lazy requires.
The renderer's delete confirm warns when a waiting transaction and its
collected signatures are about to be discarded.

Fixes PR #160 review finding P2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
flotob and others added 23 commits August 7, 2026 09:27
Retry data-less Colibri transport failures once with a fresh client and log bounded structured diagnostics. Pin ephemeral quorum providers to Ethereum Mainnet so ethers does not flood logs with network-detection retries.
Publish bidirectional Myotis availability epochs, reject reads that overlap shutdown, and invalidate cached or in-flight policy decisions on lifecycle changes. Add immediate fallback diagnostics and poll availability every second so startup and shutdown transitions are visible promptly.
Pin the verified v0.1.4 release and checksum manifest, adopt engine ABI 22, and isolate CI sync caches for the new client version. The release includes the Gnosis light-client catch-up fixes.
Pin the verified v0.1.5 release and refresh the runtime/UI version. Move CI to fresh v0.1.5 sync-cache namespaces so all three shipping platforms rerun the Ethereum and Gnosis native smoke tests from clean profiles.
Pin the published v0.1.6 checksum manifest and refresh the Myotis CI cache namespace so the three-platform live smoke suite exercises the new Roost discovery path from fresh profiles.
Myotis answers account reads from its verified head state and takes no
block parameter, so requestMyotis silently dropped the tag: a `pending`
nonce request (every signAndSendTransaction, and dapps via
wallet:chain-request) was answered from stale verified state instead of
falling through to a source that honours the tag, so two transactions in
a row could be signed with the same nonce. Fall through to the next
source for any explicit tag other than `latest`, on the account reads and
on the identical eth_estimateGas site.

feeQuote also set maxFeePerGas to the raw eth_gasPrice, leaving zero
headroom for a base fee that can rise 12.5% per block between quoting and
inclusion. Restore the wallet's long-standing market preset of
2x base fee + priority fee; effectiveGasPrice (what the UI displays) is
unchanged.
Wallet gas estimates carry a decimal wei value (ethers parseUnits output).
The router serialized it verbatim into the JSON-RPC params, so spec-compliant
nodes answered -32602 and the estimate silently degraded to whichever lenient
endpoint replied via the unverified `direct` tier — or failed outright against
a strict user-configured node. Normalise the QUANTITY fields of a call object
once, at the boundary every source shares, so eth_call/eth_estimateGas reach
the quorum tier and all endpoints compare a byte-identical body. Myotis is
unaffected: it re-decodes the value through BigInt either way.
The engine executes a call against its verified head with only
from/to/data/value: the block argument is discarded (`_block`, never
read — the servable-window gate is a host obligation) and the addon
exposes no state-override entry point. Forwarding the tag therefore only
looked like it honoured it: an eth_call at an explicit historical block
was answered from head state and labelled `verified: true`, and because
it succeeded the router never fell through to colibri/quorum/direct,
which honour (or correctly reject) the tag. State overrides in
`params[2]` and call-object `gas`/fee/`nonce`/`accessList` fields were
dropped the same way, so simulations ran without their overrides.

Apply the ffd2ec8 pattern to `eth_call`: gate on the block tag, on
`params[2]`, and on the call fields the engine cannot take, raising
SourceUnavailableError so the read continues down the source order. The
same gate now covers the structurally identical `eth_estimateGas`
branch, which dropped the extra call fields and overrides too.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Call objects carry calldata as either `data` (legacy) or `input` (the
standardized field web3.js v4 and friends send). Every source read only
`data`, so an `input`-only eth_call executed against Myotis' verified head
with empty calldata and returned that answer as verified, and an
eth_estimateGas came back as a plain-transfer 21000 — enough gas for the
resulting transaction to revert out-of-gas on chain.

Canonicalise the alias into `data` in normalizeParams, the boundary every
tier shares, so Myotis, quorum and direct all execute the same calldata.
A call carrying both aliases with different payloads is ambiguous — strict
nodes reject the pair — so Myotis declines it and the read falls through
rather than executing one side and calling it verified.
Merge current main while preserving the Myotis integration and newer browser features. Pin the Nethermind-compatible release, harden verified-source routing and downloads, and expand addon/live CI coverage across shipped operating systems.
1. ENS-page prover upsert now includes `name` so clearing the field
   collapses the override back to the builtin (matches the Chains-page
   path) instead of pinning builtin URLs in the user layer forever.
2. colibri-resolver: close the acquire→use use-after-destroy window —
   acquireClient retains optimistically then re-validates the client is
   still cached (else releases + re-acquires), and the provider is
   co-located in the clients entry so the {client, provider} pair is
   captured atomically. Replaces useClient; withColibriClientRetry now
   owns the acquire/release lifecycle.
3. reorderAccess persists only the changed key ({ access: { [key]: order } })
   so a drag no longer freezes merged builtin defaults into the user config.
4. custom-chain default read order in the UI aligned to the router's
   ['colibri','quorum','direct'] so the UI matches routing and the first
   drag can't drop colibri.
5. prover-role coverage URLs now get the same https-or-loopback SSRF
   validation as rpc URLs (both are main-process fetched); +tests.
6. endpoint Save has an in-flight guard so a double-click can't mint two
   'user-<Date.now()>' duplicate sources.
7. broadcast + fee-quote loops preserve the last real node error (code/data)
   instead of only the stringified aggregate, matching request().
Preserve Myotis chain routing and resolution behavior while integrating the current main branch's Ledger signer, adblock, shortcuts, and Swarm changes.
Bring in the Settings navigation repair and shared Linux native dependency action, then apply that setup to the Myotis and settings E2E jobs.
Bring in the reviewed Settings E2E job from main while retaining Myotis policy coverage and the shared Linux native prerequisites.
Run verified Ethereum, Gnosis, resolver, and UI checks through one production-app Myotis lifecycle. This avoids persisting peer backoff in a raw smoke and immediately restarting the same client for Playwright while retaining strict method and verification assertions.
Require every ENS and NameNFT assertion to be served by Myotis, but retry after transient peer misses that production correctly routes to Colibri. Record readiness, peer state, policy, and fallback results for actionable CI failures.
Integrate the current remote-signing branch as a prerequisite snapshot for native onchain app development.

# Conflicts:
#	scripts/build.js
#	src/main/identity-manager.js
#	src/main/identity-manager.test.js
#	src/main/preload.test.js
#	src/main/wallet/signers.js
#	src/main/wallet/signers.test.js
#	src/main/wallet/transaction-service.js
#	src/main/wallet/vault-access.js
#	src/renderer/lib/wallet/connect-ledger.js
#	src/renderer/lib/wallet/send.js
Integrate the current Safe smart-account branch on top of the OpenLV prerequisite snapshot for native onchain app development.

# Conflicts:
#	package-lock.json
#	src/main/preload.js
#	src/main/wallet/signers.js
#	src/main/wallet/signers.test.js
#	src/renderer/lib/wallet/dapp-connect.js
#	src/renderer/lib/wallet/dapp-sign.js
#	src/renderer/lib/wallet/dapp-tx.js
#	src/renderer/lib/wallet/send.js
Integrate the current verified Ethereum light-client and chain-data routing branch on top of the composed OpenLV and Safe wallet foundation.

# Conflicts:
#	README.md
#	scripts/check-binaries.js
#	src/main/ens-resolver.js
#	src/main/ens-resolver.test.js
#	src/main/index.js
#	src/main/ipc-handlers.test.js
#	src/main/preload.js
#	src/main/preload.test.js
#	src/main/profile-catalog.js
#	src/main/profile-catalog.test.js
#	src/main/profile-paths.js
#	src/main/profile-paths.test.js
#	src/main/service-registry.js
#	src/main/settings-store.js
#	src/main/wallet/wallet-ipc.js
#	src/renderer/pages/settings.html
Resolve draft ERC-8244 html() documents through Freedom's chain-data router under contract-and-chain-scoped web3 origins.

Pin the EIP-1193 provider to the app chain, enforce isolated response policy, and add navigation, history, documentation, unit coverage, and an Electron smoke test.
Bring the isolated E2E log path fix into the onchain-app preview branch so the private-window privacy assertions run against their per-test data directory.
Reuse the existing address-bar trust shield for web3 applications and show retrieval evidence, network, contract, and HTML hash. Keep provenance host-owned and scoped to the committed guest navigation so pages and stale tabs cannot spoof the modal.
Keep Chromium's chain-scoped synthetic hostname as internal plumbing while reverse-mapping browser chrome, history, bookmarks, copying, trust details, and permission prompts to the standard web3 form.
@flotob

flotob commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Update: provenance UI and standards-aligned URLs

Two browser-integration refinements have landed since this draft was first shared.

Onchain trust details

The address-bar trust shield is now active for contract-hosted applications. Its browser-owned popover shows the retrieval result and the identity of the document Freedom actually loaded:

  • retrieval verification method and finality
  • chain/network and block number
  • resolved contract address
  • Keccak-256 hash of the returned HTML bytes

This deliberately reports verifiable provenance rather than making a broader immutability claim: the strong statement is that these exact bytes were retrieved from this contract through this chain-data result. A contract's html() implementation may still depend on mutable storage or other contracts.

URL scheme research and decision

We are keeping web3:// as the public scheme. This is not Freedom-specific syntax:

  • ERC-4804 defines web3://<contract>[:<chainId>]/… for translating Web3 URLs into read-only EVM calls.
  • Draft ERC-6860 formalizes the same web3/w3 URL grammar, including the optional decimal chain ID.
  • Draft ERC-8244 supplies the complementary html() application interface used here.

We considered prettier chain-specific schemes such as ethereum://0x… and gnosis://0x…, but found no standard for that model. It would also overload ethereum:, which ERC-681 already assigns to transaction and payment requests, and would require Freedom to invent and maintain a chain-name registry.

The standard URL is therefore what users now see and share:

web3://0x…/          # Ethereum mainnet (chain 1 is implicit)
web3://0x…:100/      # Gnosis Chain

Chromium cannot use that exact text as the guest's physical origin. A bare all-hex 0x… host enters Chromium's numeric/IP-host parsing, while :<chainId> is interpreted as a network port—limited to 0–65535 and subject to restricted-port handling. Freedom consequently uses this only inside Chromium:

web3://0x….eip155-1/

The .eip155-<chainId> form is browser plumbing, not a new public URL convention or a network dependency. One central codec now maps between the two representations, so this does not require surface-by-surface aliases. The standard form is used consistently in the address bar, history, bookmarks, copied URLs, tab restoration, trust UI, and permission prompts; the internal form remains the real chain-scoped webview/storage origin. Page JavaScript and DevTools therefore still see the physical origin, which is intentional and honest.

Implementation commits:

  • bda61261 — verified onchain-app provenance in the trust popover
  • 9e3c8f8a — standard URLs across user-facing browser surfaces

Validation for the URL refinement: ESLint passed, 533 focused unit tests passed, and the Electron onchain-app smoke test passed with an assertion that history persists the standard URL rather than the internal hostname.

flotob added 3 commits August 17, 2026 23:03
Keep read-heavy dapps responsive without changing their configured source order. Apply two-second Colibri and quorum deadlines, settle quorum as soon as its result is known, and remember scoped timeouts or deterministic execution ceilings before falling through to the next configured source. Bound non-cancellable Colibri work and carry the canonical app origin through the existing provider request path.
Carry a usable RPC response from a failed quorum into an immediately following Direct tier instead of issuing the same request again. Keep pending members alive under Direct's existing compatibility budget, skip members that already failed, and preserve the failed-quorum evidence in trust metadata.
Install Freedom's EIP-1193 provider synchronously in the page main world so eager dapps can detect it during HTML parsing. Keep an idempotent DOM fallback and cover parser-time detection, fallback behavior, and private-mode isolation.
@flotob

flotob commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Update: eager wallet detection fixed

Testing another contract-hosted app exposed a timing incompatibility in Freedom wallet injection. The app captures window.ethereum synchronously from an inline script while the document is still being parsed. Freedom previously installed its EIP-1193 provider at DOMContentLoaded, so the provider eventually existed but the app had already permanently captured undefined, resulting in “No Ethereum wallet found” until reloads happened to mask the race.

Commit feb59c61 now installs the provider synchronously in the page main world from the webview preload, before document scripts execute. The existing DOM-based path remains as an idempotent defensive fallback, and private windows still receive no wallet provider or EIP-6963 announcement.

Validated with:

  • parser-time provider detection plus an immediate eth_chainId request
  • 68 focused preload/provider unit tests
  • the native onchain-app Electron harness
  • the private-window wallet-isolation Electron test
  • ESLint

This fixes both the explicit “No Ethereum wallet found” case and the broader flaky-connect class caused by dapps performing eager provider detection.

@flotob
flotob marked this pull request as ready for review August 18, 2026 08:23
@flotob

flotob commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Update: adaptive routing for read-heavy onchain apps

Live zSwap testing showed that quote-heavy, block-pinned multicalls expose compatibility limits across the verified read sources:

  • Myotis cannot yet serve the complete workload.
  • Colibri may hit prover execution limits or take too long on complex calls.
  • A public RPC quorum can fail when members impose different execution ceilings.
  • Direct RPC completes these calls reliably.

Freedom now handles this automatically while preserving the configured order (Myotis → Colibri → quorum → Direct):

  • Colibri and quorum have a 2-second interactive deadline.
  • Timeouts temporarily bypass only that source/app/workload combination.
  • Deterministic execution-limit failures bypass that combination for the session.
  • Colibri background work is bounded to prevent timed-out calls from accumulating.
  • If quorum cannot reach agreement but already received a valid response, Direct reuses it instead of repeating the request.
  • Pinned block semantics are preserved; Freedom never silently substitutes latest.

The result is that zSwap works with Freedom’s default routing order—users no longer need to move Direct RPC to the top.

Implemented in 3e0caa5b and 01dd4862, based on a reproducible corpus captured from zSwap’s live quoting workload.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants