Skip to content

feat(staking): browser-wallet signing for validator-join and wizard#367

Merged
MuncleUscles merged 23 commits into
v0.40-devfrom
feat/wizard-wallet-connect
Jul 9, 2026
Merged

feat(staking): browser-wallet signing for validator-join and wizard#367
MuncleUscles merged 23 commits into
v0.40-devfrom
feat/wizard-wallet-connect

Conversation

@MuncleUscles

Copy link
Copy Markdown
Member

What & why

Adds browser-wallet (MetaMask) signing for staking validator-join and the staking wizard, so a validator owner can keep the cold key in their wallet instead of an on-disk keystore.

Design decision: raw-viem bridge, not an SDK account

genlayer-js executeWrite (the single seam all staking writes funnel through) requires account.signTransaction — i.e. it signs locally then sendRawTransactions. MetaMask has no working eth_signTransaction; it only supports eth_sendTransaction (sign and broadcast). So a "viem account backed by the bridge" is impossible for this path.

Instead, for the send only we bypass the SDK signer:

  1. The CLI encodes calldata itself (encodeFunctionData + abi.STAKING_ABI / abi.VALIDATOR_WALLET_ABI, both already exported by genlayer-js).
  2. A dependency-free localhost page (node:http on 127.0.0.1:0) eth_sendTransactions it via the injected wallet and posts back the tx hash.
  3. The CLI waits for the receipt with a viem publicClient (reusing the existing glHttpConfig id≠0 quirk) and decodes the ValidatorJoin event for the validator wallet address.

No genlayer-js changes required. The ~30 lines of encode/decode duplicated from genlayer-js staking/actions.ts are accepted for now; a future SDK prepare-then-send split can delete them.

Bridge protocol

