feat(staking): browser-wallet signing for validator-join and wizard#367
Merged
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.