Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 walletkit crate.
  • iOS: Swift bindings, generated with UniFFI.
  • Android: Kotlin bindings, generated with UniFFI.
  • Browser: the @worldcoin/walletkit-web npm 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 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:

FeatureDefaultEffect
issuersYesClients for credential issuers, such as TfhNfcIssuer and RecoveryBindingManager.
embed-zkeysNoEmbeds the proving keys in the binary. EmbeddedZkArtifacts and CachingZkArtifacts need it, so enable it to generate proofs.
compress-zkeysNoCompresses the embedded keys, which roughly halves the bundle size but makes decompression expensive. CachingZkArtifacts caches the result on disk.
v3NoThe legacy World ID 3.0 API, in the v3 module.
semaphoreNoSemaphore 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:

  1. In Xcode, click File > Add Package Dependencies.
  2. Enter https://github.com/worldcoin/walletkit-swift, choose a version, and click Add Package.
  3. Add the WalletKit product 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:

  1. Add the GitHub Packages repository to your Gradle repositories. GitHub Packages requires credentials with the read:packages scope, even for public packages. This example reads them from the gpr.user and gpr.key Gradle properties; set those in your user-level ~/.gradle/gradle.properties or 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()
            }
        }
    }
    
  2. In the app module’s build.gradle.kts, add the dependency, replacing VERSION with a WalletKit version:

    dependencies {
        implementation("org.world:walletkit:VERSION")
    }
    
  3. 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:

  1. Registration. InitializingAuthenticator::register_with_defaults (or register) submits the account to the gateway. The app calls poll_status until the status is Finalized or Failed. A Failed status carries the gateway’s error and, when available, an error_code.
  2. Authenticator creation. After the account exists on-chain, Authenticator::init_with_defaults (or init) opens it. init_storage(now) then binds local storage to the account’s leaf index.
  3. Blinding factor. generate_credential_blinding_factor_remote asks the OPRF nodes for the blinding factor of an issuer schema.
  4. Credential subject. compute_credential_sub derives the credential’s sub locally from the account’s leaf index and the blinding factor.
  5. Storage. store_credential saves the issued credential with its blinding factor in the CredentialStore, 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

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 level argument sets the minimum level. At Trace or Debug, the level applies only to WalletKit and its direct dependencies, and other crates log at Info and above. At Info, Warn, or Error, the level applies to every crate. If you pass no level, the minimum is Info.
  • If the RUST_LOG environment variable is set, it takes precedence over level.
  • 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 localhost during 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

  1. Install the package:

    npm install @worldcoin/walletkit-web
    
  2. In browser code, initialize WalletKit, and close the instance in a finally block. 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 the try block:

    import { initializeWalletKit } from "@worldcoin/walletkit-web";
    
    const walletkit = await initializeWalletKit();
    try {
      // Steps 3 to 5 go here.
    } finally {
      await walletkit.close();
    }
    
  3. 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);
    
  4. 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();
    }
    
  5. 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 localStorage or 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, except free(), terminate(), and isStopped(), which return immediately.
  • A u64 is a bigint, a byte array is a Uint8Array, 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 generateProof defaults its now argument, 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 Error whose name is the error type, such as StorageError, and whose code is the variant, such as NullifierReplay. Hex secrets in the message are redacted.
  • An invalid argument rejects with a TypeError.
  • If storage setup fails, initializeWalletKit rejects with a StorageError whose code is PersistentStorage.
  • 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() returns true once 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:

APIReason
Logger and initLoggingNot implemented for the browser.
Vault and activity change listenersNot implemented for the browser.
Vault backup export and replacement importSupported only on iOS and Android. Use mergeVaultFromBackup to import.
proveCredentialSubCredential ownership proofs aren’t supported on WASM.
FlamingoBrowser WebSockets can’t send its signed handshake headers, and it relies on device attestation.
DeviceKeystore, AtomicBlobStore, and StorageProviderThey need synchronous host callbacks. Use StorageKeys.fromBytes.
CachingZkArtifactsThe WASM module embeds the proving keys. Use EmbeddedZkArtifacts.
UserAgent and UserAgentBuilderThe 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: default for host development, android for Android, wasm for WebAssembly, and docs for 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 .env file.

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. rustup installs them from rust-toolchain.toml.
  • nargo v1.0.0-beta.11, installed with noirup

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 android Nix shell, which provides the Rust toolchain, the Android NDK, and the linkers. The xtask doesn’t enter the shell for you, so run it through nix 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, and ndk.dir if needed, in kotlin/local.properties.

