npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

passkey-kit

v0.16.5

Published

A helper library for creating and using smart wallet accounts on the Stellar blockchain.

Readme

Passkey Kit

A TypeScript SDK for creating and using smart-wallet accounts on Stellar with WebAuthn passkeys. A wallet is a Soroban smart contract whose signers can be secp256r1 passkeys, Ed25519 keys, or policy contracts. The kit handles the WebAuthn ceremonies, deterministic wallet-address derivation, the flat multi-signer signing pipeline, fee-sponsored submission, and signer discovery.

  • Client (PasskeyKit) — runs in the browser: create/connect wallets, sign transactions, build signer-management transactions. Holds no secrets.
  • Server (PasskeyServer) — runs server-side: submits transactions through a relayer (fee sponsorship), plus convenience signer-discovery helpers over the keyless Mercury indexer. Holds the relayer secret.

[!CAUTION] Unaudited software. This repository's smart-wallet contract, SDKs, and relayer proxy have not received an independent third-party security audit. Defects can cause unauthorized transactions, loss of access, or permanent asset loss. Do not store or control assets you cannot afford to lose. Limit balances and signer permissions. Monitor wallets and maintain recovery and authorized upgrade paths. You use this software and related services at your own risk. The software has no warranty; see LICENCE, Caveats, and SECURITY.md.

[!NOTE] Looking for context rules, thresholds, and spending-limit policies? smart-account-kit is a sibling SDK built on the audited OpenZeppelin stellar-contracts account. It uses a different on-chain authorization model (context rules + an auth digest) than passkey-kit's flat Signatures map, so the two are not drop-in compatible — pick the model that fits your app.

Contents

Installation

pnpm add passkey-kit
# peer dependency
pnpm add @stellar/stellar-sdk

@stellar/stellar-sdk is a peer dependency (>=16.0.0); the kit targets Protocol 27 smart accounts, which earlier SDKs cannot express.

Packaging & exports

The package ships compiled ESM + type declarations from dist/ (not raw TypeScript — no bundler transpile step is required). It exposes three entry points, split so server secrets can never be pulled into a browser bundle:

| Import | Contents | Where it runs | |---|---|---| | passkey-kit | PasskeyKit, signers, types, errors, validation, crypto helpers, the keyless MercuryIndexer + indexer types | Browser or server | | passkey-kit/storage | MemoryStorage, LocalStorageAdapter, IndexedDBStorage | Browser (persistence) | | passkey-kit/server | PasskeyServer, RelayerClientholds the relayer secret | Server only |

// Browser
import { PasskeyKit, PasskeySigner, Ed25519Signer, SACClient, SignerKey, SignerStore } from "passkey-kit";
import { IndexedDBStorage } from "passkey-kit/storage";

// Server ONLY (never import from browser code — it carries the relayer secret)
import { PasskeyServer } from "passkey-kit/server";

Quick start

1. Configure the client (browser)

import { PasskeyKit } from "passkey-kit";

const kit = new PasskeyKit({
  rpcUrl: "https://soroban-testnet.stellar.org",
  networkPassphrase: "Test SDF Network ; September 2015",
  // Canonical smart-wallet WASM hash; see docs/deployments-*.md
  walletWasmHash: "502ea4e7bdb3ea99880941f1d35ceb67fb598692c0bb40f842ef9c9f17d58b58",
});

2. Create a wallet

createWallet runs the passkey registration ceremony and builds a signed deploy transaction. Submission is a separate, server-side step. The kit remains disconnected until the deployment succeeds and connectWallet verifies it.

const { keyIdBase64, contractId, signedTx } = await kit.createWallet(
  "My App",           // shown in the passkey prompt
  "[email protected]", // user identifier
);
// `signedTx` is a base64 XDR string ready to submit; `contractId` is the wallet address (C…).

3. Submit through the server

PasskeyServer submits via the relayer, which pays the fees. It never throws for expected failures — branch on result.success.

import { PasskeyServer } from "passkey-kit/server";

