Minimal Starknet Wallet PoC in Leptos

The Connection Problem

I needed a clean way to connect Starknet browser wallets like ArgentX and Braavos to a Leptos frontend without adding more tooling than the bridge actually needed.

For this phase, the goal was not a full production wallet layer. It was a proof of concept that could answer one question clearly: can a Rust and WebAssembly frontend talk to injected Starknet wallets in a way that is simple enough to inspect, simple enough to debug, and stable enough to build on?

A Small Bridge, Not a Bigger Toolchain

The simplest working path was a thin JavaScript bridge plus wasm-bindgen.

The implementation broke into four parts:

  1. A plain starknetWallet.js file in frontend/public/ that talks directly to the injected wallet object on window.
  2. A Rust wallet_api.rs module with #[wasm_bindgen] bindings for the bridge functions.
  3. A Leptos wallet component that stores the connected address in reactive state and updates the UI from async wallet actions.
  4. A small index.html change so the bridge script loads before the WASM app starts.

This worked well for an MVP because it avoided bundlers, NPM packages, and extra frontend indirection. For this kind of bridge, I did not need a bigger machine. I needed a small adapter with tight tolerances and a failure surface I could actually inspect. The Rust side only needed to know that the JavaScript world exposed a few predictable functions, and wasm-bindgen handled the string and promise boundary.

The Rust side of that contract was small enough to stay readable:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(catch)]
    pub async fn connectStarknetWallet() -> Result<JsValue, JsValue>;

    pub fn getStarknetAddress() -> Option<String>;

    #[wasm_bindgen(catch)]
    pub async fn disconnectStarknetWallet() -> Result<JsValue, JsValue>;
}

That kept the boundary honest. Rust did not need to understand the wallet object directly. It only needed a few stable bridge functions and the right Promise behavior on the JavaScript side.

On the Leptos side, the whole flow was just signal state plus an async action:

let (address, set_address) = create_signal(None::<String>);

let connect = create_action(move |_: &()| {
    async move {
        match wallet_api::connectStarknetWallet().await {
            Ok(val) => {
                if let Some(addr) = val.as_string() {
                    set_address.set(Some(addr));
                }
            }
            Err(err) => {
                leptos::logging::error!("Failed to connect wallet: {:?}", err);
            }
        }
    }
});

One useful detail from testing: Braavos injected compatibility properties that made the wallet discovery path look like ArgentX at first glance. That looked odd in logs, but it turned out to be a real ecosystem quirk rather than a bug in the bridge.

Where the Fit Was Loose

The first pass also surfaced a few real issues:

  • Disconnect handling initially broke because the browser was serving an older cached starknetWallet.js. Rust expected a JavaScript Promise, but the stale script returned undefined, which caused a .then(...) runtime error inside the WASM boundary.
  • The disconnect path was swallowing errors too aggressively, which meant the Rust UI could think it had disconnected cleanly even if the JavaScript layer failed.
  • The connect button was vulnerable to duplicate clicks while a wallet prompt was pending.
  • The "already connected" check on mount was only backed by in-memory state, so it was not durable across refreshes.

The caching issue was the most instructive one. Rust expected a Promise. The browser served an older bridge file that returned undefined. That is exactly the kind of mismatch that can waste an afternoon if the glue layer is doing too much behind the curtain. Once the script URL was versioned to force a fresh load, the Rust and JavaScript sides lined up again and the disconnect flow started behaving correctly.

The fix itself was not glamorous, but it made the runtime contract explicit again:

<script src="/starknetWallet.js?v=2"></script>

What I Checked

The manual verification flow was straightforward:

  1. Run trunk serve in the frontend.
  2. Open the app in a browser with ArgentX or Braavos installed.
  3. Click the connect button and approve the wallet request.
  4. Confirm the Starknet address renders in the Leptos UI.
  5. Click disconnect and verify that the UI state and wallet bridge state both clear correctly.

That was enough to prove the main architectural point: a Leptos frontend can bridge into Starknet browser wallets with a very small JavaScript layer and standard wasm-bindgen bindings.

Next Tuning Pass

The next useful move is not adding more abstraction. It is tightening the edge cases in the current bridge so the flow is durable under refresh, pending actions, and JS-side failures before building more product logic on top of it.

If there is a cleaner pattern for making the reconnect state survive refresh without bloating the bridge, that is the next thing worth tuning.


699 Words

2026-04-03