Localhost HTTP server, ephemeral port, per-session token in the URL fragment (#s=<token>), sent as X-Bridge-Token on every /api/* call.

Route Method Auth Purpose
/ GET none serves the inline HTML page
/api/session GET token chain params incl. chainIdHex (for switch/add chain)
/api/connected POST token + Origin resolves waitForConnection()
/api/next GET token long-poll (25s) → `{type:"tx"
/api/result POST token + Origin resolves/rejects the pending sendTransaction()

Security: binds 127.0.0.1 only; token required on all API routes (403 otherwise); Origin checked on POSTs (anti DNS-rebinding); Cache-Control: no-store; single session; SIGINT handler closes the server; always closed in a finally.

Scope (bounded)

  • MetaMask / any injected window.ethereum only — no WalletConnect / Ledger.
  • validator-join + wizard only. Wizard: only the join step and (browser-owner) identity step route through the bridge; step-4 operator keystore generation/export and the config-address summary are unchanged. Owner "account setup" gains a "Connect browser wallet" option.
  • Other staking/vesting write commands adopt later via the getBrowserWalletSession seam + one build*Tx helper each — the seam is ready but not wired.
  • No intelligent-contract (deploy/write/appeal) browser signing, no genlayer-js changes, no HTTPS/non-loopback/auto-SSH-tunnel, no browser-account persistence, no --no-open/custom-port flags.

Network / rpc / staking-address resolution is unchanged, so custom deployments work. --wallet browser hard-errors when combined with --password (both commands) or --account (both commands).

Tests

Automated (vitest, all headless — the MetaMask click itself is not automatable):

  • tests/libs/stakingTx.test.ts (10) — calldata for both validatorJoin overloads against the real ABI, setIdentity encoding incl. hex vs UTF-8 extraCid, ValidatorJoin decode + "event not found".
  • tests/libs/browserBridge.test.ts (15) — full HTTP protocol driven by a fetch-simulated page: session → connected → next (incl. held long-poll) → result; multi-tx sequential; 403 on bad/missing token and wrong Origin; rejected/error/timeout; done/abort delivery; clean shutdown (port closed, pending promises rejected). openUrl stubbed.
  • tests/actions/staking.test.ts (+4) — validator-join --wallet browser never touches keystore/getStakingClient, output shape matches keystore mode, bridge closed on success and failure, --password/--account rejected.
  • tests/commands/staking.test.ts (+4) — --wallet parsed on both commands, defaults to keystore.
  • tests/actions/stakingWizard.test.ts (new, 2) — browser-owner routes join through the bridge, operator keystore export still runs, keystore path untouched; --account + --wallet browser rejected up-front.

npx vitest run: 611 passed / 55 files. npm run build (esbuild) green. Prettier clean.

Manual QA (real MetaMask — not automatable in CI)

  1. genlayer staking validator-join --wallet browser --amount 1gen --network testnet-bradbury on a fresh browser profile: add-chain prompt (4221 / GEN), approve, hash returns, receipt awaited, validatorWallet printed.
  2. Same with the wallet on the wrong network → switch prompt; refuse → timeout message.
  3. Reject the tx in MetaMask → clean CLI error, server shut down (no lingering port).
  4. Full staking wizard --wallet browser on localnet (MetaMask pointed at the local RPC): browser owner, operator keystore export unchanged, join + identity both signed in the same tab, summary correct.
  5. Ctrl-C during "waiting for wallet" → SIGINT handler closes the server cleanly.

Verify during manual #1: legacy vs EIP-1559 gas on Bradbury — the SDK forces type: "legacy" for staking writes; MetaMask picks tx type from the chain's latest block. If a network rejects typed txs, gasPrice is already plumbed through BridgeTxRequest (fetch via publicClient.getGasPrice() and pass hex). Sped-up/replaced txs can't be tracked (only the original hash is held) → timeout + explorer hint, documented.

Add a dependency-free localhost bridge (node:http) that lets a browser
wallet (MetaMask / any injected window.ethereum) sign-and-broadcast the
validator-join and wizard-identity transactions.

Design: raw-viem bridge, not an SDK account. genlayer-js executeWrite
requires account.signTransaction (sign -> sendRawTransaction), which
MetaMask cannot satisfy (it only does eth_sendTransaction). The CLI
encodes calldata itself, a localhost page eth_sendTransactions it, and
the CLI waits for the receipt via a viem publicClient (reusing the
glHttpConfig id!=0 quirk) and decodes ValidatorJoin for the wallet.

New: src/lib/wallet/{stakingTx,bridgePage,browserBridge}.ts. Flag
--wallet <keystore|browser> on validator-join and wizard, with hard
errors when combined with --password/--account. Only the join + identity
sends route through the bridge; operator keystore generation/export and
config summary are unchanged. Other commands can adopt later via the
getBrowserWalletSession seam.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76111725-885a-47e8-a189-886fac7ba5de

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wizard-wallet-connect

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Generalize the #367 browser-wallet bridge into a reusable signing layer and
adopt it across every staking write command.

- Extract StakingAction.getBrowserWalletSession into a shared
  src/lib/wallet/browserSend.ts (openBrowserWalletSession); add the Lane B
  eip1193Provider shim (eth_sendTransaction -> bridge; eth_chainId/eth_accounts
  local) and setNextLabel. Owns glHttpConfig.
- Grow stakingTx.ts into txBuilders.ts (generic buildTx + encodeExtraCid);
  stakingTx re-exports for back-compat.
- Add shared addWalletModeOption registrar (--wallet <keystore|browser>).
- BaseAction: isBrowserWallet, assertWalletFlags, getBrowserSession/
  closeBrowserSession, and getClient browser mode (Lane B, skips keystore).
- Widen bridge protocol (gas/type pass-through; page drops nonce/chainId).
- Adopt --wallet browser on the 9 remaining staking writes + prime-all;
  refactor validator-join/wizard onto the shared registrar.

Tests: txBuilders, browserSend (incl. eip1193 shim), bridge gas/type
pass-through, per-command keystore-untouched + output parity + flag conflicts.
Docs regenerated.
Adopt --wallet browser (Lane A) across all 12 vesting write commands via
buildTx(VESTING_ABI, vesting, fn, args). Beneficiary resolves to the connected
wallet address; the vesting lookup runs on the account-less read-only client.

BREAKING (deprecated-flag rename): the deprecated --wallet <address> alias on
'vesting validator ...' subcommands is renamed to --validator-wallet <address>
so --wallet is free for the signing-mode flag. The recommended positional
argument form is unaffected. Internally the resolved positional is carried as
'walletAddress' (VestingConfig gains wallet mode + validatorWallet alias).

Tests: vesting action browser-mode (routes through session, keystore client
untouched, output parity) + command parsing incl. the --validator-wallet
rename. Docs regenerated.
Add --wallet browser to 'deploy' (incl. deploy-scripts mode) and 'contracts
write'. Sets walletModeOverride so BaseAction.getClient builds the genlayer-js
client with {account: <address>, provider: eip1193 shim}; the SDK's existing
json-rpc-account branch builds the addTransaction tx, deposits fees, waits, and
extracts the GenLayer txId. No genlayer-js changes. closeBrowserSession in
finally; setNextLabel for a friendly wallet prompt.

Tests: createClient wired with {account, provider} and getAccount never called
in browser mode; command parsing. Docs regenerated.
…ane B)

Add --wallet browser to 'transactions appeal', 'finalize', and 'finalize-batch'.
These already funnel through BaseAction.getClient, so browser mode is the same
Lane B client construction + closeBrowserSession in finally; the SDK's consensus
provider branch does the send. Docs regenerated.
@MuncleUscles

Copy link
Copy Markdown
Member Author

/run-e2e

Foundations for the persistent connect-once browser-wallet session:

- sessionConstants.ts: single source of truth for heartbeat/TTL/timeout
  budgets (LONG_POLL, HEARTBEAT_DEAD, TAB_DEAD_GRACE, IDLE_TTL,
  DAEMON_READY, CONNECT/TX timeouts) plus descriptor/log filenames and
  the walletMode/walletSessionTtlMinutes config keys.
- sessionDescriptor.ts: on-disk descriptor (~/.genlayer/wallet-session.json)
  written atomically at 0600; read+schema-validated (bad JSON/version -> null);
  isPidAlive() cheap first-gate (signal 0, EPERM = alive).

Descriptor unit tests: atomic 0600 write, round-trip, garbage/version
rejection, idempotent remove, pid-alive.
Add an opt-in persistent mode so one long-lived bridge can broker txs for
many CLI processes:

- New client routes (token-authed): GET /api/ping, GET /api/state,
  POST /api/enqueue, GET /api/tx?id=, POST /api/shutdown. Page routes
  (/api/connected, /api/result) keep the strict Origin check; client routes
  are Origin-exempt (a Node client sends no Origin; token-in-header forces a
  CORS preflight the server never satisfies).
- Result store so remote HTTP callers can poll a tx to done/sent/rejected/error
  (GC 10min after completion); enqueue returns 409 wallet-not-connected/tab-closed
  for fail-fast.
- Heartbeat: lastPagePollAt stamped on every /api/next; getState() exposes it.
- onConnected / onActivity / onShutdown callbacks for the daemon.
- Shared serializeBridgeTx/parseBridgeTx pair (client and server can't drift);
  serializeTx now reuses it. LONG_POLL_MS sourced from sessionConstants.

Existing bridge tests unchanged; +11 persistent-mode tests (auth, enqueue
round-trip, rejected/error, Origin exemption vs page 403, heartbeat, FIFO,
shutdown, 404 on non-persistent).
Separate "how a tx reaches the wallet" from the shared signing lanes so a
command can run over either an in-process bridge or a remote daemon:

- BridgeTransport seam; buildBrowserSession(transport, ...) holds the shared
  preflight + receipt-wait (Lane A), EIP-1193 shim (Lane B), and labels
  verbatim. BrowserSession gains kind/sessionUrl; bridge is now optional
  (present only for local sessions).
- openBrowserWalletSession re-based on a LocalBridgeTransport (existing tests
  pass unchanged). New openRemoteWalletSession wraps a RemoteSessionTransport
  whose close() is a no-op so one failed preflight never kills a shared session.
- WalletSessionClient: fetch-only client for a running daemon (ping/state/
  enqueueTx/waitForTxResult/waitForConnection/shutdown), token on every call,
  fail-fast on stale page heartbeat, 409 -> clear reconnect messages.

sessionClient tests drive a real in-process persistent bridge with a
fetch-simulated page (openUrl mocked); browserSend tests unchanged.
- sessionDaemon.ts: runWalletSessionDaemon owns the bridge + tab + descriptor
  lifecycle. Writes the descriptor only after listen succeeds; rewrites address
  onConnected; bumps lastUsed (throttled) onActivity. Self-terminates on idle
  TTL, tab-dead heartbeat loss, connect timeout, signal, or fatal error, and on
  /api/shutdown -- ALWAYS removing the descriptor first. Deterministic shutdown:
  the bridge's onShutdown fires an ordered teardown (remove descriptor -> close
  bridge -> exit); the keep-alive timer is NOT unref'd, so the daemon only ever
  exits through cleanupAndExit (never by the loop draining after the socket
  closes) -> the "daemon gone => descriptor removed" invariant always holds.
- spawnDaemon.ts: spawnWalletDaemon re-execs the bundled CLI (process.execPath +
  argv[1] + "wallet daemon"), detached + unref, stdio -> 0600 logfile; NO token
  on argv. waitForDaemonReady polls descriptor+pid+/api/ping, surfaces the log
  tail on timeout.
- sessionResolver.ts: resolveBrowserWalletSession -- discover live session (pid +
  ping), stale-cleanup, chain-mismatch hard error, and fallback
  (auto-start-and-persist default | own-bridge | error), degrading auto-start to
  own-bridge if spawn/ready fails.

In-process/mocked tests only (injected spawnFn, mocked openUrl, no real daemon
or browser): daemon descriptor/onConnected/idle/tab-dead/connect-timeout/
singleton/shutdown-cleanup; spawn argv+unref and ready/timeout; resolver
live/stale/mismatch/auto-start-degrade/error matrix.
…mon)

New `genlayer wallet` command group (registered in src/index.ts):

- connect [--network --rpc]: reuse a matching live session, or spawn the
  detached daemon, print the bridge URL + SSH port-forward hint, and wait for
  the wallet to connect. Different chain -> explicit shutdown + switch.
- status: address/network/chainId/port/URL/age/idle/heartbeat/queue; exit 0
  only when live+connected (scriptable).
- disconnect: /api/shutdown -> wait for exit (SIGTERM fallback) -> remove
  descriptor (idempotent).
- daemon (hidden): detached-process entry point -> runWalletSessionDaemon.

wallet command tests dispatch connect/status/disconnect/daemon and assert
daemon is help-hidden; index test mocks the new initializer.
Make browser mode reusable across commands and configurable as the default:

- walletMode config: BaseAction.resolveWalletMode centralises precedence
  (--wallet flag > walletMode config > keystore); invalid flag throws, unknown
  config value warns + keystore. walletOption drops the hardcoded commander
  "keystore" default so an omitted flag is distinguishable from an explicit one
  (and can defer to config). write/deploy/appeal/finalize switch their direct
  `wallet === "browser"` checks to isBrowserWallet(...).
- Adoption: BaseAction.getBrowserSession and StakingAction.getBrowserWalletSession
  now go through resolveBrowserWalletSession (fallback auto-start for writes,
  own-bridge for the wizard). Per-command finally blocks are untouched
  (closeBrowserSession/session.close() are no-ops for remote sessions, so a
  shared daemon survives); validatorJoin and the wizard switch
  session.bridge.close() -> session.close().

walletSession tests cover resolveWalletMode matrix; command/action tests updated
for the no-commander-default behaviour and session.close() (remote sessions
survive finally; wizard/validator-join assert session.close, not bridge.close).
- tests/setup.ts (wired via vitest.config setupFiles): globally mocks the `open`
  package so no automated test can ever launch a real browser or orphan a tab.
  Every bridge/daemon test already injects a mocked openUrl; this is the
  belt-and-suspenders guarantee. system.test.ts keeps its own file-level
  vi.mock("open") which takes precedence there.
- Regenerated api-references: new wallet connect/status/disconnect pages (hidden
  `daemon` excluded) and the updated --wallet help text/default across all write
  commands.
resolveWalletMode gains a session rung: with no --wallet flag and no
walletMode config, a live wallet session (descriptor present + daemon pid
alive) now resolves to browser mode, so `wallet connect` alone is enough to
route subsequent commands through the bridge. Explicit --wallet keystore or
walletMode=keystore still overrides a live session.

Add hasLiveWalletSession() (sync, never-throws descriptor+pid probe) and make
the test suite hermetic: tests/setup.ts redirects os.homedir() to a throwaway
per-worker temp dir so the descriptor read never sees the developer's real
~/.genlayer/wallet-session.json (which would otherwise flip commands into
browser mode and break otherwise-hermetic tests).
…ssion

Step 2 network picker now appends custom networks (from `genlayer network
add`) alongside the built-ins, and the post-selection echo resolves through
both maps so a custom alias no longer crashes on BUILT_IN_NETWORKS lookup.

Step 1 owner detection switches from `options.wallet === "browser"` to
resolveWalletMode(options.wallet) === "browser", so the wizard auto-selects
the browser-wallet owner for --wallet browser, walletMode=browser config, or a
live wallet session — consistent with every other command. The keystore path
and interactive "Connect browser wallet" menu item are unchanged.
account show printed `network.name`, which for a custom active network is the
inherited base chain name (e.g. "Genlayer Localnet") — a misleading label.
Print the active network alias instead (--network flag > config network key >
localnet) and add a chainId field so a custom network is unambiguous.

Add a --network flag mirroring `account send`, so both the balance query and
the printed alias/chainId reflect the chosen network. New unit test covers a
custom active network (alias + real chainId, not the base name) and the
--network override.
Add a funding-source step after network selection: keep 'Your wallet'
as the default (original flow unchanged) or fund the self-stake from a
vesting contract. For the vesting source the wizard resolves the
beneficiary's vesting contract (none -> warn + loop back; one -> use it;
many -> pick), checks the contract's available-to-stake (totalAmount -
totalWithdrawn) against the minimum stake while keeping a wallet gas
sanity warning, and builds+sends vestingValidatorJoin instead of
validatorJoin for both keystore (client.vestingValidatorJoin) and browser
(buildTx + shared session.sendTransaction) owners. Identity setup is
skipped for vesting-backed validators with a pointer to
'genlayer vesting validator set-identity'; the summary reflects the
funding source and notes funds return to the vesting contract on exit.
…over

Browser-mode commands (notably `staking wizard`) only discovered a
closed wallet tab at the final sign step, after every wizard step was
filled in. resolveBrowserWalletSession now checks the page-heartbeat
freshness in its live-session branch and throws the reconnect message
at acquire time, so the wizard fails at the balance-check step instead
of the sign step. lastPagePollAt === 0 (never polled) stays fresh, and
a just-connected page is re-read so it is not misflagged.

`wallet connect` reused a daemon that was pid-alive + pinging +
state.connected but whose tab was dead, printing "Already connected"
and returning — leaving the user stuck. The reuse branch now tears the
stale daemon down (reusing the chain-switch teardown) and starts a
fresh session; the healthy path is unchanged.

TAB_CLOSED_MESSAGE is centralized in sessionConstants and shared by the
client and the resolver.
@MuncleUscles MuncleUscles merged commit ab128c0 into v0.40-dev Jul 9, 2026
7 of 10 checks passed
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.

1 participant