const server = new PasskeyServer({
  networkPassphrase: "Test SDF Network ; September 2015",
  rpcUrl: "https://soroban-testnet.stellar.org",
  relayer: {
    baseUrl: process.env.RELAYER_BASE_URL!, // e.g. https://channels.openzeppelin.com/testnet
    apiKey: process.env.RELAYER_API_KEY!,
  },
});

const result = await server.send(signedTx);
if (result.success) {
  console.log("deployed in tx", result.hash);
} else {
  console.error(`[${result.error.code}] ${result.error.message}`);
}

4. Connect after confirmation

const { contractId } = await kit.connectWallet({ keyId: keyIdBase64 });
// Resolves the wallet from the passkey and VERIFIES the passkey is a live signer on it.

5. Sign & submit a transfer

Build any Soroban transaction, sign its wallet auth entries with the connected passkey, then submit.

import { SACClient } from "passkey-kit";

const sac = new SACClient({
  rpcUrl: "https://soroban-testnet.stellar.org",
  networkPassphrase: "Test SDF Network ; September 2015",
});
const token = sac.getSACClient("C…nativeSacId");

const tx = await token.transfer({ from: kit.contractId!, to: "C…recipient", amount: 10_000_000n });
await kit.sign(tx);            // default signer = the connected passkey
const res = await server.send(tx);

PasskeyKit (client)

Browser-side facade for wallet lifecycle and signing. Holds no secrets.

Configuration

| Option | Type | Required | Description | |---|---|---|---| | rpcUrl | string | Yes | Stellar RPC URL. | | networkPassphrase | string | Yes | Network passphrase. | | walletWasmHash | string (hex) | Yes | Smart-wallet WASM hash used to deploy new wallets. | | acceptedWasmHashes | string[] (hex) | No | Code identities accepted when connecting to a wallet resolved from an untrusted source (indexer lookup or derivation). Defaults to [walletWasmHash]. Add each hash as upgrades roll out — see security notes. | | rpId | string | No | WebAuthn Relying Party id (domain). Defaults to the current origin. | | deploySource | string (S…) | No | Secret key for a deployer you control, which then sources and pays for its own deployments. Defaults to the canonical deterministic deployer, which is sign-only and never pays. Overriding it changes derived wallet addresses (see Deterministic derivation). | | timeoutInSeconds | number | No | Transaction time bound (default 30; the relayer requires <= 30). | | storage | StorageAdapter | No | Passkey-record persistence (see Storage adapters). | | WebAuthn | WebAuthnClient | No | Custom startRegistration/startAuthentication (for testing). |

Properties

| Property | Type | Description | |---|---|---| | keyId | string \| undefined | Connected passkey's base64url credential id. | | wallet | PasskeyClient \| undefined | Connected wallet's generated contract client. | | contractId | string \| undefined | Connected wallet address (getter). | | deployerPublicKey | string | The deployer's G… address (getter). Pays only when it is a custom deploySource. | | networkPassphrase / rpcUrl / walletWasmHash / rpId | string | The resolved config. | | events | PasskeyEventEmitter | Lifecycle events: walletCreated, walletConnected, walletDisconnected. |

Lifecycle methods

| Method | Returns | Description | |---|---|---| | createKey(appName, userName, options?) | CreatedPasskey | Run a passkey registration ceremony without deploying a wallet. | | createWallet(appName, userName, options?) | CreateWalletResult | Register a passkey and build a deploy carrier. The kit remains disconnected until submission succeeds and connectWallet verifies the wallet. For the shared deployer the carrier holds a signed authorization entry and no envelope signature. | | connectWallet(options?) | ConnectWalletResult | Resolve a wallet from a passkey (local storage → injected indexer lookup → deterministic derivation) and verify the passkey is a live signer on it. | | disconnect() | void | Clear the connected wallet/keyId. | | requireWallet() | PasskeyClient | Return the connected wallet or throw WalletNotConnectedError. |

options for createKey/createWallet is { authenticatorSelection?: AuthenticatorSelectionCriteria }.

connectWallet options:

