Uh oh!
There was an error while loading. Please reload this page.
feat(staking): browser-wallet signing for validator-join and wizard - #367
Conversation
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.Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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
commented
Jul 8, 2026
/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.
Uh oh!
There was an error while loading. Please reload this page.
What & why
Adds browser-wallet (MetaMask) signing for
staking validator-joinand thestaking 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) requiresaccount.signTransaction— i.e. it signs locally thensendRawTransactions. MetaMask has no workingeth_signTransaction; it only supportseth_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:
encodeFunctionData+abi.STAKING_ABI/abi.VALIDATOR_WALLET_ABI, both already exported by genlayer-js).node:httpon127.0.0.1:0)eth_sendTransactions it via the injected wallet and posts back the tx hash.publicClient(reusing the existingglHttpConfigid≠0 quirk) and decodes theValidatorJoinevent for the validator wallet address.No genlayer-js changes required. The ~30 lines of encode/decode duplicated from genlayer-js
staking/actions.tsare 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 asX-Bridge-Tokenon every/api/*call.//api/sessionchainIdHex(for switch/add chain)/api/connectedwaitForConnection()/api/next/api/resultsendTransaction()Security: binds
127.0.0.1only; token required on all API routes (403 otherwise);Originchecked on POSTs (anti DNS-rebinding);Cache-Control: no-store; single session; SIGINT handler closes the server; always closed in afinally.Scope (bounded)
window.ethereumonly — no WalletConnect / Ledger.validator-join+wizardonly. 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.getBrowserWalletSessionseam + onebuild*Txhelper each — the seam is ready but not wired.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 browserhard-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 bothvalidatorJoinoverloads against the real ABI,setIdentityencoding incl. hex vs UTF-8extraCid,ValidatorJoindecode + "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).openUrlstubbed.tests/actions/staking.test.ts(+4) —validator-join --wallet browsernever touches keystore/getStakingClient, output shape matches keystore mode, bridge closed on success and failure,--password/--accountrejected.tests/commands/staking.test.ts(+4) —--walletparsed on both commands, defaults tokeystore.tests/actions/stakingWizard.test.ts(new, 2) — browser-owner routes join through the bridge, operator keystore export still runs, keystore path untouched;--account + --wallet browserrejected up-front.npx vitest run: 611 passed / 55 files.npm run build(esbuild) green. Prettier clean.Manual QA (real MetaMask — not automatable in CI)
genlayer staking validator-join --wallet browser --amount 1gen --network testnet-bradburyon a fresh browser profile: add-chain prompt (4221 / GEN), approve, hash returns, receipt awaited,validatorWalletprinted.staking wizard --wallet browseron localnet (MetaMask pointed at the local RPC): browser owner, operator keystore export unchanged, join + identity both signed in the same tab, summary correct.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,gasPriceis already plumbed throughBridgeTxRequest(fetch viapublicClient.getGasPrice()and pass hex). Sped-up/replaced txs can't be tracked (only the original hash is held) → timeout + explorer hint, documented.