(async (resolve) => {
- const now = new Date();
-
- now.setMinutes(0, 0, 0);
-
- const nowData = new TextEncoder().encode(now.getTime().toString());
- const hashBuffer = await crypto.subtle.digest('SHA-256', nowData);
- const keypair = Keypair.fromRawEd25519Seed(Buffer.from(hashBuffer))
- const publicKey = keypair.publicKey()
-
- rpc.getAccount(publicKey)
- .catch(() => rpc.requestAirdrop(publicKey))
- .catch(() => { })
-
- resolve(keypair)
-})
-export const fundPubkey = (await fundKeypair).publicKey()
-export const fundSigner = basicNodeSigner(await fundKeypair, import.meta.env.VITE_networkPassphrase)
-
-export const account = new PasskeyKit({
- rpcUrl: import.meta.env.VITE_rpcUrl,
- networkPassphrase: import.meta.env.VITE_networkPassphrase,
- walletWasmHash: import.meta.env.VITE_walletWasmHash,
-});
-export const server = new PasskeyServer({
- rpcUrl: import.meta.env.VITE_rpcUrl,
- relayerUrl: import.meta.env.VITE_relayerUrl,
- relayerApiKey: import.meta.env.VITE_relayerApiKey,
- mercuryProjectName: import.meta.env.VITE_mercuryProjectName,
- mercuryUrl: import.meta.env.VITE_mercuryUrl,
- mercuryJwt: import.meta.env.VITE_mercuryJwt,
-});
-
-export const sac = new SACClient({
- rpcUrl: import.meta.env.VITE_rpcUrl,
- networkPassphrase: import.meta.env.VITE_networkPassphrase,
-});
-export const native = sac.getSACClient(import.meta.env.VITE_nativeContractId)
\ No newline at end of file
diff --git a/demo/src/lib/components/ActivityLog.svelte b/demo/src/lib/components/ActivityLog.svelte
new file mode 100644
index 0000000..c3ffe98
--- /dev/null
+++ b/demo/src/lib/components/ActivityLog.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+
Activity
+
+
+
+
+ {#each app.log as entry (entry.id)}
+
+
+ {entry.message}
+ {time(entry.at)}
+
+ {#if entry.detail}
{entry.detail}
{/if}
+ {#if entry.hash}
+
+ {/if}
+
+ {:else}
+
No activity yet.
+ {/each}
+
+
diff --git a/demo/src/lib/components/AddSignerForm.svelte b/demo/src/lib/components/AddSignerForm.svelte
new file mode 100644
index 0000000..6671f98
--- /dev/null
+++ b/demo/src/lib/components/AddSignerForm.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+ {#each kinds as k}
+
+ {/each}
+
+
+ {#if kind === "Secp256r1"}
+
+
+
+
+ {:else if kind === "Ed25519"}
+
+ Uses the ephemeral Ed25519 key
+ {#if app.ed25519Public}{app.ed25519Public.slice(0, 10)}…{:else}(generated on demand){/if}.
+
+
+ {:else}
+
Policy contract {config.samplePolicyId?.slice(0, 10)}…
+ {/if}
+
+
+
+ {#each [SignerStore.Persistent, SignerStore.Temporary] as s}
+
+ {/each}
+
+
+
+
days
+
+
+
+
+
+
diff --git a/demo/src/lib/components/DiscoveryPanel.svelte b/demo/src/lib/components/DiscoveryPanel.svelte
new file mode 100644
index 0000000..80185c1
--- /dev/null
+++ b/demo/src/lib/components/DiscoveryPanel.svelte
@@ -0,0 +1,73 @@
+
+
+
+ Signer discovery
+
+ {#if !indexer.configured}
+
+ Indexer proxy not configured — set VITE_indexerProxyUrl to query Mercury / Stellar
+ Indexer. (Both backends are verified live in the F2 e2e.)
+
+ {/if}
+
+
+
Backend
+
+
+ {#each INDEXER_BACKENDS as b}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ {#if app.discovered.length}
+
+ {#each app.discovered as row}
+ -
+
+ {row.key.kind}
+ {row.status}
+
+ {shortId(row.key.value, 12)}
+
+ {row.storage}{row.expiration ? ` · expires ${new Date(row.expiration * 1000).toISOString().slice(0, 10)}` : ""}
+
+
+ {/each}
+
+ {:else}
+ No discovery results yet.
+ {/if}
+
diff --git a/demo/src/lib/components/Header.svelte b/demo/src/lib/components/Header.svelte
new file mode 100644
index 0000000..a424075
--- /dev/null
+++ b/demo/src/lib/components/Header.svelte
@@ -0,0 +1,31 @@
+
+
+
diff --git a/demo/src/lib/components/LimitsBuilder.svelte b/demo/src/lib/components/LimitsBuilder.svelte
new file mode 100644
index 0000000..763408f
--- /dev/null
+++ b/demo/src/lib/components/LimitsBuilder.svelte
@@ -0,0 +1,79 @@
+
+
+
+
Signer limits
+
+ {#each modes as m}
+
+ {/each}
+
+
{modes.find((m) => m.id === spec.mode)?.hint}
+
+ {#if spec.mode === "restricted"}
+
+ {#each spec.entries as entry, i}
+
+
+ Contract {i + 1}
+
+
+
+ {#each entry.keys as key, j}
+
+
+
+
+
+ {/each}
+
+ {#if entry.keys.length === 0}
+
No keys ⇒ unrestricted on this contract.
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/demo/src/lib/components/SignerRow.svelte b/demo/src/lib/components/SignerRow.svelte
new file mode 100644
index 0000000..40f40a0
--- /dev/null
+++ b/demo/src/lib/components/SignerRow.svelte
@@ -0,0 +1,100 @@
+
+
+
+
+
+ {signer.kind}
+ {#if isAdmin(signer)}admin{:else}session{/if}
+ {#if signer.self}this passkey{/if}
+ {#if active}active signer{/if}
+ {signer.store}
+
+
+
+ {shortId(signer.value, 12)}
+
+ {signer.label ?? ""} · limits: {signer.limitsMode} · expires: {expiryLabel}
+
+
+
+
+ {#if signer.kind === "Secp256r1" && isAdmin(signer) && !active}
+
+ {/if}
+
+
+
+
+ {#if editing}
+
+
+
Update signer
+
+
+ {#each [SignerStore.Persistent, SignerStore.Temporary] as s}
+
+ {/each}
+
+
+
+
days
+
+
+
+
+
+
+
+ {/if}
+
diff --git a/demo/src/lib/components/SignersPanel.svelte b/demo/src/lib/components/SignersPanel.svelte
new file mode 100644
index 0000000..fc466cd
--- /dev/null
+++ b/demo/src/lib/components/SignersPanel.svelte
@@ -0,0 +1,35 @@
+
+
+
diff --git a/demo/src/lib/components/TransferPanel.svelte b/demo/src/lib/components/TransferPanel.svelte
new file mode 100644
index 0000000..a4f429d
--- /dev/null
+++ b/demo/src/lib/components/TransferPanel.svelte
@@ -0,0 +1,113 @@
+
+
+
+ Balance & transfers
+
+
+
+
+
+
+
+
+
{balance ? fromStroops(balance) : "—"}
+
{token?.label}
+
+
+
+
+
+
+
+
+
+ Only XLM (native) can be funded; other tokens must be acquired separately.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sign with
+
+
+ {#each signOptions as o}
+
+ {/each}
+
+
+
+ {#if signWith === "ed25519" || signWith === "policy" || signWith === "multisig"}
+
+ Ephemeral Ed25519:
+ {#if app.ed25519Public}{shortId(app.ed25519Public, 6)}{:else}none{/if}
+
+
+ {/if}
+
+
+
diff --git a/demo/src/lib/components/WalletPanel.svelte b/demo/src/lib/components/WalletPanel.svelte
new file mode 100644
index 0000000..ee4919a
--- /dev/null
+++ b/demo/src/lib/components/WalletPanel.svelte
@@ -0,0 +1,69 @@
+
+
+
+ Wallet
+
+ {#if connected}
+
+
Smart wallet
+
{app.contractId}
+
+ active signer: {shortId(app.activeKeyId, 10)}
+
+
+
+
+
+
+ {:else}
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+ {#if app.knownPasskeys.length}
+
+ Known passkeys ({app.knownPasskeys.length})
+
+ {/if}
+
diff --git a/demo/src/lib/config.ts b/demo/src/lib/config.ts
new file mode 100644
index 0000000..b931508
--- /dev/null
+++ b/demo/src/lib/config.ts
@@ -0,0 +1,127 @@
+/**
+ * Public demo configuration + shared singletons.
+ *
+ * Everything constructed here is browser-safe. Submission and indexer discovery
+ * go through server-side proxy workers (URLs below) so no relayer key or indexer
+ * JWT is ever bundled. The old demo inlined the relayer API key + Mercury JWT and
+ * a hard-coded Stellar secret seed into the client — all purged.
+ */
+
+import { Buffer } from "buffer";
+import { Account, Keypair, StrKey } from "@stellar/stellar-sdk";
+import { Server } from "@stellar/stellar-sdk/rpc";
+import { basicNodeSigner } from "@stellar/stellar-sdk/contract";
+import { PasskeyKit, SACClient } from "passkey-kit";
+import { LocalStorageAdapter } from "passkey-kit/storage";
+import { RelayerProxyClient } from "./relayer-proxy";
+import { IndexerProxyClient } from "./indexer-proxy";
+
+const env = import.meta.env;
+
+/** A SAC/SEP-41 token the demo can transfer/read. */
+export interface TokenOption {
+ label: string;
+ contractId: string;
+ native: boolean;
+}
+
+function parseTokens(): TokenOption[] {
+ const tokens: TokenOption[] = [
+ { label: "XLM (native)", contractId: env.VITE_nativeContractId, native: true },
+ ];
+ const extra = env.VITE_extraTokenIds?.trim();
+ if (extra) {
+ for (const raw of extra.split(",")) {
+ const part = raw.trim();
+ if (!part) continue;
+ const idx = part.indexOf(":");
+ const [label, contractId] =
+ idx > 0
+ ? [part.slice(0, idx).trim(), part.slice(idx + 1).trim()]
+ : [`${part.slice(0, 4)}…${part.slice(-4)}`, part];
+ if (contractId) tokens.push({ label, contractId, native: false });
+ }
+ }
+ return tokens;
+}
+
+/** Typed, public-only config surface. */
+export const config = {
+ rpcUrl: env.VITE_rpcUrl,
+ networkPassphrase: env.VITE_networkPassphrase,
+ walletWasmHash: env.VITE_walletWasmHash,
+ nativeContractId: env.VITE_nativeContractId,
+ samplePolicyId: env.VITE_samplePolicyId?.trim() || undefined,
+ relayerProxyUrl: env.VITE_relayerProxyUrl?.trim() || undefined,
+ indexerProxyUrl: env.VITE_indexerProxyUrl?.trim() || undefined,
+ tokens: parseTokens(),
+};
+
+/** Which network the explorer links point at. */
+export const network: "testnet" | "public" = config.networkPassphrase.includes(
+ "Test",
+)
+ ? "testnet"
+ : "public";
+
+export const explorerTx = (hash: string): string =>
+ `https://stellar.expert/explorer/${network}/tx/${hash}`;
+export const explorerContract = (contractId: string): string =>
+ `https://stellar.expert/explorer/${network}/contract/${contractId}`;
+
+export const rpc = new Server(config.rpcUrl);
+
+/** Passkey-record storage (adapter, not hand-rolled localStorage). */
+export const storage = new LocalStorageAdapter();
+
+/** The browser-side kit. Signs; never submits (that's the relayer proxy). */
+export const account = new PasskeyKit({
+ rpcUrl: config.rpcUrl,
+ networkPassphrase: config.networkPassphrase,
+ walletWasmHash: config.walletWasmHash,
+ storage,
+});
+
+/** SAC factory for token balance reads + transfers. */
+export const sac = new SACClient({
+ rpcUrl: config.rpcUrl,
+ networkPassphrase: config.networkPassphrase,
+});
+
+/** Server-side submission (fee-sponsored, keyless — worker mints the key). */
+export const relayer = new RelayerProxyClient(config.relayerProxyUrl);
+
+/** Server-side signer discovery (Mercury + Stellar Indexer behind the proxy). */
+export const indexer = new IndexerProxyClient(config.indexerProxyUrl);
+
+/**
+ * An ephemeral testnet FUNDING source — a deterministic per-hour keypair kept
+ * topped up by Friendbot. This is NOT a wallet secret: it holds only throwaway
+ * testnet XLM used to seed freshly created wallets. Regenerated every hour.
+ */
+export const fundKeypair: Promise = (async () => {
+ const now = new Date();
+ now.setMinutes(0, 0, 0);
+ const seed = new TextEncoder().encode(now.getTime().toString());
+ const hash = await crypto.subtle.digest("SHA-256", seed);
+ const keypair = Keypair.fromRawEd25519Seed(Buffer.from(hash));
+
+ rpc
+ .getAccount(keypair.publicKey())
+ .catch(() => rpc.requestAirdrop(keypair.publicKey()))
+ .catch(() => {});
+
+ return keypair;
+})();
+
+export async function fundPubkey(): Promise {
+ return (await fundKeypair).publicKey();
+}
+
+export async function fundSigner() {
+ return basicNodeSigner(await fundKeypair, config.networkPassphrase);
+}
+
+/** A never-funded placeholder source for read-only simulation. */
+export const mockPubkey = StrKey.encodeEd25519PublicKey(Buffer.alloc(32));
+export const mockSource = new Account(mockPubkey, "0");
diff --git a/demo/src/lib/format.ts b/demo/src/lib/format.ts
new file mode 100644
index 0000000..c9060bc
--- /dev/null
+++ b/demo/src/lib/format.ts
@@ -0,0 +1,77 @@
+/**
+ * Display + typed-error formatting helpers.
+ */
+
+import { ContractError, PasskeyKitError } from "passkey-kit";
+
+/** Abbreviate a long id (contract, key, hash) as `ABCDEF…UVWXYZ`. */
+export function shortId(id: string | undefined, edge = 6): string {
+ if (!id) return "";
+ return id.length <= edge * 2 + 1 ? id : `${id.slice(0, edge)}…${id.slice(-edge)}`;
+}
+
+const STROOPS_PER_UNIT = 10_000_000n;
+
+/**
+ * Format i128 stroops as a human XLM/token amount (7 decimals), using exact
+ * BigInt math (no JS `Number`, which loses integer precision above ~9e15).
+ */
+export function fromStroops(stroops: string | bigint | undefined): string {
+ if (stroops === undefined) return "—";
+ const value = BigInt(stroops);
+ const negative = value < 0n;
+ const abs = negative ? -value : value;
+ const whole = abs / STROOPS_PER_UNIT;
+ const frac = (abs % STROOPS_PER_UNIT).toString().padStart(7, "0").replace(/0+$/, "");
+ return `${negative ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
+}
+
+/**
+ * Parse a human token amount into i128 stroops. String inputs are parsed with
+ * exact decimal→BigInt math (no `Number` rounding); numbers fall back to a safe
+ * scale-and-round (demo-scale amounts only).
+ */
+export function toStroops(amount: string | number): bigint {
+ const str = typeof amount === "string" ? amount.trim() : String(amount);
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(str);
+ if (!match) {
+ // Scientific notation etc. from a number input: scale then round.
+ const n = Number(amount);
+ if (!Number.isFinite(n) || n < 0) throw new Error("Invalid amount");
+ return BigInt(Math.round(n * 10_000_000));
+ }
+ const whole = BigInt(match[1]);
+ const frac = BigInt((match[2] ?? "").padEnd(7, "0").slice(0, 7));
+ return whole * STROOPS_PER_UNIT + frac;
+}
+
+/** A structured, human-readable description of any thrown value. */
+export interface ErrorDescription {
+ name: string;
+ message: string;
+ code?: number;
+ contract?: string;
+}
+
+/**
+ * Turn any thrown value into a typed, surfacing-friendly description. Recognizes
+ * the SDK's {@link PasskeyKitError} hierarchy so the UI shows real error codes
+ * and decoded contract-error names instead of `alert(err.message)`.
+ */
+export function describeError(err: unknown): ErrorDescription {
+ if (err instanceof ContractError) {
+ return {
+ name: err.name,
+ message: err.message,
+ code: err.code,
+ contract: `#${err.contractCode} ${err.contractErrorName} (${err.family})`,
+ };
+ }
+ if (err instanceof PasskeyKitError) {
+ return { name: err.name, message: err.message, code: err.code };
+ }
+ if (err instanceof Error) {
+ return { name: err.name || "Error", message: err.message };
+ }
+ return { name: "Error", message: String(err) };
+}
diff --git a/demo/src/lib/indexer-proxy.ts b/demo/src/lib/indexer-proxy.ts
new file mode 100644
index 0000000..2acec07
--- /dev/null
+++ b/demo/src/lib/indexer-proxy.ts
@@ -0,0 +1,127 @@
+/**
+ * Browser client for the indexer-proxy (signer discovery, backend toggle).
+ *
+ * The proxy holds the Mercury / Stellar Indexer credentials; the browser only
+ * picks a backend. The JSON wire shape mirrors the SDK's `src/indexer/types.ts`
+ * `WalletSigner` (B5 / todo 952) — once B5 exports it under a subpath, swap
+ * {@link DiscoveredSigner} for the SDK type. Values are strings/hex because a
+ * `SignerKey` class instance and `Uint8Array` can't survive JSON.
+ */
+
+import {
+ IndexerError,
+ PasskeyKitErrorCode,
+ type SignerKeyTag,
+} from "passkey-kit";
+
+/** The two interchangeable indexer backends. */
+export type IndexerBackend = "mercury" | "stellar-indexer";
+
+export const INDEXER_BACKENDS: { id: IndexerBackend; label: string }[] = [
+ { id: "mercury", label: "Mercury (Zephyr)" },
+ { id: "stellar-indexer", label: "Stellar Indexer" },
+];
+
+/** A signer as returned by an indexer backend (JSON wire form). */
+export interface DiscoveredSigner {
+ key: { kind: SignerKeyTag; value: string };
+ /** 65-byte secp256r1 public key, hex-encoded (Secp256r1 signers). */
+ publicKey?: string;
+ /** Expiration as a UNIX timestamp in seconds (inclusive). */
+ expiration?: number;
+ /** Per-contract limits: contract id → signer keys (`null`/absent = unlimited). */
+ limits?: Record | null;
+ storage: "persistent" | "temporary";
+ status: "live" | "expired" | "evicted" | "removed";
+}
+
+const TIMEOUT_MS = 20_000;
+
+export class IndexerProxyClient {
+ constructor(private readonly baseUrl?: string) {}
+
+ /** Whether an indexer-proxy URL is configured (discovery possible). */
+ get configured(): boolean {
+ return Boolean(this.baseUrl);
+ }
+
+ /** Enumerate every signer a backend has indexed for a wallet. */
+ async getSigners(
+ wallet: string,
+ backend: IndexerBackend,
+ ): Promise {
+ const data = await this.get("/signers", {
+ wallet,
+ backend,
+ });
+ return Array.isArray(data) ? data : [];
+ }
+
+ /** Reverse lookup: the wallet contract ids a signer key belongs to. */
+ async findWallets(
+ key: { kind: SignerKeyTag; value: string },
+ backend: IndexerBackend,
+ ): Promise {
+ const data = await this.get("/wallets", {
+ kind: key.kind,
+ value: key.value,
+ backend,
+ });
+ return Array.isArray(data) ? data : [];
+ }
+
+ /** Probe backend health (throws IndexerError on transport failure). */
+ async health(
+ backend: IndexerBackend,
+ ): Promise<{ ok: boolean; backend: string; detail?: string }> {
+ return this.get("/health", { backend });
+ }
+
+ private async get(
+ path: string,
+ params: Record,
+ ): Promise {
+ if (!this.baseUrl) {
+ throw new IndexerError(
+ "Indexer proxy is not configured (set VITE_indexerProxyUrl)",
+ PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED,
+ );
+ }
+
+ const url = new URL(
+ path.replace(/^\//, ""),
+ this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`,
+ );
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const res = await fetch(url, { signal: controller.signal });
+ const text = await res.text();
+ if (!res.ok) {
+ throw new IndexerError(
+ `Indexer proxy ${path} failed (HTTP ${res.status}): ${text.slice(0, 200)}`,
+ PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
+ { path, status: res.status, params },
+ );
+ }
+ return (text ? JSON.parse(text) : undefined) as T;
+ } catch (err) {
+ if (err instanceof IndexerError) throw err;
+ const aborted = err instanceof Error && err.name === "AbortError";
+ throw new IndexerError(
+ aborted
+ ? `Indexer proxy ${path} timed out`
+ : `Indexer proxy ${path} failed: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
+ { path, params },
+ err instanceof Error ? err : undefined,
+ );
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+}
diff --git a/demo/src/lib/relayer-proxy.ts b/demo/src/lib/relayer-proxy.ts
new file mode 100644
index 0000000..71ceedd
--- /dev/null
+++ b/demo/src/lib/relayer-proxy.ts
@@ -0,0 +1,162 @@
+/**
+ * Browser client for the relayer-proxy worker (todo 954).
+ *
+ * The worker holds the relayer API key (keyless per-IP minting); the browser
+ * sends only transaction material and receives a typed result. Two wire bodies,
+ * mirroring `PasskeyServer.send`'s routing:
+ * - `{ func, auth }` — a Soroban invoke without source-account auth.
+ * - `{ xdr }` — an envelope (deploys / source-account auth) to fee-bump.
+ */
+
+import {
+ PasskeyKitErrorCode,
+ RelayerError,
+ contractErrorFromCode,
+ decodeContractError,
+ type PasskeyKitError,
+ type TransactionResult,
+} from "passkey-kit";
+
+/** `{ func, auth }` (preferred) or `{ xdr }` (envelope) wire body. */
+export type RelayerProxyBody =
+ | { func: string; auth: string[] }
+ | { xdr: string };
+
+/**
+ * The worker's response envelope (relayer-proxy/src/index.ts):
+ * success → `{ success: true, data: { transactionId, hash, status } }`
+ * failure → `{ success: false, error: , data?: { code, details } }`
+ * with a non-2xx HTTP status.
+ * There is no `ledger` field.
+ */
+interface WorkerEnvelope {
+ success?: boolean;
+ error?: string;
+ data?: {
+ transactionId?: string;
+ hash?: string;
+ status?: string;
+ code?: string | number;
+ details?: unknown;
+ };
+}
+
+const TIMEOUT_MS = 6 * 60 * 1000;
+
+export class RelayerProxyClient {
+ constructor(private readonly baseUrl?: string) {}
+
+ /** Whether a proxy URL is configured (submission possible). */
+ get configured(): boolean {
+ return Boolean(this.baseUrl);
+ }
+
+ /**
+ * Submit transaction material through the proxy. Never throws — returns a
+ * discriminated {@link TransactionResult}, matching the SDK's submission
+ * contract so the UI can branch on `result.success`.
+ */
+ async submit(body: RelayerProxyBody): Promise {
+ if (!this.baseUrl) {
+ return fail(
+ new RelayerError(
+ "Relayer proxy is not configured (set VITE_relayerProxyUrl)",
+ PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED,
+ ),
+ );
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
+
+ try {
+ const res = await fetch(this.baseUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+
+ const text = await res.text();
+ const payload = text ? (safeParse(text) as WorkerEnvelope | undefined) : undefined;
+
+ // Failure = non-2xx OR an explicit `success: false` (defensive).
+ if (!res.ok || payload?.success === false) {
+ return fail(toError(payload, res.status, text));
+ }
+
+ // Success: unwrap the `data` envelope. The worker returns no `ledger`, so
+ // it stays undefined; `transactionId`/`status` come from `data`.
+ const ok = payload?.data ?? {};
+ if (!ok.hash) {
+ return fail(
+ new RelayerError(
+ `Relayer proxy returned no transaction hash: ${text.slice(0, 200)}`,
+ PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
+ ),
+ );
+ }
+ return {
+ success: true,
+ hash: ok.hash,
+ transactionId: ok.transactionId,
+ };
+ } catch (err) {
+ const aborted = err instanceof Error && err.name === "AbortError";
+ return fail(
+ new RelayerError(
+ aborted
+ ? "Relayer proxy request timed out"
+ : `Relayer proxy request failed: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
+ ),
+ );
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+}
+
+function fail(error: PasskeyKitError): TransactionResult {
+ return { success: false, error };
+}
+
+function safeParse(text: string): unknown {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return undefined;
+ }
+}
+
+/** Map the worker error envelope to a typed SDK error (ContractError when decodable). */
+function toError(
+ payload: WorkerEnvelope | undefined,
+ status: number,
+ raw: string,
+): PasskeyKitError {
+ const message =
+ (typeof payload?.error === "string" && payload.error) ||
+ `Relayer proxy failed (HTTP ${status}): ${raw.slice(0, 200)}`;
+ const code = payload?.data?.code;
+ const details = payload?.data?.details;
+
+ // A numeric `data.code` may be a contract error code; otherwise scan the
+ // message + raw body for an `Error(Contract, #N)` marker (the relayer surfaces
+ // the contract failure there) so contract-error codes aren't dropped.
+ const contract =
+ (typeof code === "number"
+ ? contractErrorFromCode(code, { status, details })
+ : null) ??
+ decodeContractError(message) ??
+ decodeContractError(raw);
+ if (contract) return contract;
+
+ return new RelayerError(message, PasskeyKitErrorCode.RELAYER_REQUEST_FAILED, {
+ status,
+ code: code === undefined ? undefined : String(code),
+ details,
+ });
+}
diff --git a/demo/src/lib/signers.ts b/demo/src/lib/signers.ts
new file mode 100644
index 0000000..1c9b18c
--- /dev/null
+++ b/demo/src/lib/signers.ts
@@ -0,0 +1,96 @@
+/**
+ * Local signer model + `SignerLimits` construction.
+ *
+ * The demo keeps its own optimistic registry of the signers it has added, so the
+ * core flows work before the indexer proxy is live; the Discovery panel then
+ * cross-checks the registry against a real backend. A signer's `limits` are what
+ * define "admin" vs "session": an UNLIMITED signer (`SignerLimits` = None) can
+ * authorize anything (admin); a restricted or empty map is a scoped session key.
+ */
+
+import { SignerKey, SignerStore, type SignerKeyTag, type SignerLimits } from "passkey-kit";
+
+export type SignerKind = SignerKeyTag;
+
+/** Limits presets exposed by the builder. */
+export type LimitsMode = "unlimited" | "none" | "restricted";
+
+/** One restricted-limits row: which signer keys this signer may co-authorize on a contract. */
+export interface LimitsEntry {
+ contract: string;
+ keys: { kind: SignerKind; value: string }[];
+}
+
+/** UI model for building a `SignerLimits`. */
+export interface LimitsSpec {
+ mode: LimitsMode;
+ entries: LimitsEntry[];
+}
+
+export const UNLIMITED: LimitsSpec = { mode: "unlimited", entries: [] };
+
+/** A signer the demo knows about (optimistic registry). */
+export interface LocalSigner {
+ kind: SignerKind;
+ /** keyId (base64url) for Secp256r1, G-address for Ed25519, C-address for Policy. */
+ value: string;
+ store: SignerStore;
+ limitsMode: LimitsMode;
+ expiration?: number;
+ label?: string;
+ /** True when this is the connected passkey. */
+ self?: boolean;
+ /** 65-byte secp256r1 public key (base64url), needed to `update_signer`. */
+ publicKey?: string;
+}
+
+/** Whether a signer is an unlimited "admin" signer. */
+export function isAdmin(signer: { limitsMode: LimitsMode }): boolean {
+ return signer.limitsMode === "unlimited";
+}
+
+/** Build the SDK {@link SignerKey} for a local signer (for remove/lookup). */
+export function toSignerKey(signer: { kind: SignerKind; value: string }): SignerKey {
+ switch (signer.kind) {
+ case "Secp256r1":
+ return SignerKey.Secp256r1(signer.value);
+ case "Ed25519":
+ return SignerKey.Ed25519(signer.value);
+ case "Policy":
+ return SignerKey.Policy(signer.value);
+ }
+}
+
+/** Translate a UI {@link LimitsSpec} into the SDK's {@link SignerLimits}. */
+export function buildSignerLimits(spec: LimitsSpec): SignerLimits {
+ if (spec.mode === "unlimited") return undefined;
+
+ const map = new Map();
+ if (spec.mode === "none") return map; // empty map = fail-closed (no permissions)
+
+ for (const entry of spec.entries) {
+ if (!entry.contract) continue;
+ map.set(
+ entry.contract,
+ entry.keys.length ? entry.keys.map(toSignerKey) : undefined,
+ );
+ }
+ return map;
+}
+
+/** Human summary of a limits spec for badges/log. */
+export function describeLimits(spec: LimitsSpec): string {
+ switch (spec.mode) {
+ case "unlimited":
+ return "unlimited (admin)";
+ case "none":
+ return "no permissions (fail-closed)";
+ case "restricted":
+ return `restricted · ${spec.entries.length} contract${spec.entries.length === 1 ? "" : "s"}`;
+ }
+}
+
+/** Absolute UNIX-second expiration `days` from now (contract stores timestamps). */
+export function expirationInDays(days: number): number {
+ return Math.floor(Date.now() / 1000) + Math.round(days * 86_400);
+}
diff --git a/demo/src/lib/state.svelte.ts b/demo/src/lib/state.svelte.ts
new file mode 100644
index 0000000..57df2ea
--- /dev/null
+++ b/demo/src/lib/state.svelte.ts
@@ -0,0 +1,90 @@
+/**
+ * Shared reactive application state (Svelte 5 runes).
+ *
+ * A single deeply-reactive `$state` object mutated by `actions.ts` and read by
+ * components. Kept intentionally small — flows live in `actions.ts`, config +
+ * singletons in `config.ts`.
+ */
+
+import type { StoredPasskey } from "passkey-kit";
+import type { LocalSigner } from "./signers";
+import type { DiscoveredSigner, IndexerBackend } from "./indexer-proxy";
+
+export type ConnectionStatus = "disconnected" | "connecting" | "connected";
+export type LogLevel = "info" | "success" | "error";
+
+export interface LogEntry {
+ id: number;
+ at: number;
+ level: LogLevel;
+ message: string;
+ /** Transaction hash → block explorer link. */
+ hash?: string;
+ /** Secondary line: error code / contract error / ids. */
+ detail?: string;
+}
+
+export interface AppState {
+ ready: boolean;
+ status: ConnectionStatus;
+ /** Connected passkey keyId (base64url). */
+ keyId?: string;
+ /** Connected wallet contract id (C…). */
+ contractId?: string;
+ /** Passkey keyId currently used to authorize writes (admin rotation target). */
+ activeKeyId?: string;
+ /** Optimistic registry of known signers. */
+ signers: LocalSigner[];
+ /** Passkeys persisted in the storage adapter (for reconnect UI). */
+ knownPasskeys: StoredPasskey[];
+ /** Token contract id → balance (stroops). */
+ balances: Record;
+ /** Selected token for transfers/balance. */
+ selectedToken: string;
+ /** Discovery results from the last indexer query. */
+ discovered: DiscoveredSigner[];
+ discoverBackend: IndexerBackend;
+ /** Runtime-generated ephemeral Ed25519 keypair (demo only; never persisted). */
+ ed25519Secret?: string;
+ ed25519Public?: string;
+ /** Label of the in-flight operation, or null when idle. */
+ busy: string | null;
+ log: LogEntry[];
+}
+
+export const app = $state({
+ ready: false,
+ status: "disconnected",
+ signers: [],
+ knownPasskeys: [],
+ balances: {},
+ selectedToken: "",
+ discovered: [],
+ discoverBackend: "mercury",
+ busy: null,
+ log: [],
+});
+
+let logSeq = 0;
+
+/** Append a log entry (newest first, capped). */
+export function pushLog(
+ level: LogLevel,
+ message: string,
+ extra?: { hash?: string; detail?: string },
+): void {
+ app.log.unshift({
+ id: ++logSeq,
+ at: Date.now(),
+ level,
+ message,
+ hash: extra?.hash,
+ detail: extra?.detail,
+ });
+ if (app.log.length > 80) app.log.length = 80;
+}
+
+/** True when a wallet is connected. */
+export function isConnected(): boolean {
+ return app.status === "connected" && Boolean(app.contractId);
+}
diff --git a/demo/src/lib/submit.ts b/demo/src/lib/submit.ts
new file mode 100644
index 0000000..a78add8
--- /dev/null
+++ b/demo/src/lib/submit.ts
@@ -0,0 +1,95 @@
+/**
+ * Client-side submission routing.
+ *
+ * This mirrors `PasskeyServer.send`'s logic — the routing the server-only SDK
+ * entry performs — but keeps it in the browser and forwards the transaction
+ * material to the relayer-proxy worker instead of holding a relayer key. An
+ * `invokeHostFunction` without source-account auth uses the preferred
+ * `{ func, auth }` Soroban path; everything else (deploys, source-account auth)
+ * is fee-bumped through the `{ xdr }` envelope path.
+ */
+
+import {
+ Operation,
+ TransactionBuilder,
+ type Transaction,
+} from "@stellar/stellar-sdk";
+import { AssembledTransaction } from "@stellar/stellar-sdk/contract";
+import {
+ RelayerError,
+ PasskeyKitErrorCode,
+ type TransactionResult,
+} from "passkey-kit";
+import { config, relayer } from "./config";
+
+// `any` in the generic slot: submission only touches `.built`, which is
+// independent of the result type, and `AssembledTransaction` is invariant in its
+// type parameter — so accept whatever the kit/SAC builders return.
+type Submittable = AssembledTransaction | Transaction | string;
+
+/** Normalize any submittable input to a built {@link Transaction}. */
+function toBuiltTransaction(input: Submittable): Transaction {
+ if (typeof input === "string") {
+ return TransactionBuilder.fromXDR(
+ input,
+ config.networkPassphrase,
+ ) as Transaction;
+ }
+ if (input instanceof AssembledTransaction) {
+ if (!input.built) {
+ throw new RelayerError(
+ "AssembledTransaction has not been simulated/built yet",
+ PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
+ );
+ }
+ return input.built;
+ }
+ return input;
+}
+
+/** Whether any invokeHostFunction op carries source-account auth. */
+function hasSourceAccountAuth(transaction: Transaction): boolean {
+ for (const op of transaction.operations) {
+ if (op.type !== "invokeHostFunction") continue;
+ for (const entry of (op as Operation.InvokeHostFunction).auth ?? []) {
+ if (
+ entry.credentials().switch().name === "sorobanCredentialsSourceAccount"
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * Submit a signed transaction via the relayer proxy. Never throws — returns a
+ * discriminated {@link TransactionResult}.
+ */
+export async function submit(input: Submittable): Promise {
+ let built: Transaction;
+ try {
+ built = toBuiltTransaction(input);
+ } catch (err) {
+ return {
+ success: false,
+ error:
+ err instanceof RelayerError
+ ? err
+ : new RelayerError(
+ err instanceof Error ? err.message : String(err),
+ PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
+ ),
+ };
+ }
+
+ const op = built.operations[0];
+ if (op?.type === "invokeHostFunction" && !hasSourceAccountAuth(built)) {
+ const invokeOp = op as Operation.InvokeHostFunction;
+ const func = invokeOp.func.toXDR("base64");
+ const auth = (invokeOp.auth ?? []).map((entry) => entry.toXDR("base64"));
+ return relayer.submit({ func, auth });
+ }
+
+ return relayer.submit({ xdr: built.toXDR() });
+}
diff --git a/demo/src/main.ts b/demo/src/main.ts
index 4d67e2a..a575b3b 100644
--- a/demo/src/main.ts
+++ b/demo/src/main.ts
@@ -1,8 +1,16 @@
-import './app.css'
-import App from './App.svelte'
+import { Buffer } from "buffer";
+import { mount } from "svelte";
+import "./app.css";
+import App from "./App.svelte";
-const app = new App({
- target: document.getElementById('app')!,
-})
+// stellar-sdk / passkey-kit use Node's `Buffer` for XDR + base64url work; the
+// browser has no global for it. Polyfill before anything else loads.
+if (typeof globalThis.Buffer === "undefined") {
+ globalThis.Buffer = Buffer;
+}
-export default app
+const app = mount(App, {
+ target: document.getElementById("app")!,
+});
+
+export default app;
diff --git a/demo/src/vite-env.d.ts b/demo/src/vite-env.d.ts
index 45c1e21..6580ed0 100644
--- a/demo/src/vite-env.d.ts
+++ b/demo/src/vite-env.d.ts
@@ -1,19 +1,46 @@
///
///
+/**
+ * PUBLIC-ONLY demo configuration.
+ *
+ * Every value here is safe to ship in the browser bundle. There are deliberately
+ * NO secrets: the relayer API key and indexer JWTs live only in the server-side
+ * proxy worker (todo 954), which the demo reaches over `VITE_relayerProxyUrl` /
+ * `VITE_indexerProxyUrl`. Never add a `VITE_`-prefixed secret here.
+ */
interface ImportMetaEnv {
- readonly VITE_walletWasmHash: string;
- readonly VITE_networkPassphrase: string;
- readonly VITE_nativeContractId: string;
- readonly VITE_rpcUrl: string;
- readonly VITE_relayerUrl: string;
- readonly VITE_relayerApiKey: string;
- readonly VITE_mercuryProjectName: string;
- readonly VITE_mercuryUrl: string;
- readonly VITE_mercuryJwt: string;
- readonly VITE_mercuryKey: string;
+ /** Stellar RPC URL (e.g. https://soroban-testnet.stellar.org). */
+ readonly VITE_rpcUrl: string;
+ /** Network passphrase (e.g. "Test SDF Network ; September 2015"). */
+ readonly VITE_networkPassphrase: string;
+ /** Smart-wallet WASM hash (hex), pinned from the deployments manifest. */
+ readonly VITE_walletWasmHash: string;
+ /** Native XLM Stellar Asset Contract id (C…). */
+ readonly VITE_nativeContractId: string;
+ /**
+ * Optional non-native SAC / SEP-41 token ids to offer in the token picker,
+ * comma-separated `label:C…` (or bare `C…`). Exercises non-native SAC support.
+ */
+ readonly VITE_extraTokenIds?: string;
+ /**
+ * Optional deployed `sample-policy` contract id (C…) for the policy-signer
+ * demo. Deploy an instance of the v1 sample policy and set this; when unset,
+ * the policy-signer controls are disabled with a hint.
+ */
+ readonly VITE_samplePolicyId?: string;
+ /**
+ * Relayer-proxy worker base URL (submission). The worker holds the relayer
+ * key. When unset, submission is disabled with a "pending backend" notice.
+ */
+ readonly VITE_relayerProxyUrl?: string;
+ /**
+ * Indexer-proxy base URL (signer discovery). The worker holds the Mercury /
+ * Stellar Indexer credentials. When unset, discovery shows "pending backend".
+ */
+ readonly VITE_indexerProxyUrl?: string;
}
interface ImportMeta {
- readonly env: ImportMetaEnv;
+ readonly env: ImportMetaEnv;
}
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
index 786803d..fa7a4dd 100644
--- a/demo/tsconfig.json
+++ b/demo/tsconfig.json
@@ -4,9 +4,14 @@
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
+ "moduleResolution": "bundler",
"resolveJsonModule": true,
+ "allowImportingTsExtensions": false,
"isolatedModules": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "types": ["svelte", "vite/client"]
},
- "include": ["src/**/*.ts", "src/**/*.svelte"],
+ "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.svelte.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
index c543c8d..444b8bd 100644
--- a/demo/vite.config.ts
+++ b/demo/vite.config.ts
@@ -1,10 +1,25 @@
-import { defineConfig } from 'vite'
-import { svelte } from '@sveltejs/vite-plugin-svelte'
+import { defineConfig } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
-// https://vitejs.dev/config/
+// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
+ define: {
+ // stellar-sdk / passkey-kit expect a Node-style `global`; map it to the
+ // browser realm. `Buffer` itself is polyfilled at runtime in `main.ts`.
+ global: "globalThis",
+ },
+ resolve: {
+ // The demo links `passkey-kit` via `link:..`, so passkey-kit's compiled
+ // `dist/` and the demo resolve `@stellar/stellar-sdk`/`buffer` from two
+ // physical locations. Dedupe to a single instance so `xdr`/`Buffer`
+ // `instanceof` checks hold across the boundary.
+ dedupe: ["@stellar/stellar-sdk", "buffer"],
+ },
+ optimizeDeps: {
+ include: ["@stellar/stellar-sdk", "buffer", "base64url"],
+ },
build: {
- target: "ESNext"
+ target: "esnext",
},
-})
+});
diff --git a/docs/deployments-testnet-2026-07-11.md b/docs/deployments-testnet-2026-07-11.md
new file mode 100644
index 0000000..515ba22
--- /dev/null
+++ b/docs/deployments-testnet-2026-07-11.md
@@ -0,0 +1,129 @@
+# passkey-kit v1 contract build + testnet deployment — 2026-07-11
+
+This manifest is THE canonical hash source for the passkey-kit v1 contract
+set. The Makefile, SDK configuration, `verify:bindings` drift guard, and the
+zephyr (Mercury) ingestion allowlist must all consume the hashes recorded
+here — never a locally rebuilt hash.
+
+> **Status: post-audit-gate (FINAL for testnet).** These are the reworked v1
+> contracts AFTER the independent 3-way audit (Fable + gpt-5.6-sol + terra)
+> and all seven confirmed-closed remediations (FIX-1..7 + FIX-3b). The hashes
+> below supersede the interim pre-audit values (smart-wallet
+> `9e7fad44…`, sample-policy `e6d00383…`). The audit gate's final static
+> `/code-review` pass runs after this re-pin; a further contract change would
+> re-pin this manifest again.
+
+## Build provenance
+
+- Repo commit: `0563520` (branch `overhaul/ground-up`); post-audit contract
+ set spans commit range `067f807..0563520` (A1 rework → A2 tests → A4
+ audit fixes → FIX-3b).
+- Rust/Cargo: `1.94.0` (pinned in `contracts/rust-toolchain.toml`)
+- `soroban-sdk`: `27.0.0` (`e5cb4b52c3da8e56fc48adfd7b85d85976c1a059`)
+- Stellar CLI: `27.0.0` (`5a7c5fe76530bf4248477ac812fc757146b98cc4`)
+- Build target: `wasm32v1-none`
+- Build command: `stellar contract build --locked --package --out-dir out`
+ (optimization is on by default in CLI 27; `out/.wasm` is the final artifact)
+- Contract meta: `binver: 1.0.0`, `rsver: 1.94.0`, `rssdkver: 27.0.0`
+
+Note: the wasm hash is sensitive to source line numbers (panic `Location`
+data is embedded), so only builds from the exact commit above reproduce these
+hashes. Reproducibility was verified by rebuilding from a clean `out/`.
+
+| Component | Cargo package | Bytes | SHA-256 / network WASM hash |
+|---|---|---:|---|
+| Smart wallet | `smart-wallet` | 29,275 | `84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319` |
+| Sample policy | `sample-policy` | 13,064 | `e74af5355f933f2c3421845178ca789c9bcf3ea7612a7c9966b9b57f26e59aed` |
+| Example contract | `example-contract` | 1,047 | `47a3326360bdce2ca360ed1b226ad636e14ac698f63efc11ab28e0d41f1f76aa` |
+
+The smart wallet WASM is uploaded but never deployed as a singleton: every
+user wallet deploys its own instance with a `Signer` constructor argument.
+The sample policy demonstrates the v1 policy lifecycle
+(`install`/`uninstall`/`policy__`) as a cumulative rolling-window spending
+allowance. The example contract is a test fixture (deploy-path and
+multi-transfer auth exercises); it is uploaded for completeness but nothing
+depends on it. The example-contract WASM is byte-identical to the pre-audit
+build (its source was untouched by the audit fixes), so its hash and upload
+are unchanged.
+
+## Testnet
+
+Upload source: `passkey-kit-deployer`
+(`GBUOCX45SICUYPFQG2YYQN2VTBDVG5EBS7Q4AFKAMMZIRR77CE6WHRF6`), funded via
+friendbot. Network: Test SDF Network ; September 2015.
+
+| Component | Upload transaction |
+|---|---|
+| Smart wallet | `ceb4ed26734d4e16344ddc713fe19f1a1c8cd814fcddca56d685f64fb0ae70ba` |
+| Sample policy | `2f36077bc6faf9bc8df1c58bfc1ee4c55079c762844e998da92ee4fd75ab094c` |
+| Example contract | `9d4da6b0992de465154ffea9dca3ea3e72948b0f706ec7e5a7f920dc4ddb2de7` (unchanged since A3) |
+
+### Live smoke
+
+A wallet instance was deployed from the FINAL smart-wallet WASM with an
+Ed25519 constructor signer (tx
+`4a9ff09bfad96cf5398bbac973714690b5d4915c332292dc67c173fd332ad95a`):
+
+- Instance: `CCK7VZBYMIIL7VBSEIORYQ4BTBZCKDKPNHPTYKQCRW4JO7L2IFW3X6WA`
+- `get_signer({"Ed25519": })` returned
+ `{"Ed25519":[[null],[null]]}` — the constructor-stored signer, unlimited,
+ no expiration.
+
+### Verification
+
+Every uploaded WASM was fetched back by hash
+(`stellar contract fetch --wasm-hash --network testnet`) and
+re-hashed with `shasum -a 256`. All three fetched hashes matched the local
+artifacts byte-for-byte.
+
+## Mainnet
+
+Not deployed. Mainnet upload is an orchestrator-gated endgame step and will
+be recorded in a follow-up manifest after the audit gate passes.
+
+## Deterministic wallet address derivation (NORMATIVE)
+
+Every passkey-kit wallet address is derived from the WebAuthn credential id
+(`keyId`) alone — both indexer backends and `connectWallet` reverse lookup
+depend on this exact tuple. **None of these inputs may ever change:**
+
+```text
+contractId = sha256(XDR(HashIdPreimage::EnvelopeTypeContractId {
+ networkId: sha256(network passphrase),
+ contractIdPreimage: ContractIdPreimageFromAddress {
+ address: G-address of the canonical deployer keypair,
+ salt: sha256(keyId),
+ },
+}))
+```
+
+- Canonical deployer keypair: `Keypair.fromRawEd25519Seed(sha256(utf8("kalepail")))`
+ → `GC2C7AWLS2FMFTQAHW3IBUB4ZXVP4E37XNLEF2IK7IVXBB6CMEPCSXFO`.
+ The SDK exposes a `deploySource` override, but the default MUST remain this
+ keypair or previously created wallets become unreachable by derivation.
+- The WASM hash is deliberately NOT part of the preimage: contract upgrades
+ do not move wallet addresses, and the same `keyId` derives the same address
+ across contract versions.
+- **Security consequence (audit F7)**: because the deployer is public and the
+ executable is not bound by the preimage, anyone who learns a `keyId` can
+ front-run the derived address with arbitrary code. Clients MUST verify
+ ownership before trusting a derived or reverse-looked-up address:
+ 1. the `keyId` is an actual stored signer (`get_signer` returns a value), and
+ 2. (recommended) the instance executable hash is on the known-hash list
+ from this manifest (or its legacy predecessor below).
+
+## Legacy (pre-1.0) build
+
+The pre-overhaul contract (soroban-sdk 23, `sw_v1` tuple events, errors 1-9,
+ledger-sequence expirations) has canonical WASM hash:
+
+```text
+e45c42b944a767bd5f37f8c4a469b48917d28e23481dbfd550419c84cdacde92
+```
+
+Wallets deployed from it remain live on both networks and derive from the
+SAME derivation tuple above (the tuple predates v1 and is unchanged). v1 is
+forward-only: no compatibility shims exist, and legacy wallets interact only
+with legacy tooling — but a legacy wallet can be upgraded in place to a v1
+hash via its own auth (`update_contract_code` on legacy, `upgrade` from then
+on), keeping its address.
diff --git a/PROPOSAL.md b/docs/history/PROPOSAL.md
similarity index 97%
rename from PROPOSAL.md
rename to docs/history/PROPOSAL.md
index b58f3f0..3f55c57 100644
--- a/PROPOSAL.md
+++ b/docs/history/PROPOSAL.md
@@ -1,3 +1,15 @@
+
+
# Smart wallet contract interface
With the release of [Protocol 21](https://stellar.org/blog/developers/announcing-protocol-21) (and specifically the inclusion of the secp256r1 verification curve) Soroban now has tremendous first class support for passkey powered smart wallets.
diff --git a/docs/migration-v1.md b/docs/migration-v1.md
new file mode 100644
index 0000000..2e225eb
--- /dev/null
+++ b/docs/migration-v1.md
@@ -0,0 +1,227 @@
+# Migrating to `passkey-kit` v1
+
+v1 is a ground-up overhaul of the contract, SDK, and services. It has **no backwards-compatibility layer** — every change below is a clean cut from the `0.12.x` line. If you only ever call `connectWallet()` and submit through `PasskeyServer`, the two changes that will touch your code are the **signing API** (a `Signer` instance instead of an options object) and the **`TransactionResult`** discriminated union.
+
+> [!IMPORTANT]
+> **Contract compatibility.** v1 is a new on-chain contract (new WASM hash, renumbered errors, new event schema, timestamp expirations). Wallets deployed from the pre-1.0 contract remain live and keep their addresses (the [derivation tuple](./deployments-testnet-2026-07-11.md#deterministic-wallet-address-derivation-normative) is unchanged), but they run the legacy code until upgraded in place. A v1 SDK talks to v1 wallets; it still *decodes* legacy error codes (family `SmartWalletLegacy`) so failures from a legacy wallet are legible.
+
+## Contents
+
+- [Signing pipeline](#signing-pipeline)
+- [Results & error handling](#results--error-handling)
+- [Errors](#errors)
+- [Configuration](#configuration)
+- [Signer model & expiration](#signer-model--expiration)
+- [Storage adapters](#storage-adapters)
+- [Indexer & discovery](#indexer--discovery)
+- [Packaging & imports](#packaging--imports)
+- [Removed exports](#removed-exports)
+- [A/B gap analysis](#ab-gap-analysis)
+- [Contract-side changes](#contract-side-changes)
+- [Behavior changes](#behavior-changes)
+
+---
+
+## Signing pipeline
+
+**`sign` / `signAuthEntry` now take a typed `Signer` instance** instead of a mutually-exclusive `{ keyId | keypair | policy }` options object.
+
+```ts
+// Before (0.12.x)
+await kit.sign(txn, { keyId }); // passkey
+await kit.sign(txn, { keypair }); // Ed25519
+await kit.sign(txn, { policy }); // policy
+await kit.sign(txn, { keyId: "any", expiration }); // any passkey + explicit expiration
+
+// After (v1)
+import { PasskeySigner, Ed25519Signer, PolicySigner } from "passkey-kit";
+
+await kit.sign(txn); // connected passkey (default)
+await kit.sign(txn, new PasskeySigner(keyId)); // specific passkey
+await kit.sign(txn, new Ed25519Signer(keypair)); // Ed25519
+await kit.sign(txn, new PolicySigner(policy)); // policy
+await kit.sign(txn, new PasskeySigner("any"), { expiration }); // any passkey + expiration
+```
+
+Notes:
+
+- The per-call options object is now just `{ expiration?: number }`. Per-call `rpId` is gone — `rpId` moved to the `PasskeyKit` constructor (a single source of truth).
+- `Ed25519Signer.fromSecret("S…")` builds a signer from a secret key (throws `ValidationError` on an invalid key).
+- Multi-sign by calling `sign` once per signer; each merges into the flat `Signatures` map, now sorted in Soroban **host order** (was a `localeCompare` approximation that could produce a map the host rejected).
+
+**`sign` takes a single `AssembledTransaction`.** The old `AssembledTransaction | Tx | string` tri-input silently dropped memo/fee/operations on its fallback path. If you hold XDR, rebuild first:
+
+```ts
+// Before: await kit.sign(xdrString)
+// After:
+import { AssembledTransaction } from "@stellar/stellar-sdk/contract";
+const txn = AssembledTransaction.fromXDR(options, xdrString, spec);
+await kit.sign(txn);
+```
+
+## Results & error handling
+
+**`TransactionResult` is now a discriminated union on `success`.** Narrow on `result.success` before reading `.error` or `.hash`.
+
+```ts
+// Before (0.12.x): an untyped { success, hash, error? } object; error was a string.
+const result = await server.send(txn);
+if (result.success) {
+ console.log(result.hash);
+} else {
+ console.error(result.error); // string
+}
+
+// After (v1)
+const result = await server.send(txn);
+if (result.success) {
+ // TransactionSuccess: { success: true; hash: string; ledger?; transactionId? }
+ console.log(result.hash);
+} else {
+ // TransactionFailure: { success: false; error: PasskeyKitError; hash? }
+ console.error(`[${result.error.code}] ${result.error.message}`);
+}
+```
+
+- `error` is now a **typed `PasskeyKitError`** (a `ContractError` when an on-chain code was decoded), not a string. Branch on `result.error.code`.
+- Success results have **no** `error` field; failure results have an **optional** `hash`.
+- New type exports: `TransactionSuccess`, `TransactionFailure`, `SubmissionMethod`.
+
+**Which methods return this vs. throw.** Only submission methods (`server.send`, `server.getTransaction`) return a `TransactionResult`. **Everything else throws** a typed `PasskeyKitError` subclass. A pending (non-terminal) relayer status is surfaced as a failure carrying `RELAYER_PENDING` — keep polling `getTransaction`; do not treat it as success.
+
+## Errors
+
+- All thrown errors are now `PasskeyKitError` subclasses with a numeric `code`: `ConfigurationError`, `WalletNotConnectedError`, `WalletOwnershipError`, `WebAuthnError`, `SigningError`, `SignerNotFoundError`, `SimulationError`, `SubmissionError`, `ValidationError`, `IndexerError`, `RelayerError`, `ContractError`. Codes are grouped by concern (`1xxx`–`9xxx`, `10000` for contract-level).
+- **New decoding API:** `decodeContractError(diagnostic)`, `contractErrorFromCode(code)`, `CONTRACT_ERROR_REGISTRY`, and types `ContractErrorFamily` / `ContractErrorInfo`.
+- **Contract error codes were renumbered to 100–129** (see [README → Contract error decoding](../README.md#contract-error-decoding)). The legacy 1–9 codes still decode (family `SmartWalletLegacy`), so a failure from a legacy wallet is still legible.
+
+```ts
+if (!result.success && result.error instanceof ContractError) {
+ switch (result.error.contractErrorName) {
+ case "SignerExpired": /* 102 */ break;
+ case "MissingContext": /* 110 */ break;
+ }
+}
+```
+
+## Configuration
+
+**`PasskeyKit` config** gains `rpId`, `deploySource`, and `storage`:
+
+```ts
+// Before
+new PasskeyKit({ rpcUrl, networkPassphrase, walletWasmHash, timeoutInSeconds, WebAuthn });
+
+// After
+new PasskeyKit({
+ rpcUrl, networkPassphrase, walletWasmHash,
+ rpId, // NEW: WebAuthn RP id (was read per sign()/connect() call)
+ deploySource, // NEW: S… secret for the fee payer (default = canonical deployer)
+ storage, // NEW: StorageAdapter for passkey records
+ timeoutInSeconds, WebAuthn,
+});
+```
+
+**`PasskeyServer` config is now nested** (was a flat bag of `relayer*`/`mercury*` keys):
+
+```ts
+// Before
+new PasskeyServer({
+ rpcUrl, relayerUrl, relayerApiKey,
+ mercuryProjectName, mercuryUrl, mercuryJwt, mercuryKey,
+});
+
+// After
+new PasskeyServer({
+ networkPassphrase, // NEW: now required
+ rpcUrl,
+ relayer: { baseUrl, apiKey, adminSecret?, timeout? },
+ mercury: { url, projectName, jwt?, apiKey? }, // supply jwt OR apiKey
+});
+```
+
+`networkPassphrase` is required. `relayer.baseUrl`/`apiKey` replace `relayerUrl`/`relayerApiKey`; `mercury.url`/`projectName`/`jwt`/`apiKey` replace the `mercury*` keys (`mercuryKey` → `mercury.apiKey`).
+
+## Signer model & expiration
+
+- **Expiration is a UNIX timestamp in seconds** (inclusive), not a ledger sequence number. Update any code that computed `latestLedger + N`; use `nowSeconds + N`.
+- **`SignerLimits::Some(empty map)` now means fail-closed (no permissions).** Pre-1.0 an empty map meant *unlimited*. If you passed an empty map to mean "unlimited", pass `undefined` instead.
+- **Deploy permission is decoupled from limits.** A limits entry for the wallet's own address no longer grants deploy permission; `CreateContract*` contexts require an unlimited (`undefined`-limits) signer. Granting a signer a limits entry for the wallet's own address grants it the full admin surface (it can add an unlimited signer) — treat that as full control.
+- **New `upgrade(newWasmHash)` wrapper** (contract `upgrade`, renamed from `update_contract_code`) and **new `getSigner(signerKey)`** read (contract `get_signer`).
+
+## Storage adapters
+
+The kit no longer expects apps to hand-roll `localStorage`. Import an adapter from the new `passkey-kit/storage` subpath and pass it as `storage`:
+
+```ts
+import { IndexedDBStorage } from "passkey-kit/storage";
+const kit = new PasskeyKit({ /* … */, storage: new IndexedDBStorage() });
+```
+
+`createWallet` then remembers the passkey → wallet record automatically, and `connectWallet` can resolve a wallet from local storage before falling back to an indexer.
+
+## Indexer & discovery
+
+- `PasskeyServer.getSigners` / `getContractId` keep their signatures, but the return row (the old `Signer` type) is renamed **`IndexedSigner`** (it collided with the bindings' `Signer` union).
+- A new `SignerIndexer` abstraction (`passkey-kit/server`: `MercuryIndexer`, `StellarIndexerBackend`; browser-safe types + `lookupWithRetry` from `passkey-kit`) replaces the Mercury-only coupling.
+- **The live Mercury query path is gated pending a decision** — see the [gap analysis](#ab-gap-analysis) below and [zephyr/README.md](../zephyr/README.md).
+
+## Packaging & imports
+
+- The package now ships **compiled `dist/`** (ESM + `.d.ts`) with an `exports` map. Remove any `transpilePackages: ["passkey-kit", "passkey-factory-sdk", …]` / bundler workaround you added for the old raw-TypeScript shipping.
+- `@stellar/stellar-sdk` is a **peer dependency** (`>=16.0.0`) — install it in your app.
+- Server-only code moved behind the `passkey-kit/server` subpath. Import `PasskeyServer` from `passkey-kit/server`, not `passkey-kit`, and never from browser code.
+
+```ts
+// Before
+import { PasskeyKit, PasskeyServer } from "passkey-kit";
+
+// After
+import { PasskeyKit } from "passkey-kit";
+import { PasskeyServer } from "passkey-kit/server"; // server-only
+```
+
+## Removed exports
+
+- **`passkey-factory-sdk`** — never a real package; the factory design it referenced was abandoned before v1. Remove it from imports and bundler config.
+- **`PasskeyServer` from the package root** — moved to `passkey-kit/server`.
+- **`mercuryKey` config key** — use `mercury.apiKey`.
+- The `Signer` **row type** exported for indexer results is renamed **`IndexedSigner`** (the name `Signer` now refers to the signing-pipeline interface).
+
+## A/B gap analysis
+
+An explicit accounting of capabilities the pre-1.0 version had that v1 changes, drops, or defers — and what to use instead.
+
+| Old capability | v1 status | What to do instead |
+|---|---|---|
+| `connectWallet({ walletPublicKey })` — resolve/connect a wallet by an Ed25519 `G…` key | **Removed.** `connectWallet` is passkey-ownership-based by design: it verifies the connecting `keyId` is a live secp256r1 signer. | For reverse lookup by an Ed25519 or policy signer, use `server.getContractId({ publicKey })` / `{ policy }`, then operate on that address. There is no "connect as an Ed25519 identity" — sign with an `Ed25519Signer` against a passkey-connected wallet. |
+| `sign(xdrString \| Tx)` — sign a raw XDR string or `Tx` | **Removed** (lossy fallback). | `AssembledTransaction.fromXDR(...)` first, then `sign(txn)`. |
+| Per-call `rpId` on `sign` / `connectWallet` | **Moved to the constructor.** | Set `rpId` once on `new PasskeyKit({ rpId })`. |
+| **Live signer discovery via Mercury** (`getSigners` / `getContractId`) | **Deferred** — the code path exists but the self-serve Zephyr query route Mercury exposed is retired; the backend is gated behind `zephyrExecuteConfirmed` pending a user decision (host on Mercury's Smart Account Indexer vs. self-deploy). | Reconnect via the deterministic `connectWallet()` path — it re-derives the address from the keyId and verifies ownership on-chain, needing **no** indexer, and is more robust than the old reverse-lookup. Full enumeration/reverse-lookup returns live data once the Mercury path is confirmed (see [zephyr/README.md](../zephyr/README.md)). |
+| Legacy `("sw_v1", …)` tuple events | **Replaced** by typed `#[contractevent]` events. | Consume the new `signer_added`/`signer_updated`/`signer_removed`/`upgraded` schema; the rewritten zephyr program already does. |
+| Raw-TypeScript package (import internal source files) | **Removed** — ships compiled `dist/`. | Use the public entry points (`.`, `./storage`, `./server`). |
+
+**Net:** v1 is a superset of the old *contract* surface (adds `upgrade` wrapping + `get_signer`), and a superset of the old *SDK* surface except for the three intentional API-shape changes above. The one true regression is **live server-side discovery**, which is deferred (not lost) pending a user-owned Mercury integration decision; the deterministic reconnect path covers the common case in the meantime.
+
+## Contract-side changes
+
+If you build against the contract directly (not just the SDK):
+
+- `__constructor(signer)` is the only init path (the `init` flag and un-authed first-`add_signer` are gone).
+- `update_contract_code` → `upgrade(new_wasm_hash)`; new `get_signer(signer_key) -> Option` view.
+- `SignerExpiration(Option)` is a UNIX timestamp; `SignerLimits::Some(empty)` is fail-closed; errors renumbered 100–129; events are `#[contractevent]` structs; policies gain `install`/`uninstall`.
+
+See the [CHANGELOG](../CHANGELOG.md#contract-smart-wallet-soroban-sdk-27) for the full list and [`contracts/smart-wallet-interface/src/`](../contracts/smart-wallet-interface/src) for the canonical interface.
+
+## Behavior changes
+
+| Situation | Before | After |
+|---|---|---|
+| Submission failure | `{ success: false, error: string }` | `{ success: false, error: PasskeyKitError }` |
+| Any non-submission failure | plain `Error` / failure object | typed `PasskeyKitError` subclass (thrown) |
+| `connectWallet` with a keyId not on the wallet | trusted the derived/looked-up address | throws `WalletOwnershipError` (verifies the keyId is a live signer) |
+| Empty `SignerLimits` map | unlimited | no permissions (fail-closed) |
+| Signer/signature expiration unit | ledger sequence | UNIX timestamp (seconds) |
+| WebAuthn challenge | fixed string | random 32 bytes |
+| `Signatures` map order | `localeCompare` | Soroban host ScVal order |
+| Address auth credentials | V1 | V2 (binds the wallet address; closes cross-wallet replay) |
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..3349e41
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,118 @@
+# Releasing the npm packages
+
+The repository publishes three packages. Release them in this order so each can resolve its workspace dependency to a version that already exists on npm:
+
+1. `passkey-kit-sdk` (generated smart-wallet bindings)
+2. `sac-sdk` (generated SEP-41 bindings)
+3. `passkey-kit` (the SDK — depends on both via `workspace:*`)
+
+The two binding packages are independent; publish them in either order, but both **before** `passkey-kit`. `passkey-kit`'s `dependencies` pin them as `workspace:*`, which pnpm rewrites to the concrete workspace version at pack time — that version must already be on npm for consumers to install.
+
+The versions checked into each `package.json` are authoritative; `npm view` is the source of truth for what is already published. Confirm both before publishing.
+
+| Package | Checked-in version |
+|---|---|
+| `passkey-kit-sdk` | `0.7.3` |
+| `sac-sdk` | `0.4.3` |
+| `passkey-kit` | `0.13.0` (v1 release — see [CHANGELOG](../CHANGELOG.md)) |
+
+> [!IMPORTANT]
+> Publishing is an **outward-facing, user-gated** step. Bump the versions intentionally, and have the person with npm access run the publish commands (they hold the credentials and the OTP device).
+
+## Prerequisites
+
+- Node.js 22 or newer
+- pnpm 10 or newer
+- Stellar CLI 27 or newer (only when regenerating bindings)
+- npm publish access to all three packages
+- A clean tracked Git worktree
+
+## 1. Authenticate & check the registry
+
+```bash
+npm login
+npm whoami
+npm view passkey-kit-sdk version
+npm view sac-sdk version
+npm view passkey-kit version
+```
+
+## 2. Regenerate & verify bindings
+
+Regenerate the bindings whenever the smart-wallet or SAC contract interface changes. Generation goes through `scripts/bindings/build.sh`; it regenerates directly into `packages/` from the canonical WASM and applies the post-generation patch pass (package README, `package.json` peer-demotion, `tsconfig`).
+
+```bash
+pnpm run bindings:regen # regenerate packages/*/src from the canonical WASM
+```
+
+Then prove the committed bindings match the canonical on-chain WASM:
+
+```bash
+pnpm run verify:bindings
+```
+
+`verify:bindings` (`scripts/bindings/verify.sh`) fetches the smart-wallet WASM by the pinned canonical hash recorded in [`deployments-testnet-2026-07-11.md`](./deployments-testnet-2026-07-11.md), regenerates to a temp dir, and diffs the ContractSpec base64 (the wasm-determined semantic content, ignoring CLI formatting). It exits nonzero on drift.
+
+> [!WARNING]
+> **Never hand-edit the generated bindings** to resolve drift. Fix it on the contract side, rebuild, re-pin the canonical hash in the deployments manifest, and regenerate. Hand-edits re-introduce drift that `verify:bindings` will flag.
+
+## 3. Validate
+
+```bash
+pnpm install --frozen-lockfile
+pnpm test --run
+pnpm run verify:bindings
+pnpm run build # build:bindings → tsc → verify-esm
+pnpm run build:demo # ensure the demo still builds against the SDK
+git diff --check
+git status --short
+```
+
+`pnpm run build` runs `build:bindings`, compiles the SDK to `dist/`, and runs the Node-ESM import smoke test (`verify-esm.mjs`). Commit any intended changes before continuing — publish from a clean tree.
+
+## 4. Authenticated dry run
+
+A dry run verifies npm authentication and shows the tarball contents and the versions that would publish, without uploading. Run it for each package from its directory:
+
+```bash
+pnpm --filter passkey-kit-sdk publish --dry-run --no-git-checks
+pnpm --filter sac-sdk publish --dry-run --no-git-checks
+pnpm publish --dry-run --no-git-checks # from the repo root: passkey-kit
+```
+
+Confirm the `files` whitelist (`dist`, `README.md`, `LICENCE`) is what ships, and that `passkey-kit`'s dry run resolved the `workspace:*` deps to the concrete binding versions.
+
+## 5. Publish
+
+Publish the bindings first, then the SDK. `prepublishOnly` on `passkey-kit` re-runs `verify:bindings` and `build` as a final gate.
+
+```bash
+# 1) bindings
+pnpm --filter passkey-kit-sdk publish --no-git-checks
+npm view passkey-kit-sdk version
+
+pnpm --filter sac-sdk publish --no-git-checks
+npm view sac-sdk version
+
+# 2) the SDK
+pnpm publish --no-git-checks # from the repo root
+npm view passkey-kit version
+npm view passkey-kit dependencies # confirm the binding versions resolved
+```
+
+If npm requires a one-time password, append `--otp ` to each `pnpm … publish` command:
+
+```bash
+pnpm --filter passkey-kit-sdk publish --no-git-checks --otp 123456
+```
+
+Use a **fresh** OTP for each publish (codes expire in ~30s). npm package versions are immutable — a re-run after a successful publish fails; check `npm view` before retrying.
+
+## Contract & indexer deploys (separate concerns)
+
+Publishing the npm packages does **not** deploy contracts, the indexer, or the workers. Those are separate, gated steps:
+
+- **Contract WASM** (smart-wallet upload, sample-policy deploy) and the canonical hashes: [`deployments-testnet-2026-07-11.md`](./deployments-testnet-2026-07-11.md). Every user wallet deploys its own instance via the SDK; the smart-wallet WASM is uploaded, never run as a singleton.
+- **Mercury (Zephyr) indexer** deploy/query tooling status (self-serve Zephyr retired; pending a hosting decision): [`zephyr/README.md`](../zephyr/README.md).
+- **Relayer-proxy worker** (Cloudflare): [`relayer-proxy/README.md`](../relayer-proxy/README.md) (`pnpm deploy` / `pnpm deploy:production`).
+- **Demo** (Cloudflare Pages): root `wrangler.toml`, `pnpm run deploy:demo` / `deploy:demo:prod`.
diff --git a/package.json b/package.json
index 8f6d2c0..7106b81 100644
--- a/package.json
+++ b/package.json
@@ -1,26 +1,52 @@
{
"name": "passkey-kit",
- "version": "0.12.0",
+ "version": "0.13.0",
"description": "A helper library for creating and using smart wallet accounts on the Stellar blockchain.",
- "author": "Tyler van der Hoeven",
+ "author": "Tyler van der Hoeven ",
"license": "MIT",
"type": "module",
- "main": "src/index.ts",
- "types": "types/index.d.ts",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./storage": {
+ "import": "./dist/storage/index.js",
+ "types": "./dist/storage/index.d.ts"
+ },
+ "./server": {
+ "import": "./dist/server.js",
+ "types": "./dist/server.d.ts"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENCE"
+ ],
"scripts": {
+ "sync-version": "node scripts/sync-version.js",
+ "prebuild": "node scripts/sync-version.js",
+ "build:bindings": "pnpm --filter sac-sdk run build && pnpm --filter passkey-kit-sdk run build",
+ "bindings:regen": "bash scripts/bindings/build.sh",
+ "verify:bindings": "bash scripts/bindings/verify.sh",
+ "build": "pnpm run build:bindings && tsc && node scripts/verify-esm.mjs",
+ "verify:esm": "node scripts/verify-esm.mjs",
+ "test": "vitest",
+ "clean": "rm -rf dist packages/passkey-kit-sdk/dist packages/sac-sdk/dist",
"build:demo": "cd demo && pnpm --ignore-workspace install && pnpm run build",
"deploy:demo": "npx wrangler pages deploy",
- "deploy:demo:prod": "npx wrangler pages deploy --env production",
- "prepublishOnly": "pnpm run build",
- "build:sac": "pnpm --filter=sac-sdk run build",
- "build:pks": "pnpm --filter=passkey-kit-sdk run build",
- "build": "pnpm run build:sac && pnpm run build:pks && tsc",
- "ext:cp": "./clone-js-sdk.sh"
+ "deploy:demo:prod": "npx wrangler pages deploy --branch production",
+ "prepublishOnly": "pnpm run verify:bindings && pnpm run build"
},
"dependencies": {
- "@openzeppelin/relayer-plugin-channels": "^0.5.0",
- "@simplewebauthn/browser": "^13.2.0",
- "@stellar/stellar-sdk": "^14.2.0",
+ "@openzeppelin/relayer-plugin-channels": "^0.20.0",
+ "@simplewebauthn/browser": "^13.3.0",
+ "@stellar/stellar-sdk": "16.0.1",
"base64url": "^3.0.1",
"buffer": "^6.0.3",
"passkey-kit-sdk": "workspace:*",
@@ -28,18 +54,23 @@
},
"devDependencies": {
"@types/node": "^24.6.0",
- "typescript": "^5.9.2"
+ "fake-indexeddb": "^6.2.5",
+ "typescript": "^5.9.2",
+ "vitest": "^3.2.4"
+ },
+ "peerDependencies": {
+ "@stellar/stellar-sdk": ">=16.0.0"
},
"pnpm": {
"overrides": {
"@stellar/stellar-sdk": "$@stellar/stellar-sdk"
- }
+ },
+ "onlyBuiltDependencies": [
+ "esbuild"
+ ]
},
- "workspaces": [
- "packages/*"
- ],
- "engine": {
- "node": ">=20"
+ "engines": {
+ "node": ">=22.0.0"
},
"repository": {
"type": "git",
diff --git a/packages/passkey-kit-sdk/README.md b/packages/passkey-kit-sdk/README.md
index 1529c74..40d8a97 100644
--- a/packages/passkey-kit-sdk/README.md
+++ b/packages/passkey-kit-sdk/README.md
@@ -1,6 +1,6 @@
-# passkey-kit-sdk JS
+# pks-gen JS
-JS library for interacting with [Soroban](https://soroban.stellar.org/) smart contract `passkey-kit-sdk` via Soroban RPC.
+JS library for interacting with [Soroban](https://soroban.stellar.org/) smart contract `pks-gen` via Soroban RPC.
This library was automatically generated by Soroban CLI using a command similar to:
@@ -9,7 +9,7 @@ soroban contract bindings ts \
--rpc-url INSERT_RPC_URL_HERE \
--network-passphrase "INSERT_NETWORK_PASSPHRASE_HERE" \
--contract-id INSERT_CONTRACT_ID_HERE \
- --output-dir ./path/to/passkey-kit-sdk
+ --output-dir ./path/to/pks-gen
```
The network passphrase and contract ID are exported from [index.ts](./src/index.ts) in the `networks` constant. If you are the one who generated this library and you know that this contract is also deployed to other networks, feel free to update `networks` with other valid options. This will help your contract consumers use this library more easily.
@@ -22,7 +22,7 @@ But you don't need to publish this library to NPM to use it. You can add it to y
```json
"dependencies": {
- "passkey-kit-sdk": "./path/to/this/folder"
+ "pks-gen": "./path/to/this/folder"
}
```
@@ -30,7 +30,7 @@ However, we've actually encountered [frustration](https://github.com/stellar/sor
```json
"scripts": {
- "postinstall": "soroban contract bindings ts --rpc-url INSERT_RPC_URL_HERE --network-passphrase \"INSERT_NETWORK_PASSPHRASE_HERE\" --id INSERT_CONTRACT_ID_HERE --name passkey-kit-sdk"
+ "postinstall": "soroban contract bindings ts --rpc-url INSERT_RPC_URL_HERE --network-passphrase \"INSERT_NETWORK_PASSPHRASE_HERE\" --id INSERT_CONTRACT_ID_HERE --name pks-gen"
}
```
@@ -41,7 +41,7 @@ Obviously you need to adjust the above command based on the actual command you u
Now that you have your library up-to-date and added to your project, you can import it in a file and see inline documentation for all of its exported methods:
```js
-import { Contract, networks } from "passkey-kit-sdk"
+import { Contract, networks } from "pks-gen"
const contract = new Contract({
...networks.futurenet, // for example; check which networks this library exports
diff --git a/packages/passkey-kit-sdk/package.json b/packages/passkey-kit-sdk/package.json
index 15164ec..f9d2000 100644
--- a/packages/passkey-kit-sdk/package.json
+++ b/packages/passkey-kit-sdk/package.json
@@ -1,18 +1,36 @@
{
- "version": "0.7.2",
+ "version": "0.7.3",
"name": "passkey-kit-sdk",
"type": "module",
- "main": "src/index.ts",
- "types": "types/index.d.ts",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md"
+ ],
"scripts": {
"prepublishOnly": "pnpm run build",
"build": "tsc"
},
"dependencies": {
- "@stellar/stellar-sdk": "^14.2.0",
"buffer": "^6.0.3"
},
+ "peerDependencies": {
+ "@stellar/stellar-sdk": ">=16.0.0"
+ },
"devDependencies": {
+ "@stellar/stellar-sdk": "16.0.1",
"typescript": "^5.9.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
}
}
diff --git a/packages/passkey-kit-sdk/src/index.ts b/packages/passkey-kit-sdk/src/index.ts
index c405224..edaed05 100644
--- a/packages/passkey-kit-sdk/src/index.ts
+++ b/packages/passkey-kit-sdk/src/index.ts
@@ -1,132 +1,232 @@
import { Buffer } from "buffer";
+import { Address } from "@stellar/stellar-sdk";
import {
AssembledTransaction,
Client as ContractClient,
ClientOptions as ContractClientOptions,
MethodOptions,
+ Result,
Spec as ContractSpec,
-} from '@stellar/stellar-sdk/minimal/contract';
+} from "@stellar/stellar-sdk/contract";
import type {
u32,
+ i32,
+ u64,
+ i64,
+ u128,
+ i128,
+ u256,
+ i256,
Option,
-} from '@stellar/stellar-sdk/minimal/contract';
+ Timepoint,
+ Duration,
+} from "@stellar/stellar-sdk/contract";
+export * from "@stellar/stellar-sdk";
+export * as contract from "@stellar/stellar-sdk/contract";
+export * as rpc from "@stellar/stellar-sdk/rpc";
-if (typeof window !== 'undefined') {
+if (typeof window !== "undefined") {
//@ts-ignore Buffer exists
window.Buffer = window.Buffer || Buffer;
}
+
+
+
+/**
+ * Contract errors.
+ *
+ * Deliberately renumbered for the v1 interface so the error space is disjoint
+ * from the legacy (pre-1.0) contract's 1-9 range. A client decoding an error
+ * code < 100 is talking to a legacy wallet.
+ *
+ * Ranges:
+ * - 100-109: signer storage / management
+ * - 110-119: auth (`__check_auth`)
+ * - 120-129: WebAuthn (secp256r1) verification
+ */
export const Errors = {
- 1: { message: "NotFound" },
- 2: { message: "AlreadyExists" },
- 3: { message: "MissingContext" },
- 4: { message: "SignerExpired" },
- 5: { message: "FailedSignerLimits" },
- 6: { message: "FailedPolicySignerLimits" },
- 7: { message: "SignatureKeyValueMismatch" },
- 8: { message: "ClientDataJsonChallengeIncorrect" },
- 9: { message: "JsonParseError" }
+ /**
+ * The requested signer does not exist on this smart wallet.
+ */
+ 100: {message:"SignerNotFound"},
+ /**
+ * `add_signer` was called with a signer key that already exists.
+ */
+ 101: {message:"SignerAlreadyExists"},
+ /**
+ * The signer's expiration timestamp is in the past.
+ */
+ 102: {message:"SignerExpired"},
+ /**
+ * No signer in the signatures map is permitted to authorize one of the
+ * requested auth contexts.
+ */
+ 110: {message:"MissingContext"},
+ /**
+ * A signature's variant does not match the stored signer it claims to be
+ * for (e.g. an Ed25519 signature submitted for a Policy signer key).
+ */
+ 111: {message:"SignatureKeyValueMismatch"},
+ /**
+ * clientDataJSON exceeds the 1024 byte parse buffer.
+ */
+ 120: {message:"ClientDataJsonTooLarge"},
+ /**
+ * clientDataJSON is not parseable JSON (or is missing required fields).
+ */
+ 121: {message:"ClientDataJsonParseError"},
+ /**
+ * The challenge in clientDataJSON does not match the base64url-encoded
+ * signature payload. This binds the WebAuthn assertion to the Soroban
+ * authorization entry and MUST NOT be weakened.
+ */
+ 122: {message:"ClientDataJsonChallengeIncorrect"},
+ /**
+ * clientDataJSON `type` is not "webauthn.get".
+ */
+ 123: {message:"InvalidWebAuthnType"},
+ /**
+ * authenticatorData is shorter than the WebAuthn minimum of 37 bytes
+ * (rpIdHash 32 + flags 1 + signCount 4).
+ */
+ 124: {message:"InvalidAuthenticatorData"},
+ /**
+ * The authenticator did not set the User Present (UP) flag.
+ *
+ * UP-only is the deliberate default (audit FIX-7). Requiring UP keeps
+ * silent, non-interactive assertions out while staying compatible with
+ * authenticators that cannot do User Verification (UV — biometric/PIN).
+ * UV is therefore NOT required by this contract. A deployment that wants
+ * UV-required assertions should enforce it at the client/relayer layer,
+ * or via a future per-signer flag (which would be a signer-model change,
+ * not a change to this check); the contract cannot upgrade UP-only
+ * signers to UV-required retroactively without such a flag.
+ */
+ 125: {message:"UserPresenceRequired"}
}
-export type SignerExpiration = readonly [Option];
+
+/**
+ * Full signer description used by `__constructor`, `add_signer` and
+ * `update_signer`.
+ */
+export type Signer = {tag: "Policy", values: readonly [string, SignerExpiration, SignerLimits, SignerStorage]} | {tag: "Ed25519", values: readonly [Buffer, SignerExpiration, SignerLimits, SignerStorage]} | {tag: "Secp256r1", values: readonly [Buffer, Buffer, SignerExpiration, SignerLimits, SignerStorage]};
+
+/**
+ * A signature entry in the signatures map. `Policy` carries no signature
+ * material: inclusion of the policy key authorizes an on-chain `policy__`
+ * check instead.
+ */
+export type Signature = {tag: "Policy", values: void} | {tag: "Ed25519", values: readonly [Buffer]} | {tag: "Secp256r1", values: readonly [Secp256r1Signature]};
+
+/**
+ * Storage key identifying a signer. Secp256r1 carries the WebAuthn
+ * credential id (`keyId`).
+ */
+export type SignerKey = {tag: "Policy", values: readonly [string]} | {tag: "Ed25519", values: readonly [Buffer]} | {tag: "Secp256r1", values: readonly [Buffer]};
+
+/**
+ * Stored signer value. Secp256r1 carries the SEC-1 uncompressed public key.
+ */
+export type SignerVal = {tag: "Policy", values: readonly [SignerExpiration, SignerLimits]} | {tag: "Ed25519", values: readonly [SignerExpiration, SignerLimits]} | {tag: "Secp256r1", values: readonly [Buffer, SignerExpiration, SignerLimits]};
+
+/**
+ * The `__check_auth` signature object: a map of signer keys to signatures.
+ * Map ordering is the host's ScVal ordering. EVERY entry must verify (pass
+ * 2 of `__check_auth`) — include only signatures that are needed.
+ */
+export type Signatures = readonly [Map];
+
+/**
+ * Restrictions on which auth contexts a signer may authorize.
+ *
+ * - `None`: unlimited. The signer can authorize anything, including
+ * `CreateContract*` (deploy) contexts and this wallet's own admin
+ * functions.
+ * - `Some(empty map)`: NO permissions (fail-closed). The signer can authorize
+ * nothing except removing itself (see below). v1 breaking change: pre-1.0
+ * an empty map meant unlimited, leaving two unlimited encodings and no
+ * "none" encoding.
+ * - `Some({address -> None})`: the signer may authorize any invocation of
+ * contract `address`, with no co-signers required.
+ * - `Some({address -> Some([keys])})`: the signer may authorize invocations
+ * of contract `address` only if every listed key also APPROVES. The listed
+ * keys are required CO-SIGNERS.
+ *
+ * ## Required co-signers are scope-independent approvers (audit FIX-5)
+ *
+ * A required co-signer's OWN `SignerLimits` do NOT constrain its co-signer
+ * role — a key's limits govern only its INDEPENDENT authority (whether it can
+ * cover a context on its own). This is symmetric across key kinds:
+ *
+ * -
+ */
export type SignerLimits = readonly [Option