| Option | Type | Description | |---|---|---| | keyId | string \| Uint8Array | Connect a specific credential, skipping the discovery ceremony. | | getContractId | (keyId: string) => Promise<string \| undefined> | Indexer-backed keyId → wallet lookup, tried after local storage and before deterministic derivation. | | verifyWasmHash | boolean | Override the code-identity check against acceptedWasmHashes. Defaults to on for indexer, derivation, primary prediction, and legacy storage rows. It defaults to off only for an explicitly secondary stored association, where an upgraded wallet would otherwise be locked out. |

Signing methods

| Method | Returns | Description | |---|---|---| | sign(txn, signer?, options?) | AssembledTransaction<T> | Sign every wallet auth entry of an assembled transaction. signer defaults to a PasskeySigner() for the connected passkey. | | signAuthEntry(entry, signer?, options?) | xdr.SorobanAuthorizationEntry | Sign a single auth entry. |

options is { expiration?: number } — the signature-expiration ledger (defaults to the configured timeout window).

[!IMPORTANT] sign/signAuthEntry now take a Signer instance as the second argument, replacing the old sign(txn, { keyId | keypair | policy }) option trio. See Signers.

Signers

A Signer authenticates a signature payload and produces the on-chain (SignerKey, Signature) pair the wallet's __check_auth expects. Three concrete signers cover the three signer kinds:

| Signer | Constructs | Notes | |---|---|---| | new PasskeySigner(keyId?) | secp256r1 WebAuthn assertion | keyId selects a credential; "any" lets the authenticator pick a discoverable one; omit to use the kit's connected passkey. Default for sign. | | new Ed25519Signer(keypair) / Ed25519Signer.fromSecret("S…") | Ed25519 signature | Local Stellar keypair. .address returns the G… key. | | new PolicySigner(policyAddress) | policy authorization | No signature bytes; the wallet invokes the policy's policy__ during __check_auth. |

import { PasskeySigner, Ed25519Signer, PolicySigner } from "passkey-kit";

await kit.sign(tx);                                   // connected passkey
await kit.sign(tx, new Ed25519Signer(keypair));       // Ed25519
await kit.sign(tx, new PolicySigner("C…policyAddr")); // policy co-sign
await kit.sign(tx, new PasskeySigner("any"));         // any discoverable passkey

Multi-sign by signing the same transaction with several signers in turn — each merges its entry into the flat Signatures map (host-ordered):

await kit.sign(tx, new PasskeySigner());
await kit.sign(tx, new Ed25519Signer(cosignerKeypair));

Signer management

Each method builds an AssembledTransaction (WalletTx) that wraps one contract admin function — submit it via PasskeyServer.send. A wallet must be connected.

| Method | Wrapped contract fn | Description | |---|---|---| | addSecp256r1(keyId, publicKey, limits, store, expiration?) | add_signer | Add a passkey signer. | | updateSecp256r1(keyId, limits, store, expiration?) | update_signer | Update a passkey signer's limits/storage/expiration. The public key is re-read from the ledger, never caller-supplied. | | addEd25519(publicKey, limits, store, expiration?) | add_signer | Add an Ed25519 signer (publicKey = G…). | | updateEd25519(publicKey, limits, store, expiration?) | update_signer | Update an Ed25519 signer. | | addPolicy(policy, limits, store, expiration?) | add_signer | Add a policy signer (policy = C…). Invokes the policy's install hook. | | updatePolicy(policy, limits, store, expiration?) | update_signer | Update a policy signer. | | remove(signerKey) | remove_signer | Remove a signer. A policy entry must pass its own policy__ check. | | upgrade(newWasmHash) | upgrade | Replace the wallet's WASM (Buffer/Uint8Array, 32 bytes). | | getSigner(signerKey) | get_signer | Read a signer entry from the ledger (temporary before persistent). Returns SignerVal \| null. |

Parameters:

  • keyId — passkey credential id (base64url string or raw Uint8Array).
  • publicKey — 65-byte secp256r1 key (string/Uint8Array) for passkeys, or a G… Stellar public key for Ed25519.
  • policy — policy contract address (C…).
  • limitsSignerLimits (undefined = fully unlimited).
  • storeSignerStore.Persistent or SignerStore.Temporary.
  • expiration — optional UNIX-timestamp (seconds) after which the signer is invalid.