The Kotlin project in kotlin/ has two modules:

  • walletkit: the Android library with the generated bindings
  • walletkit-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:

  1. Build and publish a version, for example 0.3.1:

    nix develop .#android --command cargo xtask kotlin local 0.3.1
    

    The command builds the Rust library for arm64-v8a, armeabi-v7a, x86_64, and x86, generates the Kotlin bindings, and publishes them to ~/.m2/repository/org/world/walletkit/.

  2. 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:

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:

  1. Page proxy. initializeWalletKit starts the worker and returns a typed proxy. The proxy turns every property access into a request to the worker and returns a Promise. Method signatures come from the declarations that wasm-bindgen generates. Only the public type names are listed by hand, in remote.ts and index.ts.
  2. 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.
  3. walletkit-web crate. A wasm-bindgen facade over walletkit-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, in camelCase.
  4. walletkit-core. The same Rust code that the Swift and Kotlin bindings use, built for wasm32-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 FinalizationRegistry then 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 as multipleciphers-opfs-sahpool, and connections open only through the encrypted wrapper, so persistence never bypasses encryption. See opfs.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_MS in walletkit.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 in destroyStorage().

Build output

bun run build in web/walletkit produces the published dist directory:

  1. cargo build compiles the walletkit-web crate for wasm32-unknown-unknown in release mode.
  2. The wasm-bindgen CLI generates the JavaScript glue and TypeScript declarations with --target web. The CLI version must match the wasm-bindgen crate in Cargo.lock; the build checks this.
  3. Binaryen’s wasm-opt -Oz --converge optimizes the module into walletkit.wasm.
  4. 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 wasm devshell pins the Rust toolchain, the wasm-bindgen CLI, Binaryen, and Bun. Without Nix, you can run the devshell in Docker with nix/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 chrome channel), 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

  1. Install the JavaScript dependencies:

    nix develop .#wasm --command bun install --cwd web/walletkit --frozen-lockfile
    
  2. 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.ts drives the built package in a page.
  • types/consumer.ts checks 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:

  1. 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_name in camelCase.
    • Hold the core object in an Arc, so that asynchronous methods can return a 'static future.
    • Take a u64 as a BigInt and validate it with js::u64_arg.
    • Return records as plain objects built with js::object.
    • Return a Promise from asynchronous methods with js::promise.
    • Convert errors with error::to_js, and reject invalid arguments with error::invalid_argument, which throws a TypeError.
  2. For a class, add a RemoteObject type alias in remote.ts and export it from index.ts. For a record or an enum, export its generated type from index.ts.
  3. Add compile-time checks to tests/types/consumer.ts and a browser test to tests/browser.spec.ts.
  4. 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:

PackagePublished toWorkflow
Rust crates walletkit, walletkit-core, walletkit-db, and walletkit-sqlitecrates.iorelease.yml
Swift packageworldcoin/walletkit-swift, tag X.Y.Zrelease-swift-kotlin.yml
Kotlin library org.world:walletkitGitHub Packagesrelease-swift-kotlin.yml
Browser package @worldcoin/walletkit-webnpmweb.yml

How a release happens

release-plz drives the release from the Conventional Commits on main:

  1. On every push to main, release.yml runs release-plz. It opens or updates a release pull request, labeled release, that bumps the workspace version and adds the new commits to crates/walletkit/CHANGELOG.md.
  2. A maintainer reviews and merges the release pull request. The bot that opens it can’t merge it.
  3. 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.
  4. Publishing the GitHub release starts release-swift-kotlin.yml and web.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:

  1. Builds the XCFramework with cargo xtask swift build, using the features in the workflow’s WALLETKIT_CARGO_FEATURES variable.
  2. Creates a draft release X.Y.Z in worldcoin/walletkit-swift and uploads the zipped XCFramework to it.
  3. Generates Package.swift with the asset URL and checksum, commits it with the Swift sources to walletkit-swift, and tags the commit X.Y.Z.
  4. 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 the next dist-tag instead of latest.
  • 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:

SettingValue
Organization or userworldcoin
Repositorywalletkit
Workflow filenameweb.yml
Environment nameproduction

Recover from a failed release

  • Crates. To publish a version that is merged on main but missing from crates.io, run release.yml manually 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:

  1. 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 on npm unpublish.
  2. Release a fixed version through the normal process.