Skip to content

darepod: persist wasm seed in OPFS instead of localStorage - #811

Merged
Roasbeef merged 3 commits into
walletdk-passkey-verbfrom
walletdk-wasm-seed-opfs
Jun 29, 2026
Merged

darepod: persist wasm seed in OPFS instead of localStorage#811
Roasbeef merged 3 commits into
walletdk-passkey-verbfrom
walletdk-wasm-seed-opfs

Conversation

@jamaljsr

Copy link
Copy Markdown
Member

Summary

The wasm daemon persists the encrypted wallet seed to browser localStorage. But localStorage is a Window-only API that doesn't exist in Web Workers, so when the daemon runs off the main thread, wallet create/unlock fails with browser localStorage is unavailable. That blocks running the runtime in a Worker, which is what we want so host UIs stay responsive during boot and heavy operations. Everything else, including the OPFS-backed SQLite stack, already works in a Worker.

This rewrites the js+wasm seed storage to use the origin-private file system (OPFS) instead. OPFS is reachable from both the window and worker globals, so the same code path works in either context.

Changes

  • Rewrite SaveEncryptedSeed / LoadEncryptedSeed / SeedFileExists in darepod/seed_storage_wasm.go to read, write, and stat a single OPFS file, the async-OPFS analog of the native file backend.
  • Reuse the existing SeedFilePath string as the OPFS path, walking it as nested directory handles so the seed lands alongside the daemon's other OPFS data.
  • Block the calling goroutine on the OPFS promises to keep the functions' synchronous signatures. This is safe because each verb runs on its own goroutine (same await-a-JS-promise pattern the wasmsqlite driver already uses: it blocks the goroutine, never the JS event loop).
  • Call sites and the native (!js) backend are untouched.

Testing

  • Verify wallet create / unlock / reload-and-persist succeed with the daemon running in a dedicated Web Worker.
  • Confirm the main-thread path still works.

@jamaljsr jamaljsr self-assigned this Jun 28, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the WebAssembly encrypted seed storage from browser localStorage to the Origin-Private File System (OPFS), enabling the daemon to run inside a Web Worker. It introduces several helper functions to handle the asynchronous JS OPFS API synchronously in Go. Feedback on the changes highlights a potential file lock leak in SaveEncryptedSeed where the writable stream is not closed if the write operation fails; a suggestion is provided to ensure close() is always called.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread darepod/seed_storage_wasm.go Outdated
localStorage is a Window-only API that does not exist in Web Workers, so
the wasm daemon could not initialize a wallet when run off the main
thread; it failed with "browser localStorage is unavailable". That
blocked running the runtime in a Worker to keep host UIs responsive.

Rewrite the js+wasm seed storage to read, write, and stat a single OPFS
file (the analog of the native file backend) via the async OPFS API,
which is available on both the window and worker globals. The functions
block the calling goroutine on the OPFS promises to preserve their
synchronous signatures, which is safe because each verb runs on its own
goroutine. Call sites and the native backend are unchanged.
@jamaljsr
jamaljsr force-pushed the walletdk-wasm-seed-opfs branch from 5c99ae0 to 7badbc9 Compare June 28, 2026 23:02
@Roasbeef

Copy link
Copy Markdown
Member

@claude review this

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 2m 4s —— View job


Review: darepod: persist wasm seed in OPFS instead of localStorage

  • Read the changed file
  • Compare against native backend & call sites
  • Analyze correctness, concurrency, error handling
  • Post review

Solid change. The OPFS rewrite faithfully mirrors the !js backend, the helper decomposition (opfsRootDir / splitSeedPath / opfsSeedDir / openSeedFile) reads cleanly, and Gemini's writable-lock-leak fix is correctly applied (lines 167–175: close() always runs, write error takes precedence). I verified the concurrency-safety claim against cmd/walletdk-wasm/main.gopromise() runs each verb on a fresh goroutine (go func() at main.go:222), so blocking on <-ch in awaitJSPromise blocks that goroutine, not the JS event loop. Call sites (server.go:1537/1579/1728/1770, wallet_ops.go:78/102) all reach these through the verb path, so the invariant holds. 👍