[!IMPORTANT] Signer key material comes from the ledger or the authenticator, not from an indexer. Indexer rows (getSigners, WalletSigner.publicKey) are for display/lookup only. The kit's updateSecp256r1 re-reads the key from the ledger, and addSecp256r1 should only ever receive the key from the WebAuthn createKey/connectWallet attestation. If you build update_signer/add_signer transactions yourself (e.g. via buildSecp256r1SignerTx), source key material from the ledger (getSigner) or the authenticator.

import { SignerStore, SignerKey } from "passkey-kit";

// Add an unlimited Ed25519 co-signer, stored persistently.
const tx = await kit.addEd25519("G…", undefined, SignerStore.Persistent);
await kit.sign(tx);                 // authorize with the connected passkey
await server.send(tx);

// Remove it.
const rm = await kit.remove(SignerKey.Ed25519("G…"));
await kit.sign(rm);
await server.send(rm);

PasskeyServer (server)

Server-only facade (passkey-kit/server). Holds the relayer secret — never import it from browser code.

Configuration

| Option | Type | Required | Description | |---|---|---|---| | networkPassphrase | string | Yes | Network passphrase. | | rpcUrl | string | No | Stellar RPC URL. Enables the temporary-signer eviction probe in getSigners. | | relayer | RelayerClientConfig | No | Fee-sponsored submission (below). | | mercury | MercuryConfig | No | Mercury's keyless hosted passkey-indexer (below). |

RelayerClientConfig: { baseUrl: string, apiKey: string, adminSecret?: string, timeout?: number } (default timeout 6 min). MercuryConfig: { url?: string } — the keyless passkey-indexer base URL; defaults to the network's hosted endpoint (https://{testnet,mainnet}.mercurydata.app/rest/passkey-indexer), so it can be omitted.

Methods

| Method | Returns | Description | |---|---|---| | send(input, options?) | TransactionResult | Submit an AssembledTransaction \| Transaction \| string via the relayer. Picks the { func, auth } Soroban path for wallet invocations and the { xdr } fee-bump path for deploys / source-account auth. Never throws. | | getTransaction(transactionId) | TransactionResult | Poll a skipWait submission by its relayer id. | | getSigners(contractId) | WalletSigner[] | Enumerate a wallet's signers via the indexer (flags evicted temporary signers when rpcUrl is set). | | getContractId(options, index?) | string \| undefined | Reverse lookup: the wallet address for a signer. options = exactly one of { keyId }, { publicKey }, { policy }. |

options for send/getTransaction is { skipWait?: boolean, fundRelayerId?: string }. getSigners/getContractId delegate to a MercuryIndexer over the keyless hosted endpoint.

Submission (relayer)

All wallet writes are fee-sponsored by the OpenZeppelin Relayer Channels service: a channel account builds/pays for the transaction so the wallet holds no XLM. PasskeyServer.send routes to the relayer and returns a discriminated TransactionResult.

Two submission modes are chosen automatically:

  • { func, auth } (submitSorobanTransaction) — for wallet invocations (transfers, signer management) whose auth is carried by Address credentials. The relayer builds the envelope.
  • { xdr } (submitTransaction) — for an already-signed envelope that needs a fee bump (custom-deploySource deploys / source-account auth). Shared-deployer deploys never take this path.

Browsers: the relayer-proxy worker

The relayer API key is a secret, so a browser must never hold it. The relayer-proxy/ Cloudflare Worker fronts the relayer and mints one API key per client IP (keyless, cached in a per-IP Durable Object). The browser POSTs { func, auth } / { xdr } to the worker with zero secrets in the bundle. See relayer-proxy/README.md.

Discovery (indexer)

The "reconnect" path needs no indexer: connectWallet resolves from storage or deterministic derivation, then confirms ownership on-chain. A stored primary row remains a deployment prediction and receives the code-identity check. Only an explicit secondary association is trusted local state. An indexer supports richer discovery across a wallet's full signer set.

The SDK abstracts discovery behind a SignerIndexer interface (getSigners / findWallets / health), implemented by the keyless MercuryIndexer — exported from the main passkey-kit entry (no secret, so it runs in the browser):

| Backend | Config | Wire | Status | |---|---|---|---| | MercuryIndexer | MercuryIndexerConfig (url?, rpc?) | Keyless REST (GET /api/wallet/*, /api/lookup/*) | Live on testnet + mainnet — both signer generations, full history. Resolve with MercuryIndexer.forNetwork(...). |

[!NOTE] Mercury's hosted passkey-indexer is public and keyless, covering testnet and mainnet with full history across both signer generations (legacy ("sw_v1", …) tuples and the v1 typed #[contractevent]s). It returns fully-decoded signers, so the client maps JSON straight onto WalletSigner. Resolve the base URL per network with MercuryIndexer.forNetwork({ rpc? }, networkPassphrase) (returns null off testnet/mainnet); passing an rpc lets it flag evicted temporary signers and confirm reverse-lookup candidates on-chain.

const indexer = MercuryIndexer.forNetwork({ rpc }, networkPassphrase);
const wallets = await lookupWithRetry(() => indexer!.findWallets(SignerKey.Secp256r1(keyId)));

lookupWithRetry(fn, { attempts?, delayMs?, predicate? }) (browser-safe) polls a lookup until it returns a non-empty result — useful right after a write, while the indexer catches up to the ledger.

Tokens (SACClient)

Helper for Stellar Asset Contracts (SEP-41): balances, metadata, and transfers.

import { SACClient, buildTokenTransferHostFunction } from "passkey-kit";

const sac = new SACClient({ rpcUrl, networkPassphrase });
const token = sac.getSACClient("C…tokenId");

const tx = await token.transfer({ from: kit.contractId!, to, amount: 1_000_000n });
await kit.sign(tx);
await server.send(tx);

buildTokenTransferHostFunction(token, from, to, amountInStroops) builds a raw transfer host function for the low-level relayer { func, auth } path when you don't need a full client.

Storage adapters

Persist the passkey → wallet association so connectWallet can resolve a wallet from a keyId without an indexer. Import from passkey-kit/storage and pass to the kit's storage config.

| Adapter | Backing store | Use | |---|---|---| | IndexedDBStorage | IndexedDB | Browser (recommended). | | LocalStorageAdapter | localStorage | Browser (simple/synchronous). | | MemoryStorage | in-memory | Tests / SSR. |

import { IndexedDBStorage } from "passkey-kit/storage";
const kit = new PasskeyKit({ rpcUrl, networkPassphrase, walletWasmHash, storage: new IndexedDBStorage() });

All adapters implement StorageAdapter (save / get / getByContract / getAll / delete / update / clear) over StoredPasskey records.

The kit stores deployment credentials with isPrimary: true. It stores added passkeys with isPrimary: false. A legacy row without this value receives the code-identity check.

Errors

Every error the kit throws is a PasskeyKitError (or subclass) carrying a numeric code, optional context, and cause. Branch on error.code (or instanceof) — never on message strings.

One deliberate exception: submission methods (server.send, getTransaction) do not throw for expected on-chain/relayer failures. They return a discriminated TransactionResult:

const result = await server.send(tx);
if (result.success) {
  // TransactionSuccess: { success: true, hash, ledger?, transactionId? }
  console.log(result.hash);
} else {
  // TransactionFailure: { success: false, error: PasskeyKitError, hash? }
  if (result.error instanceof ContractError && result.error.contractErrorName === "SignerExpired") {
    // handle an on-chain contract failure by its decoded name
  }
}

Error classes: ConfigurationError, WalletNotConnectedError, WalletOwnershipError, WebAuthnError, SigningError, SignerNotFoundError, SimulationError, SubmissionError, ValidationError, IndexerError, RelayerError, and ContractError. Codes are grouped by concern (1xxx config, 2xxx wallet, 3xxx WebAuthn, 4xxx signing, 5xxx transaction, 6xxx indexer, 7xxx relayer, 8xxx validation, 9xxx storage, 10000 contract).

Contract error decoding

On-chain failures surface as Error(Contract, #N) in diagnostics. decodeContractError, contractErrorFromCode, and CONTRACT_ERROR_REGISTRY map the code to a typed ContractError with its enum name. The v1 contract renumbered its error space to 100–129 (disjoint from the legacy 1–9 range, which is still decoded as family SmartWalletLegacy):

| Code | Name | Meaning | |---|---|---| | 100 | SignerNotFound | The requested signer does not exist. | | 101 | SignerAlreadyExists | add_signer on an existing key. | | 102 | SignerExpired | Expiration timestamp is in the past. | | 110 | MissingContext | No signer in the map may authorize a requested context. | | 111 | SignatureKeyValueMismatch | A signature's variant doesn't match its stored signer. | | 120 | ClientDataJsonTooLarge | clientDataJSON exceeds the 1024-byte parse buffer. | | 121 | ClientDataJsonParseError | clientDataJSON is not parseable. | | 122 | ClientDataJsonChallengeIncorrect | WebAuthn challenge ≠ signature payload (binding). | | 123 | InvalidWebAuthnType | type is not "webauthn.get". | | 124 | InvalidAuthenticatorData | authenticatorData shorter than 37 bytes. | | 125 | UserPresenceRequired | Authenticator did not set the User Present (UP) flag. |

Types

SignerKey

Identifies a signer. The value is the string you work with.

SignerKey.Secp256r1(keyId)     // base64url passkey credential id
SignerKey.Ed25519(publicKey)   // G… public key
SignerKey.Policy(address)      // C… policy contract

SignerLimits

type SignerLimits = Map<string, SignerKey[] | undefined> | undefined;
  • undefined (whole map) — fully unlimited: may authorize anything, including deploys and this wallet's own admin functions.
  • Map present but a contract → undefined — may authorize any call to that contract, no co-signers.
  • Map present, contract → [keys] — may authorize calls to that contract only if every listed key also approves (required co-signers).

A required policy key must remain installed as a signer and unexpired. Removing that policy immediately revokes every signer whose limits require it. A Signature::Policy always invokes policy__, including for the policy's own remove_signer context.

// This signer may only call C…token, and only alongside a passkey co-signer.
const limits = new Map([["C…token", [SignerKey.Secp256r1(keyId)]]]);

[!IMPORTANT] v1 breaking change. Some(empty map) now means no permissions (fail-closed) — pre-1.0 an empty map meant unlimited. Deploy permission is no longer grantable through a limits entry: CreateContract* contexts require a fully unlimited (undefined-limits) signer.

SignerStore

enum SignerStore { Persistent = "Persistent", Temporary = "Temporary" }

Temporary entries are cheaper but can be evicted when their ledger TTL lapses — see Caveats.

Expiration

Signer and signature expiration are UNIX timestamps in seconds (inclusive: valid while now <= expiration). Pre-1.0 these were ledger sequence numbers; timestamps don't drift as ledger close-time changes.

Caveats

[!WARNING] These risks are not exhaustive. Tests and reviews do not prove that the software has no defects.

  • Limit value and authority. Limit wallet balances, signer permissions, policy allowances, and relayer permissions. Monitor wallet activity. Keep independent recovery, submission, and authorized upgrade paths.
  • Hosted services can fail or return stale data. Do not treat relayer or indexer responses as authoritative chain state. Confirm security-sensitive state through Stellar RPC.
  • Keep at least one durable admin signer. The contract rejects any change that would remove or demote its last durable (Persistent, non-expiring) admin signer (LastAdminSigner = 103) or leave it without any durable signer (LastSigner = 104), so a wallet always retains one signer that cannot evict or expire. Signers outside that guard — Temporary storage or with an expiration — lapse on their own: add a replacement before removing or demoting an existing signer.
  • The default deployer is a shared, public keypair — its secret is publicly derivable. It salts deployment and signs only the CreateContractV2 authorization entry; the relayer supplies the envelope source, sequence, and fees. It never controls the wallet. Its determinism is load-bearing for discovery: overriding deploySource changes every derived address and breaks keyId → wallet lookup. Use a separate funded restoreSource for restoreFootprint; never fund the shared deployer. A third-party bumpSequence to INT64_MAX no longer blocks the current SDK because it never uses the shared deployer as an envelope source. Full analysis: docs/security-deterministic-deployer.md.
  • Deploy front-running remains an accepted residual. Anyone who learns a keyId before deployment could place arbitrary code at the derived address. A signer getter check alone is not proof of ownership, because arbitrary code can answer it however the client expects. connectWallet binds accepted code identity before reading signer state for indexer results, derived addresses, primary predictions, and legacy storage rows. Only an explicit secondary association skips the default check. A derivation-resolved address is still not authenticated by that check alone — see the security analysis.
  • WebAuthn requires User Presence (UP), not User Verification (UV). The contract requires the UP flag but not UV (biometric/PIN), so it stays compatible with non-UV authenticators. Enforce UV at the client/relayer layer if you need it.
  • Value-moving policies need a cumulative cap or a co-signer. A Signature::Policy carries no secret, so a per-transfer cap alone is trivially drained by repeated capped transfers. See the contract interface and sample-policy.
  • Existing binver = 1.0.0 wallets require an authorized upgrade. Installing the fixed WASM does not change deployed wallet instances. Upgrade each wallet to the canonical binver = 1.0.1 hash in the current deployment manifest.

Contract interface

The wallet is a Soroban smart contract (soroban-sdk 27, wasm32v1-none). Every user wallet is a separate instance deployed with a Signer constructor argument.

Functions: __constructor(signer) · add_signer(signer) · update_signer(signer) · remove_signer(signer_key) · upgrade(new_wasm_hash) · get_signer(signer_key) -> Option<SignerVal>. Admin functions require wallet auth (__check_auth).

Signer kinds: Policy(Address) · Ed25519(BytesN<32>) · Secp256r1(Bytes keyId), each with a SignerExpiration, SignerLimits, and SignerStorage.

Auth (__check_auth): a flat Signatures map (SignerKey → Signature) signed over the plain signature payload. Pass 1 checks every requested context is covered by some permitted, unexpired signer; pass 2 verifies every entry in the map (existence, expiration, crypto/policy). Include only the signatures you need.

Policy lifecycle: policy signers get an install(wallet) hook on add (a hard call — a panic aborts the add) and a permissionless uninstall(wallet) self-clean entrypoint. policy__ is publicly callable — stateful policies must authenticate the caller (source.require_auth()).

Events (#[contractevent], SEP-48 schema in the WASM): signer_added · signer_updated · signer_removed · upgraded. These replace the legacy ("sw_v1", …) tuple events; indexers consume them directly.

See contracts/smart-wallet-interface/src/ for the canonical trait and types.

Deterministic derivation

Every wallet address is derived from its passkey credential id (keyId) alone — this is what lets connectWallet and the indexers resolve a wallet without a lookup table:

contractId = sha256(XDR(HashIdPreimage::EnvelopeTypeContractId {
    networkId:          sha256(networkPassphrase),
    contractIdPreimage: ContractIdPreimageFromAddress {
        address: G-address of the canonical deployer keypair,
        salt:    sha256(keyId),
    },
}))
  • The canonical deployer keypair is Keypair.fromRawEd25519Seed(sha256("kalepail")). It salts the deploy and signs the deploy authorization — it never pays fees and never controls the wallet — but its determinism is load-bearing: overriding deploySource changes every derived address and breaks keyId → wallet discovery.
  • The WASM hash is deliberately not in the preimage, so an upgrade never moves a wallet's address.

This tuple is normative and must never change. See docs/deployments-2026-08-19.md for the canonical WASM hash, upload transactions, and upgrade guidance.

Repository layout & development

| Path | Contents | |---|---| | src/ | The passkey-kit SDK (client, server, signers, indexer, storage). | | packages/passkey-kit-sdk | Generated smart-wallet contract bindings (do not hand-edit — see releasing). | | packages/sac-sdk | Generated SEP-41 SAC bindings. | | contracts/ | Rust Soroban contracts: smart-wallet, smart-wallet-interface, sample-policy, example-contract. | | relayer-proxy/ | Cloudflare Worker for keyless, fee-sponsored submission. | | demo/ | Svelte 5 demo exercising the full client API. |

pnpm install
pnpm build            # regenerate bindings, compile to dist/, verify Node-ESM import
pnpm test             # vitest (co-located src/*.test.ts)
pnpm verify:bindings  # assert the committed bindings match the canonical WASM

Resources