Skip to content

feat: walletkit cli #294

Open
kilianglas wants to merge 29 commits intomainfrom
kilianglas/walletkit-cli
Open

feat: walletkit cli #294
kilianglas wants to merge 29 commits intomainfrom
kilianglas/walletkit-cli

Conversation

@kilianglas
Copy link

@kilianglas kilianglas commented Mar 12, 2026

Summary

This PR adds a CLI for WalletKit. The idea here is to get a CLI that can be used for dev and testing. This is not meant to be used as an authenticator for an actual World ID — the environment defaults to staging.

Features

Wallet management (walletkit wallet)

  • init — Bootstrap wallet data directory (~/.walletkit by default) with required subdirectories and databases
  • paths — Print resolved storage paths (useful for debugging and scripting)
  • doctor — Health check: verifies root exists, Groth16 material is cached, and databases are openable
  • export — Export the vault to a plaintext backup file
  • import — Restore credentials from a vault backup file
  • danger-clear — Permanently delete all credentials (requires --confirm)

Authenticator lifecycle (walletkit auth)

  • register — Submit a new World ID registration (returns immediately)
  • register-wait — Register and poll until finalized, with configurable interval
  • init — Initialize an authenticator for an already-registered World ID
  • info — Print authenticator details (leaf index, onchain address, packed account data)
  • remote-account-data — Fetch on-chain packed account data and compare with local state

Credential operations (walletkit credential)

  • import — Import a raw credential with a pre-computed blinding factor
  • issue — End-to-end credential issuance: generate blinding factor via OPRF, then store
  • issue-test — Issue a test credential from the staging faux issuer (issuer schema 128) in a single step (OPRF + sub + faux issuer + store)
  • list — List stored credentials, optionally filtered by issuer schema ID
  • show — Show details of the latest credential for an issuer schema
  • delete — Delete a credential by ID
  • blinding-factor — Generate a credential blinding factor via OPRF nodes
  • compute-sub — Derive a credential sub from a blinding factor

Proof generation (walletkit proof)

  • generate — Generate a ZK proof from a proof-request JSON (file or stdin)
  • generate-test-request — Generate a signed test proof request using hardcoded staging RP keys
  • inspect-request — Parse and display a proof request without generating a proof
  • verify — Verify a previously generated proof on-chain via the WorldIDVerifier contract

Global options

  • --root / WALLETKIT_ROOT — Custom wallet data directory
  • --seed / WALLETKIT_SEED — 32-byte hex authenticator seed
  • --random-seed — Generate a fresh random seed for quick testing
  • --environment — Target environment (staging or production, defaults to staging)
  • --region — OPRF/indexer region selection (eu, us, ap)
  • --rpc-url / WORLDCHAIN_RPC_URL — World Chain RPC endpoint
  • --json — Machine-readable JSON output for all commands
  • --verbose — Enable debug logging
  • --config / WALLETKIT_CONFIG — Path to a custom config JSON file (overrides --environment and --region)
  • --latency — Print per-network-call latency summary after the command

proof verify options

  • --verifier-address — Override the WorldID verifier contract address (default: mainnet); useful for testnet

@kilianglas kilianglas marked this pull request as draft March 12, 2026 16:51
@kilianglas
Copy link
Author

@cursor review

@cursor
Copy link

cursor bot commented Mar 16, 2026

Bugbot couldn't run

Bugbot is not enabled for your user on this team.

Ask your team administrator to increase your team's hard limit for Bugbot seats or add you to the allowlist in the Cursor dashboard.

@kilianglas kilianglas marked this pull request as ready for review March 16, 2026 17:36
@Dzejkop
Copy link
Contributor

Dzejkop commented Mar 17, 2026

/otto review

@agentotto
Copy link
Contributor

agentotto bot commented Mar 17, 2026

:orb-salute: On it — spinning up a review job for this PR now. Will post inline comments shortly.

Copy link
Contributor

@agentotto agentotto bot left a comment

Choose a reason for hiding this comment

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

Overall Review Summary

This is a solid developer CLI with good ergonomics overall. The command structure is clean, the latency layer is a nice touch, and the error handling is generally thoughtful. Below are several issues worth addressing before merge — ranging from a security concern (seed material in stdout) to correctness bugs and a few UX rough edges.