A few things worth considering:

1. SeedFileExists treats any error as "no wallet" — overwrite risk (medium)

opfsSeedDir(path, false)getFileHandle rejects with a NotFoundError when the seed is genuinely absent, but also rejects on transient/structural OPFS errors (and opfsRootDir returns an error when OPFS itself is unavailable). SeedFileExists collapses all of these to return err == nilfalse:

func SeedFileExists(networkDir string) bool {
	_, err := openSeedFile(SeedFilePath(networkDir))
	return err == nil
}

At server.go:1537/1728, a false here drives the daemon down the "No wallet seed found, awaiting InitWallet RPC" path. If a user then runs InitWallet, SaveEncryptedSeed opens the file handle with create: true and createWritable() truncates — so a transient read error at the wrong moment could lead to clobbering an existing encrypted seed. The native os.Stat path has the same theoretical shape, but DOMException failure modes in OPFS are broader than local stat.

Consider distinguishing genuine absence from other failures, e.g. only return false when the rejection name is NotFoundError, and surface/log anything else so a broken-storage state isn't silently read as "fresh wallet." The rejection name is available on the JS side (e.name); awaitJSPromise currently flattens it to a toString() string, so capturing the name would need a small tweak there.

Fix this →

2. Doc comment vs. actual OPFS layout (nit)

The file header and SaveEncryptedSeed doc say the seed lands "alongside the daemon's other OPFS data." Per cmd/walletdk-wasm/main.go:267-272, the OPFS-backed SQLite stack keys files by hashed flat names at the OPFS root, whereas this code walks nested directory handles (/darepo/<net>/wallet_seed.enc). So the seed isn't actually co-located with the SQLite data. Harmless, but the comment slightly oversells it — worth softening to avoid future confusion.

3. awaitJSPromise assumes a thenable (nit / defensive)

p.Call("then", ...) will panic if p is ever not a thenable. Every current caller passes a real promise, so this is fine today; just flagging it as an implicit precondition worth a one-line comment, since the helper is now the shared chokepoint for all OPFS access.

4. Test coverage

seed_manager_test.go exercises only the native backend (t.TempDir()), which is expected since OPFS needs a browser. The PR's manual test plan (create / unlock / reload in a Worker + main-thread) covers the behavior. I couldn't run GOOS=js GOARCH=wasm go build ./darepod/ in this environment (sandbox denied), so please confirm the wasm target compiles locally / in CI before merge.


Overwrite safety in #1 is the only item I'd treat as more than a nit; #2#4 are optional polish. Nothing here blocks the core goal of getting seed storage working inside a Web Worker.
walletdk-wasm-seed-opfs

Roasbeef added 2 commits June 28, 2026 16:49
SeedFileExists previously returned err == nil, collapsing every OPFS
failure into "no seed". A non-NotFound rejection (transient I/O, quota,
or a handle locked by a concurrent writer) would then make the daemon
enter WalletStateNone, where a subsequent InitWallet passes the
None->Locked CompareAndSwap guard and SaveEncryptedSeed overwrites a
seed that may in fact still exist, losing funds.

Preserve the DOMException name on rejected OPFS promises so callers can
tell a genuinely-absent file (NotFoundError) and an unavailable OPFS
API apart from an ambiguous runtime error. SeedFileExists now reports
absence only for those two confirmed cases and otherwise reports the
seed as present, keeping the daemon out of the clobber path.
The seed file is walked as a nested OPFS directory path, but the
daemon's SQLite data is stored under hashed flat names at the OPFS
root, so the two are not co-located. Reword the package comment,
which claimed the seed lands "alongside the daemon's other OPFS
data", to describe the actual layout. Also note that awaitJSPromise
requires a thenable argument, since it is now the shared chokepoint
for all OPFS access.
@Roasbeef
Roasbeef merged commit c8c1feb into walletdk-passkey-verb Jun 29, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants