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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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.

RouteMethodAuthPurpose
/GETnoneserves the inline HTML page
/api/sessionGETtokenchain params incl. chainIdHex (for switch/add chain)
/api/connectedPOSTtoken + Originresolves waitForConnection()
/api/nextGETtokenlong-poll (25s) → `{type:"tx"
/api/resultPOSTtoken + Originresolves/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

coderabbitaiBot 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
MemberAuthor

/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-devJul 9, 2026
7 of 10 checks passed
Sign up for freeto 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

@MuncleUscles