Sui wallet connect + zkLogin sign-in to replace the Ed25519 delegate-key signup wall #17
Description
Context
There is no real signup today: the account surrogate is pasting a 64-char hex Ed25519 MemWal delegate seed via Auth1 → importDelegate (apps/web/src/main.tsx:1043), stored in localStorage. The README says "no wallet connect," and apps/web/package.json has no Sui/wallet deps (only three); the root has @mysten/sui@^2.9.1. All /app/* content is hard-gated behind hasMemWalDelegate (renderShell, main.tsx:1510), so a first-timer cannot proceed without a raw private key.
Goal / user story
As a new user, I sign in with my Sui wallet (or keyless zkLogin via Google) instead of pasting a private key, my Sui address becomes my identity, and the app unlocks — with the delegate-paste flow demoted to an advanced "bring-your-own MemWal" option.
Acceptance criteria
- A "Connect wallet" entry point (built on
@mysten/dapp-kitConnectButton) lists installed Sui wallets and connects. - On connect, the wallet address becomes the hosted identity (replaces the
'anonymous'owner_iddefault) and unlocks/app/*without a pasted delegate. - A zkLogin / Google keyless option is available for users without a wallet extension.
- The Worker verifies a signed nonce (
useSignPersonalMessage) before issuing a session token — address ownership is proven, not asserted. - The Ed25519 delegate import (
Auth1/importDelegate) is moved behind an "Advanced / BYO MemWal" affordance, not the default gate. - Disconnect clears the session and returns the user to the public/landing state.
Implementation notes
- Add to
apps/web/package.json:@mysten/dapp-kit,@mysten/sui(root is already^2.9.1),@tanstack/react-query; wrap the app inSuiClientProvider+WalletProvider+QueryClientProvider. UseuseCurrentAccount,useSignPersonalMessage. - Backend: add
POST /api/auth/walletinapps/api/src/worker.ts— issue a nonce, verify the signed message, mapaddress → owner_id, return a session token in the existingme/session shape (mirrorimportDelegate's response handling atmain.tsx:1100). - zkLogin: use Enoki (
@mysten/enokiEnokiFlow) + Google OAuth; needs anENOKI_API_KEYworker secret. Enoki sponsorship means no SUI balance is required. - A wallet/zkLogin user still needs a MemWal delegate to
remember/recall; decide whether the Worker mints/holds a managed delegate keyed to the address, or keeps BYO for now (this is the open account-model decision). Keep BYO working as a fallback this milestone. - Gotcha: dapp-kit adds bundle weight — lazy-load the wallet provider on
/approutes so the landing/share pages don't ship it.
Sui Overflow angle
This is the headline Sui-native onboarding for the hackathon: real Sui wallet + keyless zkLogin sign-in, with the Sui address as on-chain identity. It also sets up address-owned namespaces and attribution receipts (the address becomes the owner/producer in the Move registry/receipt). Judges expect wallet connect; today's private-key paste is a non-starter for a Sui demo.
Dependencies
Account-model decision (delegate-as-identity vs real accounts + managed delegate). Pairs with the first-run onboarding issue. Downstream on-chain registry/receipt work uses this address as owner.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Add a real Sui identity layer in front of the existing delegate flow. Wrap the React app in
@mysten/dapp-kitproviders, register Enoki zkLogin wallets so Google keyless and browser-extension wallets share oneConnectButton. On connect, the client signs a server-issued nonce withuseSignPersonalMessage; a newPOST /api/auth/walletin the Worker verifies the signature with@mysten/sui/verify, derives the Sui address, and returns a stateless HMAC session token that the Worker accepts on subsequent requests as the owner identity (owner_id = <0x address>). The hardhasMemWalDelegategate (main.tsx:1510) is split into an identity gate (wallet/zkLogin OR delegate unlocks/app/*) and a delegate-only action gate (remember/recall to Walrus Memory stays BYO this milestone). The Ed25519 delegate-paste form is demoted to an "Advanced / BYO MemWal" disclosure. Key design decision: introduce a Worker session token (HMAC, no new D1 table/KV) because hosted auth today is header-only and has no session concept — this is the new, load-bearing piece.Prerequisites & dependencies
SuiClientProvidernetwork and the Worker's verificationSuiClientmust match. zkLogin signature verification is epoch/JWK-sensitive, so client and server must point at the same network.apps/web/package.json:@mysten/dapp-kit,@mysten/sui(pin to root^2.9.1),@mysten/enoki,@tanstack/react-query. (vite.config.ts:23-28already hasmanualChunksfor@mysten/and@tanstack/— chunking is pre-wired.)WorkerEnvatworker.ts:32-55+apps/api/cloudflare/wrangler.jsonc):CONTEXTMEM_SESSION_SECRET(secret) — HMAC key for nonce + session tokens.SUI_NETWORK(var, e.g."testnet") and optionalSUI_RPC_URLfor the verification client.ENOKI_API_KEY(Enoki public key is used client-side; if a server-side Enoki call is added later it's a secret — not needed for this milestone).VITE_*):VITE_ENOKI_API_KEY,VITE_GOOGLE_CLIENT_ID,VITE_SUI_NETWORK. Google OAuth client ID must be created in Google Cloud console with the app origin + redirect URL allow-listed; the Enoki dashboard must register that Google client.Step-by-step
Add deps + a lazy provider wrapper (client). Add the four deps to
apps/web/package.json(bun install). Createapps/web/src/lib/sui-providers.tsxexporting aSuiAppProviderscomponent that composesQueryClientProvider→SuiClientProvider(networks viacreateNetworkConfig, default fromVITE_SUI_NETWORK) →WalletProvider autoConnect. In a module-init effect callregisterEnokiWallets({ apiKey: VITE_ENOKI_API_KEY, providers: { google: { clientId: VITE_GOOGLE_CLIENT_ID, redirectUrl: location.origin } }, client: suiClient, network })so the Google zkLogin wallet appears inConnectButtonalongside extensions (satisfies the zkLogin AC with the same sign flow). Import@mysten/dapp-kit/dist/index.css.Lazy-load providers only on
/app/*(bundle hygiene). Inmain.tsx, keepApp()(main.tsx:815-821) asBrowserRouteronly. Wrap the/app/*routes — not landing/share/showcase — in<React.lazy>-loadedSuiAppProviders. Concretely, factor the/app/*<Route>s (main.tsx:1548-1603) into anAppRouteselement rendered inside aSuspense+ lazySuiAppProviders. Landing (/),/share/:shareId,/showcasestay outside so they never ship the Sui/dapp-kit chunk (addresses the implementation-note gotcha).Build the Worker auth endpoints. In
worker.tsroute dispatcher (add next tomain.tsx's/api/meatworker.ts:592and the/api/memwal/*routes ~worker.ts:637):GET /api/auth/nonce→ returns{ nonce, expiresAt }. Implement stateless:nonce = base64url(randomId|exp) + "." + base64url(HMAC-SHA256(CONTEXTMEM_SESSION_SECRET, randomId|exp)), TTL ~2 min. Uses WebCryptocrypto.subtle(available in Worker, no nodejs_compat needed).POST /api/auth/wallet→ body{ address, signature, nonce }. Validate the nonce HMAC + expiry; reconstruct the exact signed message string (e.g.ContextMEM sign-in\naddress: <addr>\nnonce: <nonce>), thenawait verifyPersonalMessageSignature(new TextEncoder().encode(message), signature, { address, client })from@mysten/sui/verify. ComparerecoveredPublicKey.toSuiAddress()(or the{address}assertion the verifier enforces) to the claimedaddress. On success issue a session token (next step) and return{ token, me }mirroringimportDelegate's response handling atmain.tsx:1100-1106("token" in result).verifyPersonalMessageSignatureneeds the networkclient(epoch/JWK) — pass the sameSuiClient/network as the client used. Flag in code that network must match.Stateless session token + Worker identity resolution. Add helpers in
worker.tsnearreadHostedRunAuth(worker.ts:1461):issueWalletSession(address)→ HMAC tokenbase64url({sub:address, owner:address, iat, exp(~7d)}) + "." + HMAC.readHostedRunAuth(worker.ts:1461-1475) into a unifiedresolveHostedAuth(request, env)that returns{ ownerId, accountId, kind: 'wallet'|'delegate', authorization? }. Source order: (a)Authorization: Bearer <session-token>verified by HMAC →{ ownerId: address, accountId: address, kind:'wallet' }; (b) existingx-memwal-account-id+x-memwal-authorizationdelegate headers →{ ownerId: hostedOwnerId(accountId), kind:'delegate', authorization }.hostedOwnerIdstays for delegate users (worker.ts:1512); walletowner_idis the raw0xaddress (the future on-chainowner).getHostedMe(worker.ts:1192-1207) andhostedMe(worker.ts:1490-1510) to reportprovider: 'wallet' | 'zklogin' | 'unknown',ownerAddress: address, and a newhasDelegateKeythat istrueonly for delegate sessions (wallet sessions gethasDelegateKey:false,authenticated:true).Relax write paths that only need an owner.
createHostedRun(worker.ts:1229-1230),listHostedRuns(worker.ts:1209-1210), namespace CRUD, and run-read useresolveHostedAuthand accept a wallet session forowner_id(so building into an address-owned private namespace works without a delegate). Keep a stricterrequireDelegate(request)guard ONLY on the MemWal remember/recall sync path (thex-memwal-authorization-dependent calls), returning the existingHOSTED_DELEGATE_REQUIREDerror with copy pointing to the Advanced/BYO affordance. This satisfies "unlocks/app/*without a pasted delegate" while keeping remember BYO.Client: connect → nonce → sign → session. In
main.tsxaddasync function connectWallet()nearimportDelegate(main.tsx:1043): readuseCurrentAccount()address,GET /api/auth/nonce, build the message,signPersonalMessage({ message: bytes })viauseSignPersonalMessage,POST /api/auth/wallet, then on{token}dosafeLocalSet("contextmem.session", token)+setSessionToken(token)+setMe(result.me)(reuse the exact pattern atmain.tsx:1102-1106). Because dapp-kit hooks must run inside the providers, place the connect UI inside the lazily-wrapped/appsubtree (or a smallWalletConnectGatecomponent rendered there).Send the session token to the Worker. Today hosted requests use
hostedDelegateHeaders()(main.tsx:7971-7979) and the local path usesauthHeaders(token). Make hosted requests sendAuthorization: Bearer <sessionToken>when a wallet session exists, falling back to delegate headers. UpdateloadMe(main.tsx:969-993): in hosted mode, ifsessionTokenis set, callGET /api/mewith the Bearer token (currently hostedloadMeshort-circuits to localStorage atmain.tsx:970-977— add a token branch first); else fall back toreadHostedDelegate()/hostedBrowserMe.Split the gate + demote the delegate form (client). Replace
hasMemWalDelegate(main.tsx:864) usage at the unlock points with a newhasIdentity = me.authenticated(wallet/zkLogin OR delegate) for:renderShellgate (main.tsx:1510),AppShell/landing locked styling,/app/settingsaccess. Keep a separatehasMemWalDelegatestrictly for remember/recall action buttons and thestartRunMemWal preconditions (main.tsx:1153-1157) where a delegate is genuinely required — or relaxstartRunto allow build-only and gate just the remember step. InLockedPreview/HostedCredentialGate(main.tsx:4576,4522) andSettingsAppPage(main.tsx:1584), makeConnectButton+ "Sign in with Google (zkLogin)" the primary affordance and move the existingAuth1/delegate paste fields (delegateAccountId/delegateKey/onImport) behind a collapsed<details>"Advanced — bring your own MemWal delegate".Disconnect clears session. Extend
logout()(main.tsx:995-1004) to also call dapp-kituseDisconnectWallet().mutate()and clearcontextmem.session; updatesessionSlot(main.tsx:1433-1438) to show the connected address and a Disconnect action returning to the public/landing state.New / changed files
apps/web/src/lib/sui-providers.tsx(providers + Enoki registration).apps/web/src/components/WalletConnectGate.tsx(ConnectButton + zkLogin + nonce-sign flow).apps/web/package.json(4 deps).apps/web/src/main.tsx(lazy provider wrap on/app/*;connectWallet;loadMe/logout/sessionSlot; gate splithasIdentityvshasMemWalDelegate; demote delegate form inLockedPreview/HostedCredentialGate/SettingsAppPage).apps/api/src/worker.ts(WorkerEnvfields;GET /api/auth/nonce;POST /api/auth/wallet;issueWalletSession/resolveHostedAuthrefactor ofreadHostedRunAuth;getHostedMe/hostedMeprovider fields; relax owner-only write paths +requireDelegatefor remember).apps/api/cloudflare/wrangler.jsonc(+wrangler.example.jsonc):SUI_NETWORKvar; documentCONTEXTMEM_SESSION_SECRETsecret..env.example(VITE_ENOKI_API_KEY, VITE_GOOGLE_CLIENT_ID, VITE_SUI_NETWORK, CONTEXTMEM_SESSION_SECRET).Test plan
apps/api): nonce issue/verify round-trip (valid, expired, tampered-HMAC); session token issue→resolveHostedAuthround-trip;POST /api/auth/walletrejects a signature for a different address. MockverifyPersonalMessageSignaturefor the happy path and add one real-signature fixture (sign withEd25519Keypairfrom@mysten/sui, verify end-to-end) — this is the assert that ownership is proven./app/*unlocks with no delegate;GET /api/mereturnsauthenticated:true,provider:'wallet',ownerAddress= your0x....owner_idin D1 equals the wallet address (run awrangler d1 execute ... "SELECT owner_id FROM contextmem_extraction_jobs ORDER BY created_at DESC LIMIT 1")./app/*re-locked.sui/querychunks (inspectvite buildoutput / network tab).HOSTED_DELEGATE_REQUIREDand the Advanced/BYO form still works (paste delegate → remember succeeds).Risks & gotchas
contextmem_auth_noncestable as the hardened follow-up. Document this in SECURITY tradeoffs.SuiClientProvidernetwork, Enoki-registered network, and the Worker verificationSuiClientmust all match (SUI_NETWORK). A mainnet/testnet split silently fails verification. zkLogin sigs may need the current epoch JWKs via theclientarg ofverifyPersonalMessageSignature.@mysten/sui/verify(BLS/zk) runs undernodejs_compat(already set,wrangler.jsonc:17) but watch the compressed bundle cap; if zkLogin verify is too heavy, fall back to verifying via the network RPC. (Unrelated to the separate@mysten/sealWorker risk in issue Spike: run @mysten/seal encrypt+decrypt inside a Cloudflare Worker #5.)useCurrentAccount/useSignPersonalMessagewill throw if the connect UI renders outside the lazily-wrapped/appsubtree; keep connect UI inside it.owner_id = 0xaddress(nohosted:/demo:prefix). Existinghosted:/demo:rows are untouched (no migration), but a delegate user and a wallet user are distinct identities even for the same person this milestone (acceptable; managed-delegate linking is the follow-up issue).window; fine for this SPA (no SSR) but the import must be client-only (it is, inside the lazy chunk).Acceptance-criteria mapping
ConnectButton" → Steps 1, 8.'anonymous') and unlocks/app/*without a delegate" → Steps 4, 5, 7, 8.registerEnokiWallets), surfaced in Step 8.Effort
L — ~4-6 person-days. ~1.5d Worker (nonce/session/verify + auth-source refactor across owner-gated paths), ~2.5d web (providers + lazy split + gate refactor across a 8.2k-line
main.tsx+ form demotion), ~1d Enoki/Google OAuth + testnet wiring and end-to-end testing.Implementation plan generated for execution (planning phase). Part of the roadmap (#4).