if !all_passed {
std::process::exit(1);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: process::exit(1) bypasses the latency reporter

When verification fails, std::process::exit(1) is called directly. This skips the if show_latency { latency::print_report(...) } block at the end of main, so the user never sees the latency summary that was the whole point of passing --latency.

It also bypasses any future cleanup hooks (destructors, async task shutdown, etc.) that might be added.

Prefer returning an Err so the normal error path in main handles the exit:

if !all_passed {
    eyre::bail!("one or more proofs failed on-chain verification");
}

Copy link
Contributor

Choose a reason for hiding this comment

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

@kilianglas sounds relevant?

Copy link
Author

Choose a reason for hiding this comment

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

/otto fix this

Copy link
Contributor

Choose a reason for hiding this comment

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

:orb-salute: On it — fixing this now.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fixed in 4f9de20 — replaced process::exit(1) with eyre::bail! so the latency reporter and cleanup hooks run normally.

.wrap_err("remote fetch failed")?;
let local = authenticator.packed_account_data();
let matches = remote.to_string() == local.to_string();

Copy link
Contributor

Choose a reason for hiding this comment

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

Correctness: comparing Uint256 via string formatting

ruint::Uint implements PartialEq, so comparing through to_string() is both unnecessary and fragile — it would silently break if the Display impl ever changes format (e.g., adds a 0x prefix).

Replace with a direct comparison:

let matches = remote == local;

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Author

Choose a reason for hiding this comment

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

/otto fix this

Copy link
Contributor

Choose a reason for hiding this comment

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

:orb-salute: On it — fixing this too.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fixed in 4f9de20 — replaced .to_string() comparison with direct PartialEq.


if let Err(err) = commands::run(cli).await {
let json_flag = std::env::args().any(|a| a == "--json");
if json_flag {
Copy link
Contributor

Choose a reason for hiding this comment

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

Redundant arg re-parse after clap has already parsed the CLI

At this point cli (which was parsed by clap at the top of main) is no longer accessible because it was moved into commands::run(cli). So the code re-scans std::env::args() for "--json" to decide how to format the error.

The simplest fix is to capture the flag before the move:

let json_mode = cli.json; // already captured above
// ... later in the error handler:
if json_mode {
    ...
}

json_mode is already saved on line 24; just reuse it in the error branch instead of re-parsing argv.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Author

Choose a reason for hiding this comment

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

/otto fix this

Copy link
Contributor

Choose a reason for hiding this comment

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

:orb-salute: Adding this to the active fix job now.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fixed in 4f9de20 — replaced the redundant std::env::args() re-parse with the already-captured json_mode variable.


let result = if let Some(ref config) = config_json {
InitializingAuthenticator::register(
&seed,
Copy link
Contributor

Choose a reason for hiding this comment

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

Code duplication: registration logic repeated verbatim in run_register and run_register_wait

Both functions perform the exact same registration call (including the config_json/register_with_defaults split and the AccountAlreadyExists early-return). The only difference is what happens after init_auth is obtained.

Extract a shared helper:

async fn do_register(cli: &Cli, recovery_address: Option<&str>) 
    -> eyre::Result<Option<InitializingAuthenticator>>

that returns None on AccountAlreadyExists. Both callers then decide what to do with the InitializingAuthenticator (single poll vs. loop). This avoids the two copies diverging silently in the future.

Copy link
Author

Choose a reason for hiding this comment

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

/otto refactor this

Copy link
Contributor

Choose a reason for hiding this comment

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

:orb-salute: On it — adding to the active job.

Copy link
Contributor

Choose a reason for hiding this comment

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

Refactored in 4f9de20 — extracted do_register helper shared by both run_register and run_register_wait.

@agentotto
Copy link
Contributor

agentotto bot commented Mar 17, 2026

Code Review — WalletKit CLI (PR #294)

Overall: This is a well-structured developer CLI. The command taxonomy (wallet / auth / credential / proof) maps cleanly to the underlying SDK, the --json envelope is consistent, and the tracing-based latency layer is a clever addition. The tests cover the happy paths and several error paths well.

I've posted inline comments on each issue below; here's a quick summary of severity:


🔴 Security

# File Issue
1 commands/wallet.rs:64 Seed printed in JSON and plaintext stdout — treat this like a private key; add a warning or gate behind --expose-seed
2 commands/wallet.rs:57 Seed file written world-readablefs::write uses the process umask; restrict to 0o600 with set_permissions
3 commands/proof.rs:285 Hardcoded staging private key without production guardgenerate-test-request should refuse to run when --environment production is active

🟠 Correctness / Reliability

# File Issue
4 commands/proof.rs:274 std::process::exit(1) in run_verify skips the latency reporter in main and any future cleanup — return Err instead
5 commands/auth.rs:203 remote.to_string() == local.to_string() for Uint256 — use remote == local directly; string comparison is fragile
6 commands/proof.rs:118 .expect() panics on system-clock failure — propagate via wrap_err() + ? like the rest of the codebase
7 commands/auth.rs:127 No timeout on the register-wait polling loop — add a --timeout arg; infinite loop on network issues is a poor UX

🟡 UX / Quality

# File Issue
8 commands/proof.rs:26 proof verify always targets mainnet regardless of --environment; staging users will get confusing failures
9 commands/credential.rs:267 issue-test doesn't guard against production use of the staging faux-issuer URL
10 src/main.rs:56 Re-parsing std::env::args() for --json after clap already parsed it — just reuse the already-captured json_mode variable
11 commands/auth.rs:46 Registration logic copy-pasted between run_register and run_register_wait — extract a shared helper

The first three items (seed exposure + file permissions + staging key guard) should be resolved before merge; the rest are improvements that would meaningfully raise code quality.

@Dzejkop
Copy link
Contributor

Dzejkop commented Mar 17, 2026

/otto resolve ci issues

@agentotto
Copy link
Contributor

agentotto bot commented Mar 17, 2026

:orb-salute: On it — investigating CI failures now.

…r-name race

sqlite3mc_cipher_name() uses a process-global static char buffer that is
overwritten on every call. Concurrent sqlite3_open_v2 calls race on this
buffer, causing intermittent "unknown cipher 'chacha20'" errors when tests
run in parallel (e.g. cargo test --workspace).

Add a process-wide OnceLock<Mutex<()>> in ffi.rs that serializes all
sqlite3_open_v2 calls on the Rust side. Only the open call is serialized;
operations on established connections are unaffected.

This fixes test_null_handling which intermittently panicked with:
  "unknown cipher 'chacha20'" at walletkit-db/src/tests.rs:96

Upstream bug: utelle/SQLite3MultipleCiphers#228
Can be removed once a fixed sqlite3mc release is pinned.
@agentotto
Copy link
Contributor

agentotto bot commented Mar 17, 2026

CI Fix — 9b9eb64

What was failing

The Tests (1.92.0) job failed with a single test panic:

thread 'tests::test_null_handling' panicked at walletkit-db/src/tests.rs:96:
open in-memory db: DbError { code: DbErrorCode(1), message: "unknown cipher 'chacha20'" }

(Tests (stable) and Tests (nightly) were cancelled as a consequence of that failure.)

The Swift Build & Foreign Binding Tests job failed independently due to a transient network timeout downloading OPRFNullifier.arks.zkey from GitHub during the world-id-proof build step — unrelated to this PR's code changes. That should self-resolve on re-run.

Root cause

sqlite3mc_cipher_name() in SQLite3MultipleCiphers 2.2.7 writes the cipher name into a process-global static char buffer and returns a raw pointer into it. When multiple threads call sqlite3_open_v2() concurrently — which is the normal behavior of cargo test --workspace — they race on that buffer. One thread can reset the buffer to "" between the moment another thread sets it to "chacha20" and the moment it reads it back, causing the cipher-name lookup to fail with "unknown cipher 'chacha20'" even though the cipher is correctly compiled in.

With walletkit-cli added to the workspace, there are now more test binaries running concurrently, which made the race significantly more likely to trigger.

This is a known upstream bug: utelle/SQLite3MultipleCiphers#228

Fix applied

Added a process-wide OnceLock<Mutex<()>> in walletkit-db/src/ffi.rs that serializes all sqlite3_open_v2 calls. Only the open call is serialized (it's cheap); operations on already-established connections are completely unaffected. The mutex is held only for the duration of the single FFI call and is released immediately after sqlite3_open_v2 returns.

// ffi.rs
fn open_mutex() -> &'static Mutex<()> {
    static OPEN_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
    OPEN_MUTEX.get_or_init(|| Mutex::new(()))
}

// In RawDb::open():
let rc = {
    let _guard = open_mutex().lock().unwrap_or_else(PoisonError::into_inner);
    unsafe { raw::sqlite3_open_v2(...) }
};

This fix can be removed once an upstream sqlite3mc release that includes the C-level fix is integrated.

kilianglas and others added 3 commits March 17, 2026 22:43
- proof.rs: replace std::process::exit(1) with eyre::bail! so the
  latency reporter and cleanup hooks run on verification failure
- auth.rs: replace .to_string() equality comparison with direct
  PartialEq on Uint256 values
- main.rs: drop redundant std::env::args() re-parse; reuse the
  already-captured json_mode variable in the error handler
- auth.rs: extract do_register() helper shared by run_register and
  run_register_wait to eliminate duplicated registration logic
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