WalletKit
WalletKit lets apps hold World ID credentials and prove things about their holder with World ID. It is part of the World ID SDK.
WalletKit is written in Rust. Apps use it in one of these forms:
- Rust: the
walletkitcrate. - iOS: Swift bindings, generated with UniFFI.
- Android: Kotlin bindings, generated with UniFFI.
- Browser: the
@worldcoin/walletkit-webnpm package, which runs WalletKit as WebAssembly in a Web Worker.
All forms share the same core, walletkit-core, and expose the same objects, such
as Authenticator and CredentialStore. The browser package omits a few of them;
see Browser limitations.
Find what you need
- To add WalletKit to an app, see Install WalletKit.
- To learn how an account is registered and how credentials are stored, see Accounts and credentials.
- To answer a relying party’s proof request, see Generate a proof.
- To change WalletKit, see Develop WalletKit.
- To publish a release, see Release WalletKit.
- For the API reference of the Rust crates, see
walletkiton docs.rs.
To preview this book locally, run the following command from the repository root and open the URL that it prints:
nix develop .#docs --command mdbook serve
Install WalletKit
WalletKit is published for Rust, iOS, Android, and the browser. Choose the section for your platform.
Rust
WalletKit is published on crates.io as walletkit. To add it
to a Rust project with the proving keys embedded, run the following command:
cargo add walletkit --features embed-zkeys
The walletkit crate has these Cargo features:
| Feature | Default | Effect |
|---|---|---|
issuers | Yes | Clients for credential issuers, such as TfhNfcIssuer and RecoveryBindingManager. |
embed-zkeys | No | Embeds the proving keys in the binary. EmbeddedZkArtifacts and CachingZkArtifacts need it, so enable it to generate proofs. |
compress-zkeys | No | Compresses the embedded keys, which roughly halves the bundle size but makes decompression expensive. CachingZkArtifacts caches the result on disk. |
v3 | No | The legacy World ID 3.0 API, in the v3 module. |
semaphore | No | Semaphore proofs for the legacy API. v3 enables it. |
The Swift and Kotlin packages are built with embed-zkeys, compress-zkeys, and
v3.
iOS
WalletKit’s Swift package is published from a separate repository,
worldcoin/walletkit-swift, which
holds the prebuilt binaries. It supports iOS 13 and later. To add it to an Xcode
project, do the following:
- In Xcode, click File > Add Package Dependencies.
- Enter
https://github.com/worldcoin/walletkit-swift, choose a version, and click Add Package. - Add the
WalletKitproduct to your app’s target.
Android
WalletKit’s Kotlin bindings are published to GitHub Packages and support Android API level 23 and later. To add them to an Android app, do the following:
-
Add the GitHub Packages repository to your Gradle repositories. GitHub Packages requires credentials with the
read:packagesscope, even for public packages. This example reads them from thegpr.userandgpr.keyGradle properties; set those in your user-level~/.gradle/gradle.propertiesor in CI, and never commit them:repositories { maven { url = uri("https://maven.pkg.github.com/worldcoin/walletkit") credentials { username = providers.gradleProperty("gpr.user").get() password = providers.gradleProperty("gpr.key").get() } } } -
In the app module’s
build.gradle.kts, add the dependency, replacingVERSIONwith a WalletKit version:dependencies { implementation("org.world:walletkit:VERSION") } -
Sync Gradle.
Browser
WalletKit is published on npm as
@worldcoin/walletkit-web. To add it to a web app, run the following
command:
npm install @worldcoin/walletkit-web
For requirements and a walkthrough, see Use WalletKit in the browser.
Accounts and credentials
This page explains how a WalletKit app registers a World ID account, opens it, and stores credentials for later proofs. Method names on this page are the Rust names; the Swift, Kotlin, and browser APIs use the same names in their own casing.
The authenticator seed
An account’s authenticator keys derive from a 32-byte seed. The host app is responsible for the seed:
- Generate it with a cryptographically secure random number generator.
- Store it securely and back it up. WalletKit doesn’t persist the seed.
- Never expose it to third parties.
The credential store
CredentialStore keeps the account’s credentials in an encrypted SQLite vault.
On iOS and Android, the app creates it from platform components that hold the
database key in the device keystore; for the types involved, see the
storage module on docs.rs.
In the browser, the app supplies the key itself; see
Protect the database key.
From registration to stored credential
The following diagram shows the calls between the app, WalletKit, and the World ID services, from registration to a stored credential.
sequenceDiagram
participant App
participant WK as WalletKit
participant Store as CredentialStore
participant OPRF as OPRF Nodes
participant GW as Gateway
participant Chain as WorldIDRegistry (on-chain)
Note over App,Store: 1) Register account on-chain
App->>WK: InitializingAuthenticator.register_with_defaults(seed, ...)
WK->>GW: gateway_register (submit registration request)
GW-->>WK: request_id / InitializingAuthenticator
WK-->>App: InitializingAuthenticator
loop until final state
App->>WK: poll_status()
WK->>GW: gateway_poll(request_id)
GW-->>WK: Queued | Batching | Submitted | Finalized | Failed
WK-->>App: RegistrationStatus
end
GW->>Chain: createAccount(...) transaction
Chain-->>GW: tx finalized
Note over App,Store: 2) Initialize Authenticator after registration
App->>WK: Authenticator.init_with_defaults(seed, ..., artifacts, store)
WK->>Chain: rpc_init (fetch account data by on-chain key)
Chain-->>WK: packed_account_data + leaf_index context
WK-->>App: Authenticator
App->>WK: init_storage(now)
WK->>Store: init(leaf_index, now)
Store-->>WK: ok
Note over App,Store: 3) Derive credential material
App->>WK: generate_credential_blinding_factor_remote(issuer_schema_id)
WK->>OPRF: OPRF request for issuer_schema_id
OPRF-->>WK: blinding_factor
WK-->>App: blinding_factor
App->>WK: compute_credential_sub(blinding_factor)
WK-->>App: sub = compute_sub(leaf_index, blinding_factor)
Note over App,Store: 4) Store issued credential
App->>Store: store_credential(credential, blinding_factor, ...)
Store-->>App: credential_id
The steps in the diagram work as follows:
- Registration.
InitializingAuthenticator::register_with_defaults(orregister) submits the account to the gateway. The app callspoll_statusuntil the status isFinalizedorFailed. AFailedstatus carries the gateway’serrorand, when available, anerror_code. - Authenticator creation. After the account exists on-chain,
Authenticator::init_with_defaults(orinit) opens it.init_storage(now)then binds local storage to the account’s leaf index. - Blinding factor.
generate_credential_blinding_factor_remoteasks the OPRF nodes for the blinding factor of an issuer schema. - Credential subject.
compute_credential_subderives the credential’ssublocally from the account’s leaf index and the blinding factor. - Storage.
store_credentialsaves the issued credential with its blinding factor in theCredentialStore, so that later proofs can use both.
Generate a proof
A relying party asks for a World ID proof by sending a proof request. This page shows how a WalletKit app answers it with a registered account. To register an account and store credentials first, see Accounts and credentials.
Before you begin
- Enable the
embed-zkeysfeature, which provides the proving keys. See Install WalletKit. - Open the account’s
CredentialStore. See The credential store.
Answer a proof request
The following Rust function opens the account, binds its storage, and answers a proof request. The Swift, Kotlin, and browser APIs take the same steps with the same names in their own casing.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use walletkit::authenticator::artifacts::embedded::EmbeddedZkArtifacts;
use walletkit::requests::ProofRequest;
use walletkit::storage::CredentialStore;
use walletkit::{Authenticator, Environment};
async fn prove(
seed: Vec<u8>,
store: Arc<CredentialStore>,
request_json: &str,
now: u64,
) -> Result<String, Box<dyn std::error::Error>> {
let authenticator = Authenticator::init_with_defaults(
seed,
None, // rpc_url: use the default.
&Environment::Staging,
None, // region: use the default.
Arc::new(EmbeddedZkArtifacts::new()),
store,
)
.await?;
authenticator.init_storage(now)?;
let request = ProofRequest::from_json(request_json)?;
let response = authenticator.generate_proof(&request, Some(now)).await?;
Ok(response.to_json()?)
}
}
In this function, now is the current time in Unix seconds. Send the JSON that it
returns back to the relying party.
On iOS and Android, use CachingZkArtifacts instead of EmbeddedZkArtifacts when
the keys are compressed, so that WalletKit decompresses them only once.
Legacy World ID 3.0 proofs
The v3 feature adds the World ID 3.0 API in the v3 module. The Swift and
Kotlin packages include it; the browser package doesn’t. docs.rs builds the crate
without v3, so read its documentation in the
v3 module source
or with cargo doc --features v3.
Logging
WalletKit logs through tracing. To send those logs to your app’s logging system,
implement the Logger interface and pass it to init_logging once, at app
startup.
Logger has one method, log(level, message). The level is one of Trace,
Debug, Info, Warn, and Error, so your app can map it to os_log, Android
Log, Crashlytics, Datadog, or any other sink. Each message starts with the name
of the module that logged it, and WalletKit redacts hex secrets from it before
your logger receives it.
On iOS and Android, WalletKit calls log from its own background thread, so your
logger must be safe to call from any thread.
init_logging behaves as follows:
- Its
levelargument sets the minimum level. AtTraceorDebug, the level applies only to WalletKit and its direct dependencies, and other crates log atInfoand above. AtInfo,Warn, orError, the level applies to every crate. If you pass no level, the minimum isInfo. - If the
RUST_LOGenvironment variable is set, it takes precedence overlevel. - Only the first call takes effect; later calls do nothing.
To check that your logger receives messages, call emit_log(level, message). To
redact hex secrets from your own messages in the same way, call
sanitize_hex_secrets(text).
Rust
#![allow(unused)]
fn main() {
use std::sync::Arc;
use walletkit::logger::{init_logging, LogLevel, Logger};
struct MyLogger;
impl Logger for MyLogger {
fn log(&self, level: LogLevel, message: String) {
println!("[{level:?}] {message}");
}
}
init_logging(Arc::new(MyLogger), Some(LogLevel::Debug));
}
Swift
In this example, Log stands for your app’s logger.
import WalletKit
final class WalletKitLoggerBridge: WalletKit.Logger {
static let shared = WalletKitLoggerBridge()
func log(level: WalletKit.LogLevel, message: String) {
switch level {
case .trace, .debug:
Log.debug(message)
case .info:
Log.info(message)
case .warn:
Log.warn(message)
case .error:
Log.error(message)
@unknown default:
Log.error(message)
}
}
}
public func setupWalletKitLogging() {
WalletKit.initLogging(logger: WalletKitLoggerBridge.shared, level: .debug)
}
Kotlin
import android.util.Log
import uniffi.walletkit_core.LogLevel
import uniffi.walletkit_core.Logger
import uniffi.walletkit_core.initLogging
class WalletKitLoggerBridge : Logger {
override fun log(level: LogLevel, message: String) {
when (level) {
LogLevel.TRACE, LogLevel.DEBUG -> Log.d("WalletKit", message)
LogLevel.INFO -> Log.i("WalletKit", message)
LogLevel.WARN -> Log.w("WalletKit", message)
LogLevel.ERROR -> Log.e("WalletKit", message)
}
}
}
fun setupWalletKitLogging() {
initLogging(WalletKitLoggerBridge(), LogLevel.DEBUG)
}
Browser
The browser package has no Logger. WalletKit writes its warnings and errors to
the worker’s console, and emitLog writes through the same path.
sanitizeHexSecrets redacts hex secrets as on the other platforms.
@worldcoin/walletkit-web
@worldcoin/walletkit-web brings WalletKit to web apps: a browser app can register
a World ID account, keep credentials in encrypted browser storage, and generate
World ID proofs. Each call to initializeWalletKit starts a dedicated Web Worker
that runs WalletKit’s Rust code, compiled to WebAssembly (WASM), off the main
thread, and returns one WalletKit instance that talks to it.
Requirements
To run WalletKit, your app needs the following:
- A secure context: HTTPS, or
localhostduring development - A browser with module workers and the Origin Private File System (OPFS),
including
FileSystemSyncAccessHandle - A bundler that emits assets referenced with
new URL(…, import.meta.url), such as Vite or the Next.js bundler, or custom asset URLs
Cross-origin isolation headers aren’t required.
Get started
-
Install the package:
npm install @worldcoin/walletkit-web -
In browser code, initialize WalletKit, and close the instance in a
finallyblock. An open instance keeps the origin’s storage, so closing it even when a later step fails lets a new instance start. Run the remaining steps inside thetryblock:import { initializeWalletKit } from "@worldcoin/walletkit-web"; const walletkit = await initializeWalletKit(); try { // Steps 3 to 5 go here. } finally { await walletkit.close(); } -
Open the account’s encrypted storage:
declare const seed: Uint8Array; // The account's authenticator seed. declare const databaseKey: Uint8Array; // See "Protect the database key". const keys = await walletkit.StorageKeys.fromBytes(databaseKey); const paths = await walletkit.StoragePaths.fromRoot("/walletkit/my-account"); const store = await walletkit.CredentialStore.new(paths, keys); -
If the account isn’t registered, register it and wait until the registration is final:
const registration = await walletkit.InitializingAuthenticator.registerWithDefaults( seed, undefined, // rpcUrl: use the default. "staging", "eu", undefined, // recoveryAddress: none. ); try { for (;;) { const status = await registration.pollStatus(); if (status.state === "finalized") break; if (status.state === "failed") throw new Error(status.error); await new Promise((resolve) => setTimeout(resolve, 2000)); } } finally { registration.free(); } -
To answer a relying party’s proof request, open the registered account and generate a proof:
declare const requestJson: string; // The relying party's proof request. const authenticator = await walletkit.Authenticator.initWithDefaults( seed, undefined, // rpcUrl: use the default. "staging", "eu", await walletkit.EmbeddedZkArtifacts.new(), store, ); await authenticator.initStorage(BigInt(Math.floor(Date.now() / 1000))); const request = await walletkit.ProofRequest.fromJson(requestJson); const proofJson = await ( await authenticator.generateProof(request) ).toJson();
Importing the package is safe during server-side rendering; call
initializeWalletKit only in the browser. For a complete registration, issuance,
and proof flow, see the
Next.js example.
Protect the database key
In the browser, your app supplies the 32-byte key that encrypts the credential
store. On iOS and Android, WalletKit creates this key and seals it with the device
keystore; browsers have none, so your app derives the key, for example from a
passkey’s PRF output, and passes it to StorageKeys.fromBytes.
- Never store the key in
localStorageor anywhere else that scripts can read. The key is the stored credentials’ only protection. - Derive the key the same way every time, and keep a stable root path per account. Reopening a store needs both.
- Clear your own copies. WalletKit clears the copy that it receives and never persists the key, but it doesn’t modify your array.
Store credentials
WalletKit keeps its encrypted SQLite databases in OPFS, subject to the browser’s storage quota and eviction policy. It never falls back to in-memory storage.
Only one instance per origin can own the storage. If another tab or instance owns
it, initializeWalletKit rejects, even for a different root path. Because a
closed instance can hold the storage briefly, initialization retries for up to
about three seconds first.
To add credentials from an authenticated vault backup, call
store.mergeVaultFromBackup(backupBytes).
Use the API
The classes, methods, and arguments mirror the Swift and Kotlin bindings, in
camelCase. The TypeScript declarations in dist/index.d.ts are the complete API
reference. Constructors and static functions are properties of the instance, for
example walletkit.FieldElement.fromU64(1n).
The browser API differs from the Swift and Kotlin bindings in these ways:
- Every WalletKit function and method returns a
Promise, exceptfree(),terminate(), andisStopped(), which return immediately. - A
u64is abigint, a byte array is aUint8Array, and a 256-bit value is a 0x-prefixed hex string. Environment,Region, and other enums are lowercase strings, such as"staging"and"eu". Records are plain objects.- Only
generateProofdefaults itsnowargument, to the browser clock. Pass Unix seconds everywhere else. - Objects stay in the worker until you call
free()or they are garbage collected.
Handle errors
WalletKit reports failures in these ways:
- A WalletKit error rejects with an
Errorwhosenameis the error type, such asStorageError, and whosecodeis the variant, such asNullifierReplay. Hex secrets in themessageare redacted. - An invalid argument rejects with a
TypeError. - If storage setup fails,
initializeWalletKitrejects with aStorageErrorwhosecodeisPersistentStorage. - A crash in WalletKit’s Rust code stops the instance, and every later call rejects. To recover, initialize a new instance.
WalletKit logs its own warnings and errors to the worker’s console. To redact hex
secrets from your own messages before you log them, call
walletkit.sanitizeHexSecrets(text).
Manage the instance
The worker runs one call at a time, in order, and calls have no timeout. A network request that never settles blocks the calls queued after it.
close()waits for queued calls, frees every object, and stops the worker. After five seconds without a response, it stops the worker anyway and rejects.terminate()stops the worker immediately and rejects pending calls.isStopped()returnstrueonce the instance can no longer serve calls.
Serve the assets from custom URLs
The package ships a worker script and a WASM module of about 40 MB. If your app serves them itself, pass their URLs:
const walletkit = await initializeWalletKit({
workerUrl: "/assets/walletkit.worker.js",
wasmUrl: "/assets/walletkit.wasm",
});
Relative URLs resolve against the page URL. Serve the worker from your page’s
origin, and serve the WASM module as application/wasm.
Browser limitations
These parts of the Swift and Kotlin bindings aren’t available in the browser:
| API | Reason |
|---|---|
Logger and initLogging | Not implemented for the browser. |
| Vault and activity change listeners | Not implemented for the browser. |
| Vault backup export and replacement import | Supported only on iOS and Android. Use mergeVaultFromBackup to import. |
proveCredentialSub | Credential ownership proofs aren’t supported on WASM. |
| Flamingo | Browser WebSockets can’t send its signed handshake headers, and it relies on device attestation. |
DeviceKeystore, AtomicBlobStore, and StorageProvider | They need synchronous host callbacks. Use StorageKeys.fromBytes. |
CachingZkArtifacts | The WASM module embeds the proving keys. Use EmbeddedZkArtifacts. |
UserAgent and UserAgentBuilder | The browser controls the User-Agent header. |
Issuer clients (TfhNfcIssuer, RecoveryBindingManager) | Not included. |
World ID v3 (walletkit_core::v3) | Not included. |
Get help
To report a bug or ask a question, open an issue. To contribute, see the browser package docs.
Develop WalletKit
This page describes how to set up a development environment and run the checks that CI runs. For platform bindings, see iOS and Swift, Android and Kotlin, and the browser package.
Before you begin
- Install Nix. The repository’s flake provides
development shells with pinned toolchains:
defaultfor host development,androidfor Android,wasmfor WebAssembly, anddocsfor this book. Without Nix, you can run the same shells in Docker; see Nix development environments. - To run the integration tests, install Foundry. The
tests use Anvil to fork World Chain from the RPC endpoint in
WORLDCHAIN_RPC_URL, which defaults to the public World Chain RPC. You can set it in a.envfile.
Run every command on this page from the repository root.
Lint and format
WalletKit gates code paths behind Cargo features, so a warning can appear in only one feature combination. To lint every combination, as CI does, run the following commands:
nix develop --command cargo clippy --workspace --all-targets --all-features -- -D warnings
nix develop --command cargo clippy --workspace --all-targets -- -D warnings
nix develop --command cargo clippy --workspace --all-targets --no-default-features -- -D warnings
To check formatting, run the following command:
nix develop --command cargo fmt -- --check
Test
To run the workspace tests with the default features, run the following command:
nix develop --command cargo test --workspace
CI runs the tests with all features enabled, using cargo nextest, on several
Rust toolchains. Locally, --all-features also enables compress-zkeys, which
makes every test that loads the proving keys decompress them, so expect much
slower runs.
Write documentation
The sources of this book are in docs/, and docs/SUMMARY.md defines its table
of contents. To preview the book while you edit it, run the following command and
open the URL that it prints:
nix develop .#docs --command mdbook serve
Link to other pages of the book with relative paths. Link to files outside docs/
with absolute GitHub URLs, because the book is served without the rest of the
repository.
The docs.yml
workflow builds the book and publishes it to GitHub Pages on every push to
main. Pull requests don’t build the book, so preview your changes locally with
mdbook serve before you merge them.
iOS and Swift
WalletKit’s Swift bindings are generated with UniFFI and built by the workspace
xtask. This page describes how to build and test them locally.
Before you begin
Building for iOS requires macOS with the following:
- Xcode
- The iOS Rust targets.
rustupinstalls them fromrust-toolchain.toml. nargov1.0.0-beta.11, installed withnoirup
The Swift build doesn’t use a Nix shell; it runs with the host’s Xcode setup.
Build a local package
To build a Swift package that an app can import locally, run the following command:
cargo xtask swift local
The package is written to swift/local_build/walletkit-swift. To build only the
bindings and swift/WalletKit.xcframework, run cargo xtask swift build.
To use the local package in an app, add it to the app’s Package.swift
dependencies, adjusting the path to your checkout:
dependencies: [
.package(name: "WalletKit", path: "../walletkit/swift/local_build/walletkit-swift"),
],
Then add the WalletKit product to each target that needs it:
.target(
name: "YourTarget",
dependencies: [
.product(name: "WalletKit", package: "WalletKit"),
]
),
Run the Swift tests
To build the bindings and run the XCTest suite on an available iPhone simulator, run:
cargo xtask swift test
To test bindings that you built separately, as CI does, add --skip-build.
Android and Kotlin
WalletKit’s Kotlin bindings are generated with UniFFI and built by the workspace
xtask. This page describes how to build, test, and publish them locally.
Before you begin
- Use the
androidNix shell, which provides the Rust toolchain, the Android NDK, and the linkers. Thextaskdoesn’t enter the shell for you, so run it throughnix develop .#android, or provide the same dependencies yourself. - For Gradle’s Android tasks, install the Android SDK and NDK from Android Studio,
in Settings > Android SDK. Then set
sdk.dir, andndk.dirif needed, inkotlin/local.properties.
The Kotlin project in kotlin/ has two modules:
walletkit: the Android library with the generated bindingswalletkit-tests: JUnit tests of the bindings
Build the bindings
To cross-compile WalletKit and generate the Kotlin bindings, run the following command:
nix develop .#android --command cargo xtask kotlin build
Run the Kotlin tests
To build a host library for macOS or Linux, generate bindings, and run the JUnit
suite on the JVM, run the following command. The android shell provides the
JDK 17 and nargo that the tests need:
nix develop .#android --command cargo xtask kotlin test
Test with an app before release
To try local changes in an Android app, publish them to Maven Local:
-
Build and publish a version, for example
0.3.1:nix develop .#android --command cargo xtask kotlin local 0.3.1The command builds the Rust library for
arm64-v8a,armeabi-v7a,x86_64, andx86, generates the Kotlin bindings, and publishes them to~/.m2/repository/org/world/walletkit/. -
In the app, add
mavenLocal()to the Gradle repositories and set the WalletKit dependency to the version that you published.
To use Rust from a non-default location, set RUSTUP_HOME and CARGO_HOME when
you run the command.
Browser package
@worldcoin/walletkit-web runs WalletKit in the browser. Its source is in
web/walletkit (TypeScript) and crates/walletkit-web (Rust). To use the package,
see Use WalletKit in the browser.
The following pages are for contributors to the package:
- How the browser package works: the worker, the page proxy, object handles, error handling, and storage.
- Develop the browser package: build and test the package, and
expose a
walletkit-coreAPI to the browser.
To publish the package, see Release WalletKit.
How the browser package works
This page explains how @worldcoin/walletkit-web runs WalletKit in a browser. It
is for contributors to the package. To use the package, see
Use WalletKit in the browser.
Components
The package has four layers. Each layer talks only to the one next to it.
flowchart LR app[App code] --> proxy["Page proxy<br/>web/walletkit/src/index.ts, remote.ts"] proxy -- postMessage --> worker["Worker<br/>web/walletkit/src/walletkit.worker.ts"] worker --> facade["walletkit-web crate<br/>crates/walletkit-web"] facade --> core["walletkit-core"]
In order from the app down:
- Page proxy.
initializeWalletKitstarts the worker and returns a typed proxy. The proxy turns every property access into a request to the worker and returns aPromise. Method signatures come from the declarations thatwasm-bindgengenerates. Only the public type names are listed by hand, inremote.tsandindex.ts. - Worker. The worker loads the WASM module, installs persistent storage, and runs requests. It keeps every Rust object and gives the page an opaque handle for each one.
walletkit-webcrate. Awasm-bindgenfacade overwalletkit-core, with one wrapper class for each UniFFI object that the browser supports. Wrappers keep the names and arguments of the Swift and Kotlin bindings, incamelCase.walletkit-core. The same Rust code that the Swift and Kotlin bindings use, built forwasm32-unknown-unknown.
The browser package doesn’t use UniFFI. The facade exports only the surface that a browser can serve: it has no foreign traits or callbacks.
Requests and objects
protocol.ts defines the messages between
the page and the worker. A call names its target in one of three ways: a
module-level function, a static function of a class, or a method of an object
handle.
Rust objects can’t cross postMessage, so the worker keeps them in a handle table.
When a call returns a Rust object, the worker stores it and returns { $ref: handle }, and the page wraps the handle in a proxy. When the page passes a proxy
as an argument, the page sends its handle, and the worker swaps it back for the
object. Every other value crosses as structured-clone data.
An object stays in the worker until one of the following happens:
- The app calls
free()on the proxy. - The proxy is garbage collected. A
FinalizationRegistrythen releases it. - The app closes the instance.
close()frees every remaining object.
A proxy belongs to the instance that created it. A call that passes it to another
instance rejects with a TypeError, because its handle could name an unrelated
object there.
The worker dispatches only functions and methods that the WASM module exports. It
rejects wasm-bindgen internals, names that start with _, and the module setup
functions that only the worker calls. As a result, an export added to the
walletkit-web crate is callable from the page with no change to the worker.
Ordering
The worker runs one request at a time, including the awaited part of asynchronous calls. Rust state shared between objects, such as a credential store that an authenticator also uses, therefore never sees interleaved operations.
The cost is head-of-line blocking: a call that never settles blocks every call
queued behind it. close() sends its request through the same queue and, after
CLOSE_TIMEOUT_MS in index.ts, terminates the
worker.
Byte arrays and secrets
Seeds and database keys cross the worker boundary as Uint8Arrays. The page copies
each array and transfers the copy’s buffer, so the page heap keeps no second copy.
The caller’s own array is left unchanged. If encoding fails before the message is
posted, the page clears the copies itself.
wasm-bindgen copies byte arguments into Rust memory when the export is invoked,
so the worker clears its copies right after the invocation returns, without
waiting for an asynchronous call to settle. In Rust, the database key lives in
zeroizing memory until its last owner releases it.
Errors and crashes
The facade converts a Rust error into a JavaScript Error. The error’s name is
the error type, its code is the variant, and its detail holds the variant’s
fields; the worker appends detail to the message before sending it to the page.
Every message and detail passes through sanitize_hex_secrets. Argument
validation throws a TypeError.
A Rust panic traps the WASM module and leaves its memory in an unknown state. The
worker detects the resulting WebAssembly.RuntimeError, marks itself crashed, and
flags the error response as fatal. The page client, the WorkerClient class in
index.ts that owns the worker and its pending requests, then stops: it rejects
pending and later calls, and isStopped() returns true. A worker error or
messageerror event stops the client the same way.
The workspace release profile sets panic = "abort", so a panic on iOS or Android
ends the app process. Code shared with those platforms must return errors instead
of panicking.
Storage
The worker installs persistent storage during initialization, before any database opens. The storage design has these parts:
- VFS. WalletKit stores its SQLite databases in OPFS through the
sync-access-handle (SAH) pool VFS from
sqlite-wasm-vfs. SQLite3MC wraps that VFS asmultipleciphers-opfs-sahpool, and connections open only through the encrypted wrapper, so persistence never bypasses encryption. Seeopfs.rs. - Synchronous access. Installing the pool is asynchronous, but SQLite calls after that are synchronous. The pool therefore reserves its capacity at install time, because synchronous SQL can’t grow it.
- Single owner. The pool holds its OPFS handles until the worker terminates, so one worker per origin owns the storage. Closing a connection doesn’t release them. A second worker fails to install the pool, even for a different root path.
- Retries. A worker that just terminated releases its handles asynchronously.
When another context still holds the pool, initialization retries with jittered
backoff, following
POOL_RETRY_DELAYS_MSinwalletkit.worker.ts, and then reports the error. It reports other installation failures immediately. - Journal mode. On WASM, databases use the rollback journal (
DELETE) instead of WAL, because the SAH pool doesn’t provide WAL’s shared-memory methods. The encrypted database format is the same as on iOS and Android. - Keys. The browser has no device keystore, so the host supplies the database
key through
StorageKeys.fromBytes. No key envelope is written, so there is no envelope to delete indestroyStorage().
Build output
bun run build in web/walletkit produces the published dist directory:
cargo buildcompiles thewalletkit-webcrate forwasm32-unknown-unknownin release mode.- The
wasm-bindgenCLI generates the JavaScript glue and TypeScript declarations with--target web. The CLI version must match thewasm-bindgencrate inCargo.lock; the build checks this. - Binaryen’s
wasm-opt -Oz --convergeoptimizes the module intowalletkit.wasm. - esbuild bundles the page entry point and a self-contained worker, and the build
copies the module and its declarations into
dist/generated.
The module embeds the proving keys uncompressed (the embed-zkeys feature), which
is why it is about 40 MB and why the browser needs no CachingZkArtifacts.
For the build commands, see Develop the browser package.
Develop the browser package
This page describes how to build and test @worldcoin/walletkit-web and how to
expose a walletkit-core API to the browser. For the design behind these steps,
see How the browser package works.
Before you begin
- Install Nix. The
wasmdevshell pins the Rust toolchain, thewasm-bindgenCLI, Binaryen, and Bun. Without Nix, you can run the devshell in Docker withnix/docker.sh; for details, see Nix development environments. - To run the browser tests, install Google Chrome on the machine that runs them.
Playwright drives the installed Chrome (the
chromechannel), not its bundled Chromium, and neither the devshell nor the Docker wrapper provides Chrome.
Run every command on this page from the repository root.
Build the package
-
Install the JavaScript dependencies:
nix develop .#wasm --command bun install --cwd web/walletkit --frozen-lockfile -
Build the WASM module and the TypeScript bundle into
web/walletkit/dist:nix develop .#wasm --command bun run --cwd web/walletkit build
To rebuild after a TypeScript-only change, run the following command. It reuses the WASM module from the last full build:
nix develop .#wasm --command bun run --cwd web/walletkit bundle
Test the package
To run the browser tests, run the following command. It type-checks the tests and the fixture app, builds the fixture with Vite, and runs the Playwright tests in Chrome:
nix develop .#wasm --command bun run --cwd web/walletkit test:browser
The tests are in web/walletkit/tests:
browser.spec.tsdrives the built package in a page.types/consumer.tschecks the public TypeScript API at compile time. To check that an invalid call doesn’t compile, mark it with// @ts-expect-error.
CI also lints the crate and checks the TypeScript formatting. To run the same checks, run these commands:
nix develop .#wasm --command cargo clippy -p walletkit-web --no-deps \
--target wasm32-unknown-unknown --locked -- -D warnings
nix develop .#wasm --command bun run --cwd web/walletkit format:check
The Next.js example is the package’s integration consumer. CI type-checks, tests, and builds it against the package from the same checkout.
Expose a core API to the browser
The worker and the page proxy dispatch new exports without changes, but the
public type names are listed by hand. To expose a walletkit-core class or
function, do the following:
- In
crates/walletkit-web/src, add a wrapper with#[wasm_bindgen]. Follow the conventions of the wrappers there:- Keep the UniFFI name and arguments, with
js_nameincamelCase. - Hold the core object in an
Arc, so that asynchronous methods can return a'staticfuture. - Take a
u64as aBigIntand validate it withjs::u64_arg. - Return records as plain objects built with
js::object. - Return a
Promisefrom asynchronous methods withjs::promise. - Convert errors with
error::to_js, and reject invalid arguments witherror::invalid_argument, which throws aTypeError.
- Keep the UniFFI name and arguments, with
- For a class, add a
RemoteObjecttype alias inremote.tsand export it fromindex.ts. For a record or an enum, export its generated type fromindex.ts. - Add compile-time checks to
tests/types/consumer.tsand a browser test totests/browser.spec.ts. - If the API appears in
Browser limitations, remove it from that
table in
web/walletkit/README.md.
Release WalletKit
This page describes how a WalletKit release is prepared and published. All
packages share one version, X.Y.Z, taken from the workspace Cargo.toml, and
one release publishes all of them:
| Package | Published to | Workflow |
|---|---|---|
Rust crates walletkit, walletkit-core, walletkit-db, and walletkit-sqlite | crates.io | release.yml |
| Swift package | worldcoin/walletkit-swift, tag X.Y.Z | release-swift-kotlin.yml |
Kotlin library org.world:walletkit | GitHub Packages | release-swift-kotlin.yml |
Browser package @worldcoin/walletkit-web | npm | web.yml |
How a release happens
release-plz drives the release from the
Conventional Commits on main:
- On every push to
main,release.ymlruns release-plz. It opens or updates a release pull request, labeledrelease, that bumps the workspace version and adds the new commits tocrates/walletkit/CHANGELOG.md. - A maintainer reviews and merges the release pull request. The bot that opens it can’t merge it.
- On the push of that merge, release-plz publishes the crates whose version isn’t
on crates.io yet, and it creates the GitHub release
vX.Y.Z. - Publishing the GitHub release starts
release-swift-kotlin.ymlandweb.yml, which build and publish the Swift, Kotlin, and browser packages from the released commit.
release-plz authenticates to GitHub with the WALLETKIT_BOT_TOKEN secret, not with
the workflow’s default token. Events created with the default token don’t start
other workflows, so the bot token is what lets the GitHub release trigger the
package workflows.
release-plz.toml
configures which crates are published and which crate’s changelog and GitHub
release represent the workspace.
Crates
The release.yml job runs in the production environment and publishes to
crates.io with trusted publishing: it
has the id-token: write permission and holds no crates.io token. Each published
crate must list this repository’s release.yml as a trusted publisher on
crates.io.
Trusted publishing can’t create a crate. To add a new published crate, publish its
first version by hand, configure its trusted publisher, and then add it to
release-plz.toml.
Swift
The build-swift job runs on macOS:
- Builds the XCFramework with
cargo xtask swift build, using the features in the workflow’sWALLETKIT_CARGO_FEATURESvariable. - Creates a draft release
X.Y.Zinworldcoin/walletkit-swiftand uploads the zipped XCFramework to it. - Generates
Package.swiftwith the asset URL and checksum, commits it with the Swift sources towalletkit-swift, and tags the commitX.Y.Z. - Publishes the draft and marks it as the latest release.
The job writes to walletkit-swift with WALLETKIT_BOT_TOKEN.
Kotlin
The prepare-kotlin job builds the native library for the four Android targets in
parallel, with nix/build-android.sh. The publish-kotlin job then assembles the
jniLibs, generates the Kotlin bindings, and runs ./gradlew walletkit:publish,
which publishes org.world:walletkit:X.Y.Z to GitHub Packages. Gradle reads the
version from the workspace Cargo.toml.
Browser package
The web.yml workflow builds and tests the package, stamps X.Y.Z from the
release tag into package.json, and packs the tarball. The tag must have the form
vX.Y.Z or vX.Y.Z-<pre-release>; any other tag fails the build. A second job, in the production environment,
publishes that exact tarball to npm with provenance:
- A version with a pre-release suffix, such as
X.Y.Z-rc.1, goes to thenextdist-tag instead oflatest. - If the version is already on npm, the job skips publishing.
The job authenticates to npm with
trusted publishing, so the repository
stores no npm token. This is a one-time setup for an owner of the npm package. On
npmjs.com, in the @worldcoin/walletkit-web package settings, add a trusted
publisher with these values:
| Setting | Value |
|---|---|
| Organization or user | worldcoin |
| Repository | walletkit |
| Workflow filename | web.yml |
| Environment name | production |
Recover from a failed release
- Crates. To publish a version that is merged on
mainbut missing from crates.io, runrelease.ymlmanually from the Actions tab. A manual run only publishes; it doesn’t open a release pull request. - Swift, Kotlin, and the browser package. Rerun the failed jobs of the workflow run that the GitHub release started. The npm job skips a version that is already published.
Roll back a release
Published versions can’t be replaced, so a rollback marks the bad version and is followed by a fixed release:
- Mark the bad version:
- Crates:
cargo yank --version X.Y.Z <crate>for each published crate. - Browser package:
npm deprecate @worldcoin/walletkit-web@X.Y.Z "<reason>". Don’t rely onnpm unpublish.
- Crates:
- Release a fixed version through the normal process.