diff --git a/.cursorrules b/.cursorrules index abd00e0..563d23a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,170 +1,69 @@ -# Passkey Kit – Cursor Workspace Rules (v2025-06-12) - -These rules provide canonical guidance to AI assistants and contributors interacting with this repository. They supersede any previous `.cursorrules` file. -Their intent is to stay *future-proof*: they encode principles that should hold even as tooling evolves. When in doubt, prefer the spirit of the rule over its letter. - ---- - -## 1 Repository Topology (overview) - -• `src/` – Core **passkey-kit** TypeScript library (published to npm). -• `packages/passkey-kit-sdk/` – Stand-alone TS SDK used by the main package. -• `packages/sac-sdk/` – SAC (smart-account contract) SDK. -• `contracts/` – Soroban smart contracts written in Rust. -• `zephyr/` – Deterministic Zephyr program exposing HTTP endpoints. -• `demo/` – Svelte demo showcasing end-to-end usage. - -This repo is a **pnpm workspace**; always use `pnpm` (≥ 8) for Node tasks. - ---- - -## 2 Language & Toolchain Baselines - -| Domain | Minimum Supported | Notes | -|--------|-------------------|-------| -| Node.js | 20 LTS | ESM by default, top-level `await` allowed | -| TypeScript | 5.4 | `strict`, `moduleResolution: node16` | -| Rust (contracts) | 1.79 | Follow the current stable Soroban toolchain | -| Soroban SDK | 22.x | Keep minor versions in lock-step across contracts | -| Bun (tests) | 1.x | Used for ultrafast unit tests (`bun test`) | -| pnpm | 8.x | Managed via **Corepack**; lockfile v5 enforced | - -Upgrade the baselines on every **major** release of the kit. - ---- - -## 3 General Coding Principles - -1. **Functional & immutable** – avoid shared mutable state; favour pure functions. -2. **Fail loudly, fail early** – surface rich, actionable error messages. -3. **Minimal dependencies** – prefer platform APIs or internal utils; justify every new dep. -4. **Typed first** – all public surfaces must be *fully* typed; export `.d.ts` files. -5. **Async-first** – use `async/await`; never chain raw `Promise` callbacks. -6. **Workspace integrity** – after changes run `pnpm run build` at the repo root to guarantee type-safety across packages. - ---- - -## 4 JavaScript / TypeScript Specifics - -• Import Stellar via `@stellar/stellar-sdk/minimal` unless full bundle is strictly required. -• Base-64 URL encode/decode with `base64url`; avoid `Buffer.from(x, 'base64')`. -• Prefer **named exports**; avoid `export default` in new code. -• File extensions: `.ts` / `.tsx` (React) / `.svelte` (demo) only. -• Module import grouping: external → workspace → relative → style imports; blank line between groups. -• Leverage modern ECMAScript (`??`, `?.`, `Array#at`, `Intl`) – but avoid stage-3 proposals unless polyfilled. -• Publish ESM-first: every package must declare `"type": "module"` and expose an `exports` map with `import`, `require` (fallback), and `types` conditions. -• Mark packages `"sideEffects": false` when safe to enable aggressive tree-shaking. -• Ship first-party `.d.ts` bundles (either via `tsc --emitDeclarationOnly` or `api-extractor`). Do **not** rely on DefinitelyTyped for kit packages. -• Top-level entrypoints should be 100 % statically analyzable – avoid dynamic `require()` or `eval()`. - ---- - -## 5 Rust / Soroban Contract Rules - -1. Contracts live in `contracts/*/src`. -2. `#![no_std]` mandatory; rely on `soroban-sdk` helpers instead of `std`. -3. Expose a top-level `Contract` struct marked with `#[contract]`. -4. Model errors with `#[contracterror]`; surface via `panic_with_error!`. -5. Tests belong under `contracts/*/src/tests` using `soroban_sdk::testutils`. -6. New contracts must be registered in `contracts/Cargo.toml` workspace members. - ---- - -## 6 Zephyr Program Rules - -• Zephyr code resides in `zephyr/src` and **must remain deterministic** – avoid non-deterministic data sources (e.g. system time, randomness) unless explicitly mocked. -• Each externally exposed endpoint must be documented *above* its `#[no_mangle]` function. - ---- - -## 7 Svelte / Demo Rules - -• Demo code lives in `demo/src`. -• Follow existing component conventions: PascalCase filenames, ` -
- - - - - {#if contractId} -

{contractId}

- - {#if balance} -

{parseFloat((Number(balance) / 10_000_000).toFixed(7))} XLM

- {/if} - - - -
- - -
- - -
- - -
- -
- {/if} - - +
+ + {#if connected} + + + + {/if} +
- +
diff --git a/demo/src/app.css b/demo/src/app.css index bd6a1f6..26563a4 100644 --- a/demo/src/app.css +++ b/demo/src/app.css @@ -1,14 +1,316 @@ :root { + --bg: #0b1120; + --panel: #111a2e; + --panel-2: #16223c; + --border: #26324d; + --text: #e6edf7; + --muted: #93a1bd; + --accent: #38bdf8; + --accent-strong: #0ea5e9; + --good: #34d399; + --bad: #f87171; + --warn: #fbbf24; + --radius: 12px; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; - font-weight: 400; + color: var(--text); + background-color: var(--bg); + color-scheme: dark; + -webkit-font-smoothing: antialiased; +} - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f7fc; + --panel: #ffffff; + --panel-2: #eef2f9; + --border: #d7deea; + --text: #0f1b33; + --muted: #55617a; + --accent: #0284c7; + --accent-strong: #0369a1; + --good: #059669; + --bad: #dc2626; + --warn: #b45309; + color-scheme: light; + } +} - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} \ No newline at end of file +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +h1, +h2, +h3 { + margin: 0; + line-height: 1.2; +} + +a { + color: var(--accent); +} + +code, +.mono { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + font-size: 0.85em; +} + +.app { + max-width: 1120px; + margin: 0 auto; + padding: 24px 20px 80px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 16px; + align-items: start; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; +} + +.panel h2 { + font-size: 15px; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 14px; +} + +.row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.col { + display: flex; + flex-direction: column; + gap: 10px; +} + +.spread { + justify-content: space-between; +} + +button { + font: inherit; + cursor: pointer; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + border-radius: 8px; + padding: 8px 12px; + transition: border-color 0.15s, background 0.15s; +} + +button:hover:not(:disabled) { + border-color: var(--accent); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + background: var(--accent-strong); + border-color: var(--accent-strong); + color: #04121f; + font-weight: 600; +} + +button.ghost { + background: transparent; +} + +button.danger { + color: var(--bad); +} + +button.sm { + padding: 4px 8px; + font-size: 0.85em; +} + +input, +select { + font: inherit; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + min-width: 0; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--accent); +} + +label, +.label { + font-size: 0.85em; + color: var(--muted); +} + +.label { + display: block; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field input, +.field select { + width: 100%; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); +} + +.badge.admin { + color: var(--warn); + border-color: var(--warn); +} +.badge.self { + color: var(--accent); + border-color: var(--accent); +} +.badge.good { + color: var(--good); + border-color: var(--good); +} +.badge.bad { + color: var(--bad); + border-color: var(--bad); +} + +.muted { + color: var(--muted); +} + +.hint { + font-size: 0.82em; + color: var(--muted); +} + +.divider { + height: 1px; + background: var(--border); + margin: 12px 0; + border: 0; +} + +.big-id { + font-family: ui-monospace, monospace; + font-size: 0.9em; + word-break: break-all; + color: var(--text); +} + +.balance { + font-size: 1.8rem; + font-weight: 700; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.signer { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: var(--panel-2); +} + +.log { + max-height: 320px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.86em; +} + +.log-entry { + padding: 6px 10px; + border-radius: 8px; + border-left: 3px solid var(--border); + background: var(--panel-2); +} +.log-entry.success { + border-left-color: var(--good); +} +.log-entry.error { + border-left-color: var(--bad); +} +.log-entry.info { + border-left-color: var(--accent); +} +.log-detail { + color: var(--muted); + font-size: 0.92em; +} + +.pending { + border: 1px dashed var(--warn); + color: var(--warn); + border-radius: 8px; + padding: 8px 10px; + font-size: 0.85em; +} + +.seg { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.seg button { + border: 0; + border-radius: 0; + background: transparent; +} +.seg button.active { + background: var(--accent-strong); + color: #04121f; + font-weight: 600; +} diff --git a/demo/src/lib/actions.ts b/demo/src/lib/actions.ts new file mode 100644 index 0000000..ee9c38d --- /dev/null +++ b/demo/src/lib/actions.ts @@ -0,0 +1,581 @@ +/** + * Application flows: every user action that touches the SDK lives here, so + * components stay thin (markup + an `onclick` that calls an action). Each flow + * mutates the reactive {@link app} store and surfaces typed errors + real + * tx hashes through the activity log. + */ + +import { Buffer } from "buffer"; +import { Keypair } from "@stellar/stellar-sdk"; +import base64url from "base64url"; +import { + Ed25519Signer, + PasskeySigner, + PolicySigner, + SignerStore, + type Signer, + type TransactionResult, +} from "passkey-kit"; +import { + account, + config, + fundKeypair, + fundSigner, + indexer, + sac, + storage, +} from "./config"; +import { submit } from "./submit"; +import { app, isConnected, pushLog } from "./state.svelte"; +import { describeError } from "./format"; +import type { IndexerBackend } from "./indexer-proxy"; +import { + buildSignerLimits, + describeLimits, + toSignerKey, + UNLIMITED, + type LimitsSpec, + type LocalSigner, + type SignerKind, +} from "./signers"; + +/** The AssembledTransaction the kit's add/update/remove builders return. */ +type WalletWriteTx = Awaited>; + +// -- Small internals ---------------------------------------------------------- + +/** Run a flow with busy state + typed error surfacing. Returns undefined on error. */ +async function run(label: string, fn: () => Promise): Promise { + if (app.busy) { + pushLog("error", `Busy — "${app.busy}" is still running`); + return undefined; + } + app.busy = label; + try { + return await fn(); + } catch (err) { + const d = describeError(err); + pushLog("error", `${label} failed: ${d.message}`, { detail: errorDetail(d) }); + return undefined; + } finally { + app.busy = null; + } +} + +function errorDetail(d: ReturnType): string { + const parts = [d.name]; + if (d.code !== undefined) parts.push(`[${d.code}]`); + if (d.contract) parts.push(`· ${d.contract}`); + return parts.join(" "); +} + +/** Log a submission {@link TransactionResult}. Returns success. */ +function reportResult(label: string, res: TransactionResult): boolean { + if (res.success) { + pushLog("success", `${label} ✓`, { + hash: res.hash, + detail: res.ledger ? `ledger ${res.ledger}` : undefined, + }); + return true; + } + const d = describeError(res.error); + pushLog("error", `${label} failed: ${d.message}`, { + hash: res.hash, + detail: errorDetail(d), + }); + return false; +} + +/** The Signer object used to authorize wallet writes (the active admin passkey). */ +function adminSigner(): Signer { + return new PasskeySigner(app.activeKeyId ?? app.keyId); +} + +function upsertSigner(signer: LocalSigner): void { + const i = app.signers.findIndex( + (s) => s.kind === signer.kind && s.value === signer.value, + ); + if (i >= 0) app.signers[i] = signer; + else app.signers.push(signer); +} + +function removeFromRegistry(kind: SignerKind, value: string): void { + app.signers = app.signers.filter((s) => !(s.kind === kind && s.value === value)); +} + +function localToken(): ReturnType { + return sac.getSACClient(app.selectedToken || config.nativeContractId); +} + +// -- Boot --------------------------------------------------------------------- + +/** Refresh the list of passkeys held by the storage adapter. */ +export async function refreshKnown(): Promise { + app.knownPasskeys = await storage.getAll().catch(() => []); +} + +/** On load, offer the most recently-used stored passkey for a one-tap reconnect. */ +export async function boot(): Promise { + app.selectedToken = config.nativeContractId; + try { + const known = await storage.getAll(); + app.knownPasskeys = known; + if (known.length) { + const latest = [...known].sort( + (a, b) => (b.lastUsedAt ?? b.createdAt) - (a.lastUsedAt ?? a.createdAt), + )[0]!; + pushLog("info", `Found stored passkey ${latest.keyId.slice(0, 10)}… — reconnecting`); + await connect(latest.keyId); + } + } catch (err) { + pushLog("info", `No stored passkey (${describeError(err).message})`); + } finally { + app.ready = true; + } +} + +// -- Wallet lifecycle --------------------------------------------------------- + +export async function register(name: string): Promise { + await run("Create wallet", async () => { + const { keyIdBase64, contractId, signedTx, keyId } = await account.createWallet( + "Passkey Kit Demo", + name, + ); + const ok = reportResult("Deploy wallet", await submit(signedTx)); + if (!ok) throw new Error("Wallet deploy was not accepted by the relayer"); + + setConnected(keyIdBase64, contractId); + // The constructor signer is unlimited (admin). + const passkey = await storage.get(keyIdBase64); + upsertSigner({ + kind: "Secp256r1", + value: keyIdBase64, + store: SignerStore.Persistent, + limitsMode: "unlimited", + self: true, + label: name, + publicKey: passkey ? base64url(Buffer.from(passkey.publicKey)) : undefined, + }); + pushLog("success", `Wallet created`, { detail: `keyId ${keyIdBase64.slice(0, 10)}… · ${bytesLabel(keyId)}` }); + await refreshKnown(); + + await refreshBalance(); + // Auto-fund the fresh wallet. Call the UNWRAPPED helper: `app.busy` is + // already set by this `run("Create wallet")` block, so `fundWallet` (which + // wraps in its own `run`) would hit the busy-guard and silently no-op. A + // fund failure must not fail wallet creation (the wallet is already live). + try { + await fundWalletInner(100); + } catch (err) { + pushLog("error", `Auto-fund failed: ${describeError(err).message}`); + } + }); +} + +export async function connect(keyId?: string): Promise { + await run("Connect wallet", async () => { + app.status = "connecting"; + const { keyIdBase64, contractId } = await account.connectWallet({ + keyId, + getContractId: (kid) => + indexer + .findWallets({ kind: "Secp256r1", value: kid }, app.discoverBackend) + .then((ids) => ids[0]) + .catch(() => undefined), + }); + + setConnected(keyIdBase64, contractId); + await storage.update(keyIdBase64, { lastUsedAt: Date.now() }).catch(() => {}); + const passkey = await storage.get(keyIdBase64); + upsertSigner({ + kind: "Secp256r1", + value: keyIdBase64, + store: SignerStore.Persistent, + limitsMode: "unlimited", + self: true, + label: passkey?.nickname ?? "This passkey", + publicKey: passkey ? base64url(Buffer.from(passkey.publicKey)) : undefined, + }); + pushLog("success", `Connected — ownership verified`, { + detail: `wallet ${contractId.slice(0, 8)}…`, + }); + await refreshKnown(); + await refreshBalance(); + }); +} + +function setConnected(keyId: string, contractId: string): void { + app.keyId = keyId; + app.activeKeyId = keyId; + app.contractId = contractId; + app.status = "connected"; +} + +export function disconnect(): void { + account.disconnect(); + app.status = "disconnected"; + app.keyId = undefined; + app.contractId = undefined; + app.activeKeyId = undefined; + app.signers = []; + app.balances = {}; + app.discovered = []; + pushLog("info", "Disconnected"); +} + +export async function forgetAll(): Promise { + await run("Forget stored passkeys", async () => { + await storage.clear(); + disconnect(); + app.knownPasskeys = []; + pushLog("info", "Cleared passkey storage"); + }); +} + +// -- Signer management -------------------------------------------------------- + +export interface AddSignerInput { + store: SignerStore; + limits: LimitsSpec; + expiration?: number; +} + +export async function addPasskeySigner(name: string, input: AddSignerInput): Promise { + if (!requireConnected()) return; + await run("Add passkey signer", async () => { + const created = await account.createKey("Passkey Kit Demo", name); + const limits = buildSignerLimits(input.limits); + const at = await account.addSecp256r1( + created.keyId, + created.publicKey, + limits, + input.store, + input.expiration, + ); + if (!(await signAndSubmit("Add passkey signer", at))) return; + + // Persist so it is reconnectable + has a retrievable public key. + await storage + .save({ + keyId: created.keyId, + publicKey: created.publicKey, + contractId: app.contractId!, + nickname: name, + createdAt: Date.now(), + }) + .catch(() => {}); + upsertSigner({ + kind: "Secp256r1", + value: created.keyId, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + label: name, + publicKey: base64url(Buffer.from(created.publicKey)), + }); + await refreshKnown(); + pushLog("info", `Passkey signer added · ${describeLimits(input.limits)}`); + }); +} + +export async function addEd25519Signer(input: AddSignerInput): Promise { + if (!requireConnected()) return; + const pub = ensureEd25519().publicKey(); + await run("Add Ed25519 signer", async () => { + const at = await account.addEd25519( + pub, + buildSignerLimits(input.limits), + input.store, + input.expiration, + ); + if (!(await signAndSubmit("Add Ed25519 signer", at))) return; + upsertSigner({ + kind: "Ed25519", + value: pub, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + label: "Ed25519 key", + }); + pushLog("info", `Ed25519 signer added · ${describeLimits(input.limits)}`); + }); +} + +export async function addPolicySigner(input: AddSignerInput): Promise { + if (!requireConnected()) return; + if (!config.samplePolicyId) { + pushLog("error", "No sample policy configured (set VITE_samplePolicyId)"); + return; + } + const policy = config.samplePolicyId; + await run("Add policy signer", async () => { + const at = await account.addPolicy( + policy, + buildSignerLimits(input.limits), + input.store, + input.expiration, + ); + if (!(await signAndSubmit("Add policy signer", at))) return; + upsertSigner({ + kind: "Policy", + value: policy, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + label: "Policy", + }); + pushLog("info", `Policy signer added · ${describeLimits(input.limits)}`); + }); +} + +export async function updateSigner( + signer: LocalSigner, + input: AddSignerInput, +): Promise { + if (!requireConnected()) return; + await run(`Update ${signer.kind} signer`, async () => { + const limits = buildSignerLimits(input.limits); + let at: WalletWriteTx; + switch (signer.kind) { + case "Secp256r1": { + const publicKey = await resolvePublicKey(signer); + if (!publicKey) throw new Error("Missing public key for this passkey (cannot update)"); + at = await account.updateSecp256r1(signer.value, publicKey, limits, input.store, input.expiration); + break; + } + case "Ed25519": + at = await account.updateEd25519(signer.value, limits, input.store, input.expiration); + break; + case "Policy": + at = await account.updatePolicy(signer.value, limits, input.store, input.expiration); + break; + } + if (!(await signAndSubmit(`Update ${signer.kind} signer`, at))) return; + upsertSigner({ + ...signer, + store: input.store, + limitsMode: input.limits.mode, + expiration: input.expiration, + }); + pushLog("info", `Signer updated · ${describeLimits(input.limits)}`); + }); +} + +export async function removeSigner(signer: LocalSigner): Promise { + if (!requireConnected()) return; + await run(`Remove ${signer.kind} signer`, async () => { + const at = await account.remove(toSignerKey(signer)); + if (!(await signAndSubmit(`Remove ${signer.kind} signer`, at))) return; + removeFromRegistry(signer.kind, signer.value); + if (signer.kind === "Secp256r1") { + await storage.delete(signer.value).catch(() => {}); + await refreshKnown(); + } + pushLog("info", `Signer removed`); + }); +} + +/** Admin rotation: add a fresh unlimited passkey and make it the active signer. */ +export async function rotateAdmin(name: string): Promise { + if (!requireConnected()) return; + await addPasskeySigner(name, { store: SignerStore.Persistent, limits: UNLIMITED }); + const added = app.signers.find((s) => s.kind === "Secp256r1" && s.label === name); + if (added) { + setActiveSigner(added.value); + pushLog("success", `Admin rotated — new active signer ${added.value.slice(0, 10)}…`); + } +} + +export function setActiveSigner(keyId: string): void { + app.activeKeyId = keyId; + pushLog("info", `Active signer set to ${keyId.slice(0, 10)}…`); +} + +// -- Transfers + balances ----------------------------------------------------- + +export type SignWith = "passkey" | "ed25519" | "policy" | "multisig"; + +export async function transfer(to: string, amount: bigint, signWith: SignWith): Promise { + if (!requireConnected()) return; + await doTransfer(`Transfer (${signWith})`, to, amount, signersFor(signWith)); +} + +/** Quick 1-token transfer back to the funding account, signed by one signer. */ +export async function quickTransferFrom(signer: LocalSigner): Promise { + if (!requireConnected()) return; + const to = (await fundKeypair).publicKey(); + await doTransfer(`Transfer via ${signer.kind}`, to, 10_000_000n, signersForLocal(signer)); +} + +async function doTransfer( + label: string, + to: string, + amount: bigint, + signers: Signer[], +): Promise { + await run(label, async () => { + const token = localToken(); + let at = await token.transfer({ from: app.contractId!, to, amount }); + for (const signer of signers) { + at = await account.sign(at, signer); + } + if (!reportResult(label, await submit(at))) return; + await refreshBalance(); + }); +} + +/** The signer object(s) for a specific registry row. */ +function signersForLocal(signer: LocalSigner): Signer[] { + switch (signer.kind) { + case "Secp256r1": + return [new PasskeySigner(signer.value)]; + case "Ed25519": + return [Ed25519Signer.fromSecret(ensureEd25519().secret())]; + case "Policy": + return [Ed25519Signer.fromSecret(ensureEd25519().secret()), new PolicySigner(signer.value)]; + } +} + +function signersFor(signWith: SignWith): Signer[] { + switch (signWith) { + case "passkey": + return [adminSigner()]; + case "ed25519": + return [Ed25519Signer.fromSecret(ensureEd25519().secret())]; + case "policy": + // Sample policy co-signs alongside its required Ed25519 co-signer. + return [Ed25519Signer.fromSecret(ensureEd25519().secret()), new PolicySigner(requirePolicy())]; + case "multisig": + return [ + adminSigner(), + Ed25519Signer.fromSecret(ensureEd25519().secret()), + ...(config.samplePolicyId ? [new PolicySigner(config.samplePolicyId)] : []), + ]; + } +} + +export async function fundWallet(amountXlm: number): Promise { + if (!requireConnected()) return; + await run(`Fund wallet (${amountXlm} XLM)`, () => fundWalletInner(amountXlm)); +} + +/** + * The fund body WITHOUT a `run()` wrapper, so it can be called from inside + * another flow's `run()` block (e.g. `register`) without tripping the busy-guard + * (which early-returns when `app.busy` is already set). + */ +async function fundWalletInner(amountXlm: number): Promise { + const fundKp = await fundKeypair; + const signer = await fundSigner(); + const native = sac.getSACClient(config.nativeContractId); + const at = await native.transfer({ + to: app.contractId!, + from: fundKp.publicKey(), + // `` allows fractional XLM; BigInt() throws on a + // non-integer, so scale then round to whole stroops. + amount: BigInt(Math.round(amountXlm * 10_000_000)), + }); + await at.signAuthEntries({ + address: fundKp.publicKey(), + signAuthEntry: signer.signAuthEntry, + }); + if (!reportResult(`Fund wallet`, await submit(at))) return; + await refreshBalance(); +} + +export async function refreshBalance(): Promise { + if (!app.contractId) return; + try { + const token = localToken(); + const { result } = await token.balance({ id: app.contractId }); + app.balances[app.selectedToken || config.nativeContractId] = result.toString(); + } catch (err) { + pushLog("error", `Balance read failed: ${describeError(err).message}`); + } +} + +export async function selectToken(contractId: string): Promise { + app.selectedToken = contractId; + await refreshBalance(); +} + +// -- Discovery (both indexer backends) ---------------------------------------- + +export async function discover(backend: IndexerBackend): Promise { + if (!app.contractId) return; + app.discoverBackend = backend; + if (!indexer.configured) { + pushLog("info", `Discovery pending: set VITE_indexerProxyUrl to query ${backend}`); + return; + } + await run(`Discover via ${backend}`, async () => { + const rows = await indexer.getSigners(app.contractId!, backend); + app.discovered = rows; + pushLog("success", `Discovered ${rows.length} signer(s) via ${backend}`, { + detail: rows.map((r) => `${r.key.kind}:${r.status}`).join(", ") || "none", + }); + }); +} + +export async function reverseLookup(backend: IndexerBackend): Promise { + if (!app.keyId) return; + if (!indexer.configured) { + pushLog("info", `Reverse lookup pending: set VITE_indexerProxyUrl`); + return; + } + await run(`Reverse lookup via ${backend}`, async () => { + const ids = await indexer.findWallets( + { kind: "Secp256r1", value: app.keyId! }, + backend, + ); + const match = ids.includes(app.contractId ?? ""); + pushLog(match ? "success" : "info", `keyId → ${ids.length} wallet(s) via ${backend}`, { + detail: ids.length + ? `${ids.map((i) => i.slice(0, 8) + "…").join(", ")}${match ? " · matches connected ✓" : ""}` + : "none", + }); + }); +} + +// -- Ed25519 demo key --------------------------------------------------------- + +/** Generate (once) an ephemeral Ed25519 keypair for the Ed25519/policy demos. */ +export function ensureEd25519(): Keypair { + if (app.ed25519Secret) return Keypair.fromSecret(app.ed25519Secret); + const kp = Keypair.random(); + app.ed25519Secret = kp.secret(); + app.ed25519Public = kp.publicKey(); + pushLog("info", `Generated ephemeral Ed25519 key ${kp.publicKey().slice(0, 8)}…`); + return kp; +} + +// -- Helpers ------------------------------------------------------------------ + +async function signAndSubmit(label: string, at: WalletWriteTx): Promise { + const signed = await account.sign(at, adminSigner()); + return reportResult(label, await submit(signed)); +} + +async function resolvePublicKey(signer: LocalSigner): Promise { + if (signer.publicKey) return base64url.toBuffer(signer.publicKey); + const stored = await storage.get(signer.value).catch(() => null); + return stored?.publicKey; +} + +function requireConnected(): boolean { + if (!isConnected()) { + pushLog("error", "Connect a wallet first"); + return false; + } + return true; +} + +function requirePolicy(): string { + if (!config.samplePolicyId) throw new Error("No sample policy configured (VITE_samplePolicyId)"); + return config.samplePolicyId; +} + +function bytesLabel(bytes: Uint8Array): string { + return `${bytes.length}-byte credential`; +} diff --git a/demo/src/lib/common.ts b/demo/src/lib/common.ts deleted file mode 100644 index 0adc1a9..0000000 --- a/demo/src/lib/common.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { PasskeyKit, PasskeyServer, SACClient } from "passkey-kit"; -import { Account, Keypair, StrKey } from "@stellar/stellar-sdk/minimal" -import { Buffer } from "buffer"; -import { basicNodeSigner } from "@stellar/stellar-sdk/minimal/contract"; -import { Server } from "@stellar/stellar-sdk/minimal/rpc"; - -export const rpc = new Server(import.meta.env.VITE_rpcUrl); - -export const mockPubkey = StrKey.encodeEd25519PublicKey(Buffer.alloc(32)) -export const mockSource = new Account(mockPubkey, '0') - -export const fundKeypair = new Promise(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 @@ + + +
+
+ +
+

Passkey Kit

+

Stellar smart-wallet accounts with WebAuthn passkeys

+
+
+ +
+ {network} + {#if app.busy} + {app.busy}… + {:else if app.status === "connected"} + connected + {:else} + {app.status} + {/if} + {#if !relayer.configured} + no relayer proxy + {/if} + {#if !indexer.configured} + no indexer proxy + {/if} +
+
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 @@ + + +
    +

    Signers

    + +
      + {#each app.signers as signer (signer.kind + signer.value)} + + {:else} +
    • No signers tracked yet.
    • + {/each} +
    + +
    +

    Add signer

    + + +
    +

    Admin rotation

    +

    Add a fresh unlimited passkey and make it the active signer.

    +
    + + +
    +
    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}) +
      + {#each app.knownPasskeys as pk (pk.keyId)} +
    • +
      + {shortId(pk.keyId, 8)} + {pk.nickname ?? "passkey"} → {shortId(pk.contractId, 6)} +
      + +
    • + {/each} +
    + {/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>>>]; -export type SignerStorage = { tag: "Persistent", values: void } | { tag: "Temporary", values: void }; -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] }; -export type SignerKey = { tag: "Policy", values: readonly [string] } | { tag: "Ed25519", values: readonly [Buffer] } | { tag: "Secp256r1", values: readonly [Buffer] }; -export type SignerVal = { tag: "Policy", values: readonly [SignerExpiration, SignerLimits] } | { tag: "Ed25519", values: readonly [SignerExpiration, SignerLimits] } | { tag: "Secp256r1", values: readonly [Buffer, SignerExpiration, SignerLimits] }; +/** + * Which durability a signer entry is stored under. At most one entry exists + * per signer key; lookups check Temporary before Persistent. + */ +export type SignerStorage = {tag: "Persistent", values: void} | {tag: "Temporary", values: void}; + +/** + * Optional expiration for a signer as a UNIX timestamp in seconds, INCLUSIVE: + * the signer is valid while `ledger timestamp <= expiration` and expired once + * `ledger timestamp > expiration`. `None` never expires. + * + * v1 breaking change: this was a ledger sequence number pre-1.0. Timestamps + * don't drift with changes to ledger close time (e.g. CAP-0070 dynamic + * timing), which ledger-sequence expirations did. + */ +export type SignerExpiration = readonly [Option]; + + +/** + * A WebAuthn assertion over the Soroban authorization payload. The signed + * message is `authenticator_data || sha256(client_data_json)` and the + * payload binding lives in clientDataJSON's `challenge` field. + */ export interface Secp256r1Signature { authenticator_data: Buffer; client_data_json: Buffer; signature: Buffer; } -export type Signature = { tag: "Policy", values: void } | { tag: "Ed25519", values: readonly [Buffer] } | { tag: "Secp256r1", values: readonly [Secp256r1Signature] }; -export type Signatures = readonly [Map]; -export interface Client { - /** - * Construct and simulate a add_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - add_signer: ({ signer }: { signer: Signer }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + +export interface Client { /** - * Construct and simulate a update_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Construct and simulate a upgrade transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - update_signer: ({ signer }: { signer: Signer }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; + upgrade: ({new_wasm_hash}: {new_wasm_hash: Buffer}, options?: MethodOptions) => Promise>> - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; + /** + * Construct and simulate a add_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + add_signer: ({signer}: {signer: Signer}, options?: MethodOptions) => Promise>> - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + /** + * Construct and simulate a get_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + get_signer: ({signer_key}: {signer_key: SignerKey}, options?: MethodOptions) => Promise>> /** * Construct and simulate a remove_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - remove_signer: ({ signer_key }: { signer_key: SignerKey }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> - - /** - * Construct and simulate a update_contract_code transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - update_contract_code: ({ hash }: { hash: Buffer }, options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - }) => Promise> + remove_signer: ({signer_key}: {signer_key: SignerKey}, options?: MethodOptions) => Promise>> + + /** + * Construct and simulate a update_signer transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + update_signer: ({signer}: {signer: Signer}, options?: MethodOptions) => Promise>> + } export class Client extends ContractClient { static async deploy( - /** Constructor/Initialization Args for the contract's `__constructor` method */ - { signer }: { signer: Signer }, + /** Constructor/Initialization Args for the contract's `__constructor` method */ + {signer}: {signer: Signer}, /** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { @@ -136,36 +236,41 @@ export class Client extends ContractClient { salt?: Buffer | Uint8Array; /** The format used to decode `wasmHash`, if it's provided as a string. */ format?: "hex" | "base64"; - address?: string; } ): Promise> { - return ContractClient.deploy({ signer }, options) + return ContractClient.deploy({signer}, options) } constructor(public readonly options: ContractClientOptions) { super( - new ContractSpec(["AAAABAAAAAAAAAAAAAAABUVycm9yAAAAAAAACQAAAAAAAAAITm90Rm91bmQAAAABAAAAAAAAAA1BbHJlYWR5RXhpc3RzAAAAAAAAAgAAAAAAAAAOTWlzc2luZ0NvbnRleHQAAAAAAAMAAAAAAAAADVNpZ25lckV4cGlyZWQAAAAAAAAEAAAAAAAAABJGYWlsZWRTaWduZXJMaW1pdHMAAAAAAAUAAAAAAAAAGEZhaWxlZFBvbGljeVNpZ25lckxpbWl0cwAAAAYAAAAAAAAAGVNpZ25hdHVyZUtleVZhbHVlTWlzbWF0Y2gAAAAAAAAHAAAAAAAAACBDbGllbnREYXRhSnNvbkNoYWxsZW5nZUluY29ycmVjdAAAAAgAAAAAAAAADkpzb25QYXJzZUVycm9yAAAAAAAJ", - "AAAAAQAAAAAAAAAAAAAAEFNpZ25lckV4cGlyYXRpb24AAAABAAAAAAAAAAEwAAAAAAAD6AAAAAQ=", - "AAAAAQAAAAAAAAAAAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAAATAAAAAAAAPoAAAD7AAAABMAAAPoAAAD6gAAB9AAAAAJU2lnbmVyS2V5AAAA", - "AAAAAgAAAAAAAAAAAAAADVNpZ25lclN0b3JhZ2UAAAAAAAACAAAAAAAAAAAAAAAKUGVyc2lzdGVudAAAAAAAAAAAAAAAAAAJVGVtcG9yYXJ5AAAA", - "AAAAAgAAAAAAAAAAAAAABlNpZ25lcgAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAABAAAABMAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAB9AAAAANU2lnbmVyU3RvcmFnZQAAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAABAAAA+4AAAAgAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAAFAAAADgAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAA=", - "AAAAAgAAAAAAAAAAAAAACVNpZ25lcktleQAAAAAAAAMAAAABAAAAAAAAAAZQb2xpY3kAAAAAAAEAAAATAAAAAQAAAAAAAAAHRWQyNTUxOQAAAAABAAAD7gAAACAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAABAAAADg==", - "AAAAAgAAAAAAAAAAAAAACVNpZ25lclZhbAAAAAAAAAMAAAABAAAAAAAAAAZQb2xpY3kAAAAAAAIAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAAB0VkMjU1MTkAAAAAAgAAB9AAAAAQU2lnbmVyRXhwaXJhdGlvbgAAB9AAAAAMU2lnbmVyTGltaXRzAAAAAQAAAAAAAAAJU2VjcDI1NnIxAAAAAAAAAwAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHM=", - "AAAAAQAAAAAAAAAAAAAAElNlY3AyNTZyMVNpZ25hdHVyZQAAAAAAAwAAAAAAAAASYXV0aGVudGljYXRvcl9kYXRhAAAAAAAOAAAAAAAAABBjbGllbnRfZGF0YV9qc29uAAAADgAAAAAAAAAJc2lnbmF0dXJlAAAAAAAD7gAAAEA=", - "AAAAAgAAAAAAAAAAAAAACVNpZ25hdHVyZQAAAAAAAAMAAAAAAAAAAAAAAAZQb2xpY3kAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAAAQAAA+4AAABAAAAAAQAAAAAAAAAJU2VjcDI1NnIxAAAAAAAAAQAAB9AAAAASU2VjcDI1NnIxU2lnbmF0dXJlAAA=", - "AAAAAQAAAAAAAAAAAAAAClNpZ25hdHVyZXMAAAAAAAEAAAAAAAAAATAAAAAAAAPsAAAH0AAAAAlTaWduZXJLZXkAAAAAAAfQAAAACVNpZ25hdHVyZQAAAA==", + new ContractSpec([ "AAAAAAAAAAAAAAAHdXBncmFkZQAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAABAAAD6QAAAAIAAAAD", + "AAAAAAAAAAAAAAAKYWRkX3NpZ25lcgAAAAAAAQAAAAAAAAAGc2lnbmVyAAAAAAfQAAAABlNpZ25lcgAAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAAAAAAAKZ2V0X3NpZ25lcgAAAAAAAQAAAAAAAAAKc2lnbmVyX2tleQAAAAAH0AAAAAlTaWduZXJLZXkAAAAAAAABAAAD6AAAB9AAAAAJU2lnbmVyVmFsAAAA", + "AAAAAAAAAAAAAAAMX19jaGVja19hdXRoAAAAAwAAAAAAAAARc2lnbmF0dXJlX3BheWxvYWQAAAAAAAPuAAAAIAAAAAAAAAAKc2lnbmF0dXJlcwAAAAAH0AAAAApTaWduYXR1cmVzAAAAAAAAAAAADWF1dGhfY29udGV4dHMAAAAAAAPqAAAH0AAAAAdDb250ZXh0AAAAAAEAAAPpAAAAAgAAAAM=", "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAA=", - "AAAAAAAAAAAAAAAKYWRkX3NpZ25lcgAAAAAAAQAAAAAAAAAGc2lnbmVyAAAAAAfQAAAABlNpZ25lcgAAAAAAAA==", - "AAAAAAAAAAAAAAANdXBkYXRlX3NpZ25lcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAA=", - "AAAAAAAAAAAAAAANcmVtb3ZlX3NpZ25lcgAAAAAAAAEAAAAAAAAACnNpZ25lcl9rZXkAAAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAA==", - "AAAAAAAAAAAAAAAUdXBkYXRlX2NvbnRyYWN0X2NvZGUAAAABAAAAAAAAAARoYXNoAAAD7gAAACAAAAAA", - "AAAAAAAAAAAAAAAMX19jaGVja19hdXRoAAAAAwAAAAAAAAARc2lnbmF0dXJlX3BheWxvYWQAAAAAAAPuAAAAIAAAAAAAAAAKc2lnbmF0dXJlcwAAAAAH0AAAAApTaWduYXR1cmVzAAAAAAAAAAAADWF1dGhfY29udGV4dHMAAAAAAAPqAAAH0AAAAAdDb250ZXh0AAAAAAEAAAPpAAAD7QAAAAAAAAAD"]), + "AAAAAAAAAAAAAAANcmVtb3ZlX3NpZ25lcgAAAAAAAAEAAAAAAAAACnNpZ25lcl9rZXkAAAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAA+kAAAACAAAAAw==", + "AAAAAAAAAAAAAAANdXBkYXRlX3NpZ25lcgAAAAAAAAEAAAAAAAAABnNpZ25lcgAAAAAH0AAAAAZTaWduZXIAAAAAAAEAAAPpAAAAAgAAAAM=", + "AAAABAAAAVBDb250cmFjdCBlcnJvcnMuCgpEZWxpYmVyYXRlbHkgcmVudW1iZXJlZCBmb3IgdGhlIHYxIGludGVyZmFjZSBzbyB0aGUgZXJyb3Igc3BhY2UgaXMgZGlzam9pbnQKZnJvbSB0aGUgbGVnYWN5IChwcmUtMS4wKSBjb250cmFjdCdzIDEtOSByYW5nZS4gQSBjbGllbnQgZGVjb2RpbmcgYW4gZXJyb3IKY29kZSA8IDEwMCBpcyB0YWxraW5nIHRvIGEgbGVnYWN5IHdhbGxldC4KClJhbmdlczoKLSAxMDAtMTA5OiBzaWduZXIgc3RvcmFnZSAvIG1hbmFnZW1lbnQKLSAxMTAtMTE5OiBhdXRoIChgX19jaGVja19hdXRoYCkKLSAxMjAtMTI5OiBXZWJBdXRobiAoc2VjcDI1NnIxKSB2ZXJpZmljYXRpb24AAAAAAAAABUVycm9yAAAAAAAACwAAADlUaGUgcmVxdWVzdGVkIHNpZ25lciBkb2VzIG5vdCBleGlzdCBvbiB0aGlzIHNtYXJ0IHdhbGxldC4AAAAAAAAOU2lnbmVyTm90Rm91bmQAAAAAAGQAAAA+YGFkZF9zaWduZXJgIHdhcyBjYWxsZWQgd2l0aCBhIHNpZ25lciBrZXkgdGhhdCBhbHJlYWR5IGV4aXN0cy4AAAAAABNTaWduZXJBbHJlYWR5RXhpc3RzAAAAAGUAAAAxVGhlIHNpZ25lcidzIGV4cGlyYXRpb24gdGltZXN0YW1wIGlzIGluIHRoZSBwYXN0LgAAAAAAAA1TaWduZXJFeHBpcmVkAAAAAAAAZgAAAF1ObyBzaWduZXIgaW4gdGhlIHNpZ25hdHVyZXMgbWFwIGlzIHBlcm1pdHRlZCB0byBhdXRob3JpemUgb25lIG9mIHRoZQpyZXF1ZXN0ZWQgYXV0aCBjb250ZXh0cy4AAAAAAAAOTWlzc2luZ0NvbnRleHQAAAAAAG4AAACJQSBzaWduYXR1cmUncyB2YXJpYW50IGRvZXMgbm90IG1hdGNoIHRoZSBzdG9yZWQgc2lnbmVyIGl0IGNsYWltcyB0byBiZQpmb3IgKGUuZy4gYW4gRWQyNTUxOSBzaWduYXR1cmUgc3VibWl0dGVkIGZvciBhIFBvbGljeSBzaWduZXIga2V5KS4AAAAAAAAZU2lnbmF0dXJlS2V5VmFsdWVNaXNtYXRjaAAAAAAAAG8AAAAyY2xpZW50RGF0YUpTT04gZXhjZWVkcyB0aGUgMTAyNCBieXRlIHBhcnNlIGJ1ZmZlci4AAAAAABZDbGllbnREYXRhSnNvblRvb0xhcmdlAAAAAAB4AAAARWNsaWVudERhdGFKU09OIGlzIG5vdCBwYXJzZWFibGUgSlNPTiAob3IgaXMgbWlzc2luZyByZXF1aXJlZCBmaWVsZHMpLgAAAAAAABhDbGllbnREYXRhSnNvblBhcnNlRXJyb3IAAAB5AAAAtlRoZSBjaGFsbGVuZ2UgaW4gY2xpZW50RGF0YUpTT04gZG9lcyBub3QgbWF0Y2ggdGhlIGJhc2U2NHVybC1lbmNvZGVkCnNpZ25hdHVyZSBwYXlsb2FkLiBUaGlzIGJpbmRzIHRoZSBXZWJBdXRobiBhc3NlcnRpb24gdG8gdGhlIFNvcm9iYW4KYXV0aG9yaXphdGlvbiBlbnRyeSBhbmQgTVVTVCBOT1QgYmUgd2Vha2VuZWQuAAAAAAAgQ2xpZW50RGF0YUpzb25DaGFsbGVuZ2VJbmNvcnJlY3QAAAB6AAAALGNsaWVudERhdGFKU09OIGB0eXBlYCBpcyBub3QgIndlYmF1dGhuLmdldCIuAAAAE0ludmFsaWRXZWJBdXRoblR5cGUAAAAAewAAAGlhdXRoZW50aWNhdG9yRGF0YSBpcyBzaG9ydGVyIHRoYW4gdGhlIFdlYkF1dGhuIG1pbmltdW0gb2YgMzcgYnl0ZXMKKHJwSWRIYXNoIDMyICsgZmxhZ3MgMSArIHNpZ25Db3VudCA0KS4AAAAAAAAYSW52YWxpZEF1dGhlbnRpY2F0b3JEYXRhAAAAfAAAAlpUaGUgYXV0aGVudGljYXRvciBkaWQgbm90IHNldCB0aGUgVXNlciBQcmVzZW50IChVUCkgZmxhZy4KClVQLW9ubHkgaXMgdGhlIGRlbGliZXJhdGUgZGVmYXVsdCAoYXVkaXQgRklYLTcpLiBSZXF1aXJpbmcgVVAga2VlcHMKc2lsZW50LCBub24taW50ZXJhY3RpdmUgYXNzZXJ0aW9ucyBvdXQgd2hpbGUgc3RheWluZyBjb21wYXRpYmxlIHdpdGgKYXV0aGVudGljYXRvcnMgdGhhdCBjYW5ub3QgZG8gVXNlciBWZXJpZmljYXRpb24gKFVWIOKAlCBiaW9tZXRyaWMvUElOKS4KVVYgaXMgdGhlcmVmb3JlIE5PVCByZXF1aXJlZCBieSB0aGlzIGNvbnRyYWN0LiBBIGRlcGxveW1lbnQgdGhhdCB3YW50cwpVVi1yZXF1aXJlZCBhc3NlcnRpb25zIHNob3VsZCBlbmZvcmNlIGl0IGF0IHRoZSBjbGllbnQvcmVsYXllciBsYXllciwKb3IgdmlhIGEgZnV0dXJlIHBlci1zaWduZXIgZmxhZyAod2hpY2ggd291bGQgYmUgYSBzaWduZXItbW9kZWwgY2hhbmdlLApub3QgYSBjaGFuZ2UgdG8gdGhpcyBjaGVjayk7IHRoZSBjb250cmFjdCBjYW5ub3QgdXBncmFkZSBVUC1vbmx5CnNpZ25lcnMgdG8gVVYtcmVxdWlyZWQgcmV0cm9hY3RpdmVseSB3aXRob3V0IHN1Y2ggYSBmbGFnLgAAAAAAFFVzZXJQcmVzZW5jZVJlcXVpcmVkAAAAfQ==", + "AAAAAgAAAFJGdWxsIHNpZ25lciBkZXNjcmlwdGlvbiB1c2VkIGJ5IGBfX2NvbnN0cnVjdG9yYCwgYGFkZF9zaWduZXJgIGFuZApgdXBkYXRlX3NpZ25lcmAuAAAAAAAAAAAABlNpZ25lcgAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAABAAAABMAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAB9AAAAANU2lnbmVyU3RvcmFnZQAAAAAAAAEAAAAAAAAAB0VkMjU1MTkAAAAABAAAA+4AAAAgAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAABAAAAAAAAAAlTZWNwMjU2cjEAAAAAAAAFAAAADgAAA+4AAABBAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAA=", + "AAAAAgAAAJ1BIHNpZ25hdHVyZSBlbnRyeSBpbiB0aGUgc2lnbmF0dXJlcyBtYXAuIGBQb2xpY3lgIGNhcnJpZXMgbm8gc2lnbmF0dXJlCm1hdGVyaWFsOiBpbmNsdXNpb24gb2YgdGhlIHBvbGljeSBrZXkgYXV0aG9yaXplcyBhbiBvbi1jaGFpbiBgcG9saWN5X19gCmNoZWNrIGluc3RlYWQuAAAAAAAAAAAAAAlTaWduYXR1cmUAAAAAAAADAAAAAAAAAAAAAAAGUG9saWN5AAAAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAEAAAPuAAAAQAAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAEAAAfQAAAAElNlY3AyNTZyMVNpZ25hdHVyZQAA", + "AAAAAgAAAFlTdG9yYWdlIGtleSBpZGVudGlmeWluZyBhIHNpZ25lci4gU2VjcDI1NnIxIGNhcnJpZXMgdGhlIFdlYkF1dGhuCmNyZWRlbnRpYWwgaWQgKGBrZXlJZGApLgAAAAAAAAAAAAAJU2lnbmVyS2V5AAAAAAAAAwAAAAEAAAAAAAAABlBvbGljeQAAAAAAAQAAABMAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAEAAAPuAAAAIAAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAEAAAAO", + "AAAAAgAAAElTdG9yZWQgc2lnbmVyIHZhbHVlLiBTZWNwMjU2cjEgY2FycmllcyB0aGUgU0VDLTEgdW5jb21wcmVzc2VkIHB1YmxpYyBrZXkuAAAAAAAAAAAAAAlTaWduZXJWYWwAAAAAAAADAAAAAQAAAAAAAAAGUG9saWN5AAAAAAACAAAH0AAAABBTaWduZXJFeHBpcmF0aW9uAAAH0AAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAdFZDI1NTE5AAAAAAIAAAfQAAAAEFNpZ25lckV4cGlyYXRpb24AAAfQAAAADFNpZ25lckxpbWl0cwAAAAEAAAAAAAAACVNlY3AyNTZyMQAAAAAAAAMAAAPuAAAAQQAAB9AAAAAQU2lnbmVyRXhwaXJhdGlvbgAAB9AAAAAMU2lnbmVyTGltaXRz", + "AAAAAQAAANNUaGUgYF9fY2hlY2tfYXV0aGAgc2lnbmF0dXJlIG9iamVjdDogYSBtYXAgb2Ygc2lnbmVyIGtleXMgdG8gc2lnbmF0dXJlcy4KTWFwIG9yZGVyaW5nIGlzIHRoZSBob3N0J3MgU2NWYWwgb3JkZXJpbmcuIEVWRVJZIGVudHJ5IG11c3QgdmVyaWZ5IChwYXNzCjIgb2YgYF9fY2hlY2tfYXV0aGApIOKAlCBpbmNsdWRlIG9ubHkgc2lnbmF0dXJlcyB0aGF0IGFyZSBuZWVkZWQuAAAAAAAAAAAKU2lnbmF0dXJlcwAAAAAAAQAAAAAAAAABMAAAAAAAA+wAAAfQAAAACVNpZ25lcktleQAAAAAAB9AAAAAJU2lnbmF0dXJlAAAA", + "AAAAAQAABABSZXN0cmljdGlvbnMgb24gd2hpY2ggYXV0aCBjb250ZXh0cyBhIHNpZ25lciBtYXkgYXV0aG9yaXplLgoKLSBgTm9uZWA6IHVubGltaXRlZC4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplIGFueXRoaW5nLCBpbmNsdWRpbmcKYENyZWF0ZUNvbnRyYWN0KmAgKGRlcGxveSkgY29udGV4dHMgYW5kIHRoaXMgd2FsbGV0J3Mgb3duIGFkbWluCmZ1bmN0aW9ucy4KLSBgU29tZShlbXB0eSBtYXApYDogTk8gcGVybWlzc2lvbnMgKGZhaWwtY2xvc2VkKS4gVGhlIHNpZ25lciBjYW4gYXV0aG9yaXplCm5vdGhpbmcgZXhjZXB0IHJlbW92aW5nIGl0c2VsZiAoc2VlIGJlbG93KS4gdjEgYnJlYWtpbmcgY2hhbmdlOiBwcmUtMS4wCmFuIGVtcHR5IG1hcCBtZWFudCB1bmxpbWl0ZWQsIGxlYXZpbmcgdHdvIHVubGltaXRlZCBlbmNvZGluZ3MgYW5kIG5vCiJub25lIiBlbmNvZGluZy4KLSBgU29tZSh7YWRkcmVzcyAtPiBOb25lfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgYW55IGludm9jYXRpb24gb2YKY29udHJhY3QgYGFkZHJlc3NgLCB3aXRoIG5vIGNvLXNpZ25lcnMgcmVxdWlyZWQuCi0gYFNvbWUoe2FkZHJlc3MgLT4gU29tZShba2V5c10pfSlgOiB0aGUgc2lnbmVyIG1heSBhdXRob3JpemUgaW52b2NhdGlvbnMKb2YgY29udHJhY3QgYGFkZHJlc3NgIG9ubHkgaWYgZXZlcnkgbGlzdGVkIGtleSBhbHNvIEFQUFJPVkVTLiBUaGUgbGlzdGVkCmtleXMgYXJlIHJlcXVpcmVkIENPLVNJR05FUlMuCgojIyBSZXF1aXJlZCBjby1zaWduZXJzIGFyZSBzY29wZS1pbmRlcGVuZGVudCBhcHByb3ZlcnMgKGF1ZGl0IEZJWC01KQoKQSByZXF1aXJlZCBjby1zaWduZXIncyBPV04gYFNpZ25lckxpbWl0c2AgZG8gTk9UIGNvbnN0cmFpbiBpdHMgY28tc2lnbmVyCnJvbGUg4oCUIGEga2V5J3MgbGltaXRzIGdvdmVybiBvbmx5IGl0cyBJTkRFUEVOREVOVCBhdXRob3JpdHkgKHdoZXRoZXIgaXQgY2FuCmNvdmVyIGEgY29udGV4dCBvbiBpdHMgb3duKS4gVGhpcyBpcyBzeW1tZXRyaWMgYWNyb3NzIGtleSBraW5kczoKCi0gAAAAAAAAAAxTaWduZXJMaW1pdHMAAAABAAAAAAAAAAEwAAAAAAAD6AAAA+wAAAATAAAD6AAAA+oAAAfQAAAACVNpZ25lcktleQAAAA==", + "AAAAAgAAAIRXaGljaCBkdXJhYmlsaXR5IGEgc2lnbmVyIGVudHJ5IGlzIHN0b3JlZCB1bmRlci4gQXQgbW9zdCBvbmUgZW50cnkgZXhpc3RzCnBlciBzaWduZXIga2V5OyBsb29rdXBzIGNoZWNrIFRlbXBvcmFyeSBiZWZvcmUgUGVyc2lzdGVudC4AAAAAAAAADVNpZ25lclN0b3JhZ2UAAAAAAAACAAAAAAAAAAAAAAAKUGVyc2lzdGVudAAAAAAAAAAAAAAAAAAJVGVtcG9yYXJ5AAAA", + "AAAAAQAAAY5PcHRpb25hbCBleHBpcmF0aW9uIGZvciBhIHNpZ25lciBhcyBhIFVOSVggdGltZXN0YW1wIGluIHNlY29uZHMsIElOQ0xVU0lWRToKdGhlIHNpZ25lciBpcyB2YWxpZCB3aGlsZSBgbGVkZ2VyIHRpbWVzdGFtcCA8PSBleHBpcmF0aW9uYCBhbmQgZXhwaXJlZCBvbmNlCmBsZWRnZXIgdGltZXN0YW1wID4gZXhwaXJhdGlvbmAuIGBOb25lYCBuZXZlciBleHBpcmVzLgoKdjEgYnJlYWtpbmcgY2hhbmdlOiB0aGlzIHdhcyBhIGxlZGdlciBzZXF1ZW5jZSBudW1iZXIgcHJlLTEuMC4gVGltZXN0YW1wcwpkb24ndCBkcmlmdCB3aXRoIGNoYW5nZXMgdG8gbGVkZ2VyIGNsb3NlIHRpbWUgKGUuZy4gQ0FQLTAwNzAgZHluYW1pYwp0aW1pbmcpLCB3aGljaCBsZWRnZXItc2VxdWVuY2UgZXhwaXJhdGlvbnMgZGlkLgAAAAAAAAAAABBTaWduZXJFeHBpcmF0aW9uAAAAAQAAAAAAAAABMAAAAAAAA+gAAAAG", + "AAAAAQAAAMhBIFdlYkF1dGhuIGFzc2VydGlvbiBvdmVyIHRoZSBTb3JvYmFuIGF1dGhvcml6YXRpb24gcGF5bG9hZC4gVGhlIHNpZ25lZAptZXNzYWdlIGlzIGBhdXRoZW50aWNhdG9yX2RhdGEgfHwgc2hhMjU2KGNsaWVudF9kYXRhX2pzb24pYCBhbmQgdGhlCnBheWxvYWQgYmluZGluZyBsaXZlcyBpbiBjbGllbnREYXRhSlNPTidzIGBjaGFsbGVuZ2VgIGZpZWxkLgAAAAAAAAASU2VjcDI1NnIxU2lnbmF0dXJlAAAAAAADAAAAAAAAABJhdXRoZW50aWNhdG9yX2RhdGEAAAAAAA4AAAAAAAAAEGNsaWVudF9kYXRhX2pzb24AAAAOAAAAAAAAAAlzaWduYXR1cmUAAAAAAAPuAAAAQA==", + "AAAABQAAASBUaGUgY29udHJhY3QncyB3YXNtIHdhcyByZXBsYWNlZCB2aWEgYHVwZ3JhZGVgLiBgb2xkX2hhc2hgIGlzIGBOb25lYCBvbiBhCndhbGxldCdzIGZpcnN0LWV2ZXIgdXBncmFkZTogdGhlIGhvc3QgZXhwb3NlcyBubyB3YXkgZm9yIGEgY29udHJhY3QgdG8KcmVhZCBpdHMgb3duIGV4ZWN1dGFibGUgaGFzaCwgc28gdGhlIHdhbGxldCBjYWNoZXMgdGhlIGhhc2ggaW4gaW5zdGFuY2UKc3RvcmFnZSBhdCBlYWNoIHVwZ3JhZGUgYW5kIHRoZSBnZW5lc2lzIGhhc2ggaXMgdW5rbm93YWJsZSBpbi1jb250cmFjdC4AAAAAAAAACFVwZ3JhZGVkAAAAAQAAAAh1cGdyYWRlZAAAAAIAAAAAAAAACG9sZF9oYXNoAAAD6AAAA+4AAAAgAAAAAAAAAAAAAAAIbmV3X2hhc2gAAAPuAAAAIAAAAAAAAAAC", + "AAAABQAAADlBIHNpZ25lciB3YXMgYWRkZWQgKHZpYSBgX19jb25zdHJ1Y3RvcmAgb3IgYGFkZF9zaWduZXJgKS4AAAAAAAAAAAAAC1NpZ25lckFkZGVkAAAAAAEAAAAMc2lnbmVyX2FkZGVkAAAAAwAAAAAAAAADa2V5AAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAAAAAAAADdmFsAAAAB9AAAAAJU2lnbmVyVmFsAAAAAAAAAAAAAAAAAAAHc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAg==", + "AAAABQAAAGFBIHNpZ25lciB3YXMgcmVtb3ZlZCB2aWEgYHJlbW92ZV9zaWduZXJgLiBgc3RvcmFnZWAgaXMgdGhlIGR1cmFiaWxpdHkgdGhlCmVudHJ5IHdhcyByZW1vdmVkIGZyb20uAAAAAAAAAAAAAA1TaWduZXJSZW1vdmVkAAAAAAAAAQAAAA5zaWduZXJfcmVtb3ZlZAAAAAAAAgAAAAAAAAADa2V5AAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAAAAAAAAHc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAg==", + "AAAABQAAADRBbiBleGlzdGluZyBzaWduZXIgd2FzIG1vZGlmaWVkIHZpYSBgdXBkYXRlX3NpZ25lcmAuAAAAAAAAAA1TaWduZXJVcGRhdGVkAAAAAAAAAQAAAA5zaWduZXJfdXBkYXRlZAAAAAAABAAAAAAAAAADa2V5AAAAB9AAAAAJU2lnbmVyS2V5AAAAAAAAAQAAAAAAAAADdmFsAAAAB9AAAAAJU2lnbmVyVmFsAAAAAAAAAAAAAAAAAAAHc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAAAAAAtvbGRfc3RvcmFnZQAAAAfQAAAADVNpZ25lclN0b3JhZ2UAAAAAAAAAAAAAAg==" ]), options ) } public readonly fromJSON = { - add_signer: this.txFromJSON, - update_signer: this.txFromJSON, - remove_signer: this.txFromJSON, - update_contract_code: this.txFromJSON + upgrade: this.txFromJSON>, + add_signer: this.txFromJSON>, + get_signer: this.txFromJSON>, + remove_signer: this.txFromJSON>, + update_signer: this.txFromJSON> } } \ No newline at end of file diff --git a/packages/passkey-kit-sdk/tsconfig.json b/packages/passkey-kit-sdk/tsconfig.json index 9d42a28..2ab5e5f 100644 --- a/packages/passkey-kit-sdk/tsconfig.json +++ b/packages/passkey-kit-sdk/tsconfig.json @@ -1,98 +1,16 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - /* Language and Environment */ - "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - /* Modules */ - "module": "NodeNext", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./types", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - // "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - // "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - // "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true }, - "include": [ - "src/*" - ] -} \ No newline at end of file + "include": ["src/**/*"] +} diff --git a/packages/sac-sdk/package.json b/packages/sac-sdk/package.json index a89ea55..3fd45df 100644 --- a/packages/sac-sdk/package.json +++ b/packages/sac-sdk/package.json @@ -1,18 +1,36 @@ { - "version": "0.4.2", + "version": "0.4.3", "name": "sac-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/sac-sdk/src/index.ts b/packages/sac-sdk/src/index.ts index fe60b54..af5697d 100644 --- a/packages/sac-sdk/src/index.ts +++ b/packages/sac-sdk/src/index.ts @@ -5,11 +5,11 @@ import { ClientOptions as ContractClientOptions, MethodOptions, Spec as ContractSpec, -} from '@stellar/stellar-sdk/minimal/contract'; +} from '@stellar/stellar-sdk/contract'; import type { u32, i128, -} from '@stellar/stellar-sdk/minimal/contract'; +} from '@stellar/stellar-sdk/contract'; if (typeof window !== 'undefined') { //@ts-ignore Buffer exists diff --git a/packages/sac-sdk/tsconfig.json b/packages/sac-sdk/tsconfig.json index 9d42a28..2ab5e5f 100644 --- a/packages/sac-sdk/tsconfig.json +++ b/packages/sac-sdk/tsconfig.json @@ -1,98 +1,16 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - /* Language and Environment */ - "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - /* Modules */ - "module": "NodeNext", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./types", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - // "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - // "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - /* Type Checking */ - // "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true }, - "include": [ - "src/*" - ] -} \ No newline at end of file + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14f8ca..66c9da9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,21 +5,21 @@ settings: excludeLinksFromLockfile: false overrides: - '@stellar/stellar-sdk': ^14.2.0 + '@stellar/stellar-sdk': 16.0.1 importers: .: dependencies: '@openzeppelin/relayer-plugin-channels': - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.20.0 + version: 0.20.0 '@simplewebauthn/browser': - specifier: ^13.2.0 - version: 13.2.0 + specifier: ^13.3.0 + version: 13.3.0 '@stellar/stellar-sdk': - specifier: ^14.2.0 - version: 14.2.0 + specifier: 16.0.1 + version: 16.0.1 base64url: specifier: ^3.0.1 version: 3.0.1 @@ -36,32 +36,38 @@ importers: '@types/node': specifier: ^24.6.0 version: 24.6.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 typescript: specifier: ^5.9.2 version: 5.9.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.6.0) packages/passkey-kit-sdk: dependencies: - '@stellar/stellar-sdk': - specifier: ^14.2.0 - version: 14.2.0 buffer: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@stellar/stellar-sdk': + specifier: 16.0.1 + version: 16.0.1 typescript: specifier: ^5.9.2 version: 5.9.2 packages/sac-sdk: dependencies: - '@stellar/stellar-sdk': - specifier: ^14.2.0 - version: 14.2.0 buffer: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@stellar/stellar-sdk': + specifier: 16.0.1 + version: 16.0.1 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -74,51 +80,384 @@ packages: '@actions/io@1.1.3': resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@openzeppelin/relayer-plugin-channels@0.5.0': - resolution: {integrity: sha512-mn8TklTFwwqKVVOjYQtJGGX/8IKJwg4hbwfAsz7kvb5L5EhjJzwCjgfT84TL6ZVmHb7DoQ6h2lPgNussDiHy5g==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/ed25519@3.1.0': + resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@openzeppelin/relayer-plugin-channels@0.20.0': + resolution: {integrity: sha512-buZ0Xcir1mPcevMBU3vzbq4v77YUsikWX+E1k31YxdE6p97GEn40RnxNzow/eLNVnS3WNwYZ1SuEZgsQE7KHAg==} engines: {node: '>=22.18.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} - '@openzeppelin/relayer-sdk@1.8.0': - resolution: {integrity: sha512-2nMn9Sg0j52kp/GdhavvL8zgwRJqDqXq4yM1uYewfYndns6WGUS3zxcDICgHenMVURo8YWdJG7E7Ockck3SNFg==} + '@openzeppelin/relayer-sdk@1.10.0': + resolution: {integrity: sha512-C7v4C63VnFR4keISicpcDf4Ix646sa1OdokzddFeSPEGlJIY2b/XJ9sIWiXOBVJVGzbB+RdX3/fbkygAPhcVNw==} engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} - '@simplewebauthn/browser@13.2.0': - resolution: {integrity: sha512-N3fuA1AAnTo5gCStYoIoiasPccC+xPLx2YU88Dv0GeAmPQTWHETlZQq5xZ0DgUq1H9loXMWQH5qqUjcI7BHJ1A==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@stellar/js-xdr@4.0.0': + resolution: {integrity: sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==} + engines: {node: '>=20.0.0', pnpm: '>=9.0.0'} + + '@stellar/stellar-sdk@16.0.1': + resolution: {integrity: sha512-bxKohaiyKVqoudRhbOOHeHhHIaeYV5Zab4rCjxhP4Ty1h1ozTLBOv8lWFnZz9ilBzXG8Bb7usQI3rlEcfvUynA==} + engines: {node: '>=22.0.0'} + hasBin: true - '@stellar/js-xdr@3.1.2': - resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@stellar/stellar-base@14.0.1': - resolution: {integrity: sha512-mI6Kjh9hGWDA1APawQTtCbR7702dNT/8Te1uuRFPqqdoAKBk3WpXOQI3ZSZO+5olW7BSHpmVG5KBPZpIpQxIvw==} - engines: {node: '>=20.0.0'} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@stellar/stellar-sdk@14.2.0': - resolution: {integrity: sha512-7nh2ogzLRMhfkIC0fGjn1LHUzk3jqVw8tjAuTt5ADWfL9CSGBL18ILucE9igz2L/RU2AZgeAvhujAnW91Ut/oQ==} - engines: {node: '>=20.0.0'} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/node@24.6.0': resolution: {integrity: sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} base32.js@0.1.0: resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} @@ -131,31 +470,48 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bignumber.js@11.1.3: + resolution: {integrity: sha512-+esZiNSo6VgFokTsYX6mYqNJfFd/IczzZCd4Z7cR8e+AQWhvIcj6nqQ1h9814D9u/TApU0jjTVmfWL0Pd1ZBdA==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} @@ -173,6 +529,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -181,15 +540,44 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - eventsource@2.0.2: - resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@4.1.0: + resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==} + engines: {node: '>=20.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -197,14 +585,15 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -220,9 +609,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -235,26 +621,25 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-retry-allowed@3.0.0: resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} engines: {node: '>=12'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -268,53 +653,172 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} - sha.js@2.4.12: - resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} - engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - to-buffer@1.2.1: - resolution: {integrity: sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==} - engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.13.0: resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} - urijs@1.19.11: - resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true snapshots: @@ -324,78 +828,278 @@ snapshots: '@actions/io@1.1.3': {} - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true - '@noble/hashes@1.8.0': {} + '@esbuild/linux-loong64@0.28.1': + optional: true - '@openzeppelin/relayer-plugin-channels@0.5.0': + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/ed25519@3.1.0': {} + + '@noble/hashes@2.2.0': {} + + '@openzeppelin/relayer-plugin-channels@0.20.0': dependencies: '@actions/exec': 1.1.1 - '@openzeppelin/relayer-sdk': 1.8.0 - '@stellar/stellar-sdk': 14.2.0 - axios: 1.13.2 + '@openzeppelin/relayer-sdk': 1.10.0 + '@stellar/stellar-sdk': 16.0.1 + axios: 1.16.1 transitivePeerDependencies: - debug + - supports-color - '@openzeppelin/relayer-sdk@1.8.0': + '@openzeppelin/relayer-sdk@1.10.0': dependencies: - axios: 1.13.2 + axios: 1.16.1 transitivePeerDependencies: - debug + - supports-color + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true - '@simplewebauthn/browser@13.2.0': {} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true - '@stellar/js-xdr@3.1.2': {} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true - '@stellar/stellar-base@14.0.1': + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@simplewebauthn/browser@13.3.0': {} + + '@stellar/js-xdr@4.0.0': {} + + '@stellar/stellar-sdk@16.0.1': dependencies: - '@noble/curves': 1.9.7 - '@stellar/js-xdr': 3.1.2 + '@noble/ed25519': 3.1.0 + '@noble/hashes': 2.2.0 + '@stellar/js-xdr': 4.0.0 + axios: 1.16.1 base32.js: 0.1.0 - bignumber.js: 9.3.1 + bignumber.js: 11.1.3 buffer: 6.0.3 - sha.js: 2.4.12 - - '@stellar/stellar-sdk@14.2.0': - dependencies: - '@stellar/stellar-base': 14.0.1 - axios: 1.12.2 - bignumber.js: 9.3.1 - eventsource: 2.0.2 + commander: 14.0.3 + eventsource: 4.1.0 feaxios: 0.0.23 - randombytes: 2.1.0 - toml: 3.0.0 - urijs: 1.19.11 + smol-toml: 1.6.1 + uint8array-extras: 1.5.0 transitivePeerDependencies: - debug + - supports-color + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} '@types/node@24.6.0': dependencies: undici-types: 7.13.0 - asynckit@0.4.0: {} + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.6.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.6.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 - available-typed-arrays@1.0.7: + '@vitest/utils@3.2.7': dependencies: - possible-typed-array-names: 1.1.0 + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 - axios@1.12.2: + agent-base@6.0.2: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.4 - proxy-from-env: 1.1.0 + debug: 4.4.3 transitivePeerDependencies: - - debug + - supports-color + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} - axios@1.13.2: + axios@1.16.1: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.4 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color base32.js@0.1.0: {} @@ -403,39 +1107,41 @@ snapshots: base64url@3.0.1: {} - bignumber.js@9.3.1: {} + bignumber.js@11.1.3: {} buffer@6.0.3: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: + chai@5.3.3: dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 + check-error@2.1.3: {} combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - define-data-property@1.1.4: + commander@14.0.3: {} + + debug@4.4.3: dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 + ms: 2.1.3 + + deep-eql@5.0.2: {} delayed-stream@1.0.0: {} @@ -449,6 +1155,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -460,19 +1168,60 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - eventsource@2.0.2: {} + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 - feaxios@0.0.23: + eventsource-parser@3.1.0: {} + + eventsource@4.1.0: dependencies: - is-retry-allowed: 3.0.0 + eventsource-parser: 3.1.0 + + expect-type@1.4.0: {} + + fake-indexeddb@6.2.5: {} - follow-redirects@1.15.9: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 - for-each@0.3.5: + feaxios@0.0.23: dependencies: - is-callable: 1.2.7 + is-retry-allowed: 3.0.0 - form-data@4.0.4: + follow-redirects@1.16.0: {} + + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -480,6 +1229,9 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -502,10 +1254,6 @@ snapshots: gopd@1.2.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -516,19 +1264,24 @@ snapshots: dependencies: function-bind: 1.1.2 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} - inherits@2.0.4: {} + is-retry-allowed@3.0.0: {} - is-callable@1.2.7: {} + js-tokens@9.0.1: {} - is-retry-allowed@3.0.0: {} + loupe@3.2.1: {} - is-typed-array@1.1.15: + magic-string@0.30.21: dependencies: - which-typed-array: 1.1.19 - - isarray@2.0.5: {} + '@jridgewell/sourcemap-codec': 1.5.5 math-intrinsics@1.1.0: {} @@ -538,57 +1291,167 @@ snapshots: dependencies: mime-db: 1.52.0 - possible-typed-array-names@1.1.0: {} + ms@2.1.3: {} - proxy-from-env@1.1.0: {} + nanoid@3.3.15: {} - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 + pathe@2.0.3: {} - safe-buffer@5.2.1: {} + pathval@2.0.1: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 + picocolors@1.1.1: {} - sha.js@2.4.12: + picomatch@4.0.5: {} + + postcss@8.5.17: dependencies: - inherits: 2.0.4 - safe-buffer: 5.2.1 - to-buffer: 1.2.1 + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} - to-buffer@1.2.1: + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: dependencies: - isarray: 2.0.5 - safe-buffer: 5.2.1 - typed-array-buffer: 1.0.3 + js-tokens: 9.0.1 - toml@3.0.0: {} + tinybench@2.9.0: {} - typed-array-buffer@1.0.3: + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} typescript@5.9.2: {} - undici-types@7.13.0: {} + uint8array-extras@1.5.0: {} - urijs@1.19.11: {} + undici-types@7.13.0: {} - which-typed-array@1.1.19: + vite-node@3.2.4(@types/node@24.6.0): dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.6.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.6.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.17 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.6.0 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@24.6.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.6.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.6.0) + vite-node: 3.2.4(@types/node@24.6.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.6.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/relayer-proxy/.dev.vars.example b/relayer-proxy/.dev.vars.example new file mode 100644 index 0000000..8628bc3 --- /dev/null +++ b/relayer-proxy/.dev.vars.example @@ -0,0 +1,7 @@ +# passkey-kit relayer-proxy local overrides. +# Copy to .dev.vars only if you want to override the wrangler.toml [vars] +# defaults during `wrangler dev`. No secrets are required — the worker mints +# Relayer API keys itself via the keyless /gen endpoint. + +NETWORK="testnet" +RELAYER_BASE_URL="https://channels.openzeppelin.com/testnet" diff --git a/relayer-proxy/.gitignore b/relayer-proxy/.gitignore new file mode 100644 index 0000000..9a16be9 --- /dev/null +++ b/relayer-proxy/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.wrangler/ +.dev.vars +.dev.vars.* +!.dev.vars.example +*.log diff --git a/relayer-proxy/README.md b/relayer-proxy/README.md new file mode 100644 index 0000000..84a3e15 --- /dev/null +++ b/relayer-proxy/README.md @@ -0,0 +1,82 @@ +# passkey-kit relayer-proxy + +A Cloudflare Worker that fronts the [OpenZeppelin Relayer Channels](https://docs.openzeppelin.com/relayer/stellar) +service so the browser can submit fee-sponsored transactions with **zero +secrets in the bundle**. It mints and caches one Relayer API key per client IP, +so `PasskeyKit`/`PasskeyServer` never ship a relayer key to the client. + +This replaces passkey-kit's old defect of inlining `VITE_relayerApiKey` into the +demo's client JS (audit #597/#598). + +## How it works + +- **Keyless per-IP key minting.** On the first request from an IP the worker + calls the Relayer's unauthenticated `GET {RELAYER_BASE_URL}/gen` endpoint and + stores the returned key. Custody lives in a **per-IP Durable Object** + (`ApiKeyStore`, one instance per `CF-Connecting-IP`): its `blockConcurrencyWhile` + serializes get-or-create, so N concurrent first-requests from one IP mint at + most ONE key — the atomic get-or-create that KV cannot provide (no CAS). The + key persists indefinitely; the Relayer resets usage limits every 24h. +- **Client IP** is taken ONLY from `CF-Connecting-IP` (always set at the CF + edge, unspoofable). `X-Forwarded-For` / `X-Real-IP` are client-controlled and + deliberately not trusted for key custody. +- **Two submission modes** (`POST /`): + - `{ func, auth }` → `submitSorobanTransaction` — Relayer builds the tx with + channel accounts (Address credentials: transfers, wallet ops). + - `{ xdr }` → `submitTransaction` — Relayer fee-bumps an already-signed tx + (source-account auth: deploys). + Exactly one of the two must be present (400 otherwise). +- **Error mapping**: `PluginExecutionError` → 400 `{error, data:{code,details}}`; + `PluginTransportError` → its `statusCode`; malformed JSON → 400; anything else + → 500. Success → `{ success: true, data: { transactionId, hash, status } }`. +- **Testnet Friendbot retry**: if the Relayer reports a missing channel account + (after a testnet reset), the worker funds it via Friendbot and retries for up + to 5 minutes. Mainnet never retries (no Friendbot). + +## Endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/` | health: `{ status, service, network }` | +| POST | `/` | submit: `{ func, auth } \| { xdr }` | +| GET | `/status` | client IP, network, whether a key is cached | + +## Config + +`wrangler.toml`: + +- Testnet (default): `NETWORK=testnet`, `RELAYER_BASE_URL=https://channels.openzeppelin.com/testnet`. +- Mainnet (`[env.production]`): `NETWORK=mainnet`, `RELAYER_BASE_URL=https://channels.openzeppelin.com`. +- Durable Object binding `API_KEY_DO` → class `ApiKeyStore` (+ a `[[migrations]]` + entry), one per environment. No namespace id to provision — the class is bound + directly, and the DO is created on first use. + +No secrets are needed; the worker mints Relayer keys itself. + +## Develop / test + +```sh +cd relayer-proxy +pnpm install --ignore-workspace # or: npm install +pnpm test # vitest — worker unit tests +pnpm typecheck # tsc --noEmit +pnpm dev # wrangler dev (local) +``` + +The tests mock `@openzeppelin/relayer-plugin-channels` and the DO namespace, and +cover: IP extraction (CF-Connecting-IP only), mode validation, both submission +paths, the three error mappings, testnet-vs-mainnet retry behavior, and the +`ApiKeyStore` DO get-or-create logic (returns stored / mints from `/gen` / +502-on-mint-failure / `/peek`). + +## Deploy + +> Deploys are orchestrator-gated (not run from the services todo). + +```sh +pnpm deploy # testnet: wrangler deploy +pnpm deploy:production # mainnet: wrangler deploy --env production +``` + +Relayer keys for live verification can be generated at +`{RELAYER_BASE_URL}/gen` (testnet: `https://channels.openzeppelin.com/testnet/gen`). diff --git a/relayer-proxy/package.json b/relayer-proxy/package.json new file mode 100644 index 0000000..c65542e --- /dev/null +++ b/relayer-proxy/package.json @@ -0,0 +1,23 @@ +{ + "name": "passkey-kit-relayer-proxy", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "deploy:production": "wrangler deploy --env production", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openzeppelin/relayer-plugin-channels": "^0.20.0", + "hono": "^4.12.11" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241202.0", + "typescript": "^5.7.2", + "vitest": "^2.1.8", + "wrangler": "^4.80.0" + } +} diff --git a/relayer-proxy/pnpm-lock.yaml b/relayer-proxy/pnpm-lock.yaml new file mode 100644 index 0000000..ee99738 --- /dev/null +++ b/relayer-proxy/pnpm-lock.yaml @@ -0,0 +1,2279 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@openzeppelin/relayer-plugin-channels': + specifier: ^0.20.0 + version: 0.20.0 + hono: + specifier: ^4.12.11 + version: 4.12.29 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20241202.0 + version: 4.20260702.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9 + wrangler: + specifier: ^4.80.0 + version: 4.110.0(@cloudflare/workers-types@4.20260702.1) + +packages: + + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260708.1': + resolution: {integrity: sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260708.1': + resolution: {integrity: sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260708.1': + resolution: {integrity: sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260708.1': + resolution: {integrity: sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260708.1': + resolution: {integrity: sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@openzeppelin/relayer-plugin-channels@0.20.0': + resolution: {integrity: sha512-buZ0Xcir1mPcevMBU3vzbq4v77YUsikWX+E1k31YxdE6p97GEn40RnxNzow/eLNVnS3WNwYZ1SuEZgsQE7KHAg==} + engines: {node: '>=22.18.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} + + '@openzeppelin/relayer-sdk@1.10.0': + resolution: {integrity: sha512-C7v4C63VnFR4keISicpcDf4Ix646sa1OdokzddFeSPEGlJIY2b/XJ9sIWiXOBVJVGzbB+RdX3/fbkygAPhcVNw==} + engines: {node: '>=22.14.0', npm: use pnpm, pnpm: '>=9', yarn: use pnpm} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + '@stellar/js-xdr@3.1.2': + resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + + '@stellar/stellar-base@14.1.0': + resolution: {integrity: sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==} + engines: {node: '>=20.0.0'} + deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. + + '@stellar/stellar-sdk@14.6.1': + resolution: {integrity: sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + base32.js@0.1.0: + resolution: {integrity: sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==} + engines: {node: '>=0.12.0'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + feaxios@0.0.23: + resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.29: + resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} + engines: {node: '>=16.9.0'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-retry-allowed@3.0.0: + resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} + engines: {node: '>=12'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + miniflare@4.20260708.1: + resolution: {integrity: sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==} + engines: {node: '>=22.0.0'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + urijs@1.19.11: + resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260708.1: + resolution: {integrity: sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.110.0: + resolution: {integrity: sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260708.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@actions/exec@1.1.1': + dependencies: + '@actions/io': 1.1.3 + + '@actions/io@1.1.3': {} + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260708.1 + + '@cloudflare/workerd-darwin-64@1.20260708.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260708.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260708.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260708.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260708.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@openzeppelin/relayer-plugin-channels@0.20.0': + dependencies: + '@actions/exec': 1.1.1 + '@openzeppelin/relayer-sdk': 1.10.0 + '@stellar/stellar-sdk': 14.6.1 + axios: 1.18.1 + transitivePeerDependencies: + - debug + - supports-color + + '@openzeppelin/relayer-sdk@1.10.0': + dependencies: + axios: 1.18.1 + transitivePeerDependencies: + - debug + - supports-color + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + '@stellar/js-xdr@3.1.2': {} + + '@stellar/stellar-base@14.1.0': + dependencies: + '@noble/curves': 1.9.7 + '@stellar/js-xdr': 3.1.2 + base32.js: 0.1.0 + bignumber.js: 9.3.1 + buffer: 6.0.3 + sha.js: 2.4.12 + + '@stellar/stellar-sdk@14.6.1': + dependencies: + '@stellar/stellar-base': 14.1.0 + axios: 1.18.1 + bignumber.js: 9.3.1 + commander: 14.0.3 + eventsource: 2.0.2 + feaxios: 0.0.23 + randombytes: 2.1.0 + toml: 3.0.0 + urijs: 1.19.11 + transitivePeerDependencies: + - debug + - supports-color + + '@types/estree@1.0.9': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + base32.js@0.1.0: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + blake3-wasm@2.1.5: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + cookie@1.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + error-stack-parser-es@1.0.5: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventsource@2.0.2: {} + + expect-type@1.4.0: {} + + feaxios@0.0.23: + dependencies: + is-retry-allowed: 3.0.0 + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.29: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + is-callable@1.2.7: {} + + is-retry-allowed@3.0.0: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + isarray@2.0.5: {} + + kleur@4.1.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + miniflare@4.20260708.1: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260708.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + path-to-regexp@6.3.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.5.17: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + toml@3.0.0: {} + + tslib@2.8.1: + optional: true + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + urijs@1.19.11: {} + + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.17 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + vitest@2.1.9: + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260708.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260708.1 + '@cloudflare/workerd-darwin-arm64': 1.20260708.1 + '@cloudflare/workerd-linux-64': 1.20260708.1 + '@cloudflare/workerd-linux-arm64': 1.20260708.1 + '@cloudflare/workerd-windows-64': 1.20260708.1 + + wrangler@4.110.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260708.1 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260708.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/relayer-proxy/src/constants.ts b/relayer-proxy/src/constants.ts new file mode 100644 index 0000000..9f7a6c9 --- /dev/null +++ b/relayer-proxy/src/constants.ts @@ -0,0 +1,61 @@ +/** + * Constants for the passkey-kit Relayer Proxy. + */ + +// ============================================================================ +// Service Metadata +// ============================================================================ + +/** Service name for health checks */ +export const SERVICE_NAME = "passkey-kit-relayer-proxy"; + +// ============================================================================ +// Network URLs +// ============================================================================ + +/** Stellar Friendbot URL for testnet funding */ +export const FRIENDBOT_URL = "https://friendbot.stellar.org"; + +// ============================================================================ +// API Key Configuration +// ============================================================================ + +/** Possible field names for the API key in the /gen response */ +export const API_KEY_FIELD_NAMES = ["apiKey", "api_key", "key", "token"] as const; + +/** Minimum length for a valid API key response */ +export const API_KEY_MIN_LENGTH = 10; + +/** Maximum length for a valid API key response */ +export const API_KEY_MAX_LENGTH = 200; + +// ============================================================================ +// Stellar Address Validation +// ============================================================================ + +/** Length of a Stellar account address (G-address), excluding the leading G */ +export const STELLAR_ADDRESS_LENGTH = 55; + +/** Regex pattern for extracting a missing account from a Relayer error */ +export const MISSING_ACCOUNT_PATTERN = /Account not found:\s*(G[A-Z0-9]{55})/; + +// ============================================================================ +// Retry Configuration +// ============================================================================ + +/** How long to retry on testnet when channel accounts need funding (5 minutes) */ +export const TESTNET_RETRY_DURATION_MS = 5 * 60 * 1000; + +// ============================================================================ +// HTTP Headers +// ============================================================================ + +// Only CF-Connecting-IP is trusted: Cloudflare always sets it at the edge and +// the client cannot spoof it. X-Forwarded-For / X-Real-IP are client-controlled +// and deliberately NOT used for per-IP key custody (review LOW). +export const IP_HEADERS = { + CF_CONNECTING_IP: "CF-Connecting-IP", +} as const; + +/** Default IP value when none can be determined */ +export const UNKNOWN_IP = "unknown"; diff --git a/relayer-proxy/src/index.test.ts b/relayer-proxy/src/index.test.ts new file mode 100644 index 0000000..4c21375 --- /dev/null +++ b/relayer-proxy/src/index.test.ts @@ -0,0 +1,507 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + PluginExecutionError, + PluginTransportError, +} from "@openzeppelin/relayer-plugin-channels"; +import worker, { ApiKeyStore, extractMissingAccount, getClientIP } from "./index"; +import { SERVICE_NAME } from "./constants"; + +// --------------------------------------------------------------------------- +// Mock the Channels plugin: a stub ChannelsClient whose two submit methods are +// controllable vi.fns, plus the two error classes the handler maps by instance. +// --------------------------------------------------------------------------- +const { submitSorobanTransaction, submitTransaction } = vi.hoisted(() => ({ + submitSorobanTransaction: vi.fn(), + submitTransaction: vi.fn(), +})); + +vi.mock("@openzeppelin/relayer-plugin-channels", () => { + class PluginExecutionError extends Error { + errorDetails?: { code?: unknown; details?: unknown }; + constructor( + message: string, + errorDetails?: { code?: unknown; details?: unknown } + ) { + super(message); + this.name = "PluginExecutionError"; + this.errorDetails = errorDetails; + } + } + class PluginTransportError extends Error { + statusCode?: number; + constructor(message: string, statusCode?: number) { + super(message); + this.name = "PluginTransportError"; + this.statusCode = statusCode; + } + } + class ChannelsClient { + constructor(_opts: unknown) {} + submitSorobanTransaction = submitSorobanTransaction; + submitTransaction = submitTransaction; + } + return { ChannelsClient, PluginExecutionError, PluginTransportError }; +}); + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- +const DO_KEY = "sk_do_relay_key_1234567"; +const CLIENT_IP = "203.0.113.7"; + +// Fake per-IP DurableObjectNamespace. The stub's fetch answers /get (returns a +// key, or 502 when `key` is null) and /peek (presence). This exercises the +// worker's DO integration; the ApiKeyStore's own get-or-create logic is tested +// directly below. +function makeApiKeyDO(opts: { key?: string | null } = {}) { + const key = opts.key === undefined ? DO_KEY : opts.key; + const stub = { + fetch: vi.fn(async (input: unknown) => { + const url = + typeof input === "string" ? input : (input as Request).url ?? ""; + if (url.includes("/peek")) { + return new Response(JSON.stringify({ hasKey: key !== null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (key === null) return new Response("mint failed", { status: 502 }); + return new Response(key, { status: 200 }); + }), + }; + return { + idFromName: vi.fn((_ip: string) => "do-id"), + get: vi.fn((_id: unknown) => stub), + _stub: stub, + }; +} + +function makeEnv( + opts: { network?: "testnet" | "mainnet"; do?: ReturnType } = {} +) { + const network = opts.network ?? "testnet"; + return { + API_KEY_DO: opts.do ?? makeApiKeyDO(), + NETWORK: network, + RELAYER_BASE_URL: + network === "mainnet" + ? "https://channels.openzeppelin.com" + : "https://channels.openzeppelin.com/testnet", + } as any; +} + +const ctx = () => + ({ waitUntil: vi.fn(), passThroughOnException: vi.fn() }) as any; + +function makeRequest( + path: string, + opts: { method?: string; body?: unknown; ip?: string } = {} +) { + const headers: Record = { + "CF-Connecting-IP": opts.ip ?? CLIENT_IP, + }; + const init: RequestInit = { method: opts.method ?? "GET", headers }; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = + typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body); + } + return new Request(`http://localhost${path}`, init); +} + +function okJson(obj: unknown) { + return { ok: true, status: 200, text: async () => JSON.stringify(obj) }; +} +function okText(text: string) { + return { ok: true, status: 200, text: async () => text }; +} +function notOk(status = 500, text = "error") { + return { ok: false, status, text: async () => text }; +} + +// Stub the GLOBAL fetch (used by generateApiKey's /gen call and friendbot). +function stubFetch(handlers: { gen?: () => unknown; friendbot?: () => unknown }) { + const fetchMock = vi.fn(async (input: unknown) => { + const url = typeof input === "string" ? input : (input as Request).url; + if (url.includes("/gen")) return handlers.gen?.() ?? okJson({}); + if (url.includes("friendbot")) return handlers.friendbot?.() ?? okText("ok"); + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +// Fake DurableObjectState for direct ApiKeyStore tests. +function fakeState(initial?: string) { + const store = new Map(); + if (initial !== undefined) store.set("apiKey", initial); + return { + storage: { + get: vi.fn(async (k: string) => store.get(k)), + put: vi.fn(async (k: string, v: unknown) => { + store.set(k, v); + }), + }, + blockConcurrencyWhile: vi.fn(async (fn: () => Promise) => fn()), + _store: store, + } as any; +} + +const G_ADDRESS = `G${"A".repeat(55)}`; + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- +describe("getClientIP", () => { + it("uses CF-Connecting-IP", () => { + expect( + getClientIP( + new Request("http://x", { headers: { "CF-Connecting-IP": "1.1.1.1" } }) + ) + ).toBe("1.1.1.1"); + }); + + it("ignores spoofable X-Forwarded-For / X-Real-IP, falling back to 'unknown'", () => { + expect( + getClientIP( + new Request("http://x", { + headers: { "X-Forwarded-For": "2.2.2.2, 3.3.3.3" }, + }) + ) + ).toBe("unknown"); + expect( + getClientIP( + new Request("http://x", { headers: { "X-Real-IP": "4.4.4.4" } }) + ) + ).toBe("unknown"); + expect(getClientIP(new Request("http://x"))).toBe("unknown"); + }); +}); + +describe("extractMissingAccount", () => { + it("pulls the G-address out of a not-found error", () => { + expect(extractMissingAccount(`Account not found: ${G_ADDRESS}`)).toBe( + G_ADDRESS + ); + }); + + it("returns null when the message has no account", () => { + expect(extractMissingAccount("some other failure")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Health +// --------------------------------------------------------------------------- +describe("GET /", () => { + it("reports service and network", async () => { + const res = await worker.fetch(makeRequest("/"), makeEnv(), ctx()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + status: "ok", + service: SERVICE_NAME, + network: "testnet", + }); + }); +}); + +// --------------------------------------------------------------------------- +// POST / — mode validation +// --------------------------------------------------------------------------- +describe("POST / validation", () => { + it("rejects a request with neither xdr nor func+auth (400)", async () => { + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: {} }), + makeEnv(), + ctx() + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "Request must include 'xdr' OR ('func' and 'auth')", + }); + expect(submitSorobanTransaction).not.toHaveBeenCalled(); + expect(submitTransaction).not.toHaveBeenCalled(); + }); + + it("rejects a request with both xdr and func+auth (400)", async () => { + const res = await worker.fetch( + makeRequest("/", { + method: "POST", + body: { xdr: "AAA", func: "BBB", auth: ["CCC"] }, + }), + makeEnv(), + ctx() + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "Request must include 'xdr' OR ('func' and 'auth'), not both", + }); + }); + + it("rejects malformed JSON (400)", async () => { + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: "{not json" }), + makeEnv(), + ctx() + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "Invalid JSON body", + }); + }); +}); + +// --------------------------------------------------------------------------- +// POST / — submission paths +// --------------------------------------------------------------------------- +describe("POST / submission", () => { + it("submits a Soroban transaction (func+auth) and returns the result", async () => { + submitSorobanTransaction.mockResolvedValueOnce({ + transactionId: "tx1", + hash: "h1", + status: "pending", + }); + + const res = await worker.fetch( + makeRequest("/", { + method: "POST", + body: { func: "FUNC", auth: ["A1", "A2"] }, + }), + makeEnv(), + ctx() + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + success: true, + data: { transactionId: "tx1", hash: "h1", status: "pending" }, + }); + expect(submitSorobanTransaction).toHaveBeenCalledWith({ + func: "FUNC", + auth: ["A1", "A2"], + }); + expect(submitTransaction).not.toHaveBeenCalled(); + }); + + it("fee-bumps a signed transaction (xdr) and returns the result", async () => { + submitTransaction.mockResolvedValueOnce({ + transactionId: "tx2", + hash: "h2", + status: "submitted", + }); + + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { xdr: "XDR" } }), + makeEnv(), + ctx() + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + success: true, + data: { transactionId: "tx2", hash: "h2", status: "submitted" }, + }); + expect(submitTransaction).toHaveBeenCalledWith({ xdr: "XDR" }); + expect(submitSorobanTransaction).not.toHaveBeenCalled(); + }); + + it("maps PluginExecutionError to 400 with code/details", async () => { + submitSorobanTransaction.mockRejectedValueOnce( + new PluginExecutionError("exec failed", { code: "E123", details: "nope" }) + ); + + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }), + makeEnv(), + ctx() + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "exec failed", + data: { code: "E123", details: "nope" }, + }); + }); + + it("maps PluginTransportError to its status code", async () => { + submitSorobanTransaction.mockRejectedValueOnce( + new PluginTransportError("upstream unavailable", 502) + ); + + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }), + makeEnv(), + ctx() + ); + + expect(res.status).toBe(502); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "upstream unavailable", + }); + }); + + it("maps an unknown error to 500", async () => { + submitSorobanTransaction.mockRejectedValueOnce(new Error("boom")); + + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }), + makeEnv(), + ctx() + ); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "boom", + }); + }); + + it("returns 500 when the API key cannot be obtained", async () => { + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }), + makeEnv({ do: makeApiKeyDO({ key: null }) }), + ctx() + ); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ + success: false, + error: "Could not obtain API key. Service may be misconfigured.", + }); + expect(submitSorobanTransaction).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// POST / — testnet friendbot retry +// --------------------------------------------------------------------------- +describe("POST / testnet retry", () => { + it("funds a missing channel account via friendbot then retries", async () => { + const fetchMock = stubFetch({ friendbot: () => okText("funded") }); + submitSorobanTransaction + .mockRejectedValueOnce(new Error(`Account not found: ${G_ADDRESS}`)) + .mockResolvedValueOnce({ transactionId: "tx3", hash: "h3", status: "ok" }); + + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }), + makeEnv({ network: "testnet" }), + ctx() + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + success: true, + data: { transactionId: "tx3", hash: "h3", status: "ok" }, + }); + expect(submitSorobanTransaction).toHaveBeenCalledTimes(2); + const friendbotCalls = fetchMock.mock.calls.filter(([u]) => + String(u).includes("friendbot") + ); + expect(friendbotCalls).toHaveLength(1); + expect(String(friendbotCalls[0][0])).toContain(G_ADDRESS); + }); + + it("does NOT retry a missing account on mainnet (no friendbot)", async () => { + const fetchMock = stubFetch({}); + submitSorobanTransaction.mockRejectedValueOnce( + new Error(`Account not found: ${G_ADDRESS}`) + ); + + const res = await worker.fetch( + makeRequest("/", { method: "POST", body: { func: "F", auth: ["A"] } }), + makeEnv({ network: "mainnet" }), + ctx() + ); + + expect(res.status).toBe(500); + expect(submitSorobanTransaction).toHaveBeenCalledTimes(1); + expect( + fetchMock.mock.calls.some(([u]) => String(u).includes("friendbot")) + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// ApiKeyStore Durable Object — serialized get-or-create +// --------------------------------------------------------------------------- +describe("ApiKeyStore (Durable Object)", () => { + it("returns the stored key without minting", async () => { + const state = fakeState("sk_stored_key_1234567"); + const fetchMock = stubFetch({ gen: () => okJson({ apiKey: "sk_unused" }) }); + const store = new ApiKeyStore(state, makeEnv()); + + const res = await store.fetch(new Request("https://do/get")); + expect(res.status).toBe(200); + expect((await res.text()).trim()).toBe("sk_stored_key_1234567"); + expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/gen"))).toBe( + false + ); + }); + + it("mints from /gen and stores when absent", async () => { + const state = fakeState(); + stubFetch({ gen: () => okJson({ apiKey: "sk_minted_key_1234567" }) }); + const store = new ApiKeyStore(state, makeEnv()); + + const res = await store.fetch(new Request("https://do/get")); + expect(res.status).toBe(200); + expect((await res.text()).trim()).toBe("sk_minted_key_1234567"); + expect(state._store.get("apiKey")).toBe("sk_minted_key_1234567"); + }); + + it("returns 502 when minting fails", async () => { + const state = fakeState(); + stubFetch({ gen: () => notOk(500, "denied") }); + const store = new ApiKeyStore(state, makeEnv()); + + const res = await store.fetch(new Request("https://do/get")); + expect(res.status).toBe(502); + expect(state._store.get("apiKey")).toBeUndefined(); + }); + + it("/peek reports presence without minting", async () => { + const present = new ApiKeyStore( + fakeState("sk_present_key_123456"), + makeEnv() + ); + const fetchMock = stubFetch({ gen: () => okJson({ apiKey: "sk_unused" }) }); + const res = await present.fetch(new Request("https://do/peek")); + await expect(res.json()).resolves.toEqual({ hasKey: true }); + + const absent = new ApiKeyStore(fakeState(), makeEnv()); + const res2 = await absent.fetch(new Request("https://do/peek")); + await expect(res2.json()).resolves.toEqual({ hasKey: false }); + + expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/gen"))).toBe( + false + ); + }); +}); + +// --------------------------------------------------------------------------- +// GET /status +// --------------------------------------------------------------------------- +describe("GET /status", () => { + it("returns client IP, network, and key presence", async () => { + const res = await worker.fetch(makeRequest("/status"), makeEnv(), ctx()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + success: true, + data: { + clientIP: CLIENT_IP, + network: "testnet", + hasKey: true, + }, + }); + }); +}); diff --git a/relayer-proxy/src/index.ts b/relayer-proxy/src/index.ts new file mode 100644 index 0000000..c947c9a --- /dev/null +++ b/relayer-proxy/src/index.ts @@ -0,0 +1,459 @@ +/** + * passkey-kit Relayer Proxy — Cloudflare Worker + * + * A keyless proxy in front of the OpenZeppelin Relayer Channels service. It + * mints and caches one Relayer API key per client IP so the browser can submit + * fee-sponsored transactions with ZERO secrets in the bundle (this replaces + * passkey-kit's old defect of inlining VITE_relayerApiKey into client JS). + * + * Uses the official `@openzeppelin/relayer-plugin-channels` SDK. + * + * Features: + * - Automatic API-key generation per IP, custody in a per-IP Durable Object so + * concurrent first-requests mint at most ONE key (KV has no CAS — review M5). + * - One API key per IP; the Relayer's usage limits reset every 24h on its side. + * - Two submission modes: `{ func, auth }` (Address credentials) and `{ xdr }` + * (source-account auth, e.g. deploys). + * - Testnet: funds missing channel accounts via Friendbot and retries. + * - Separate testnet (default) and mainnet (`--env production`) deployments. + */ + +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { + ChannelsClient, + PluginExecutionError, + PluginTransportError, +} from "@openzeppelin/relayer-plugin-channels"; +import { + SERVICE_NAME, + FRIENDBOT_URL, + API_KEY_FIELD_NAMES, + API_KEY_MIN_LENGTH, + API_KEY_MAX_LENGTH, + MISSING_ACCOUNT_PATTERN, + TESTNET_RETRY_DURATION_MS, + IP_HEADERS, + UNKNOWN_IP, +} from "./constants"; + +/** Durable Object storage key holding the IP's Relayer API key. */ +const DO_KEY = "apiKey"; + +// Hono app +const app = new Hono<{ Bindings: Env }>(); + +// CORS is intentionally open: the browser calls this cross-origin and the +// endpoint holds no secret the caller could exfiltrate (it mints its own keys). +// SECURITY DECISION (endgame, before any MAINNET deploy): decide whether an +// open, keyless, fee-sponsoring `POST /` is acceptable on mainnet, or whether +// it needs an origin allowlist / auth / stricter rate limiting. On testnet the +// open policy is fine. +app.use("*", cors()); + +app.onError((error, c) => { + console.error("Unhandled worker error:", error); + return c.json( + { + success: false, + error: "Internal server error", + }, + 500 + ); +}); + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get the client IP from the request. Uses ONLY `CF-Connecting-IP`, which + * Cloudflare always sets at the edge and the client cannot spoof. The + * `X-Forwarded-For` / `X-Real-IP` headers are client-controlled and must NOT be + * trusted for per-IP key custody (review LOW). + */ +export function getClientIP(request: Request): string { + return request.headers.get(IP_HEADERS.CF_CONNECTING_IP) || UNKNOWN_IP; +} + +/** + * Validate API key format before use. + */ +function isValidApiKey(apiKey: string): boolean { + const trimmed = apiKey.trim(); + return ( + trimmed.length >= API_KEY_MIN_LENGTH && trimmed.length <= API_KEY_MAX_LENGTH + ); +} + +/** + * Generate a new API key from the Relayer service. The /gen endpoint requires + * no authentication (GET). + * @see https://docs.openzeppelin.com/relayer/1.5.x/guides/stellar-channels-guide + */ +async function generateApiKey(env: Env): Promise { + try { + const response = await fetch(`${env.RELAYER_BASE_URL}/gen`, { + method: "GET", + }); + + const text = await response.text(); + + if (response.ok) { + try { + const data = JSON.parse(text) as Record; + const apiKey = API_KEY_FIELD_NAMES.map((name) => data[name]).find( + (v) => v + ); + if (typeof apiKey === "string") { + return apiKey; + } + // Do NOT log the body: it may contain the key under an unexpected + // field. Log only that extraction failed (review LOW: no secret logs). + console.error("API key field not found in /gen response"); + return null; + } catch { + // Response might be a plain-text API key — never log the raw text. + if ( + text && + text.length > API_KEY_MIN_LENGTH && + text.length < API_KEY_MAX_LENGTH + ) { + return text.trim(); + } + console.error( + `Could not parse /gen response (length ${text.length})` + ); + return null; + } + } + + console.error( + `Failed to generate API key: status ${response.status} (body length ${text.length})` + ); + return null; + } catch (error) { + console.error("Error generating API key:", error); + return null; + } +} + +/** + * Per-IP Relayer API key custody. + * + * One Durable Object instance per client IP (`idFromName(ip)`). All access to a + * given IP's key is serialized by the DO input gate + `blockConcurrencyWhile`, + * so N concurrent first-requests from one IP mint at most ONE `/gen` key. This + * is the atomic get-or-create KV cannot provide (KV has no CAS — review M5). + * + * Routes: + * - default (`/get`): get-or-create → 200 with the key, or 502 if minting fails. + * - `/peek`: report presence WITHOUT minting → `{ hasKey }` (for GET /status). + */ +export class ApiKeyStore implements DurableObject { + constructor( + private readonly state: DurableObjectState, + private readonly env: Env + ) {} + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === "/peek") { + const existing = await this.state.storage.get(DO_KEY); + return Response.json({ + hasKey: !!existing && isValidApiKey(existing), + }); + } + + // Get-or-create, serialized per instance. + const apiKey = await this.state.blockConcurrencyWhile(async () => { + const existing = await this.state.storage.get(DO_KEY); + if (existing && isValidApiKey(existing)) { + return existing; + } + const minted = await generateApiKey(this.env); + if (!minted) { + return null; + } + await this.state.storage.put(DO_KEY, minted); + return minted; + }); + + if (!apiKey) { + return new Response("Could not mint API key", { status: 502 }); + } + return new Response(apiKey, { status: 200 }); + } +} + +/** + * Get (or lazily mint) the Relayer API key for an IP via its Durable Object. + */ +async function getApiKeyForIp(env: Env, ip: string): Promise { + const id = env.API_KEY_DO.idFromName(ip); + const stub = env.API_KEY_DO.get(id); + const res = await stub.fetch("https://api-key-store/get"); + if (!res.ok) { + return null; + } + const apiKey = (await res.text()).trim(); + return isValidApiKey(apiKey) ? apiKey : null; +} + +/** + * Create a ChannelsClient for the configured network. + */ +function createClient(env: Env, apiKey: string): ChannelsClient { + return new ChannelsClient({ + baseUrl: env.RELAYER_BASE_URL, + apiKey, + }); +} + +/** + * Extract the account address from an "Account not found" error message. + */ +export function extractMissingAccount(errorMessage: string): string | null { + const match = errorMessage.match(MISSING_ACCOUNT_PATTERN); + return match ? match[1] : null; +} + +/** + * Fund an account via Friendbot (testnet only). + */ +async function fundWithFriendbot(account: string): Promise { + try { + const response = await fetch( + `${FRIENDBOT_URL}?addr=${encodeURIComponent(account)}` + ); + return response.ok; + } catch (error) { + console.error("Friendbot funding failed:", error); + return false; + } +} + +// ============================================================================ +// API Endpoints +// ============================================================================ + +// Health check +app.get("/", (c) => { + return c.json({ + status: "ok", + service: SERVICE_NAME, + network: c.env.NETWORK, + }); +}); + +/** + * Submit a transaction via the Relayer. + * POST / + * + * Two modes: + * 1. { func: string, auth: string[] } — Relayer builds the tx with channel + * accounts (Address credentials: transfers, wallet operations). + * 2. { xdr: string } — Relayer fee-bumps an already-signed transaction + * (source-account auth: deploys). + * + * On testnet, if channel accounts are missing (after a testnet reset), fund + * them via Friendbot and retry for up to 5 minutes. + */ +app.post("/", async (c) => { + try { + const ip = getClientIP(c.req.raw); + const apiKey = await getApiKeyForIp(c.env, ip); + + if (!apiKey) { + return c.json( + { + success: false, + error: "Could not obtain API key. Service may be misconfigured.", + }, + 500 + ); + } + + const body = await c.req.json<{ + func?: string; + auth?: string[]; + xdr?: string; + }>(); + + // Validate: must have either xdr OR (func AND auth). + const hasXdr = !!body.xdr; + const hasFuncAuth = !!body.func && !!body.auth; + + if (!hasXdr && !hasFuncAuth) { + return c.json( + { + success: false, + error: "Request must include 'xdr' OR ('func' and 'auth')", + }, + 400 + ); + } + + if (hasXdr && hasFuncAuth) { + return c.json( + { + success: false, + error: "Request must include 'xdr' OR ('func' and 'auth'), not both", + }, + 400 + ); + } + + const client = createClient(c.env, apiKey); + const isTestnet = c.env.NETWORK === "testnet"; + + // On testnet, retry for up to 5 minutes to handle channel accounts needing + // funding. On mainnet, only try once (no Friendbot). Network wait time does + // not count toward the worker CPU limit. + const deadline = isTestnet ? Date.now() + TESTNET_RETRY_DURATION_MS : 0; + const fundedAccounts = new Set(); + + while (true) { + try { + const result = hasXdr + ? await client.submitTransaction({ xdr: body.xdr! }) + : await client.submitSorobanTransaction({ + func: body.func!, + auth: body.auth!, + }); + + return c.json({ + success: true, + data: { + transactionId: result.transactionId, + hash: result.hash, + status: result.status, + }, + }); + } catch (submitError) { + const errorMessage = + submitError instanceof Error + ? submitError.message + : String(submitError); + + const missingAccount = extractMissingAccount(errorMessage); + const timeRemaining = deadline - Date.now(); + + if (missingAccount && isTestnet && timeRemaining > 0) { + if (!fundedAccounts.has(missingAccount)) { + console.log( + `Account ${missingAccount} not found. Funding via friendbot (${Math.round( + timeRemaining / 1000 + )}s remaining)...` + ); + + const funded = await fundWithFriendbot(missingAccount); + if (funded) { + console.log( + `Successfully funded ${missingAccount}. Retrying submission...` + ); + fundedAccounts.add(missingAccount); + } else { + console.error(`Failed to fund ${missingAccount}`); + } + } else { + console.log(`Account ${missingAccount} already funded, retrying...`); + } + + continue; // Retry immediately. + } + + // Not a recoverable error or deadline exceeded — throw to outer handler. + throw submitError; + } + } + } catch (error) { + console.error("Relayer submission error:", error); + + if (error instanceof SyntaxError) { + return c.json( + { + success: false, + error: "Invalid JSON body", + }, + 400 + ); + } + + if (error instanceof PluginExecutionError) { + return c.json( + { + success: false, + error: error.message, + data: { + code: error.errorDetails?.code, + details: error.errorDetails?.details, + }, + }, + 400 + ); + } + + if (error instanceof PluginTransportError) { + const status = error.statusCode || 500; + return c.json( + { + success: false, + error: error.message, + }, + status as 400 | 401 | 403 | 404 | 500 | 502 | 503 + ); + } + + return c.json( + { + success: false, + error: error instanceof Error ? error.message : "Relayer request failed", + }, + 500 + ); + } +}); + +/** + * Get proxy status and client info. + * GET /status + */ +app.get("/status", async (c) => { + try { + const ip = getClientIP(c.req.raw); + const id = c.env.API_KEY_DO.idFromName(ip); + const stub = c.env.API_KEY_DO.get(id); + const res = await stub.fetch("https://api-key-store/peek"); + const peek = (res.ok ? await res.json() : { hasKey: false }) as { + hasKey: boolean; + }; + + return c.json({ + success: true, + data: { + clientIP: ip, + network: c.env.NETWORK, + hasKey: peek.hasKey, + }, + }); + } catch (error) { + console.error("Status endpoint failed:", error); + return c.json( + { + success: false, + error: "Could not read status", + }, + 500 + ); + } +}); + +// ============================================================================ +// Worker Export +// ============================================================================ + +export default { + fetch: app.fetch, +}; diff --git a/relayer-proxy/tsconfig.json b/relayer-proxy/tsconfig.json new file mode 100644 index 0000000..3b3e4cd --- /dev/null +++ b/relayer-proxy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "Bundler", + "types": ["@cloudflare/workers-types", "vitest/globals"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "worker-configuration.d.ts"] +} diff --git a/relayer-proxy/worker-configuration.d.ts b/relayer-proxy/worker-configuration.d.ts new file mode 100644 index 0000000..bd6749f --- /dev/null +++ b/relayer-proxy/worker-configuration.d.ts @@ -0,0 +1,13 @@ +/// + +// Worker bindings. Regenerate the full Cloudflare runtime typings with +// `wrangler types` if you need them; this hand-written subset is all the worker +// code references. +interface Env { + /** Per-IP Relayer API key custody (serialized get-or-create). */ + API_KEY_DO: DurableObjectNamespace; + /** Which network this instance serves. */ + NETWORK: "testnet" | "mainnet"; + /** OpenZeppelin Relayer Channels base URL for this network. */ + RELAYER_BASE_URL: string; +} diff --git a/relayer-proxy/wrangler.toml b/relayer-proxy/wrangler.toml new file mode 100644 index 0000000..73446aa --- /dev/null +++ b/relayer-proxy/wrangler.toml @@ -0,0 +1,44 @@ +name = "passkey-kit-relayer-proxy" +main = "src/index.ts" +compatibility_date = "2024-12-01" +compatibility_flags = ["nodejs_compat"] + +# Per-IP Relayer API key custody. A Durable Object (one instance per client IP) +# serializes get-or-create so concurrent first-requests mint at most ONE key — +# the atomic get-or-create KV cannot provide (no CAS). No namespace id to +# provision; the class is bound directly. +[[durable_objects.bindings]] +name = "API_KEY_DO" +class_name = "ApiKeyStore" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["ApiKeyStore"] + +# Raise the CPU limit for testnet's Friendbot-funding retry loop (up to 5 +# minutes). Network wait time (fetch) does not count toward CPU time. Mainnet +# never retries, so it uses the default limit. +[limits] +cpu_ms = 300_000 + +[vars] +NETWORK = "testnet" +RELAYER_BASE_URL = "https://channels.openzeppelin.com/testnet" + +# ============================================================================ +# Production environment (mainnet). Deploy: wrangler deploy --env production +# ============================================================================ +[env.production] +name = "passkey-kit-relayer-proxy-mainnet" + +[env.production.vars] +NETWORK = "mainnet" +RELAYER_BASE_URL = "https://channels.openzeppelin.com" + +[[env.production.durable_objects.bindings]] +name = "API_KEY_DO" +class_name = "ApiKeyStore" + +[[env.production.migrations]] +tag = "v1" +new_sqlite_classes = ["ApiKeyStore"] diff --git a/scripts/bindings/build.sh b/scripts/bindings/build.sh new file mode 100755 index 0000000..ea1df9c --- /dev/null +++ b/scripts/bindings/build.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# Regenerate the passkey-kit-sdk bindings from the canonical smart-wallet WASM. +# +# The canonical hash is THE source of truth (docs/deployments-testnet-*.md). The +# WASM is fetched by hash from testnet, bindings are generated to a temp dir with +# the pinned Stellar CLI, and only the generated `src/index.ts` (+ README) are +# copied into the package — the B1 packaging (package.json, tsconfig, dist build) +# is preserved. NEVER hand-edit the generated bindings; add post-gen steps here. +# +# Usage: bash scripts/bindings/build.sh +set -euo pipefail + +# Canonical smart-wallet WASM hash — keep in sync with the deployments manifest +# (docs/deployments-testnet-2026-07-11.md). Pinned Stellar CLI: 27.0.0. +CANONICAL_HASH="84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319" +NETWORK="testnet" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PKG_SRC="$ROOT/packages/passkey-kit-sdk/src/index.ts" +PKG_README="$ROOT/packages/passkey-kit-sdk/README.md" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "Fetching canonical WASM ${CANONICAL_HASH} from ${NETWORK}…" +stellar contract fetch --wasm-hash "$CANONICAL_HASH" --network "$NETWORK" \ + --out-file "$TMP/smart-wallet.wasm" + +ACTUAL="$(shasum -a 256 "$TMP/smart-wallet.wasm" | awk '{print $1}')" +if [ "$ACTUAL" != "$CANONICAL_HASH" ]; then + echo "✗ Fetched WASM hash ($ACTUAL) != canonical ($CANONICAL_HASH)" >&2 + exit 1 +fi + +echo "Generating TypeScript bindings…" +# The CLI derives the generated README's package name from the output-dir +# basename; keep it "pks-gen" so the released README name stays stable across +# regens (verify.sh compares only the name-independent spec base64). +stellar contract bindings typescript --wasm "$TMP/smart-wallet.wasm" \ + --overwrite --output-dir "$TMP/pks-gen" >/dev/null + +echo "Applying post-gen patches (copy generated spec, preserve packaging)…" +cp "$TMP/pks-gen/src/index.ts" "$PKG_SRC" +cp "$TMP/pks-gen/README.md" "$PKG_README" + +echo "✓ Regenerated passkey-kit-sdk from canonical WASM ${CANONICAL_HASH}" +echo " Run 'pnpm --filter passkey-kit-sdk run build' to rebuild dist." diff --git a/scripts/bindings/verify.sh b/scripts/bindings/verify.sh new file mode 100755 index 0000000..1095183 --- /dev/null +++ b/scripts/bindings/verify.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Bindings drift guard. +# +# Proves the committed passkey-kit-sdk bindings match the canonical smart-wallet +# WASM: fetch the WASM by its pinned hash, regenerate to a temp dir, and diff the +# ContractSpec base64 array (the semantic content — CLI formatting/comments are +# ignored) against the committed bindings. Fails the build/release on drift. +# +# Usage: bash scripts/bindings/verify.sh +set -euo pipefail + +CANONICAL_HASH="84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319" +NETWORK="testnet" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PKG_SRC="$ROOT/packages/passkey-kit-sdk/src/index.ts" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "verify:bindings — canonical WASM ${CANONICAL_HASH} (${NETWORK})" + +stellar contract fetch --wasm-hash "$CANONICAL_HASH" --network "$NETWORK" \ + --out-file "$TMP/smart-wallet.wasm" + +ACTUAL="$(shasum -a 256 "$TMP/smart-wallet.wasm" | awk '{print $1}')" +if [ "$ACTUAL" != "$CANONICAL_HASH" ]; then + echo "✗ Fetched WASM hash ($ACTUAL) != canonical ($CANONICAL_HASH)" >&2 + exit 1 +fi + +stellar contract bindings typescript --wasm "$TMP/smart-wallet.wasm" \ + --overwrite --output-dir "$TMP/pks-gen" >/dev/null + +# Extract the ContractSpec base64 string literals (>= 16 chars, base64 charset), +# in order. This is the wasm-determined spec, independent of CLI TS formatting. +extract_spec() { + grep -oE '"[A-Za-z0-9+/]{16,}={0,2}"' "$1" +} + +if diff -u \ + <(extract_spec "$PKG_SRC") \ + <(extract_spec "$TMP/pks-gen/src/index.ts"); then + echo "✓ Committed bindings match canonical WASM ${CANONICAL_HASH}" +else + echo "" >&2 + echo "✗ Bindings DRIFT from the canonical WASM." >&2 + echo " Regenerate with: bash scripts/bindings/build.sh" >&2 + echo " (then rebuild + review the diff before committing)" >&2 + exit 1 +fi diff --git a/scripts/e2e/agent-browser-webauthn-helper.mjs b/scripts/e2e/agent-browser-webauthn-helper.mjs new file mode 100755 index 0000000..00a926c --- /dev/null +++ b/scripts/e2e/agent-browser-webauthn-helper.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node + +/** + * Attach a virtual WebAuthn authenticator (CDP `WebAuthn.addVirtualAuthenticator`) + * to a live agent-browser session, keep the CDP attachment open while a child + * command runs, and tear the authenticator down in a `finally` — so passkey + * ceremonies resolve headlessly and nothing leaks between runs. + * + * Ported from smart-account-kit (project 34); generic — no passkey-kit specifics. + * + * Usage: + * node scripts/e2e/agent-browser-webauthn-helper.mjs run --session -- [args...] + */ + +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_AUTHENTICATOR_OPTIONS = { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, +}; + +function printUsage() { + console.error(` +Usage: + node scripts/e2e/agent-browser-webauthn-helper.mjs run [options] -- [args...] + +Description: + Attaches a virtual WebAuthn authenticator to a live agent-browser session, keeps + the CDP attachment open while your command runs, and cleans it up afterward. + +Options: + --session Existing agent-browser session name + --cdp-url Explicit CDP WebSocket URL (overrides --session lookup) + --url Page URL to bind within the browser session + --transport internal | usb | nfc | ble | cable + --protocol ctap2 | u2f + --resident-key true | false + --user-verification true | false + --verified true | false + --presence true | false + +Example: + agent-browser --session demo open http://127.0.0.1:5173 + node scripts/e2e/agent-browser-webauthn-helper.mjs run --session demo -- \\ + bash scripts/e2e/browser-full-e2e-audit.sh +`); +} + +function parseArgs(argv) { + const normalized = argv[0] === "--" ? argv.slice(1) : argv; + const command = normalized[0]; + if (!command) { + printUsage(); + process.exit(1); + } + + const separator = normalized.indexOf("--"); + const optionTokens = + separator === -1 ? normalized.slice(1) : normalized.slice(1, separator); + const childCommand = separator === -1 ? [] : normalized.slice(separator + 1); + + const args = { _: [] }; + for (let i = 0; i < optionTokens.length; i += 1) { + const token = optionTokens[i]; + if (!token.startsWith("--")) { + args._.push(token); + continue; + } + + const key = token.slice(2); + const next = optionTokens[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + continue; + } + + args[key] = next; + i += 1; + } + + return { command, args, childCommand }; +} + +function parseBoolean(value, fallback) { + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + + const normalized = String(value).toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; + throw new Error(`Expected a boolean value, received "${value}"`); +} + +async function runAgentBrowser(args) { + const { stdout } = await execFileAsync("agent-browser", args, { + encoding: "utf8", + }); + return stdout.trim(); +} + +function getLastNonEmptyLine(output) { + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); +} + +async function resolveCDPUrl(args) { + if (args["cdp-url"] && args["cdp-url"] !== true) return args["cdp-url"]; + + const session = args.session; + if (!session || session === true) { + throw new Error("Provide --session or --cdp-url"); + } + + const output = await runAgentBrowser(["--session", session, "get", "cdp-url"]); + const cdpUrl = getLastNonEmptyLine(output); + if (!cdpUrl) { + throw new Error(`Unable to resolve CDP URL for session "${session}"`); + } + return cdpUrl; +} + +async function resolveCurrentUrl(args) { + if (args.url && args.url !== true) return args.url; + + const session = args.session; + if (!session || session === true) return null; + + const output = await runAgentBrowser(["--session", session, "get", "url"]); + return getLastNonEmptyLine(output) ?? null; +} + +function createCDPConnection(browserWsUrl) { + const socket = new WebSocket(browserWsUrl); + let nextId = 0; + const pending = new Map(); + + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + } + }; + + const ready = new Promise((resolve, reject) => { + socket.onopen = () => resolve(); + socket.onerror = (event) => + reject(new Error(`WebSocket error: ${event.type}`)); + }); + + const send = (method, params = {}, sessionId) => + new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + const payload = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + socket.send(JSON.stringify(payload)); + }); + + const close = () => { + if ( + socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING + ) { + socket.close(); + } + }; + + return { ready, send, close }; +} + +async function attachToPage(send, preferredUrl) { + const { targetInfos } = await send("Target.getTargets"); + const pages = targetInfos.filter((target) => target.type === "page"); + + const pageTarget = + (preferredUrl + ? pages.find((target) => target.url === preferredUrl) + : null) ?? + pages.find((target) => target.url && target.url !== "about:blank") ?? + pages.at(-1); + + if (!pageTarget) { + throw new Error("Unable to find a page target in the remote browser"); + } + + const { sessionId } = await send("Target.attachToTarget", { + targetId: pageTarget.targetId, + flatten: true, + }); + + return { sessionId, pageUrl: pageTarget.url, targetId: pageTarget.targetId }; +} + +async function runChildCommand(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", env: process.env }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`Child command exited via signal ${signal}`)); + return; + } + resolve(code ?? 0); + }); + }); +} + +async function commandRun(args, childCommand) { + if (childCommand.length === 0) { + throw new Error("run requires a child command after --"); + } + + const cdpUrl = await resolveCDPUrl(args); + const preferredUrl = await resolveCurrentUrl(args); + const connection = createCDPConnection(cdpUrl); + await connection.ready; + + const { sessionId, pageUrl } = await attachToPage( + connection.send, + preferredUrl, + ); + const authenticatorOptions = { + protocol: args.protocol || DEFAULT_AUTHENTICATOR_OPTIONS.protocol, + transport: args.transport || DEFAULT_AUTHENTICATOR_OPTIONS.transport, + hasResidentKey: parseBoolean( + args["resident-key"], + DEFAULT_AUTHENTICATOR_OPTIONS.hasResidentKey, + ), + hasUserVerification: parseBoolean( + args["user-verification"], + DEFAULT_AUTHENTICATOR_OPTIONS.hasUserVerification, + ), + isUserVerified: parseBoolean( + args.verified, + DEFAULT_AUTHENTICATOR_OPTIONS.isUserVerified, + ), + automaticPresenceSimulation: parseBoolean( + args.presence, + DEFAULT_AUTHENTICATOR_OPTIONS.automaticPresenceSimulation, + ), + }; + + await connection.send("WebAuthn.enable", {}, sessionId); + const { authenticatorId } = await connection.send( + "WebAuthn.addVirtualAuthenticator", + { options: authenticatorOptions }, + sessionId, + ); + + console.error( + JSON.stringify( + { + session: typeof args.session === "string" ? args.session : null, + cdpUrl, + pageUrl, + authenticatorId, + options: authenticatorOptions, + }, + null, + 2, + ), + ); + + let exitCode = 0; + try { + exitCode = await runChildCommand(childCommand[0], childCommand.slice(1)); + } finally { + try { + await connection.send( + "WebAuthn.removeVirtualAuthenticator", + { authenticatorId }, + sessionId, + ); + } catch (error) { + console.error( + `Cleanup warning: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + await connection.send("WebAuthn.disable", {}, sessionId); + } catch (error) { + console.error( + `Cleanup warning: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + connection.close(); + } + + process.exitCode = exitCode; +} + +const { command, args, childCommand } = parseArgs(process.argv.slice(2)); + +try { + switch (command) { + case "run": + await commandRun(args, childCommand); + break; + case "help": + case "--help": + case "-h": + printUsage(); + break; + default: + throw new Error(`Unknown command "${command}"`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/e2e/browser-full-e2e-audit.sh b/scripts/e2e/browser-full-e2e-audit.sh new file mode 100755 index 0000000..2a5543e --- /dev/null +++ b/scripts/e2e/browser-full-e2e-audit.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash + +# Full browser e2e audit for the passkey-kit demo. +# +# Drives the real Svelte demo through the passkey-kit flow and asserts REAL +# outputs (a C… contract id, 64-hex tx hashes, explicit failure patterns on every +# wait). Uses Svelte `data-testid` selectors — no React native-setter shims. +# +# Runs inside testnet-passkey-smoke.sh (a live agent-browser session with a +# virtual WebAuthn authenticator). Layers gate automatically: if the demo header +# shows "no relayer proxy" the on-chain layers are skipped (explicitly, never a +# silent pass); discovery is skipped when "no indexer proxy" is shown. +# +# Distinct exit code per layer: +# 10 setup 20 create 21 reconnect 30 fund 31 transfer +# 40 add-secp 41 add-ed25519 42 add-policy 43 update 44 per-signer-transfer 45 remove +# 50 discover-mercury 51 discover-stellar-indexer 52 reverse-lookup + +set -euo pipefail + +SESSION_NAME="${SESSION_NAME:?SESSION_NAME is required}" +DEMO_URL="${DEMO_URL:-http://127.0.0.1:5173}" +SKIP_ONCHAIN="${SKIP_ONCHAIN:-auto}" +# Destination for the primary transfer; falls back to the demo's own funding +# address (always exists) via the "use fund addr" control when unset. +RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-}" + +ab() { agent-browser --session "$SESSION_NAME" "$@"; } + +CURRENT_LAYER="setup" +CURRENT_CODE=10 +layer() { + CURRENT_LAYER="$1" + CURRENT_CODE="$2" + echo "── [$CURRENT_CODE] $CURRENT_LAYER ────────────────────────────────" +} +fail() { + echo "FAIL [$CURRENT_CODE] $CURRENT_LAYER: $1" >&2 + echo "---- log-box ----" >&2 + log_box >&2 || true + exit "$CURRENT_CODE" +} + +log_box() { ab get text '[data-testid="log-box"]' 2>/dev/null || true; } +header_text() { ab get text '[data-testid="header"]' 2>/dev/null || true; } +body_text() { ab get text body 2>/dev/null || true; } +contract_id() { ab get text '[data-testid="wallet-contract-id"]' 2>/dev/null | tr -d '[:space:]'; } +# querySelector returns the FIRST (newest, list is prepended) tx-hash → deterministic. +latest_hash() { + ab eval 'document.querySelector("[data-testid=\"tx-hash\"]")?.dataset.hash ?? ""' 2>/dev/null | tr -d '[:space:]"' +} +log_line_count() { printf '%s\n' "$(log_box)" | wc -l | tr -d ' '; } + +# Wait for a NEW log entry (newest-first) matching success/failure since a prior +# line count. Returns 0 success, 2 failure-pattern, 1 timeout. +wait_new_log() { + local previous_count="$1" success="$2" failure="$3" attempts="${4:-60}" + for _ in $(seq 1 "$attempts"); do + local all total new_count slice + all="$(log_box)" + total="$(printf '%s\n' "$all" | wc -l | tr -d ' ')" + new_count=$(( total - previous_count )) + if (( new_count > 0 )); then + slice="$(printf '%s\n' "$all" | head -n "$new_count")" + else + slice="" + fi + if [[ -n "$failure" ]] && printf '%s\n' "$slice" | grep -Eq "$failure"; then + return 2 + fi + if printf '%s\n' "$slice" | grep -Eq "$success"; then + return 0 + fi + sleep 2 + done + return 1 +} + +# Click a testid; fail the layer if the element is missing. +click_tid() { + ab click "[data-testid=\"$1\"]" >/dev/null 2>&1 || fail "missing/again unclickable: $1" +} +fill_tid() { + ab fill "[data-testid=\"$1\"]" "$2" >/dev/null 2>&1 || fail "missing input: $1" +} +has_tid() { [[ "$(ab get count "[data-testid=\"$1\"]" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; } + +# Wait until a control is present AND enabled. Flows set `app.busy` for their +# whole duration (disabling action buttons), and `createWallet` keeps busy set +# through its trailing auto-fund AFTER the "Wallet created" log — so a layer that +# acts on the "created" log alone can click a still-disabled button (a no-op). +wait_enabled() { + local tid="$1" attempts="${2:-90}" + for _ in $(seq 1 "$attempts"); do + if [[ "$(ab eval "document.querySelector('[data-testid=\"$tid\"]')?.disabled === false" 2>/dev/null | tr -d '[:space:]"')" == "true" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + +# Best-effort "let the previous flow settle" gate. Every action button is +# `disabled={app.busy}`, and a flow can keep busy set for a moment AFTER its +# success log (e.g. a trailing balance refresh) — so the next layer, clicking +# immediately, would hit a still-disabled button (a silent no-op → timeout). +# Gates on the always-present `disconnect` button; a no-op while disconnected +# (button absent, e.g. the create layer). Never hard-fails — the action's own +# wait catches a genuine stall. +wait_idle() { + for _ in $(seq 1 "${1:-90}"); do + local s + s="$(ab eval '(()=>{const b=document.querySelector("[data-testid=\"disconnect\"]");return b?(b.disabled?"busy":"idle"):"absent";})()' 2>/dev/null | tr -d '[:space:]"')" + [[ "$s" == "idle" || "$s" == "absent" ]] && return 0 + sleep 1 + done + return 0 +} + +# Run one write layer: capture the log count, run the click closure, wait. +expect_after() { + local success="$1" failure="$2" attempts="${3:-90}"; shift 3 + wait_idle + local before; before="$(log_line_count)" + "$@" + case "$(wait_new_log "$before" "$success" "$failure" "$attempts"; echo $?)" in + 0) echo " ✓ $success" ;; + 2) fail "hit failure pattern ($failure)" ;; + *) fail "timed out waiting for: $success" ;; + esac +} + +######################################################################## +layer "setup" 10 +echo "Opening $DEMO_URL" +ab open "$DEMO_URL" >/dev/null || fail "could not open demo" +sleep 2 +# The demo must have mounted (header rendered). A blank body means the module +# crashed — usually a missing demo/.env.local (copy demo/.env.example). +if ! printf '%s\n' "$(body_text)" | grep -q "Passkey Kit"; then + fail "demo did not render — configure demo/.env.local (copy demo/.env.example)" +fi +HEADER="$(header_text)" +echo " header: $HEADER" + +ONCHAIN=1 +case "$SKIP_ONCHAIN" in + 1|true|yes) ONCHAIN=0 ;; + 0|false|no) ONCHAIN=1 ;; + *) printf '%s\n' "$HEADER" | grep -q "no relayer proxy" && ONCHAIN=0 ;; +esac +INDEXER=1 +printf '%s\n' "$HEADER" | grep -q "no indexer proxy" && INDEXER=0 + +if (( ONCHAIN == 0 )); then + echo + echo "on-chain layers SKIPPED: relayer proxy not configured." + echo "Set VITE_relayerProxyUrl in demo/.env.local (and start the relayer-proxy" + echo "worker) to run create → transfer → signer → discovery live (F2)." + # Still assert the disconnected UI is wired. + has_tid "register" || fail "register control missing on disconnected UI" + echo "Disconnected UI wired (register control present). Static UI audit passed." + exit 0 +fi + +######################################################################## +layer "create" 20 +USER_NAME="audit$(date +%s)" +echo "Creating wallet for $USER_NAME" +fill_tid "register-name" "$USER_NAME" +expect_after "Wallet created" "Create wallet failed|Deploy wallet failed" 150 click_tid "register" +CONTRACT_ID="$(contract_id)" +printf '%s\n' "$CONTRACT_ID" | grep -Eq '^C[A-Z2-7]{55}$' || fail "no valid contract id (got: '$CONTRACT_ID')" +echo " contract: $CONTRACT_ID" +CREATE_HASH="$(latest_hash)" +printf '%s\n' "$CREATE_HASH" | grep -Eq '^[0-9a-f]{64}$' || fail "no valid deploy tx hash (got: '$CREATE_HASH')" +echo " deploy tx: $CREATE_HASH" + +######################################################################## +layer "reconnect" 21 +echo "Disconnect + reconnect via discoverable passkey" +# createWallet stays busy through its auto-fund; wait for that to settle so the +# disconnect button is actually enabled before we click it. +wait_enabled "disconnect" 90 || fail "disconnect never became enabled (create auto-fund still running?)" +click_tid "disconnect" +sleep 1 +has_tid "signin" || fail "signin control missing after disconnect" +expect_after "Connected — ownership verified" "Connect wallet failed" 120 click_tid "signin" +RECONNECT_ID="$(contract_id)" +[[ "$RECONNECT_ID" == "$CONTRACT_ID" ]] || fail "reconnected to a different wallet ($RECONNECT_ID != $CONTRACT_ID)" +echo " reconnected to $RECONNECT_ID (ownership verified)" + +######################################################################## +layer "fund" 30 +echo "Funding wallet" +expect_after "Fund wallet ✓" "Fund wallet failed|Create wallet failed" 150 click_tid "fund-wallet" + +######################################################################## +layer "transfer" 31 +echo "Primary transfer (signed by passkey)" +click_tid "use-fund-addr" +sleep 1 +[[ -n "$RECIPIENT_ADDRESS" ]] && fill_tid "transfer-to" "$RECIPIENT_ADDRESS" +fill_tid "transfer-amount" "1" +expect_after "Transfer \(passkey\) ✓" "Transfer \(passkey\) failed" 150 click_tid "transfer-send" +TRANSFER_HASH="$(latest_hash)" +printf '%s\n' "$TRANSFER_HASH" | grep -Eq '^[0-9a-f]{64}$' || fail "no valid transfer tx hash" +echo " transfer tx: $TRANSFER_HASH" + +######################################################################## +layer "add-secp" 40 +echo "Add secp256r1 passkey signer" +click_tid "add-kind-Secp256r1" +fill_tid "add-signer-name" "session-$(date +%s)" +expect_after "Add passkey signer ✓" "Add passkey signer failed" 150 click_tid "add-signer-submit" + +######################################################################## +layer "add-ed25519" 41 +echo "Add Ed25519 signer" +click_tid "add-kind-Ed25519" +expect_after "Add Ed25519 signer ✓" "Add Ed25519 signer failed" 150 click_tid "add-signer-submit" + +######################################################################## +layer "add-policy" 42 +POLICY_ENABLED="$(ab eval 'document.querySelector("[data-testid=\"add-kind-Policy\"]")?.disabled === false' 2>/dev/null | tr -d '[:space:]')" +if [[ "$POLICY_ENABLED" == "true" ]]; then + echo "Add policy signer" + click_tid "add-kind-Policy" + expect_after "Add policy signer ✓" "Add policy signer failed" 150 click_tid "add-signer-submit" +else + echo " SKIP: no VITE_samplePolicyId configured (policy-signer controls disabled)" +fi + +######################################################################## +layer "update" 43 +echo "Update the Ed25519 signer (limits/store)" +ED_ROW='[data-testid="signer-row"][data-kind="Ed25519"]' +if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then + wait_idle + ab click "$ED_ROW [data-testid=\"signer-update\"]" >/dev/null 2>&1 || fail "update control missing" + sleep 1 + before="$(log_line_count)" + ab click "$ED_ROW [data-testid=\"signer-update-save\"]" >/dev/null 2>&1 || fail "update-save missing" + case "$(wait_new_log "$before" "Update Ed25519 signer ✓" "Update Ed25519 signer failed" 150; echo $?)" in + 0) echo " ✓ Update Ed25519 signer ✓" ;; + 2) fail "update hit failure pattern" ;; + *) fail "update timed out" ;; + esac +else + echo " SKIP: no Ed25519 row present" +fi + +######################################################################## +layer "per-signer-transfer" 44 +echo "Per-signer transfer via the Ed25519 signer row" +if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then + wait_idle + before="$(log_line_count)" + ab click "$ED_ROW [data-testid=\"signer-transfer\"]" >/dev/null 2>&1 || fail "per-signer transfer control missing" + case "$(wait_new_log "$before" "Transfer via Ed25519 ✓" "Transfer via Ed25519 failed" 150; echo $?)" in + 0) echo " ✓ Transfer via Ed25519 ✓" ;; + 2) fail "per-signer transfer hit failure pattern" ;; + *) fail "per-signer transfer timed out" ;; + esac +else + echo " SKIP: no Ed25519 row present" +fi + +######################################################################## +layer "remove" 45 +echo "Remove the Ed25519 signer" +if [[ "$(ab get count "$ED_ROW" 2>/dev/null | tr -d '[:space:]')" != "0" ]]; then + wait_idle + before="$(log_line_count)" + ab click "$ED_ROW [data-testid=\"signer-remove\"]" >/dev/null 2>&1 || fail "remove control missing" + case "$(wait_new_log "$before" "Remove Ed25519 signer ✓" "Remove Ed25519 signer failed" 150; echo $?)" in + 0) echo " ✓ Remove Ed25519 signer ✓" ;; + 2) fail "remove hit failure pattern" ;; + *) fail "remove timed out" ;; + esac +else + echo " SKIP: no Ed25519 row present" +fi + +######################################################################## +if (( INDEXER == 0 )); then + echo + echo "discovery layers SKIPPED: indexer proxy not configured." + echo "Set VITE_indexerProxyUrl in demo/.env.local to run Mercury + Stellar" + echo "Indexer discovery live (F2)." + echo + echo "On-chain audit passed for contract $CONTRACT_ID" + exit 0 +fi + +# lookupWithRetry: indexers lag the ledger, so poll until a signer is discovered. +discover_backend() { + local backend="$1" code="$2" + layer "discover-$backend" "$code" + click_tid "backend-$backend" + local ok="" + for _ in $(seq 1 20); do + before="$(log_line_count)" + ab click '[data-testid="discover"]' >/dev/null 2>&1 || fail "discover control missing" + if wait_new_log "$before" "Discovered [1-9][0-9]* signer\(s\) via $backend" "Discover via $backend failed" 6; then + ok=1 + break + fi + sleep 3 + done + [[ -n "$ok" ]] || fail "no signers discovered via $backend after retries" + [[ "$(ab get count '[data-testid="discovered-list"] .signer' 2>/dev/null | tr -d '[:space:]')" != "0" ]] \ + || fail "discovered list empty for $backend" + echo " ✓ discovery via $backend returned signers" +} + +discover_backend "mercury" 50 +discover_backend "stellar-indexer" 51 + +layer "reverse-lookup" 52 +echo "Reverse lookup keyId → wallet(s)" +before="$(log_line_count)" +click_tid "reverse-lookup" +case "$(wait_new_log "$before" "matches connected ✓|keyId → [1-9][0-9]* wallet" "Reverse lookup .* failed" 60; echo $?)" in + 0) echo " ✓ reverse lookup resolved the wallet" ;; + 2) fail "reverse lookup hit failure pattern" ;; + *) fail "reverse lookup timed out" ;; +esac + +echo +echo "FULL e2e audit passed for contract $CONTRACT_ID" +echo " deploy tx: $CREATE_HASH" +echo " transfer tx: $TRANSFER_HASH" diff --git a/scripts/e2e/testnet-passkey-smoke.sh b/scripts/e2e/testnet-passkey-smoke.sh new file mode 100755 index 0000000..176b12b --- /dev/null +++ b/scripts/e2e/testnet-passkey-smoke.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Testnet smoke orchestration for the passkey-kit demo. +# +# - Starts the demo's Vite server (idempotently — an lsof guard skips the start +# and the kill if something is already listening on the port). +# - Opens a fresh agent-browser session and attaches a virtual WebAuthn +# authenticator (via agent-browser-webauthn-helper.mjs) for the child run. +# - EXIT trap kills ONLY the server this script started and closes ONLY this +# script's agent-browser session — no orphaned Chrome-for-Testing. +# +# MODE selects the child: `audit` (default, full e2e) or `probe` (backend-free +# WebAuthn sanity). Demo config comes from demo/.env.local (copy .env.example). +# Ported from smart-account-kit (project 34); adapted to the single passkey-kit demo. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DEMO_DIR="$ROOT_DIR/demo" + +DEV_HOST="${DEV_HOST:-127.0.0.1}" +DEMO_PORT="${DEMO_PORT:-5173}" +DEMO_URL="${DEMO_URL:-http://${DEV_HOST}:${DEMO_PORT}}" +SESSION_NAME="${SESSION_NAME:-passkey-kit-smoke-$(date +%s)}" +MODE="${MODE:-audit}" +case "$MODE" in + probe) E2E_CHILD="${E2E_CHILD:-$ROOT_DIR/scripts/e2e/webauthn-browser-probe.sh}" ;; + audit) E2E_CHILD="${E2E_CHILD:-$ROOT_DIR/scripts/e2e/browser-full-e2e-audit.sh}" ;; + *) E2E_CHILD="${E2E_CHILD:?unknown MODE (use probe|audit) and no E2E_CHILD override}" ;; +esac + +DEMO_SERVER_PID="" + +wait_for_url() { + local url="$1" + local attempts="${2:-60}" + for _ in $(seq 1 "$attempts"); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for $url" >&2 + return 1 +} + +start_demo_if_needed() { + local log_path="/tmp/passkey-kit-demo-smoke.log" + + if lsof -nP -iTCP:"$DEMO_PORT" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Demo already running on port $DEMO_PORT (leaving it running)" + return 0 + fi + + echo "Starting demo on ${DEV_HOST}:${DEMO_PORT}" + ( + cd "$DEMO_DIR" + pnpm --ignore-workspace exec vite --host "$DEV_HOST" --port "$DEMO_PORT" --strictPort + ) >"$log_path" 2>&1 & + DEMO_SERVER_PID=$! + wait_for_url "$DEMO_URL" || { + echo "Demo did not come up; last log lines:" >&2 + tail -n 20 "$log_path" >&2 || true + return 1 + } +} + +cleanup() { + local exit_code=$? + if [[ -n "$DEMO_SERVER_PID" ]]; then + kill "$DEMO_SERVER_PID" >/dev/null 2>&1 || true + fi + agent-browser --session "$SESSION_NAME" close >/dev/null 2>&1 || true + exit "$exit_code" +} +trap cleanup EXIT + +start_demo_if_needed + +# Fresh session + first navigation, so the helper can resolve the CDP url and +# attach the authenticator to a live page. +agent-browser --session "$SESSION_NAME" open "$DEMO_URL" >/dev/null + +echo "Running child under a virtual WebAuthn authenticator: $E2E_CHILD" +node "$ROOT_DIR/scripts/e2e/agent-browser-webauthn-helper.mjs" run \ + --session "$SESSION_NAME" --url "$DEMO_URL" -- \ + env \ + SESSION_NAME="$SESSION_NAME" \ + DEMO_URL="$DEMO_URL" \ + PROBE_URL="$DEMO_URL" \ + RELAYER_PROXY_URL="${RELAYER_PROXY_URL:-}" \ + INDEXER_PROXY_URL="${INDEXER_PROXY_URL:-}" \ + RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-}" \ + SKIP_ONCHAIN="${SKIP_ONCHAIN:-auto}" \ + bash "$E2E_CHILD" diff --git a/scripts/e2e/webauthn-browser-probe.sh b/scripts/e2e/webauthn-browser-probe.sh new file mode 100755 index 0000000..9ee68ff --- /dev/null +++ b/scripts/e2e/webauthn-browser-probe.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +# Isolated WebAuthn sanity probe: in a live agent-browser session (with a virtual +# authenticator attached by agent-browser-webauthn-helper.mjs), run a full +# navigator.credentials.create -> get -> crypto.subtle.verify round-trip and print +# the credential id + raw P-256 public key + verification result as JSON. +# +# This proves the CDP virtual authenticator + secp256r1 WebAuthn stack works +# before the full demo audit blames a real failure on the browser layer. +# +# Ported from smart-account-kit (project 34); generic. + +set -euo pipefail + +SESSION_NAME="${SESSION_NAME:?SESSION_NAME is required}" +PROBE_URL="${PROBE_URL:-http://localhost:5173}" + +agent-browser --session "$SESSION_NAME" open "$PROBE_URL" >/dev/null + +PROBE_JS="$(cat <<'JS' +(() => { + const toBase64Url = (bytes) => { + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + }; + + const toHex = (bytes) => + Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + document.body.innerHTML = '
    ';
    +  window.__probeResult = null;
    +
    +  document.getElementById("probe").onclick = async () => {
    +    try {
    +      const registrationChallenge = crypto.getRandomValues(new Uint8Array(32));
    +      const userId = crypto.getRandomValues(new Uint8Array(16));
    +      const credential = await navigator.credentials.create({
    +        publicKey: {
    +          challenge: registrationChallenge,
    +          rp: { id: location.hostname, name: "Probe" },
    +          user: {
    +            id: userId,
    +            name: `probe-${Date.now()}`,
    +            displayName: "Probe User",
    +          },
    +          pubKeyCredParams: [{ alg: -7, type: "public-key" }],
    +          authenticatorSelection: {
    +            residentKey: "preferred",
    +            userVerification: "preferred",
    +          },
    +          timeout: 60000,
    +        },
    +      });
    +
    +      const spki = credential.response.getPublicKey();
    +      const publicKey = await crypto.subtle.importKey(
    +        "spki",
    +        spki,
    +        { name: "ECDSA", namedCurve: "P-256" },
    +        true,
    +        ["verify"]
    +      );
    +      const rawPublicKey = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
    +
    +      const authChallenge = crypto.getRandomValues(new Uint8Array(32));
    +      const assertion = await navigator.credentials.get({
    +        publicKey: {
    +          challenge: authChallenge,
    +          rpId: location.hostname,
    +          allowCredentials: [{ id: credential.rawId, type: "public-key" }],
    +          userVerification: "preferred",
    +          timeout: 60000,
    +        },
    +      });
    +
    +      const authenticatorData = new Uint8Array(assertion.response.authenticatorData);
    +      const clientDataJSON = new Uint8Array(assertion.response.clientDataJSON);
    +      const signature = new Uint8Array(assertion.response.signature);
    +      const clientDataHash = new Uint8Array(await crypto.subtle.digest("SHA-256", clientDataJSON));
    +      const signedMessage = new Uint8Array(authenticatorData.length + clientDataHash.length);
    +      signedMessage.set(authenticatorData);
    +      signedMessage.set(clientDataHash, authenticatorData.length);
    +
    +      const verified = await crypto.subtle.verify(
    +        { name: "ECDSA", hash: "SHA-256" },
    +        publicKey,
    +        signature,
    +        signedMessage
    +      );
    +
    +      window.__probeResult = {
    +        credentialId: toBase64Url(new Uint8Array(credential.rawId)),
    +        rawPublicKeyHex: toHex(rawPublicKey),
    +        verified,
    +      };
    +      document.getElementById("result").textContent = JSON.stringify(window.__probeResult, null, 2);
    +    } catch (error) {
    +      window.__probeResult = {
    +        error: error instanceof Error ? error.message : String(error),
    +      };
    +      document.getElementById("result").textContent = JSON.stringify(window.__probeResult, null, 2);
    +    }
    +  };
    +
    +  return "ready";
    +})()
    +JS
    +)"
    +
    +agent-browser --session "$SESSION_NAME" eval "$PROBE_JS" >/dev/null
    +agent-browser --session "$SESSION_NAME" click "#probe" >/dev/null
    +
    +for _ in $(seq 1 60); do
    +  RESULT="$(agent-browser --session "$SESSION_NAME" eval "JSON.stringify(window.__probeResult)" | tr -d '\r')"
    +  if [[ "$RESULT" != "null" ]]; then
    +    printf '%s\n' "$RESULT"
    +    if printf '%s\n' "$RESULT" | grep -q '"verified":true'; then
    +      exit 0
    +    fi
    +    echo "Probe completed but verification did not pass" >&2
    +    exit 1
    +  fi
    +  sleep 1
    +done
    +
    +echo "Probe timed out" >&2
    +exit 1
    diff --git a/scripts/sync-version.js b/scripts/sync-version.js
    new file mode 100644
    index 0000000..0b3f6d9
    --- /dev/null
    +++ b/scripts/sync-version.js
    @@ -0,0 +1,33 @@
    +#!/usr/bin/env node
    +
    +/**
    + * Syncs the version + name from package.json into src/version.ts.
    + *
    + * Runs automatically before `build` (prebuild), so the compiled package always
    + * reports the version it was published as.
    + */
    +
    +import { readFileSync, writeFileSync } from "node:fs";
    +import { fileURLToPath } from "node:url";
    +import { dirname, join } from "node:path";
    +
    +const __dirname = dirname(fileURLToPath(import.meta.url));
    +const rootDir = join(__dirname, "..");
    +
    +const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf-8"));
    +
    +const contents = `/**
    + * Package version — auto-generated from package.json.
    + * @packageDocumentation
    + */
    +
    +// This file is auto-generated. Do not edit manually.
    +// Run \`pnpm run sync-version\` (or \`pnpm build\`) to update.
    +
    +export const VERSION = "${pkg.version}";
    +export const NAME = "${pkg.name}";
    +`;
    +
    +writeFileSync(join(rootDir, "src/version.ts"), contents);
    +
    +console.log(`✓ Synced version to ${pkg.version}`);
    diff --git a/scripts/verify-esm.mjs b/scripts/verify-esm.mjs
    new file mode 100644
    index 0000000..3058bb1
    --- /dev/null
    +++ b/scripts/verify-esm.mjs
    @@ -0,0 +1,58 @@
    +#!/usr/bin/env node
    +/**
    + * Node native-ESM import smoke test.
    + *
    + * Regression gate for a real class of packaging bug: `tsc` under
    + * `moduleResolution: "bundler"` emits extensionless relative imports
    + * (`from "./kit"`) that Node's native ESM loader rejects
    + * (`ERR_MODULE_NOT_FOUND` / `ERR_UNSUPPORTED_DIR_IMPORT`). Such a package imports
    + * fine under a bundler (Vite/webpack) but crashes for any plain Node consumer —
    + * a gap a Vite-only demo e2e never exercises.
    + *
    + * This imports the built package the way a Node consumer would — by name, via
    + * package self-reference through `exports` — and asserts key exports resolve. It
    + * runs as part of `pnpm build`, so a regression fails the build (and release).
    + */
    +const failures = [];
    +
    +async function check(specifier, expectedExports) {
    +  try {
    +    const mod = await import(specifier);
    +    for (const name of expectedExports) {
    +      if (!(name in mod)) {
    +        failures.push(`${specifier}: missing export "${name}"`);
    +      }
    +    }
    +    console.log(`  ok   ${specifier} (${expectedExports.join(", ")})`);
    +  } catch (err) {
    +    const detail = `${err.code ?? ""} ${String(err.message).split("\n")[0]}`;
    +    failures.push(`${specifier}: ${detail}`);
    +    console.log(`  FAIL ${specifier}: ${detail}`);
    +  }
    +}
    +
    +console.log("Node ESM import smoke test:");
    +await check("passkey-kit", [
    +  "PasskeyKit",
    +  "PasskeyKitError",
    +  "PasskeyEventEmitter",
    +  "deriveContractAddress",
    +  "buildTokenTransferHostFunction",
    +]);
    +await check("passkey-kit/storage", [
    +  "MemoryStorage",
    +  "LocalStorageAdapter",
    +  "IndexedDBStorage",
    +]);
    +await check("passkey-kit/server", ["PasskeyServer", "RelayerClient"]);
    +
    +if (failures.length) {
    +  console.error("\nESM import smoke test FAILED:");
    +  for (const f of failures) console.error("  - " + f);
    +  console.error(
    +    "\nThe built package cannot be imported by a Node ESM consumer. This usually\n" +
    +      "means a relative import is missing its explicit .js extension (NodeNext)."
    +  );
    +  process.exit(1);
    +}
    +console.log("\nESM import smoke test passed.");
    diff --git a/src/base.ts b/src/base.ts
    deleted file mode 100644
    index c1b0821..0000000
    --- a/src/base.ts
    +++ /dev/null
    @@ -1,18 +0,0 @@
    -import { Server } from "@stellar/stellar-sdk/minimal/rpc"
    -
    -// TODO consider adding support for a signAuthEntry method that conforms to the ed25519 signature scheme of this passkey interface
    -// once we do that we can clean the code a little with the `xdr.HashIdPreimage.envelopeTypeSorobanAuthorization` stuff
    -// ... note I've re-read the above and I've currently got no clue what this is asking for. Maybe check git-blame for when it was added to try and find some context
    -// actually this is just talking about adding support for signing transactions with an ed25519 key as well as a passkey. Simple enough
    -
    -export class PasskeyBase {
    -    public rpcUrl: string | undefined
    -    public rpc: Server | undefined
    -    
    -    constructor(rpcUrl?: string) {
    -        if (rpcUrl) {
    -            this.rpcUrl = rpcUrl
    -            this.rpc = new Server(rpcUrl)
    -        }
    -    }
    -}
    \ No newline at end of file
    diff --git a/src/base64url.ts b/src/base64url.ts
    new file mode 100644
    index 0000000..c1c108a
    --- /dev/null
    +++ b/src/base64url.ts
    @@ -0,0 +1,19 @@
    +/**
    + * `base64url` interop shim.
    + *
    + * The `base64url` package ships ESM-style type declarations
    + * (`export default base64url`) over a CommonJS runtime. Under NodeNext module
    + * resolution the default import is typed as the module namespace object rather
    + * than the callable `Base64Url`, so both calling it (`base64url(...)`) and
    + * member access (`base64url.toBuffer(...)`) fail to type-check. At runtime
    + * `esModuleInterop` already yields the callable, so this is a type-only
    + * correction, centralized here rather than repeated at every call site.
    + *
    + * Every module in the kit imports base64url from here, never directly from the
    + * package, so the interop fix lives in exactly one place.
    + */
    +import base64urlDefault, { type Base64Url } from "base64url";
    +
    +const base64url = base64urlDefault as unknown as Base64Url;
    +
    +export default base64url;
    diff --git a/src/constants.ts b/src/constants.ts
    new file mode 100644
    index 0000000..8dcbf2e
    --- /dev/null
    +++ b/src/constants.ts
    @@ -0,0 +1,128 @@
    +/**
    + * Constants used throughout the Passkey Kit SDK.
    + *
    + * Cryptographic sizes mirror the smart-wallet contract; the deployer seed is
    + * load-bearing for deterministic contract-id derivation (see below).
    + *
    + * @packageDocumentation
    + */
    +
    +// ============================================================================
    +// Cryptographic constants (mirror the smart-wallet contract)
    +// ============================================================================
    +
    +/** Size of an uncompressed secp256r1 (P-256) public key, in bytes. */
    +export const SECP256R1_PUBLIC_KEY_SIZE = 65;
    +
    +/** First byte of an uncompressed secp256r1 public key (0x04). */
    +export const UNCOMPRESSED_PUBKEY_PREFIX = 0x04;
    +
    +/** Size of an Ed25519 public key, in bytes. */
    +export const ED25519_PUBLIC_KEY_SIZE = 32;
    +
    +/** Size of an Ed25519 signature, in bytes. */
    +export const ED25519_SIGNATURE_SIZE = 64;
    +
    +/**
    + * Order (n) of the secp256r1 (P-256) curve, used to enforce low-S signatures.
    + * @see https://github.com/stellar/stellar-protocol/discussions/1435
    + */
    +export const SECP256R1_CURVE_ORDER = BigInt(
    +  "0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"
    +);
    +
    +// ============================================================================
    +// WebAuthn configuration
    +// ============================================================================
    +
    +/** Default timeout for WebAuthn ceremonies, in milliseconds. */
    +export const WEBAUTHN_TIMEOUT_MS = 60_000;
    +
    +/** Length, in bytes, of a randomly generated WebAuthn challenge. */
    +export const WEBAUTHN_CHALLENGE_SIZE = 32;
    +
    +// ============================================================================
    +// Deployer
    +// ============================================================================
    +
    +/**
    + * Seed for the default (deterministic) deployer keypair.
    + *
    + * The deployer keypair is derived as
    + * `Keypair.fromRawEd25519Seed(sha256(utf8(DEFAULT_DEPLOYER_SEED)))`. Deriving it
    + * from a fixed, well-known string makes smart-wallet contract IDs reproducible
    + * across clients from a passkey credential ID alone (`salt = sha256(keyId)`,
    + * `deployer = this keypair`), which is exactly what the indexer reverse-lookup
    + * (keyId → contract) depends on.
    + *
    + * This value MUST remain `"kalepail"`: every wallet ever deployed by passkey-kit
    + * used this deployer, and the derivation is load-bearing for discovery. The
    + * deployer only pays fees and salts the deploy — it never controls the wallet —
    + * but it IS a shared, publicly-derivable keypair. Provide your own
    + * `deploySource` secret for a dedicated fee payer (note: a different deployer
    + * changes the derived contract addresses and breaks keyId → contract discovery).
    + */
    +export const DEFAULT_DEPLOYER_SEED = "kalepail";
    +
    +// ============================================================================
    +// Transaction timeouts
    +// ============================================================================
    +
    +/**
    + * Default transaction timeout, in seconds.
    + *
    + * The OpenZeppelin Relayer requires a time-bound of <= 30s on submitted
    + * transactions, so the kit defaults to that.
    + */
    +export const DEFAULT_TIMEOUT_SECONDS = 30;
    +
    +// ============================================================================
    +// Storage configuration
    +// ============================================================================
    +
    +/** Default IndexedDB database name for passkey credential storage. */
    +export const DB_NAME = "passkey-kit";
    +
    +/** Current IndexedDB schema version. */
    +export const DB_VERSION = 1;
    +
    +/** IndexedDB object-store name for passkey credentials. */
    +export const IDB_STORE_CREDENTIALS = "credentials";
    +
    +/** IndexedDB index name for contract-id lookups. */
    +export const IDB_INDEX_CONTRACT_ID = "contractId";
    +
    +/** localStorage key under which passkey credentials are persisted. */
    +export const LOCALSTORAGE_CREDENTIALS_KEY = "passkey-kit:credentials";
    +
    +// ============================================================================
    +// Relayer
    +// ============================================================================
    +
    +/**
    + * Default relayer request timeout, in milliseconds (6 minutes).
    + *
    + * Long enough to absorb testnet channel-account funding retries after a network
    + * reset. Mainnet requests return quickly; this only bounds the maximum wait.
    + */
    +export const DEFAULT_RELAYER_TIMEOUT_MS = 360_000;
    +
    +/** OpenZeppelin managed Channels relayer endpoint (mainnet). */
    +export const CHANNELS_MAINNET_URL = "https://channels.openzeppelin.com";
    +
    +/** OpenZeppelin managed Channels relayer endpoint (testnet). */
    +export const CHANNELS_TESTNET_URL = "https://channels.openzeppelin.com/testnet";
    +
    +// ============================================================================
    +// Indexer
    +// ============================================================================
    +
    +/** Default indexer request timeout, in milliseconds. */
    +export const DEFAULT_INDEXER_TIMEOUT_MS = 10_000;
    +
    +// ============================================================================
    +// Networks
    +// ============================================================================
    +
    +/** Stellar Friendbot URL for testnet funding. */
    +export const FRIENDBOT_URL = "https://friendbot.stellar.org";
    diff --git a/src/contract-errors.test.ts b/src/contract-errors.test.ts
    new file mode 100644
    index 0000000..f3edcbe
    --- /dev/null
    +++ b/src/contract-errors.test.ts
    @@ -0,0 +1,145 @@
    +import { describe, expect, it } from "vitest";
    +import { Errors as BindingsErrors } from "passkey-kit-sdk";
    +import {
    +  CONTRACT_ERROR_REGISTRY,
    +  contractErrorFromCode,
    +  decodeContractError,
    +  failedTransaction,
    +  simulationFailure,
    +  submissionFailure,
    +} from "./contract-errors.js";
    +import {
    +  ContractError,
    +  SimulationError,
    +  SubmissionError,
    +  ValidationError,
    +  PasskeyKitErrorCode,
    +} from "./errors.js";
    +
    +describe("decodeContractError", () => {
    +  it("decodes a v1 contract error from a diagnostic string", () => {
    +    const error = decodeContractError(
    +      "HostError: Error(Contract, #102) ... SignerExpired"
    +    );
    +    expect(error).toBeInstanceOf(ContractError);
    +    expect(error?.contractCode).toBe(102);
    +    expect(error?.contractErrorName).toBe("SignerExpired");
    +    expect(error?.family).toBe("SmartWallet");
    +    expect(error?.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR);
    +  });
    +
    +  it.each([
    +    [100, "SignerNotFound"],
    +    [101, "SignerAlreadyExists"],
    +    [110, "MissingContext"],
    +    [122, "ClientDataJsonChallengeIncorrect"],
    +    [123, "InvalidWebAuthnType"],
    +    [125, "UserPresenceRequired"],
    +  ])("decodes v1 #%i as %s", (code, name) => {
    +    const error = decodeContractError(`Error(Contract, #${code})`);
    +    expect(error?.contractCode).toBe(code);
    +    expect(error?.contractErrorName).toBe(name);
    +    expect(error?.family).toBe("SmartWallet");
    +  });
    +
    +  it("still decodes legacy (< 100) codes as SmartWalletLegacy", () => {
    +    const error = decodeContractError("Error(Contract, #4)");
    +    expect(error?.contractErrorName).toBe("SignerExpired");
    +    expect(error?.family).toBe("SmartWalletLegacy");
    +  });
    +
    +  it("handles whitespace variations in the marker", () => {
    +    expect(decodeContractError("Error(Contract,#101)")?.contractErrorName).toBe(
    +      "SignerAlreadyExists"
    +    );
    +    expect(
    +      decodeContractError("Error(Contract,   #101)")?.contractErrorName
    +    ).toBe("SignerAlreadyExists");
    +  });
    +
    +  it("decodes from an Error object's message", () => {
    +    const error = decodeContractError(new Error("Error(Contract, #111)"));
    +    expect(error?.contractErrorName).toBe("SignatureKeyValueMismatch");
    +  });
    +
    +  it("returns null when there is no contract marker", () => {
    +    expect(decodeContractError("some unrelated error")).toBeNull();
    +    expect(decodeContractError("")).toBeNull();
    +    expect(decodeContractError(null)).toBeNull();
    +    expect(decodeContractError(undefined)).toBeNull();
    +  });
    +
    +  it("returns null for an unknown contract code", () => {
    +    expect(decodeContractError("Error(Contract, #9999)")).toBeNull();
    +  });
    +});
    +
    +describe("contractErrorFromCode", () => {
    +  it("builds a ContractError for a known code", () => {
    +    const error = contractErrorFromCode(120);
    +    expect(error).toBeInstanceOf(ContractError);
    +    expect(error?.contractErrorName).toBe("ClientDataJsonTooLarge");
    +  });
    +
    +  it("returns null for an unknown code", () => {
    +    expect(contractErrorFromCode(1234)).toBeNull();
    +  });
    +});
    +
    +describe("CONTRACT_ERROR_REGISTRY", () => {
    +  it("stays in sync with the generated bindings' Errors map", () => {
    +    // The bindings' Errors map is the source of truth; a regen must not drift
    +    // from this registry (this is the guard the B4 resync relies on).
    +    const bindingsEntries = Object.entries(
    +      BindingsErrors as Record
    +    );
    +    expect(bindingsEntries.length).toBeGreaterThan(0);
    +
    +    for (const [codeStr, { message: name }] of bindingsEntries) {
    +      const code = Number(codeStr);
    +      const info = CONTRACT_ERROR_REGISTRY[code];
    +      expect(info, `registry missing SmartWallet code ${code}`).toBeDefined();
    +      expect(info.name).toBe(name);
    +      expect(info.family).toBe("SmartWallet");
    +    }
    +  });
    +
    +  it("keeps every entry's key aligned with its code field", () => {
    +    for (const [key, info] of Object.entries(CONTRACT_ERROR_REGISTRY)) {
    +      expect(info.code).toBe(Number(key));
    +    }
    +  });
    +});
    +
    +describe("transaction failure helpers", () => {
    +  it("failedTransaction exposes error.code and omits an empty hash", () => {
    +    const failure = failedTransaction(new ValidationError("bad input"));
    +    expect(failure.success).toBe(false);
    +    expect(failure.error).toBeInstanceOf(ValidationError);
    +    expect(failure.error.code).toBe(PasskeyKitErrorCode.INVALID_INPUT);
    +    expect(failure.hash).toBeUndefined();
    +  });
    +
    +  it("failedTransaction preserves a provided hash", () => {
    +    const failure = failedTransaction(new SubmissionError("boom"), "abc123");
    +    expect(failure.hash).toBe("abc123");
    +  });
    +
    +  it("simulationFailure decodes a contract error when present", () => {
    +    const failure = simulationFailure("Error(Contract, #102)");
    +    expect(failure.error).toBeInstanceOf(ContractError);
    +    expect(failure.error.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR);
    +  });
    +
    +  it("simulationFailure falls back to SimulationError", () => {
    +    const failure = simulationFailure("host is unreachable");
    +    expect(failure.error).toBeInstanceOf(SimulationError);
    +    expect(failure.error.message).toContain("host is unreachable");
    +  });
    +
    +  it("submissionFailure falls back to SubmissionError and keeps the hash", () => {
    +    const failure = submissionFailure("rejected", "tx-hash");
    +    expect(failure.error).toBeInstanceOf(SubmissionError);
    +    expect(failure.hash).toBe("tx-hash");
    +  });
    +});
    diff --git a/src/contract-errors.ts b/src/contract-errors.ts
    new file mode 100644
    index 0000000..1ceb6de
    --- /dev/null
    +++ b/src/contract-errors.ts
    @@ -0,0 +1,181 @@
    +/**
    + * Contract error decoding.
    + *
    + * Maps on-chain smart-wallet failure codes (surfaced in simulation/submission
    + * diagnostics as `Error(Contract, #N)`) to typed {@link ContractError}s with the
    + * enum variant name and a human-readable message.
    + *
    + * The `SmartWallet` family is the source of truth in the generated bindings'
    + * `Errors` map; `contract-errors.test.ts` asserts this registry stays in sync
    + * with it, so a bindings regen surfaces any drift.
    + *
    + * The v1 contract deliberately renumbered its error space to 100-129 so it is
    + * disjoint from the legacy (pre-1.0) 1-9 range: a decoded code < 100 means the
    + * client is talking to a legacy wallet. Both ranges are kept here so errors
    + * from legacy deployed wallets still decode (family `SmartWalletLegacy`).
    + *
    + * @packageDocumentation
    + */
    +
    +import {
    +  ContractError,
    +  SimulationError,
    +  SubmissionError,
    +  PasskeyKitError,
    +} from "./errors.js";
    +import type { TransactionFailure } from "./types.js";
    +
    +/**
    + * Contract families that expose custom error codes.
    + */
    +export type ContractErrorFamily = "SmartWallet" | "SmartWalletLegacy";
    +
    +/**
    + * A single contract error entry: code, enum variant name, family, and a
    + * human-readable message.
    + */
    +export interface ContractErrorInfo {
    +  code: number;
    +  name: string;
    +  family: ContractErrorFamily;
    +  message: string;
    +}
    +
    +function entry(
    +  code: number,
    +  name: string,
    +  family: ContractErrorFamily,
    +  message: string
    +): [number, ContractErrorInfo] {
    +  return [code, { code, name, family, message }];
    +}
    +
    +/**
    + * Registry of every known contract error code, keyed by numeric code.
    + */
    +export const CONTRACT_ERROR_REGISTRY: Readonly<
    +  Record
    +> = Object.freeze(
    +  Object.fromEntries([
    +    // --- v1 SmartWallet (100-129) — source of truth in the bindings Errors map ---
    +    // 100-109: signer storage / management
    +    entry(100, "SignerNotFound", "SmartWallet", "The requested signer does not exist on this smart wallet."),
    +    entry(101, "SignerAlreadyExists", "SmartWallet", "add_signer was called with a signer key that already exists."),
    +    entry(102, "SignerExpired", "SmartWallet", "The signer's expiration timestamp is in the past."),
    +    // 110-119: auth (__check_auth)
    +    entry(110, "MissingContext", "SmartWallet", "No signer in the signatures map is permitted to authorize one of the requested auth contexts."),
    +    entry(111, "SignatureKeyValueMismatch", "SmartWallet", "A signature's variant does not match the stored signer it claims to be for."),
    +    // 120-129: WebAuthn (secp256r1) verification
    +    entry(120, "ClientDataJsonTooLarge", "SmartWallet", "clientDataJSON exceeds the 1024-byte parse buffer."),
    +    entry(121, "ClientDataJsonParseError", "SmartWallet", "clientDataJSON is not parseable JSON (or is missing required fields)."),
    +    entry(122, "ClientDataJsonChallengeIncorrect", "SmartWallet", "The clientDataJSON challenge does not match the signature payload; this binding MUST NOT be weakened."),
    +    entry(123, "InvalidWebAuthnType", "SmartWallet", 'clientDataJSON `type` is not "webauthn.get".'),
    +    entry(124, "InvalidAuthenticatorData", "SmartWallet", "authenticatorData is shorter than the WebAuthn minimum of 37 bytes."),
    +    entry(125, "UserPresenceRequired", "SmartWallet", "The authenticator did not set the User Present (UP) flag."),
    +
    +    // --- Legacy (pre-1.0) 1-9 — kept so errors from legacy deployed wallets decode ---
    +    entry(1, "NotFound", "SmartWalletLegacy", "[legacy] The specified signer was not found."),
    +    entry(2, "AlreadyExists", "SmartWalletLegacy", "[legacy] The signer already exists on this wallet."),
    +    entry(3, "MissingContext", "SmartWalletLegacy", "[legacy] The signature did not cover a required authorization context."),
    +    entry(4, "SignerExpired", "SmartWalletLegacy", "[legacy] The signer has expired."),
    +    entry(5, "FailedSignerLimits", "SmartWalletLegacy", "[legacy] The signer is not permitted to authorize this context by its limits."),
    +    entry(6, "FailedPolicySignerLimits", "SmartWalletLegacy", "[legacy] A policy signer's limits rejected this context."),
    +    entry(7, "SignatureKeyValueMismatch", "SmartWalletLegacy", "[legacy] A signature entry's key and value did not match."),
    +    entry(8, "ClientDataJsonChallengeIncorrect", "SmartWalletLegacy", "[legacy] The WebAuthn clientDataJSON challenge did not match the signature payload."),
    +    entry(9, "JsonParseError", "SmartWalletLegacy", "[legacy] The WebAuthn clientDataJSON could not be parsed."),
    +  ])
    +);
    +
    +/**
    + * Matches the host's rendering of a contract error, e.g. `Error(Contract, #4)`.
    + */
    +const CONTRACT_ERROR_PATTERN = /Error\(Contract,\s*#(\d+)\)/;
    +
    +function diagnosticToString(diagnostic: unknown): string {
    +  if (diagnostic == null) return "";
    +  if (typeof diagnostic === "string") return diagnostic;
    +  if (diagnostic instanceof Error) return diagnostic.message;
    +  try {
    +    return JSON.stringify(diagnostic);
    +  } catch {
    +    return String(diagnostic);
    +  }
    +}
    +
    +/**
    + * Build a {@link ContractError} for a known contract code, or `null` if the code
    + * is not in the registry.
    + */
    +export function contractErrorFromCode(
    +  code: number,
    +  context?: Record
    +): ContractError | null {
    +  const info = CONTRACT_ERROR_REGISTRY[code];
    +  if (!info) return null;
    +  return new ContractError(info.code, info.name, info.family, info.message, {
    +    context,
    +  });
    +}
    +
    +/**
    + * Decode a simulation/submission diagnostic into a typed {@link ContractError}.
    + *
    + * Scans the (stringified) diagnostic for an `Error(Contract, #N)` marker and
    + * looks the code up in {@link CONTRACT_ERROR_REGISTRY}. Returns `null` when no
    + * contract code is present or the code is unknown, letting callers fall back to
    + * a generic {@link SimulationError}/{@link SubmissionError}.
    + */
    +export function decodeContractError(diagnostic: unknown): ContractError | null {
    +  const text = diagnosticToString(diagnostic);
    +  const match = text.match(CONTRACT_ERROR_PATTERN);
    +  if (!match) return null;
    +  const code = Number.parseInt(match[1]!, 10);
    +  return contractErrorFromCode(code, { diagnostic: text });
    +}
    +
    +/**
    + * Build a {@link TransactionFailure} from an already-typed error.
    + */
    +export function failedTransaction(
    +  error: PasskeyKitError,
    +  hash?: string
    +): TransactionFailure {
    +  return {
    +    success: false,
    +    error,
    +    ...(hash ? { hash } : {}),
    +  };
    +}
    +
    +/**
    + * Build a {@link TransactionFailure} from a simulation diagnostic, decoding a
    + * contract error when present and otherwise wrapping it in a
    + * {@link SimulationError}.
    + */
    +export function simulationFailure(
    +  diagnostic: unknown,
    +  hash?: string
    +): TransactionFailure {
    +  const error =
    +    decodeContractError(diagnostic) ??
    +    new SimulationError(diagnosticToString(diagnostic) || "Simulation failed");
    +  return failedTransaction(error, hash);
    +}
    +
    +/**
    + * Build a {@link TransactionFailure} from a submission diagnostic, decoding a
    + * contract error when present and otherwise wrapping it in a
    + * {@link SubmissionError}.
    + */
    +export function submissionFailure(
    +  diagnostic: unknown,
    +  hash?: string
    +): TransactionFailure {
    +  const error =
    +    decodeContractError(diagnostic) ??
    +    new SubmissionError(
    +      diagnosticToString(diagnostic) || "Submission failed",
    +      hash
    +    );
    +  return failedTransaction(error, hash);
    +}
    diff --git a/src/errors.test.ts b/src/errors.test.ts
    new file mode 100644
    index 0000000..9d5e340
    --- /dev/null
    +++ b/src/errors.test.ts
    @@ -0,0 +1,91 @@
    +import { describe, expect, it } from "vitest";
    +import {
    +  PasskeyKitError,
    +  PasskeyKitErrorCode,
    +  ContractError,
    +  WalletNotConnectedError,
    +  WebAuthnError,
    +  ValidationError,
    +  wrapError,
    +} from "./errors.js";
    +
    +describe("PasskeyKitError", () => {
    +  it("carries code, context, and cause", () => {
    +    const cause = new Error("root cause");
    +    const err = new PasskeyKitError("boom", PasskeyKitErrorCode.INVALID_CONFIG, {
    +      context: { foo: "bar" },
    +      cause,
    +    });
    +    expect(err.code).toBe(PasskeyKitErrorCode.INVALID_CONFIG);
    +    expect(err.context).toEqual({ foo: "bar" });
    +    expect(err.cause).toBe(cause);
    +    expect(err).toBeInstanceOf(Error);
    +  });
    +
    +  it("formats a detailed string with code, context, and cause", () => {
    +    const err = new PasskeyKitError("boom", PasskeyKitErrorCode.INVALID_CONFIG, {
    +      context: { foo: "bar" },
    +      cause: new Error("underlying"),
    +    });
    +    const detailed = err.toDetailedString();
    +    expect(detailed).toContain(`[${PasskeyKitErrorCode.INVALID_CONFIG}]`);
    +    expect(detailed).toContain("boom");
    +    expect(detailed).toContain("foo");
    +    expect(detailed).toContain("Caused by: underlying");
    +  });
    +});
    +
    +describe("error subclasses", () => {
    +  it("WalletNotConnectedError references the operation", () => {
    +    const err = new WalletNotConnectedError("add a signer");
    +    expect(err.code).toBe(PasskeyKitErrorCode.WALLET_NOT_CONNECTED);
    +    expect(err.message).toContain("add a signer");
    +    expect(err.name).toBe("WalletNotConnectedError");
    +  });
    +
    +  it("WebAuthnError carries its cause", () => {
    +    const cause = new Error("user cancelled");
    +    const err = new WebAuthnError(
    +      "registration failed",
    +      PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED,
    +      cause
    +    );
    +    expect(err.cause).toBe(cause);
    +    expect(err.code).toBe(PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED);
    +  });
    +
    +  it("ContractError exposes the raw contract code, name, and family", () => {
    +    const err = new ContractError(4, "SignerExpired", "SmartWallet", "expired");
    +    expect(err.code).toBe(PasskeyKitErrorCode.CONTRACT_ERROR);
    +    expect(err.contractCode).toBe(4);
    +    expect(err.contractErrorName).toBe("SignerExpired");
    +    expect(err.family).toBe("SmartWallet");
    +    expect(err.context).toMatchObject({
    +      contractCode: 4,
    +      contractErrorName: "SignerExpired",
    +    });
    +  });
    +});
    +
    +describe("wrapError", () => {
    +  it("passes through an existing PasskeyKitError", () => {
    +    const original = new ValidationError("bad");
    +    expect(wrapError(original)).toBe(original);
    +  });
    +
    +  it("wraps a plain Error, preserving message and cause", () => {
    +    const plain = new Error("kaboom");
    +    const wrapped = wrapError(plain, PasskeyKitErrorCode.SIMULATION_FAILED);
    +    expect(wrapped).toBeInstanceOf(PasskeyKitError);
    +    expect(wrapped.message).toBe("kaboom");
    +    expect(wrapped.code).toBe(PasskeyKitErrorCode.SIMULATION_FAILED);
    +    expect(wrapped.cause).toBe(plain);
    +  });
    +
    +  it("wraps a non-Error value with its string form", () => {
    +    const wrapped = wrapError("just a string");
    +    expect(wrapped.message).toBe("just a string");
    +    expect(wrapped.code).toBe(PasskeyKitErrorCode.INVALID_INPUT);
    +    expect(wrapped.cause).toBeUndefined();
    +  });
    +});
    diff --git a/src/errors.ts b/src/errors.ts
    new file mode 100644
    index 0000000..de5c018
    --- /dev/null
    +++ b/src/errors.ts
    @@ -0,0 +1,349 @@
    +/**
    + * Typed error model for the Passkey Kit SDK.
    + *
    + * Every error the kit throws is a {@link PasskeyKitError} (or subclass) carrying
    + * a numeric {@link PasskeyKitErrorCode}, optional structured `context`, and an
    + * optional `cause`. Callers branch on `error.code` (or `instanceof`) instead of
    + * matching on message strings.
    + *
    + * Submission methods are the one exception to "throw on failure": they return a
    + * discriminated {@link import("./types.js").TransactionResult} whose failure arm
    + * carries one of these typed errors (see `contract-errors.ts`).
    + *
    + * @packageDocumentation
    + */
    +
    +/**
    + * Error codes for Passkey Kit operations, grouped by concern.
    + *
    + * Ranges (per SDK architecture spec #599 §5, extended with validation/storage):
    + * - 1xxx configuration
    + * - 2xxx wallet state
    + * - 3xxx WebAuthn
    + * - 4xxx signing
    + * - 5xxx transaction (simulation/submission)
    + * - 6xxx indexer
    + * - 7xxx relayer
    + * - 8xxx validation
    + * - 9xxx storage
    + * - 10000 contract-level failure decoded from on-chain diagnostics (the raw
    + *   contract code 1-9 is carried separately on {@link ContractError}).
    + */
    +export enum PasskeyKitErrorCode {
    +  // Configuration (1xxx)
    +  INVALID_CONFIG = 1001,
    +  MISSING_CONFIG = 1002,
    +
    +  // Wallet state (2xxx)
    +  WALLET_NOT_CONNECTED = 2001,
    +  WALLET_ALREADY_EXISTS = 2002,
    +  WALLET_NOT_FOUND = 2003,
    +  WALLET_OWNERSHIP_MISMATCH = 2004,
    +
    +  // WebAuthn (3xxx)
    +  WEBAUTHN_REGISTRATION_FAILED = 3001,
    +  WEBAUTHN_AUTHENTICATION_FAILED = 3002,
    +  WEBAUTHN_NOT_SUPPORTED = 3003,
    +  WEBAUTHN_CANCELLED = 3004,
    +  PUBLIC_KEY_EXTRACTION_FAILED = 3005,
    +
    +  // Signing (4xxx)
    +  SIGNING_FAILED = 4001,
    +  SIGNER_NOT_FOUND = 4002,
    +  INVALID_SIGNER = 4003,
    +  INVALID_SIGNATURE_EXPIRATION = 4004,
    +  UNSUPPORTED_CREDENTIALS = 4005,
    +
    +  // Transaction (5xxx)
    +  SIMULATION_FAILED = 5001,
    +  SUBMISSION_FAILED = 5002,
    +  TRANSACTION_TIMEOUT = 5003,
    +  RESTORE_REQUIRED = 5004,
    +
    +  // Indexer (6xxx)
    +  INDEXER_NOT_CONFIGURED = 6001,
    +  INDEXER_REQUEST_FAILED = 6002,
    +
    +  // Relayer (7xxx)
    +  RELAYER_NOT_CONFIGURED = 7001,
    +  RELAYER_REQUEST_FAILED = 7002,
    +  /** Submitted but not yet in a terminal state — keep polling getTransaction. */
    +  RELAYER_PENDING = 7003,
    +
    +  // Validation (8xxx)
    +  INVALID_ADDRESS = 8001,
    +  INVALID_AMOUNT = 8002,
    +  INVALID_INPUT = 8003,
    +  INVALID_PUBLIC_KEY = 8004,
    +
    +  // Storage (9xxx)
    +  STORAGE_READ_FAILED = 9001,
    +  STORAGE_WRITE_FAILED = 9002,
    +
    +  // Contract-level failure decoded from on-chain diagnostics (10xxx).
    +  // The raw contract code (1-9) is carried separately on ContractError so the
    +  // SDK code space never collides with the contract's own small code range.
    +  CONTRACT_ERROR = 10000,
    +}
    +
    +/**
    + * Base error class for all Passkey Kit errors.
    + */
    +export class PasskeyKitError extends Error {
    +  /** Error code for programmatic handling. */
    +  readonly code: PasskeyKitErrorCode;
    +
    +  /** Structured context about the error. */
    +  readonly context?: Record;
    +
    +  /** The underlying error that caused this one, if any. */
    +  readonly cause?: Error;
    +
    +  constructor(
    +    message: string,
    +    code: PasskeyKitErrorCode,
    +    options?: {
    +      context?: Record;
    +      cause?: Error;
    +    }
    +  ) {
    +    super(message);
    +    this.name = "PasskeyKitError";
    +    this.code = code;
    +    this.context = options?.context;
    +    this.cause = options?.cause;
    +
    +    // Maintain a proper stack trace in V8 environments.
    +    if (Error.captureStackTrace) {
    +      Error.captureStackTrace(this, PasskeyKitError);
    +    }
    +  }
    +
    +  /** Format the error with its code, context, and cause for logging. */
    +  toDetailedString(): string {
    +    let msg = `[${this.code}] ${this.message}`;
    +    if (this.context) {
    +      msg += `\nContext: ${JSON.stringify(this.context, null, 2)}`;
    +    }
    +    if (this.cause) {
    +      msg += `\nCaused by: ${this.cause.message}`;
    +    }
    +    return msg;
    +  }
    +}
    +
    +/** Thrown when the client is misconfigured. */
    +export class ConfigurationError extends PasskeyKitError {
    +  constructor(
    +    message: string,
    +    code:
    +      | PasskeyKitErrorCode.INVALID_CONFIG
    +      | PasskeyKitErrorCode.MISSING_CONFIG = PasskeyKitErrorCode.INVALID_CONFIG,
    +    context?: Record
    +  ) {
    +    super(message, code, { context });
    +    this.name = "ConfigurationError";
    +  }
    +}
    +
    +/** Thrown when an operation requires a connected wallet but none is connected. */
    +export class WalletNotConnectedError extends PasskeyKitError {
    +  constructor(operation?: string) {
    +    super(
    +      operation
    +        ? `A connected wallet is required to ${operation}`
    +        : "Wallet not connected",
    +      PasskeyKitErrorCode.WALLET_NOT_CONNECTED,
    +      { context: operation ? { operation } : undefined }
    +    );
    +    this.name = "WalletNotConnectedError";
    +  }
    +}
    +
    +/**
    + * Thrown when a connected wallet does not actually own the credential used to
    + * connect (connectWallet ownership verification, #601 F7).
    + */
    +export class WalletOwnershipError extends PasskeyKitError {
    +  constructor(message: string, context?: Record) {
    +    super(message, PasskeyKitErrorCode.WALLET_OWNERSHIP_MISMATCH, { context });
    +    this.name = "WalletOwnershipError";
    +  }
    +}
    +
    +/** Thrown when a WebAuthn ceremony fails. */
    +export class WebAuthnError extends PasskeyKitError {
    +  constructor(
    +    message: string,
    +    code:
    +      | PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED
    +      | PasskeyKitErrorCode.WEBAUTHN_AUTHENTICATION_FAILED
    +      | PasskeyKitErrorCode.WEBAUTHN_NOT_SUPPORTED
    +      | PasskeyKitErrorCode.WEBAUTHN_CANCELLED
    +      | PasskeyKitErrorCode.PUBLIC_KEY_EXTRACTION_FAILED,
    +    cause?: Error
    +  ) {
    +    super(message, code, { cause });
    +    this.name = "WebAuthnError";
    +  }
    +}
    +
    +/** Thrown when signing an authorization entry fails. */
    +export class SigningError extends PasskeyKitError {
    +  constructor(
    +    message: string,
    +    code:
    +      | PasskeyKitErrorCode.SIGNING_FAILED
    +      | PasskeyKitErrorCode.INVALID_SIGNER
    +      | PasskeyKitErrorCode.INVALID_SIGNATURE_EXPIRATION
    +      | PasskeyKitErrorCode.UNSUPPORTED_CREDENTIALS = PasskeyKitErrorCode.SIGNING_FAILED,
    +    context?: Record
    +  ) {
    +    super(message, code, { context });
    +    this.name = "SigningError";
    +  }
    +}
    +
    +/** Thrown when a requested signer cannot be found. */
    +export class SignerNotFoundError extends PasskeyKitError {
    +  constructor(identifier: string, hint?: string) {
    +    super(
    +      hint
    +        ? `No signer found for: ${identifier}. ${hint}`
    +        : `No signer found for: ${identifier}`,
    +      PasskeyKitErrorCode.SIGNER_NOT_FOUND,
    +      { context: { identifier } }
    +    );
    +    this.name = "SignerNotFoundError";
    +  }
    +}
    +
    +/** Thrown when transaction simulation fails. */
    +export class SimulationError extends PasskeyKitError {
    +  constructor(message: string, context?: Record) {
    +    super(message, PasskeyKitErrorCode.SIMULATION_FAILED, { context });
    +    this.name = "SimulationError";
    +  }
    +}
    +
    +/** Thrown when transaction submission fails. */
    +export class SubmissionError extends PasskeyKitError {
    +  constructor(message: string, hash?: string, context?: Record) {
    +    super(message, PasskeyKitErrorCode.SUBMISSION_FAILED, {
    +      context: { hash, ...context },
    +    });
    +    this.name = "SubmissionError";
    +  }
    +}
    +
    +/** Thrown when input validation fails. */
    +export class ValidationError extends PasskeyKitError {
    +  constructor(
    +    message: string,
    +    code:
    +      | PasskeyKitErrorCode.INVALID_ADDRESS
    +      | PasskeyKitErrorCode.INVALID_AMOUNT
    +      | PasskeyKitErrorCode.INVALID_INPUT
    +      | PasskeyKitErrorCode.INVALID_PUBLIC_KEY = PasskeyKitErrorCode.INVALID_INPUT,
    +    context?: Record
    +  ) {
    +    super(message, code, { context });
    +    this.name = "ValidationError";
    +  }
    +}
    +
    +/** Thrown when an indexer request fails or the indexer is not configured. */
    +export class IndexerError extends PasskeyKitError {
    +  constructor(
    +    message: string,
    +    code:
    +      | PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      | PasskeyKitErrorCode.INDEXER_REQUEST_FAILED = PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +    context?: Record,
    +    cause?: Error
    +  ) {
    +    super(message, code, { context, cause });
    +    this.name = "IndexerError";
    +  }
    +}
    +
    +/** Thrown when a relayer request fails or the relayer is not configured. */
    +export class RelayerError extends PasskeyKitError {
    +  constructor(
    +    message: string,
    +    code:
    +      | PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED
    +      | PasskeyKitErrorCode.RELAYER_REQUEST_FAILED
    +      | PasskeyKitErrorCode.RELAYER_PENDING = PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
    +    context?: Record,
    +    cause?: Error
    +  ) {
    +    super(message, code, { context, cause });
    +    this.name = "RelayerError";
    +  }
    +}
    +
    +/**
    + * Error decoded from an on-chain smart-wallet failure code.
    + *
    + * Produced by `decodeContractError` when a simulation/submission diagnostic
    + * reports an `Error(Contract, #N)`. Carries the raw contract code and its enum
    + * variant name so callers can branch on the exact failure.
    + *
    + * @example
    + * ```typescript
    + * const result = await server.send(tx);
    + * if (!result.success && result.error instanceof ContractError) {
    + *   if (result.error.contractErrorName === "SignerExpired") { ... }
    + * }
    + * ```
    + */
    +export class ContractError extends PasskeyKitError {
    +  /** Raw contract error code (e.g. 4). */
    +  readonly contractCode: number;
    +
    +  /** Enum variant name from the contract (e.g. "SignerExpired"). */
    +  readonly contractErrorName: string;
    +
    +  /** Contract family the code belongs to (e.g. "SmartWallet"). */
    +  readonly family: string;
    +
    +  constructor(
    +    contractCode: number,
    +    contractErrorName: string,
    +    family: string,
    +    message: string,
    +    options?: {
    +      context?: Record;
    +      cause?: Error;
    +    }
    +  ) {
    +    super(message, PasskeyKitErrorCode.CONTRACT_ERROR, {
    +      context: { contractCode, contractErrorName, family, ...options?.context },
    +      cause: options?.cause,
    +    });
    +    this.name = "ContractError";
    +    this.contractCode = contractCode;
    +    this.contractErrorName = contractErrorName;
    +    this.family = family;
    +  }
    +}
    +
    +/**
    + * Wrap an unknown thrown value in a {@link PasskeyKitError}.
    + *
    + * Passes through values that are already {@link PasskeyKitError}s.
    + */
    +export function wrapError(
    +  err: unknown,
    +  defaultCode: PasskeyKitErrorCode = PasskeyKitErrorCode.INVALID_INPUT
    +): PasskeyKitError {
    +  if (err instanceof PasskeyKitError) {
    +    return err;
    +  }
    +
    +  const message = err instanceof Error ? err.message : String(err);
    +  const cause = err instanceof Error ? err : undefined;
    +
    +  return new PasskeyKitError(message, defaultCode, { cause });
    +}
    diff --git a/src/events.test.ts b/src/events.test.ts
    new file mode 100644
    index 0000000..0e9a806
    --- /dev/null
    +++ b/src/events.test.ts
    @@ -0,0 +1,69 @@
    +import { describe, expect, it, vi } from "vitest";
    +import { PasskeyEventEmitter } from "./events.js";
    +import { SignerStore } from "./types.js";
    +
    +describe("PasskeyEventEmitter", () => {
    +  it("delivers events to subscribers and returns an unsubscribe function", () => {
    +    const emitter = new PasskeyEventEmitter();
    +    const listener = vi.fn();
    +
    +    const off = emitter.on("walletConnected", listener);
    +    emitter.emit("walletConnected", { contractId: "C1", keyId: "k1" });
    +    expect(listener).toHaveBeenCalledWith({ contractId: "C1", keyId: "k1" });
    +
    +    off();
    +    emitter.emit("walletConnected", { contractId: "C2", keyId: "k2" });
    +    expect(listener).toHaveBeenCalledTimes(1);
    +  });
    +
    +  it("once() fires exactly once", () => {
    +    const emitter = new PasskeyEventEmitter();
    +    const listener = vi.fn();
    +    emitter.once("walletDisconnected", listener);
    +    emitter.emit("walletDisconnected", { contractId: "C1" });
    +    emitter.emit("walletDisconnected", { contractId: "C1" });
    +    expect(listener).toHaveBeenCalledTimes(1);
    +  });
    +
    +  it("off() removes a specific listener", () => {
    +    const emitter = new PasskeyEventEmitter();
    +    const a = vi.fn();
    +    const b = vi.fn();
    +    emitter.on("signerAdded", a);
    +    emitter.on("signerAdded", b);
    +    emitter.off("signerAdded", a);
    +    emitter.emit("signerAdded", {
    +      contractId: "C1",
    +      kind: "Ed25519",
    +      storage: SignerStore.Persistent,
    +    });
    +    expect(a).not.toHaveBeenCalled();
    +    expect(b).toHaveBeenCalledTimes(1);
    +  });
    +
    +  it("isolates a throwing listener and routes its error to the handler", () => {
    +    const emitter = new PasskeyEventEmitter();
    +    const errorHandler = vi.fn();
    +    emitter.setErrorHandler(errorHandler);
    +
    +    const good = vi.fn();
    +    emitter.on("transactionSubmitted", () => {
    +      throw new Error("bad listener");
    +    });
    +    emitter.on("transactionSubmitted", good);
    +
    +    emitter.emit("transactionSubmitted", { hash: "h1", success: true });
    +    expect(good).toHaveBeenCalledTimes(1);
    +    expect(errorHandler).toHaveBeenCalledTimes(1);
    +    expect(errorHandler.mock.calls[0]![0]).toBe("transactionSubmitted");
    +  });
    +
    +  it("tracks listener counts and clears all listeners", () => {
    +    const emitter = new PasskeyEventEmitter();
    +    emitter.on("walletCreated", vi.fn());
    +    emitter.on("walletCreated", vi.fn());
    +    expect(emitter.listenerCount("walletCreated")).toBe(2);
    +    emitter.removeAllListeners("walletCreated");
    +    expect(emitter.listenerCount("walletCreated")).toBe(0);
    +  });
    +});
    diff --git a/src/events.ts b/src/events.ts
    new file mode 100644
    index 0000000..297d7ed
    --- /dev/null
    +++ b/src/events.ts
    @@ -0,0 +1,169 @@
    +/**
    + * Typed event emitter for passkey / wallet lifecycle events.
    + *
    + * `on()` returns an unsubscribe function; a listener that throws is isolated so
    + * it never prevents the other listeners from running.
    + *
    + * @packageDocumentation
    + */
    +
    +import type { SignerStore } from "./types.js";
    +
    +/**
    + * The events the kit can emit, mapped to their payload shapes.
    + */
    +export type PasskeyEventMap = {
    +  /** A new wallet was deployed. */
    +  walletCreated: { contractId: string; keyId: string };
    +
    +  /** An existing wallet was connected. */
    +  walletConnected: { contractId: string; keyId: string };
    +
    +  /** The connected wallet was disconnected. */
    +  walletDisconnected: { contractId: string };
    +
    +  /** A signer was added to the connected wallet. */
    +  signerAdded: {
    +    contractId: string;
    +    kind: "Policy" | "Ed25519" | "Secp256r1";
    +    storage: SignerStore;
    +  };
    +
    +  /** A signer was updated on the connected wallet. */
    +  signerUpdated: {
    +    contractId: string;
    +    kind: "Policy" | "Ed25519" | "Secp256r1";
    +    storage: SignerStore;
    +  };
    +
    +  /** A signer was removed from the connected wallet. */
    +  signerRemoved: {
    +    contractId: string;
    +    kind: "Policy" | "Ed25519" | "Secp256r1";
    +  };
    +
    +  /** An authorization entry was signed. */
    +  transactionSigned: { contractId: string; keyId?: string };
    +
    +  /** A transaction was submitted. */
    +  transactionSubmitted: { hash: string; success: boolean };
    +};
    +
    +/** The set of event names the kit emits. */
    +export type PasskeyEvent = keyof PasskeyEventMap;
    +
    +/** An event listener. */
    +export type EventListener = (data: T) => void;
    +
    +/**
    + * Default handler for errors thrown by listeners: log to the console so a
    + * misbehaving listener is visible rather than silently swallowed.
    + */
    +function defaultListenerErrorHandler(
    +  event: PasskeyEvent,
    +  error: unknown
    +): void {
    +  console.error(`[PasskeyKit] Listener for "${event}" threw:`, error);
    +}
    +
    +/**
    + * A small typed event emitter for passkey lifecycle events.
    + *
    + * @example
    + * ```typescript
    + * const events = new PasskeyEventEmitter();
    + * const off = events.on("walletConnected", ({ contractId }) => {
    + *   console.log("connected", contractId);
    + * });
    + * events.emit("walletConnected", { contractId: "C…", keyId: "…" });
    + * off();
    + * ```
    + */
    +export class PasskeyEventEmitter {
    +  private listeners: Map<
    +    PasskeyEvent,
    +    Set>
    +  > = new Map();
    +
    +  private errorHandler:
    +    | ((event: PasskeyEvent, error: unknown) => void)
    +    | undefined = defaultListenerErrorHandler;
    +
    +  /**
    +   * Set the handler invoked when a listener throws. Pass `undefined` to silence
    +   * listener errors entirely.
    +   */
    +  setErrorHandler(
    +    handler: ((event: PasskeyEvent, error: unknown) => void) | undefined
    +  ): void {
    +    this.errorHandler = handler;
    +  }
    +
    +  /** Subscribe to an event. Returns an unsubscribe function. */
    +  on(
    +    event: E,
    +    listener: EventListener
    +  ): () => void {
    +    if (!this.listeners.has(event)) {
    +      this.listeners.set(event, new Set());
    +    }
    +    const listeners = this.listeners.get(event)!;
    +    listeners.add(
    +      listener as EventListener
    +    );
    +    return () => {
    +      listeners.delete(
    +        listener as EventListener
    +      );
    +    };
    +  }
    +
    +  /** Subscribe to an event, firing at most once. Returns an unsubscribe fn. */
    +  once(
    +    event: E,
    +    listener: EventListener
    +  ): () => void {
    +    const unsubscribe = this.on(event, (data) => {
    +      unsubscribe();
    +      listener(data);
    +    });
    +    return unsubscribe;
    +  }
    +
    +  /** Unsubscribe a specific listener from an event. */
    +  off(
    +    event: E,
    +    listener: EventListener
    +  ): void {
    +    this.listeners
    +      .get(event)
    +      ?.delete(listener as EventListener);
    +  }
    +
    +  /** Emit an event to all subscribers, isolating any that throw. */
    +  emit(event: E, data: PasskeyEventMap[E]): void {
    +    const listeners = this.listeners.get(event);
    +    if (!listeners) return;
    +    for (const listener of listeners) {
    +      try {
    +        listener(data);
    +      } catch (err) {
    +        this.errorHandler?.(event, err);
    +      }
    +    }
    +  }
    +
    +  /** Remove all listeners for an event, or for every event when omitted. */
    +  removeAllListeners(event?: PasskeyEvent): void {
    +    if (event) {
    +      this.listeners.delete(event);
    +    } else {
    +      this.listeners.clear();
    +    }
    +  }
    +
    +  /** Number of listeners registered for an event. */
    +  listenerCount(event: PasskeyEvent): number {
    +    return this.listeners.get(event)?.size ?? 0;
    +  }
    +}
    diff --git a/src/index.ts b/src/index.ts
    index 80383f6..189a507 100644
    --- a/src/index.ts
    +++ b/src/index.ts
    @@ -1,5 +1,117 @@
    -export { SignerStore, SignerKey, type Signer, type SignerLimits } from './types'
    -export { PasskeyKit } from "./kit"
    -export { PasskeyServer } from "./server"
    -export { SACClient } from "./sac"
    -export { Client as PasskeyClient } from 'passkey-kit-sdk'
    \ No newline at end of file
    +/**
    + * Passkey Kit — create and use smart-wallet accounts on Stellar with WebAuthn
    + * passkeys.
    + *
    + * Storage adapters live under the `passkey-kit/storage` subpath; the server-only
    + * entrypoint lives under `passkey-kit/server`.
    + *
    + * @packageDocumentation
    + */
    +
    +// Core client. (The server-only `PasskeyServer` lives at `passkey-kit/server`
    +// so it — and the relayer secret it holds — can never be bundled into browser
    +// code.)
    +export {
    +  PasskeyKit,
    +  type PasskeyKitConfig,
    +  type CreateOptions,
    +  type ConnectOptions,
    +} from "./kit.js";
    +export { SACClient, buildTokenTransferHostFunction } from "./sac.js";
    +
    +// Generated contract client
    +export { Client as PasskeyClient } from "passkey-kit-sdk";
    +
    +// Signer abstraction
    +export {
    +  PasskeySigner,
    +  Ed25519Signer,
    +  PolicySigner,
    +  type Signer,
    +  type SignerContext,
    +  type PreparedSignature,
    +  type WebAuthnAuthenticator,
    +} from "./signers.js";
    +
    +// Signer-key helpers, indexer row, storage + result types
    +export {
    +  SignerKey,
    +  SignerStore,
    +  type SignerKeyTag,
    +  type SignerLimits,
    +  type IndexedSigner,
    +  type StoredPasskey,
    +  type StorageAdapter,
    +  type CreateWalletResult,
    +  type ConnectWalletResult,
    +  type TransactionResult,
    +  type TransactionSuccess,
    +  type TransactionFailure,
    +  type SubmissionMethod,
    +} from "./types.js";
    +
    +// Typed errors
    +export {
    +  PasskeyKitError,
    +  PasskeyKitErrorCode,
    +  ConfigurationError,
    +  WalletNotConnectedError,
    +  WalletOwnershipError,
    +  WebAuthnError,
    +  SigningError,
    +  SignerNotFoundError,
    +  SimulationError,
    +  SubmissionError,
    +  ValidationError,
    +  IndexerError,
    +  RelayerError,
    +  ContractError,
    +  wrapError,
    +} from "./errors.js";
    +
    +// Contract-error decoding
    +export {
    +  CONTRACT_ERROR_REGISTRY,
    +  contractErrorFromCode,
    +  decodeContractError,
    +  type ContractErrorFamily,
    +  type ContractErrorInfo,
    +} from "./contract-errors.js";
    +
    +// Indexer abstraction: browser-safe types + the pure retry helper. The concrete
    +// token-holding backends are exported from `passkey-kit/server`.
    +export {
    +  lookupWithRetry,
    +  type SignerIndexer,
    +  type WalletSigner,
    +  type IndexerHealth,
    +  type SignerStatus,
    +  type SignerStorageClass,
    +} from "./indexer/index.js";
    +
    +// Events
    +export {
    +  PasskeyEventEmitter,
    +  type PasskeyEvent,
    +  type PasskeyEventMap,
    +  type EventListener,
    +} from "./events.js";
    +
    +// Crypto / derivation helpers
    +export {
    +  deriveContractAddress,
    +  extractPublicKeyFromAttestation,
    +  compactSignature,
    +  generateChallenge,
    +} from "./utils.js";
    +
    +// Client-side validation
    +export {
    +  validateAddress,
    +  validateAmount,
    +  validateExpiration,
    +  validateSecp256r1PublicKey,
    +} from "./validation.js";
    +
    +// Package metadata
    +export { VERSION, NAME } from "./version.js";
    diff --git a/src/indexer/codec.test.ts b/src/indexer/codec.test.ts
    new file mode 100644
    index 0000000..3c8c72e
    --- /dev/null
    +++ b/src/indexer/codec.test.ts
    @@ -0,0 +1,126 @@
    +import { describe, expect, it } from "vitest";
    +import { Keypair, StrKey, xdr } from "@stellar/stellar-sdk";
    +import base64url from "../base64url.js";
    +import { SignerKey } from "../types.js";
    +import { SIGNER_VAL_UDT } from "../kit/auth-payload.js";
    +import {
    +  decodeSignerVal,
    +  deriveStatus,
    +  scValToSignerKey,
    +  signerKeyToContractScVal,
    +  signerKeyToIndexerJson,
    +  walletSpec,
    +} from "./codec.js";
    +import { toContractSignerKey } from "../kit/wallet-ops.js";
    +
    +/** Encode a native SignerVal to its ScVal via the contract spec. */
    +function encodeSignerVal(native: unknown): xdr.ScVal {
    +  return walletSpec().nativeToScVal(native, SIGNER_VAL_UDT);
    +}
    +
    +describe("decodeSignerVal", () => {
    +  it("decodes a Secp256r1 signer value (publicKey, expiration, unlimited)", () => {
    +    const publicKey = Buffer.alloc(65, 4);
    +    const scVal = encodeSignerVal({
    +      tag: "Secp256r1",
    +      values: [publicKey, [1000], [undefined]],
    +    });
    +
    +    const decoded = decodeSignerVal(scVal);
    +    expect(decoded.kind).toBe("Secp256r1");
    +    expect(Buffer.from(decoded.publicKey!)).toEqual(publicKey);
    +    expect(decoded.expiration).toBe(1000);
    +    expect(decoded.limits).toBeUndefined();
    +  });
    +
    +  it("decodes an Ed25519 signer value with no expiration", () => {
    +    const scVal = encodeSignerVal({
    +      tag: "Ed25519",
    +      values: [[undefined], [undefined]],
    +    });
    +    const decoded = decodeSignerVal(scVal);
    +    expect(decoded.kind).toBe("Ed25519");
    +    expect(decoded.publicKey).toBeUndefined();
    +    expect(decoded.expiration).toBeUndefined();
    +  });
    +});
    +
    +describe("decodeSignerVal — limits-nested keys (audit M1)", () => {
    +  it("decodes a limits-nested Ed25519 key as a G-address that round-trips", () => {
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 4)).publicKey();
    +    const rawPk = Keypair.fromPublicKey(g).rawPublicKey();
    +    const contract = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +    // SignerVal Ed25519 = [ SignerExpiration, SignerLimits ]; limits = Some({
    +    // contract -> Some([ Ed25519 key ]) }).
    +    const scVal = encodeSignerVal({
    +      tag: "Ed25519",
    +      values: [
    +        [undefined],
    +        [new Map([[contract, [{ tag: "Ed25519", values: [rawPk] }]]])],
    +      ],
    +    });
    +
    +    const nested = decodeSignerVal(scVal).limits!.get(contract)![0]!;
    +    expect(nested.key).toBe("Ed25519");
    +    expect(StrKey.isValidEd25519PublicKey(nested.value)).toBe(true);
    +    expect(nested.value).toBe(g); // a G-address, NOT hex
    +    // The old hex encoding threw here (Keypair.fromPublicKey(hex)).
    +    expect(() => toContractSignerKey(nested)).not.toThrow();
    +  });
    +});
    +
    +describe("scValToSignerKey", () => {
    +  it("round-trips a Secp256r1 key", () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 0xab));
    +    const scVal = signerKeyToContractScVal(SignerKey.Secp256r1(keyId));
    +    const decoded = scValToSignerKey(scVal);
    +    expect(decoded.key).toBe("Secp256r1");
    +    expect(decoded.value).toBe(keyId);
    +  });
    +
    +  it("round-trips an Ed25519 key (raw bytes <-> G-address)", () => {
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey();
    +    const scVal = signerKeyToContractScVal(SignerKey.Ed25519(g));
    +    const decoded = scValToSignerKey(scVal);
    +    expect(decoded.key).toBe("Ed25519");
    +    expect(StrKey.isValidEd25519PublicKey(decoded.value)).toBe(true);
    +    expect(decoded.value).toBe(g);
    +  });
    +
    +  it("round-trips a Policy key", () => {
    +    const c = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +    const decoded = scValToSignerKey(
    +      signerKeyToContractScVal(SignerKey.Policy(c))
    +    );
    +    expect(decoded.key).toBe("Policy");
    +    expect(decoded.value).toBe(c);
    +  });
    +});
    +
    +describe("signerKeyToIndexerJson", () => {
    +  it("encodes a Secp256r1 key as a symbol+bytes(hex) vector", () => {
    +    const keyId = base64url.encode(Buffer.from([0xde, 0xad]));
    +    expect(signerKeyToIndexerJson(SignerKey.Secp256r1(keyId))).toEqual({
    +      vec: [{ symbol: "Secp256r1" }, { bytes: "dead" }],
    +    });
    +  });
    +
    +  it("encodes a Policy key as a symbol+address vector", () => {
    +    const c = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +    expect(signerKeyToIndexerJson(SignerKey.Policy(c))).toEqual({
    +      vec: [{ symbol: "Policy" }, { address: c }],
    +    });
    +  });
    +});
    +
    +describe("deriveStatus", () => {
    +  it("derives live / expired / evicted / removed", () => {
    +    expect(deriveStatus({ nowSeconds: 100 })).toBe("live");
    +    expect(deriveStatus({ expiration: 100, nowSeconds: 100 })).toBe("live"); // inclusive
    +    expect(deriveStatus({ expiration: 99, nowSeconds: 100 })).toBe("expired");
    +    expect(deriveStatus({ evicted: true, nowSeconds: 100 })).toBe("evicted");
    +    expect(deriveStatus({ tombstoned: true, evicted: true, nowSeconds: 100 })).toBe(
    +      "removed"
    +    );
    +  });
    +});
    diff --git a/src/indexer/codec.ts b/src/indexer/codec.ts
    new file mode 100644
    index 0000000..7a26dca
    --- /dev/null
    +++ b/src/indexer/codec.ts
    @@ -0,0 +1,200 @@
    +/**
    + * Shared SignerKey/SignerVal codec used by both indexer backends.
    + *
    + * Centralizes the encoding both backends need: the contract `SignerKey` ScVal
    + * (for a Stellar Indexer key predicate), the JSON predicate shape, decoding a
    + * stored `SignerVal` into {@link WalletSigner} parts, and the status derivation.
    + *
    + * WIRE NOTE: the Stellar Indexer key/val predicate JSON shape (and whether bytes
    + * are hex- or base64-encoded) is confirmed live against the beta API in F2. The
    + * documented `{ vec: [{ symbol }, { bytes|address }] }` shape (with hex bytes)
    + * is implemented here; adjust in one place if F2 shows otherwise.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Address, StrKey, xdr } from "@stellar/stellar-sdk";
    +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract";
    +import { Client as PasskeyClient, type SignerVal } from "passkey-kit-sdk";
    +import {
    +  SignerKey,
    +  type SignerKeyTag,
    +  type SignerLimits,
    +} from "../types.js";
    +import { signerKeyToScVal, SIGNER_VAL_UDT } from "../kit/auth-payload.js";
    +import { toContractSignerKey } from "../kit/wallet-ops.js";
    +import type { SignerStatus, SignerStorageClass, WalletSigner } from "./types.js";
    +
    +// The contract Spec is network-independent, so memoize a single instance built
    +// from a throwaway client (constructing a client does not touch the network).
    +let cachedSpec: ContractSpec | undefined;
    +export function walletSpec(): ContractSpec {
    +  if (!cachedSpec) {
    +    cachedSpec = (
    +      new PasskeyClient({
    +        contractId:
    +          "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL",
    +        networkPassphrase: "Test SDF Network ; September 2015",
    +        rpcUrl: "https://rpc.invalid",
    +      }) as unknown as { spec: ContractSpec }
    +    ).spec;
    +  }
    +  return cachedSpec;
    +}
    +
    +/** Encode an SDK-side {@link SignerKey} into its contract `SignerKey` ScVal. */
    +export function signerKeyToContractScVal(key: SignerKey): xdr.ScVal {
    +  return signerKeyToScVal(walletSpec(), toContractSignerKey(key));
    +}
    +
    +/**
    + * Build the JSON key predicate for the Stellar Indexer contract-data query.
    + *
    + * @see WIRE NOTE at the top of this module.
    + */
    +export function signerKeyToIndexerJson(key: SignerKey): unknown {
    +  const contractKey = toContractSignerKey(key);
    +  if (contractKey.tag === "Policy") {
    +    return {
    +      vec: [{ symbol: "Policy" }, { address: contractKey.values[0] }],
    +    };
    +  }
    +  const bytes = Buffer.from(contractKey.values[0]).toString("hex");
    +  return { vec: [{ symbol: contractKey.tag }, { bytes }] };
    +}
    +
    +/**
    + * Decode a contract `SignerKey` ScVal (`scvVec([symbol, bytes|address])`) back
    + * into an SDK-side {@link SignerKey}. Used by the Stellar Indexer backend, which
    + * returns the stored ledger-entry key.
    + */
    +export function scValToSignerKey(scVal: xdr.ScVal): SignerKey {
    +  const vec = scVal.vec();
    +  if (!vec || vec.length < 2 || vec[0]!.switch().name !== "scvSymbol") {
    +    throw new Error("SignerKey ScVal is not a tagged vector");
    +  }
    +  const tag = vec[0]!.sym().toString() as SignerKeyTag;
    +  switch (tag) {
    +    case "Secp256r1":
    +      return SignerKey.Secp256r1(
    +        Buffer.from(vec[1]!.bytes()).toString("base64url")
    +      );
    +    case "Ed25519":
    +      return SignerKey.Ed25519(
    +        StrKey.encodeEd25519PublicKey(Buffer.from(vec[1]!.bytes()))
    +      );
    +    case "Policy":
    +      return SignerKey.Policy(
    +        Address.fromScAddress(vec[1]!.address()).toString()
    +      );
    +    default:
    +      throw new Error(`Unknown SignerKey tag: ${tag}`);
    +  }
    +}
    +
    +/** Decode a stored `SignerVal` ScVal into WalletSigner value parts. */
    +export function decodeSignerVal(valScVal: xdr.ScVal): {
    +  kind: SignerKeyTag;
    +  publicKey?: Uint8Array;
    +  expiration?: number;
    +  limits?: SignerLimits;
    +} {
    +  const native = walletSpec().scValToNative(valScVal, SIGNER_VAL_UDT) as SignerVal;
    +
    +  // SignerExpiration = [Option]; SignerLimits = [Option].
    +  const expiration = expirationOf(native);
    +  const limits = limitsOf(native);
    +
    +  if (native.tag === "Secp256r1") {
    +    return {
    +      kind: "Secp256r1",
    +      publicKey: new Uint8Array(native.values[0]),
    +      expiration,
    +      limits,
    +    };
    +  }
    +  return { kind: native.tag, expiration, limits };
    +}
    +
    +function expirationOf(native: SignerVal): number | undefined {
    +  const exp =
    +    native.tag === "Secp256r1" ? native.values[1] : native.values[0];
    +  const value = exp?.[0];
    +  return value == null ? undefined : Number(value);
    +}
    +
    +function limitsOf(native: SignerVal): SignerLimits {
    +  const rawLimits =
    +    native.tag === "Secp256r1" ? native.values[2] : native.values[1];
    +  const map = rawLimits?.[0];
    +  if (!map) return undefined;
    +
    +  const out: SignerLimits = new Map();
    +  // `spec.scValToNative` decodes an ScMap to an ARRAY of `[key, value]` pairs
    +  // (spec.js), not a JS Map — despite the `Map` static type. Iterate the pairs
    +  // directly; `map.entries()` would yield `[index, pair]` and mis-key every
    +  // entry under a numeric index. Direct iteration is correct for both an
    +  // array-of-pairs and a real Map, so it stays robust if the SDK ever changes.
    +  for (const [contract, keys] of map) {
    +    out.set(
    +      contract,
    +      keys
    +        ? keys.map((k) => {
    +            switch (k.tag) {
    +              case "Policy":
    +                return SignerKey.Policy(k.values[0]);
    +              case "Ed25519":
    +                // Ed25519 SignerKey.value is a `G…` strkey (matches
    +                // scValToSignerKey and types.ts), NOT hex — a hex value throws
    +                // in Keypair.fromPublicKey when re-encoded (audit M1).
    +                return SignerKey.Ed25519(
    +                  StrKey.encodeEd25519PublicKey(Buffer.from(k.values[0]))
    +                );
    +              case "Secp256r1":
    +                return SignerKey.Secp256r1(
    +                  Buffer.from(k.values[0]).toString("base64url")
    +                );
    +            }
    +          })
    +        : undefined
    +    );
    +  }
    +  return out;
    +}
    +
    +/**
    + * Derive a signer's lifecycle status.
    + *
    + * @param nowSeconds - Current UNIX time in seconds (compared against expiration)
    + */
    +export function deriveStatus(args: {
    +  expiration?: number;
    +  evicted?: boolean;
    +  tombstoned?: boolean;
    +  nowSeconds: number;
    +}): SignerStatus {
    +  if (args.tombstoned) return "removed";
    +  if (args.evicted) return "evicted";
    +  // Expiration is inclusive: valid while now <= expiration (contract semantics).
    +  if (args.expiration != null && args.nowSeconds > args.expiration) {
    +    return "expired";
    +  }
    +  return "live";
    +}
    +
    +/** Assemble a {@link WalletSigner} from a key, decoded value, storage, status. */
    +export function buildWalletSigner(
    +  key: SignerKey,
    +  decoded: ReturnType,
    +  storage: SignerStorageClass,
    +  status: SignerStatus
    +): WalletSigner {
    +  return {
    +    key,
    +    ...(decoded.publicKey ? { publicKey: decoded.publicKey } : {}),
    +    ...(decoded.expiration != null ? { expiration: decoded.expiration } : {}),
    +    limits: decoded.limits,
    +    storage,
    +    status,
    +  };
    +}
    diff --git a/src/indexer/index.ts b/src/indexer/index.ts
    new file mode 100644
    index 0000000..c0ad67c
    --- /dev/null
    +++ b/src/indexer/index.ts
    @@ -0,0 +1,89 @@
    +/**
    + * Indexer abstraction barrel.
    + *
    + * The {@link SignerIndexer} interface + {@link WalletSigner} types and the pure
    + * {@link lookupWithRetry} helper are browser-safe. The concrete backends
    + * ({@link MercuryIndexer}, {@link StellarIndexerBackend}) take server-side
    + * tokens, so they are re-exported from the `passkey-kit/server` entrypoint.
    + *
    + * @packageDocumentation
    + */
    +
    +export type {
    +  SignerIndexer,
    +  WalletSigner,
    +  IndexerHealth,
    +  SignerStatus,
    +  SignerStorageClass,
    +  FindWalletsHardeningDeps,
    +} from "./types.js";
    +
    +export {
    +  MercuryIndexer,
    +  type MercuryIndexerConfig,
    +  type MercurySignerRow,
    +} from "./mercury.js";
    +export {
    +  StellarIndexerBackend,
    +  type StellarIndexerConfig,
    +  type StellarIndexerEntry,
    +  MAX_CONTRACTS_PER_QUERY,
    +  STELLAR_INDEXER_NETWORK,
    +  jsonScValToXdr,
    +} from "./stellar-indexer.js";
    +
    +export {
    +  signerKeyToContractScVal,
    +  signerKeyToIndexerJson,
    +  scValToSignerKey,
    +  decodeSignerVal,
    +  deriveStatus,
    +} from "./codec.js";
    +
    +import type { SignerIndexer } from "./types.js";
    +import { MercuryIndexer, type MercuryIndexerConfig } from "./mercury.js";
    +import {
    +  StellarIndexerBackend,
    +  type StellarIndexerConfig,
    +} from "./stellar-indexer.js";
    +
    +/**
    + * Null-tolerant factory: returns the configured backend, or `null` when no
    + * indexer is configured (callers treat `null` as "discovery disabled").
    + */
    +export function indexerForConfig(config?: {
    +  mercury?: MercuryIndexerConfig;
    +  stellarIndexer?: StellarIndexerConfig;
    +}): SignerIndexer | null {
    +  if (config?.mercury) return new MercuryIndexer(config.mercury);
    +  if (config?.stellarIndexer) return new StellarIndexerBackend(config.stellarIndexer);
    +  return null;
    +}
    +
    +/**
    + * Poll a lookup until it satisfies `predicate` (defaults to "non-empty"), for
    + * post-write discovery assertions where the indexer lags the ledger. Defaults
    + * to 20 attempts × 2s.
    + */
    +export async function lookupWithRetry(
    +  fn: () => Promise,
    +  options?: {
    +    attempts?: number;
    +    delayMs?: number;
    +    predicate?: (result: T[]) => boolean;
    +  }
    +): Promise {
    +  const attempts = options?.attempts ?? 20;
    +  const delayMs = options?.delayMs ?? 2000;
    +  const predicate = options?.predicate ?? ((r) => r.length > 0);
    +
    +  let last: T[] = [];
    +  for (let attempt = 0; attempt < attempts; attempt += 1) {
    +    last = await fn();
    +    if (predicate(last)) return last;
    +    if (attempt < attempts - 1) {
    +      await new Promise((resolve) => setTimeout(resolve, delayMs));
    +    }
    +  }
    +  return last;
    +}
    diff --git a/src/indexer/mercury.test.ts b/src/indexer/mercury.test.ts
    new file mode 100644
    index 0000000..42111f2
    --- /dev/null
    +++ b/src/indexer/mercury.test.ts
    @@ -0,0 +1,208 @@
    +import { afterEach, describe, expect, it, vi } from "vitest";
    +import { Keypair, xdr } from "@stellar/stellar-sdk";
    +import base64url from "../base64url.js";
    +import { SignerKey } from "../types.js";
    +import { IndexerError } from "../errors.js";
    +import { SIGNER_VAL_UDT } from "../kit/auth-payload.js";
    +import { deriveContractAddress } from "../utils.js";
    +import { MercuryIndexer, type MercurySignerRow } from "./mercury.js";
    +import { signerKeyToContractScVal, walletSpec } from "./codec.js";
    +
    +const TESTNET = "Test SDF Network ; September 2015";
    +const DEPLOYER = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 9)).publicKey();
    +const OTHER_WALLET = "CBQU3NIOXC3IDGERJWV3YVMSQSIOU2S6NSMH35OS3GPG6XARZFAAT2NL";
    +
    +function secp256r1ValXdr(expiration?: number): string {
    +  return walletSpec()
    +    .nativeToScVal(
    +      {
    +        tag: "Secp256r1",
    +        values: [Buffer.alloc(65, 4), [expiration], [undefined]],
    +      },
    +      SIGNER_VAL_UDT
    +    )
    +    .toXDR("base64");
    +}
    +
    +/** Stub global fetch, routing by the Zephyr `fname` in the request body. */
    +function stubFetch(byFname: Record) {
    +  const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
    +    const body = JSON.parse(init.body);
    +    const fname = body.mode.Function.fname as string;
    +    return {
    +      ok: true,
    +      json: async () => byFname[fname],
    +      text: async () => "",
    +    } as unknown as Response;
    +  });
    +  vi.stubGlobal("fetch", fetchMock);
    +  return fetchMock;
    +}
    +
    +afterEach(() => {
    +  vi.unstubAllGlobals();
    +});
    +
    +describe("MercuryIndexer.getSigners", () => {
    +  it("maps rows to WalletSigners (live Secp256r1)", async () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 1));
    +    const rows: MercurySignerRow[] = [
    +      { kind: "Secp256r1", key: keyId, val: secp256r1ValXdr(4_000_000_000), storage: "Persistent" },
    +    ];
    +    stubFetch({ get_signers_by_address: rows });
    +
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
    +      now: () => 1000,
    +    });
    +    const signers = await indexer.getSigners("CWALLET");
    +
    +    expect(signers).toHaveLength(1);
    +    expect(signers[0]!.key.key).toBe("Secp256r1");
    +    expect(signers[0]!.key.value).toBe(keyId);
    +    expect(signers[0]!.storage).toBe("persistent");
    +    expect(signers[0]!.status).toBe("live");
    +    expect(signers[0]!.publicKey).toHaveLength(65);
    +  });
    +});
    +
    +describe("MercuryIndexer pending-endpoint gate (F2b, todo 967)", () => {
    +  // No zephyrExecuteConfirmed -> the /zephyr/execute route is retired (404), so
    +  // queries must fail loud rather than silently 404.
    +  function gated() {
    +    return new MercuryIndexer({ url: "https://mercury.test", projectName: "proj", jwt: "jwt" });
    +  }
    +
    +  it("throws a clear pending IndexerError from queries instead of 404-ing", async () => {
    +    await expect(gated().getSigners("CWALLET")).rejects.toBeInstanceOf(IndexerError);
    +    await expect(
    +      gated().findWallets(SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 1))))
    +    ).rejects.toBeInstanceOf(IndexerError);
    +  });
    +
    +  it("reports health ok:false with the pending detail (no silent failure)", async () => {
    +    const h = await gated().health();
    +    expect(h.ok).toBe(false);
    +    expect(h.detail).toMatch(/pending|todo 967|retired/i);
    +  });
    +});
    +
    +describe("MercuryIndexer eviction probe (audit H2)", () => {
    +  const WALLET = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +  const keyId = base64url.encode(Buffer.alloc(16, 5));
    +
    +  function tempRow(): MercurySignerRow {
    +    return {
    +      kind: "Secp256r1",
    +      key: keyId,
    +      val: secp256r1ValXdr(4_000_000_000),
    +      storage: "Temporary",
    +    };
    +  }
    +
    +  function fakeRpc(getLedgerEntries: ReturnType) {
    +    return { getLedgerEntries } as never;
    +  }
    +
    +  it("probes by the SignerKey ScVal (not raw keyId bytes) and keeps a live temporary signer", async () => {
    +    stubFetch({ get_signers_by_address: [tempRow()] });
    +    // A present entry: readContractData resolves a non-null ScVal.
    +    const present = { contractData: () => ({ val: () => xdr.ScVal.scvVoid() }) };
    +    const getLedgerEntries = vi.fn(async () => ({ entries: [{ val: present }] }));
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
    +      now: () => 1000,
    +      rpc: fakeRpc(getLedgerEntries),
    +    });
    +
    +    const signers = await indexer.getSigners(WALLET);
    +
    +    expect(signers[0]!.status).toBe("live");
    +    expect(getLedgerEntries).toHaveBeenCalledTimes(1);
    +    // The probed ledger key must carry the SignerKey vec, NOT scvBytes(keyId).
    +    const ledgerKey = getLedgerEntries.mock.calls[0]![0] as xdr.LedgerKey;
    +    const probedKey = ledgerKey.contractData().key();
    +    expect(probedKey.toXDR("base64")).toBe(
    +      signerKeyToContractScVal(SignerKey.Secp256r1(keyId)).toXDR("base64")
    +    );
    +    expect(probedKey.switch().name).toBe("scvVec");
    +  });
    +
    +  it("marks a temporary signer evicted only on a genuine not-found", async () => {
    +    stubFetch({ get_signers_by_address: [tempRow()] });
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
    +      now: () => 1000,
    +      rpc: fakeRpc(vi.fn(async () => ({ entries: [] }))),
    +    });
    +
    +    const signers = await indexer.getSigners(WALLET);
    +    expect(signers[0]!.status).toBe("evicted");
    +  });
    +
    +  it("does NOT evict on a transport error during the probe", async () => {
    +    stubFetch({ get_signers_by_address: [tempRow()] });
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
    +      now: () => 1000,
    +      rpc: fakeRpc(
    +        vi.fn(async () => {
    +          throw new Error("429 too many requests");
    +        })
    +      ),
    +    });
    +
    +    const signers = await indexer.getSigners(WALLET);
    +    expect(signers[0]!.status).toBe("live"); // left as reported, not false-evicted
    +  });
    +});
    +
    +describe("MercuryIndexer.findWallets hardening", () => {
    +  it("keeps only the deterministically-derived candidate when no rpc is available", async () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 7));
    +    const derived = deriveContractAddress(
    +      base64url.toBuffer(keyId),
    +      DEPLOYER,
    +      TESTNET
    +    );
    +    stubFetch({ get_addresses_by_signer: [derived, OTHER_WALLET] });
    +
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
    +      hardening: { networkPassphrase: TESTNET, deployerPublicKey: DEPLOYER },
    +    });
    +
    +    const wallets = await indexer.findWallets(SignerKey.Secp256r1(keyId));
    +    // OTHER_WALLET is dropped: not the derived address and no rpc to confirm it.
    +    expect(wallets).toEqual([derived]);
    +  });
    +
    +  it("returns candidates unfiltered when neither rpc nor hardening is configured", async () => {
    +    stubFetch({ get_addresses_by_signer: [OTHER_WALLET] });
    +    const indexer = new MercuryIndexer({
    +      url: "https://mercury.test",
    +      projectName: "proj",
    +      jwt: "jwt",
    +      zephyrExecuteConfirmed: true,
    +    });
    +    const wallets = await indexer.findWallets(
    +      SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 8)))
    +    );
    +    expect(wallets).toEqual([OTHER_WALLET]);
    +  });
    +});
    diff --git a/src/indexer/mercury.ts b/src/indexer/mercury.ts
    new file mode 100644
    index 0000000..ca22947
    --- /dev/null
    +++ b/src/indexer/mercury.ts
    @@ -0,0 +1,312 @@
    +/**
    + * Mercury (Zephyr) indexer backend.
    + *
    + * Calls the deployed Zephyr program's serverless functions
    + * (`get_signers_by_address`, `get_addresses_by_signer`) via
    + * `POST {url}/zephyr/execute`. Expiration is treated as a UNIX timestamp
    + * (seconds), per the reworked contract (#602).
    + *
    + * ⚠️ RECONCILIATION NOTE — the query endpoint is STALE (F2b, todo 967).
    + * F2 (todo 959 c2502) confirmed `POST /zephyr/execute` returns **404** on the
    + * current Mercury `/rest` backend, and Mercury's current docs no longer document
    + * the Zephyr execute route at all — custom-program querying moved to
    + * "Retroshades" (`POST /rest/retroshade/query` with SQL, e.g.
    + * `SELECT * FROM retroshade.program__`), which requires the reworked
    + * `passkey-kit-indexer` program to be deployed (its deploy path is the blocked
    + * opus-services workstream).
    + *
    + * Separately, Mercury now ships a **public, no-auth Smart Account Indexer** for
    + * passkey wallets on BOTH networks:
    + *   `https://{testnet,mainnet}.mercurydata.app/rest/smart-account-indexer`
    + *     GET /api/contract/:contractId        -> signers (this backend's getSigners)
    + *     GET /api/lookup/:credentialId        -> wallets by passkey keyId (findWallets)
    + *     GET /api/lookup/address/:address     -> wallets by signer address
    + * That likely obviates the custom Zephyr deploy entirely. It is NOT wired here
    + * yet because (a) its `GET /api/contract/:contractId` signer response schema is
    + * not published, and (b) its data model (context rules; native/external/
    + * delegated signers; policies) may not map 1:1 onto this backend's v1
    + * `SignerKey`/`SignerVal`/`SignerLimits`/durability/status. Reconciling needs a
    + * captured live response + a model-mapping decision — coordinated with
    + * opus-services' deploy characterization on todo 967.
    + *
    + * Because A-vs-B is a pending USER decision, this backend is NOT re-pointed at
    + * either surface. Instead it is GATED: unless {@link MercuryIndexerConfig.zephyrExecuteConfirmed}
    + * is set, every query throws a clear "pending" error (todo 967) rather than
    + * firing at the dead `/zephyr/execute` route — honest failure, no silent 404.
    + *
    + * @packageDocumentation
    + */
    +
    +import { xdr } from "@stellar/stellar-sdk";
    +import { Durability, type Server } from "@stellar/stellar-sdk/rpc";
    +import { SignerKey } from "../types.js";
    +import { IndexerError, PasskeyKitErrorCode } from "../errors.js";
    +import { DEFAULT_INDEXER_TIMEOUT_MS } from "../constants.js";
    +import { getSigner } from "../kit/wallet-ops.js";
    +import { contractDataExists } from "../rpc-data.js";
    +import base64url from "../base64url.js";
    +import type {
    +  FindWalletsHardeningDeps,
    +  IndexerHealth,
    +  SignerIndexer,
    +  SignerStorageClass,
    +  WalletSigner,
    +} from "./types.js";
    +import {
    +  buildWalletSigner,
    +  decodeSignerVal,
    +  deriveStatus,
    +  signerKeyToContractScVal,
    +  walletSpec,
    +} from "./codec.js";
    +import { deriveContractAddress } from "../utils.js";
    +
    +/** A raw signer row returned by the Zephyr program (documented shape). */
    +export interface MercurySignerRow {
    +  kind: "Secp256r1" | "Ed25519" | "Policy";
    +  /** Signer key: base64url keyId (Secp256r1), hex pubkey (Ed25519), or address. */
    +  key: string;
    +  /** Base64 XDR of the stored SignerVal. */
    +  val: string;
    +  /** Storage durability. */
    +  storage: "Persistent" | "Temporary";
    +  evicted?: boolean;
    +}
    +
    +export interface MercuryIndexerConfig {
    +  url: string;
    +  projectName: string;
    +  jwt?: string;
    +  apiKey?: string;
    +  /** RPC server — enables the temporary-signer eviction probe + findWallets hardening. */
    +  rpc?: Server;
    +  /** Deps that let findWallets confirm candidates by deterministic derivation. */
    +  hardening?: FindWalletsHardeningDeps;
    +  /** Clock source (seconds); injectable for tests. Defaults to `Date.now()`. */
    +  now?: () => number;
    +  /**
    +   * OPT-IN to the (currently non-functional) `POST /zephyr/execute` query path.
    +   *
    +   * The Mercury query integration is PENDING a strategy decision the USER owns
    +   * (todo 967): the SDK's assumed `/zephyr/execute` route is RETIRED — it 404s
    +   * even with a valid JWT (F2b, todo 959 c2502/2504). The two unresolved options:
    +   *   A. Mercury's hosted Smart Account Indexer REST surface
    +   *      (`/rest/smart-account-indexer/api/lookup/*`) — the same product
    +   *      smart-account-kit uses; would need Mercury to index passkey-kit's wasm.
    +   *   B. Keep this self-deployed zephyr program model and obtain the current
    +   *      Mercury deploy + query tooling.
    +   * Until that lands the backend is GATED: unless this is `true`, every query
    +   * throws a clear "pending" {@link IndexerError} instead of silently 404-ing.
    +   * Set it to `true` only once strategy B has a live endpoint at `url`.
    +   */
    +  zephyrExecuteConfirmed?: boolean;
    +}
    +
    +export class MercuryIndexer implements SignerIndexer {
    +  constructor(private readonly config: MercuryIndexerConfig) {
    +    if (!config.url || !config.projectName) {
    +      throw new IndexerError(
    +        "MercuryIndexer requires url and projectName",
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +  }
    +
    +  private nowSeconds(): number {
    +    return this.config.now ? this.config.now() : Math.floor(Date.now() / 1000);
    +  }
    +
    +  private async execute(fname: string, args: unknown): Promise {
    +    // Gate: the `/zephyr/execute` route is retired (404) and the replacement is
    +    // a pending user decision (todo 967). Fail loud + actionable rather than
    +    // firing a request that just 404s. `health()` catches this → `{ ok: false }`.
    +    if (!this.config.zephyrExecuteConfirmed) {
    +      throw new IndexerError(
    +        "Mercury query integration is pending a strategy decision (todo 967): the assumed " +
    +          "POST /zephyr/execute route is retired (404 with a valid JWT). Resolve strategy A " +
    +          "(hosted Smart Account Indexer /rest/smart-account-indexer) vs B (self-deployed zephyr " +
    +          "program + current tooling), then wire the chosen endpoint. Set " +
    +          "zephyrExecuteConfirmed:true to force the legacy path once B has a live endpoint.",
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +
    +    const controller = new AbortController();
    +    const timeout = setTimeout(
    +      () => controller.abort(),
    +      DEFAULT_INDEXER_TIMEOUT_MS
    +    );
    +    try {
    +      const response = await fetch(`${this.config.url}/zephyr/execute`, {
    +        method: "POST",
    +        headers: {
    +          "Content-Type": "application/json",
    +          Authorization: this.config.jwt
    +            ? `Bearer ${this.config.jwt}`
    +            : this.config.apiKey ?? "",
    +        },
    +        body: JSON.stringify({
    +          project_name: this.config.projectName,
    +          mode: { Function: { fname, arguments: JSON.stringify(args) } },
    +        }),
    +        signal: controller.signal,
    +      });
    +      if (!response.ok) {
    +        const body = await response.text().catch(() => "");
    +        throw new IndexerError(
    +          `Mercury ${fname} failed (${response.status}): ${body.slice(0, 200)}`,
    +          PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +          { fname, status: response.status }
    +        );
    +      }
    +      return (await response.json()) as T;
    +    } catch (err) {
    +      if (err instanceof IndexerError) throw err;
    +      const aborted = err instanceof Error && err.name === "AbortError";
    +      throw new IndexerError(
    +        aborted ? `Mercury ${fname} timed out` : String(err),
    +        PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +        { fname },
    +        err instanceof Error ? err : undefined
    +      );
    +    } finally {
    +      clearTimeout(timeout);
    +    }
    +  }
    +
    +  private rowToWalletSigner(row: MercurySignerRow): WalletSigner {
    +    const key = signerKeyFromRow(row);
    +    const decoded = decodeSignerVal(
    +      xdr.ScVal.fromXDR(Buffer.from(row.val, "base64"))
    +    );
    +    const storage: SignerStorageClass =
    +      row.storage === "Temporary" ? "temporary" : "persistent";
    +    const status = deriveStatus({
    +      expiration: decoded.expiration,
    +      evicted: row.evicted,
    +      nowSeconds: this.nowSeconds(),
    +    });
    +    return buildWalletSigner(key, decoded, storage, status);
    +  }
    +
    +  async getSigners(wallet: string): Promise {
    +    const rows = await this.execute(
    +      "get_signers_by_address",
    +      { address: wallet }
    +    );
    +
    +    // Eviction probe: Mercury cannot observe temporary-entry eviction, so
    +    // confirm temporary signers still exist on-chain (per #598 F6).
    +    if (this.config.rpc) {
    +      for (const row of rows) {
    +        if (row.storage === "Temporary" && !row.evicted) {
    +          try {
    +            row.evicted = !(await this.entryExists(wallet, row));
    +          } catch {
    +            // Transport error (429/5xx/timeout): eviction is undeterminable, so
    +            // leave the row as the indexer reported it rather than
    +            // false-evicting a live signer.
    +          }
    +        }
    +      }
    +    }
    +
    +    return rows.map((row) => this.rowToWalletSigner(row));
    +  }
    +
    +  /**
    +   * Whether the signer's temporary ledger entry still exists on-chain. Probes by
    +   * the `SignerKey` ScVal — the exact key the contract stores the entry under
    +   * (`storage().temporary().set::`), NOT the raw keyId
    +   * bytes — so it actually matches. Throws on a transport error (caller decides
    +   * eviction only from a genuine not-found).
    +   */
    +  private async entryExists(
    +    wallet: string,
    +    row: MercurySignerRow
    +  ): Promise {
    +    return contractDataExists(
    +      this.config.rpc!,
    +      wallet,
    +      signerKeyToContractScVal(signerKeyFromRow(row)),
    +      Durability.Temporary
    +    );
    +  }
    +
    +  async findWallets(key: SignerKey): Promise {
    +    const args = signerToReverseArgs(key);
    +    const candidates = await this.execute(
    +      "get_addresses_by_signer",
    +      args
    +    );
    +    return this.confirmCandidates(candidates, key);
    +  }
    +
    +  /**
    +   * Harden the reverse lookup (#598 F3): keep a candidate only if it is either
    +   * the deterministic derivation of the keyId OR still holds the signer entry
    +   * on-chain — never trust an unverified indexer row.
    +   */
    +  private async confirmCandidates(
    +    candidates: string[],
    +    key: SignerKey
    +  ): Promise {
    +    const { rpc, hardening } = this.config;
    +    if (!rpc && !hardening) return candidates; // nothing to verify with
    +
    +    const derived =
    +      hardening && key.key === "Secp256r1"
    +        ? deriveContractAddress(
    +            base64url.toBuffer(key.value),
    +            hardening.deployerPublicKey,
    +            hardening.networkPassphrase
    +          )
    +        : undefined;
    +
    +    const confirmed: string[] = [];
    +    for (const candidate of candidates) {
    +      if (candidate === derived) {
    +        confirmed.push(candidate);
    +        continue;
    +      }
    +      if (rpc) {
    +        const signer = await getSigner({ rpc, spec: walletSpec() }, candidate, key);
    +        if (signer) confirmed.push(candidate);
    +      }
    +    }
    +    return confirmed;
    +  }
    +
    +  async health(): Promise {
    +    try {
    +      await this.execute("get_signers_by_address", {
    +        address: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
    +      });
    +      return { ok: true, backend: "mercury" };
    +    } catch (err) {
    +      return {
    +        ok: false,
    +        backend: "mercury",
    +        detail: err instanceof Error ? err.message : String(err),
    +      };
    +    }
    +  }
    +}
    +
    +function signerKeyFromRow(row: MercurySignerRow): SignerKey {
    +  switch (row.kind) {
    +    case "Secp256r1":
    +      return SignerKey.Secp256r1(row.key);
    +    case "Ed25519":
    +      return SignerKey.Ed25519(row.key);
    +    case "Policy":
    +      return SignerKey.Policy(row.key);
    +  }
    +}
    +
    +function signerToReverseArgs(key: SignerKey): {
    +  key: string;
    +  kind: "Secp256r1" | "Ed25519" | "Policy";
    +} {
    +  return { key: key.value, kind: key.key };
    +}
    diff --git a/src/indexer/stellar-indexer.test.ts b/src/indexer/stellar-indexer.test.ts
    new file mode 100644
    index 0000000..dfd2d48
    --- /dev/null
    +++ b/src/indexer/stellar-indexer.test.ts
    @@ -0,0 +1,222 @@
    +import { afterEach, describe, expect, it, vi } from "vitest";
    +import { Address, Keypair, Networks, nativeToScVal, xdr } from "@stellar/stellar-sdk";
    +import base64url from "../base64url.js";
    +import { SignerKey } from "../types.js";
    +import { IndexerError } from "../errors.js";
    +import {
    +  StellarIndexerBackend,
    +  jsonScValToXdr,
    +  type StellarIndexerEntry,
    +} from "./stellar-indexer.js";
    +import { scValToSignerKey, signerKeyToIndexerJson } from "./codec.js";
    +
    +const CONTRACT = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +function backend(): StellarIndexerBackend {
    +  return new StellarIndexerBackend({
    +    url: "https://indexer.test",
    +    accessToken: "token",
    +    now: () => 1000,
    +  });
    +}
    +
    +/** Documented JSON-ScVal `val` for a Secp256r1 SignerVal (65-byte pubkey). */
    +function secpValJson(expiration?: number, limits?: unknown): unknown {
    +  const exp = expiration == null ? { void: null } : { u64: String(expiration) };
    +  return {
    +    vec: [
    +      { symbol: "Secp256r1" },
    +      { bytes: Buffer.alloc(65, 4).toString("hex") },
    +      { vec: [exp] }, // SignerExpiration = [Option]
    +      { vec: [limits ?? { void: null }] }, // SignerLimits = [Option]
    +    ],
    +  };
    +}
    +
    +describe("jsonScValToXdr (Stellar Indexer wire decode)", () => {
    +  const cases: Array<[string, unknown, xdr.ScVal]> = [
    +    ["symbol", { symbol: "Secp256r1" }, xdr.ScVal.scvSymbol("Secp256r1")],
    +    ["bytes (hex)", { bytes: "deadbeef" }, xdr.ScVal.scvBytes(Buffer.from("deadbeef", "hex"))],
    +    ["void", { void: null }, xdr.ScVal.scvVoid()],
    +    ["bool", { bool: true }, xdr.ScVal.scvBool(true)],
    +    ["u32", { u32: 42 }, xdr.ScVal.scvU32(42)],
    +    ["u64 (string)", { u64: "1786422535" }, nativeToScVal(1786422535n, { type: "u64" })],
    +    ["address", { address: CONTRACT }, Address.fromString(CONTRACT).toScVal()],
    +  ];
    +
    +  it.each(cases)("decodes %s to the same ScVal", (_name, json, expected) => {
    +    expect(jsonScValToXdr(json).toXDR("base64")).toBe(expected.toXDR("base64"));
    +  });
    +
    +  it("treats a bare null as void", () => {
    +    expect(jsonScValToXdr(null).toXDR("base64")).toBe(
    +      xdr.ScVal.scvVoid().toXDR("base64")
    +    );
    +  });
    +
    +  it("decodes a nested vec", () => {
    +    const json = { vec: [{ symbol: "Secp256r1" }, { bytes: "aabb" }] };
    +    const expected = xdr.ScVal.scvVec([
    +      xdr.ScVal.scvSymbol("Secp256r1"),
    +      xdr.ScVal.scvBytes(Buffer.from("aabb", "hex")),
    +    ]);
    +    expect(jsonScValToXdr(json).toXDR("base64")).toBe(expected.toXDR("base64"));
    +  });
    +
    +  it("decodes a map ([{key,val}])", () => {
    +    const json = { map: [{ key: { symbol: "a" }, val: { u32: 1 } }] };
    +    const expected = xdr.ScVal.scvMap([
    +      new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol("a"), val: xdr.ScVal.scvU32(1) }),
    +    ]);
    +    expect(jsonScValToXdr(json).toXDR("base64")).toBe(expected.toXDR("base64"));
    +  });
    +
    +  it("throws on an unknown or malformed variant (no silent misdecode)", () => {
    +    expect(() => jsonScValToXdr({ wat: 1 })).toThrow(IndexerError);
    +    expect(() => jsonScValToXdr({ symbol: "a", extra: 1 })).toThrow(IndexerError);
    +    expect(() => jsonScValToXdr("nope")).toThrow(IndexerError);
    +  });
    +
    +  it("round-trips a real SignerKey through the SDK's own documented-JSON encoder", () => {
    +    // signerKeyToIndexerJson emits the documented JSON-ScVal key predicate; the
    +    // live response uses the same convention, so decode must invert it.
    +    const keyId = base64url.encode(Buffer.alloc(16, 0xab));
    +    const decoded = scValToSignerKey(
    +      jsonScValToXdr(signerKeyToIndexerJson(SignerKey.Secp256r1(keyId)))
    +    );
    +    expect(decoded.key).toBe("Secp256r1");
    +    expect(decoded.value).toBe(keyId);
    +
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 2)).publicKey();
    +    const ed = scValToSignerKey(
    +      jsonScValToXdr(signerKeyToIndexerJson(SignerKey.Ed25519(g)))
    +    );
    +    expect(ed.key).toBe("Ed25519");
    +    expect(ed.value).toBe(g);
    +  });
    +});
    +
    +describe("StellarIndexerBackend.entriesToSigners (JSON ScVal entries)", () => {
    +  it("prefers a live entry over a tombstoned twin (durability flip)", () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 1));
    +    const key = signerKeyToIndexerJson(SignerKey.Secp256r1(keyId));
    +    const entries: StellarIndexerEntry[] = [
    +      { key, val: secpValJson(), durability: "temporary", deleted_at: "2026-07-11" },
    +      { key, val: secpValJson(), durability: "persistent", deleted_at: null },
    +    ];
    +
    +    const signers = backend().entriesToSigners(entries);
    +    expect(signers).toHaveLength(1);
    +    expect(signers[0]!.status).toBe("live");
    +    expect(signers[0]!.storage).toBe("persistent");
    +    expect(signers[0]!.key.value).toBe(keyId);
    +    expect(signers[0]!.publicKey).toHaveLength(65);
    +  });
    +
    +  it("marks a key removed when every entry is tombstoned", () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 2));
    +    const entries: StellarIndexerEntry[] = [
    +      {
    +        key: signerKeyToIndexerJson(SignerKey.Secp256r1(keyId)),
    +        val: secpValJson(),
    +        durability: "persistent",
    +        deleted_at: "2026-07-11",
    +      },
    +    ];
    +    expect(backend().entriesToSigners(entries)[0]!.status).toBe("removed");
    +  });
    +
    +  it("marks a live-but-expired entry expired", () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 3));
    +    const entries: StellarIndexerEntry[] = [
    +      {
    +        key: signerKeyToIndexerJson(SignerKey.Secp256r1(keyId)),
    +        val: secpValJson(500), // expiration 500 < now 1000
    +        durability: "persistent",
    +        deleted_at: null,
    +      },
    +    ];
    +    expect(backend().entriesToSigners(entries)[0]!.status).toBe("expired");
    +  });
    +
    +  it("decodes an Ed25519 signer (no stored public key)", () => {
    +    const g = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).publicKey();
    +    const entries: StellarIndexerEntry[] = [
    +      {
    +        key: signerKeyToIndexerJson(SignerKey.Ed25519(g)),
    +        val: { vec: [{ symbol: "Ed25519" }, { vec: [{ void: null }] }, { vec: [{ void: null }] }] },
    +        durability: "persistent",
    +        deleted_at: null,
    +      },
    +    ];
    +    const signer = backend().entriesToSigners(entries)[0]!;
    +    expect(signer.key.key).toBe("Ed25519");
    +    expect(signer.key.value).toBe(g);
    +    expect(signer.status).toBe("live");
    +    expect(signer.publicKey).toBeUndefined();
    +  });
    +});
    +
    +describe("StellarIndexerBackend mainnet-only network awareness", () => {
    +  const cfg = { url: "https://indexer.test", accessToken: "token" };
    +
    +  it("forNetwork returns null off mainnet (discovery disabled, not empty signers)", () => {
    +    expect(StellarIndexerBackend.forNetwork(cfg, Networks.TESTNET)).toBeNull();
    +    expect(StellarIndexerBackend.forNetwork(cfg, Networks.FUTURENET)).toBeNull();
    +  });
    +
    +  it("forNetwork returns a backend on mainnet", () => {
    +    expect(StellarIndexerBackend.forNetwork(cfg, Networks.PUBLIC)).toBeInstanceOf(
    +      StellarIndexerBackend
    +    );
    +  });
    +
    +  it("constructing pinned to a non-mainnet passphrase throws (no silent [])", () => {
    +    expect(
    +      () => new StellarIndexerBackend({ ...cfg, networkPassphrase: Networks.TESTNET })
    +    ).toThrow(IndexerError);
    +  });
    +});
    +
    +describe("StellarIndexerBackend.findWallets", () => {
    +  it("returns [] for non-Secp256r1 keys (not derivable here)", async () => {
    +    const result = await backend().findWallets(
    +      SignerKey.Ed25519(
    +        "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
    +      )
    +    );
    +    expect(result).toEqual([]);
    +  });
    +});
    +
    +describe("StellarIndexerBackend response-shape handling (audit LOW)", () => {
    +  const WALLET = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +  afterEach(() => vi.unstubAllGlobals());
    +
    +  function stubResponse(json: unknown) {
    +    vi.stubGlobal(
    +      "fetch",
    +      vi.fn(async () => ({
    +        ok: true,
    +        status: 200,
    +        json: async () => json,
    +        text: async () => "",
    +      }))
    +    );
    +  }
    +
    +  it("throws IndexerError on an unrecognized non-empty response shape", async () => {
    +    stubResponse({ results: [{ key: "x" }] });
    +    await expect(backend().getSigners(WALLET)).rejects.toBeInstanceOf(
    +      IndexerError
    +    );
    +  });
    +
    +  it("treats a genuinely-empty object/array as no entries", async () => {
    +    stubResponse({});
    +    expect(await backend().getSigners(WALLET)).toEqual([]);
    +    stubResponse({ entries: [] });
    +    expect(await backend().getSigners(WALLET)).toEqual([]);
    +  });
    +});
    diff --git a/src/indexer/stellar-indexer.ts b/src/indexer/stellar-indexer.ts
    new file mode 100644
    index 0000000..30ec861
    --- /dev/null
    +++ b/src/indexer/stellar-indexer.ts
    @@ -0,0 +1,390 @@
    +/**
    + * Stellar Indexer backend (Creit Tech), over `POST /v1/contract-data/`.
    + *
    + * The wallet stores one top-level contract-data entry per signer
    + * (`key = SignerKey ScVal`, `val = SignerVal`), so signer enumeration is a
    + * contract-scoped query and reverse lookup is derive-then-confirm: derive the
    + * wallet address from the keyId, then confirm the signer entry exists there.
    + * Removed signers appear as tombstoned (`deleted_at`) entries.
    + *
    + * WIRE FORMAT (F2-confirmed, todo 959 c2502 / todo 967):
    + * - The live API returns each entry's `key`/`val` as **JSON ScVal**, e.g.
    + *   `{"vec":[{"symbol":"Secp256r1"},{"bytes":""}]}` — NOT base64 XDR. We
    + *   convert JSON ScVal -> `xdr.ScVal` ({@link jsonScValToXdr}) and reuse the
    + *   contract-spec decoders (`scValToSignerKey`/`decodeSignerVal`). The JSON
    + *   convention is the standard Stellar one: `symbol`, `bytes` (hex), `address`
    + *   (strkey), `vec` (array), `map` (`[{key,val}]`), `u64`/`i64` (string),
    + *   `u32`/`i32` (number), `bool`, `string`, `void` (None).
    + * - Entry fields: `id, contract_id, durability, key, val,
    + *   last_modified_ledger_seq, tx_meta_version, timestamp, created_at,
    + *   updated_at, deleted_at`.
    + *
    + * NETWORK: **the Stellar Indexer indexes MAINNET only** — a testnet contract
    + * returns HTTP 200 with zero entries (F2). To keep "unsupported network" from
    + * masquerading as "wallet has no signers", the backend is network-aware: use
    + * {@link StellarIndexerBackend.forNetwork} (returns `null` off mainnet), and a
    + * config pinned to a non-mainnet passphrase throws at construction rather than
    + * silently returning `[]`. Live validation therefore needs a mainnet v1 wallet
    + * (endgame-gated); until then this is unit-tested against the documented shape.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Address, Networks, nativeToScVal, xdr } from "@stellar/stellar-sdk";
    +import { SignerKey } from "../types.js";
    +import { IndexerError, PasskeyKitErrorCode } from "../errors.js";
    +import { DEFAULT_INDEXER_TIMEOUT_MS } from "../constants.js";
    +import { deriveContractAddress } from "../utils.js";
    +import type {
    +  FindWalletsHardeningDeps,
    +  IndexerHealth,
    +  SignerIndexer,
    +  SignerStorageClass,
    +  WalletSigner,
    +} from "./types.js";
    +import {
    +  buildWalletSigner,
    +  decodeSignerVal,
    +  deriveStatus,
    +  scValToSignerKey,
    +  signerKeyToIndexerJson,
    +} from "./codec.js";
    +
    +/** Max contract ids per contract-data request (API cap). */
    +export const MAX_CONTRACTS_PER_QUERY = 25;
    +
    +/** The only network the Stellar Indexer indexes (mainnet). */
    +export const STELLAR_INDEXER_NETWORK = Networks.PUBLIC;
    +
    +/**
    + * A raw contract-data entry (F2-confirmed live shape). `key`/`val` are JSON
    + * ScVal objects (see {@link jsonScValToXdr}), not base64-XDR strings; the extra
    + * fields are carried through untyped since only these four drive decoding.
    + */
    +export interface StellarIndexerEntry {
    +  /** JSON ScVal of the SignerKey. */
    +  key: unknown;
    +  /** JSON ScVal of the SignerVal. */
    +  val: unknown;
    +  durability: "persistent" | "temporary";
    +  deleted_at: string | null;
    +  id?: string;
    +  contract_id?: string;
    +  last_modified_ledger_seq?: number;
    +  tx_meta_version?: string | number;
    +  timestamp?: string;
    +  created_at?: string;
    +  updated_at?: string;
    +}
    +
    +export interface StellarIndexerConfig {
    +  /** Base URL, e.g. https://api.stellarindexer.com */
    +  url: string;
    +  /** Bearer access token (server-side secret). */
    +  accessToken: string;
    +  /**
    +   * Network passphrase this backend serves. The Stellar Indexer indexes mainnet
    +   * only; a non-mainnet passphrase throws at construction (prefer
    +   * {@link StellarIndexerBackend.forNetwork} to get a `null` off mainnet). When
    +   * omitted, mainnet is assumed.
    +   */
    +  networkPassphrase?: string;
    +  /** Deps enabling derive-then-confirm reverse lookup. */
    +  hardening?: FindWalletsHardeningDeps;
    +  /** Clock source (seconds); injectable for tests. */
    +  now?: () => number;
    +}
    +
    +export class StellarIndexerBackend implements SignerIndexer {
    +  constructor(private readonly config: StellarIndexerConfig) {
    +    if (!config.url || !config.accessToken) {
    +      throw new IndexerError(
    +        "StellarIndexerBackend requires url and accessToken",
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +    if (
    +      config.networkPassphrase &&
    +      config.networkPassphrase !== STELLAR_INDEXER_NETWORK
    +    ) {
    +      throw new IndexerError(
    +        `Stellar Indexer indexes mainnet only; refusing a backend pinned to "${config.networkPassphrase}". ` +
    +          `Use StellarIndexerBackend.forNetwork(...) to get null off mainnet.`,
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +  }
    +
    +  /**
    +   * Network-aware factory: the backend for mainnet, or `null` for any other
    +   * network (the Stellar Indexer doesn't index testnet, so "discovery disabled"
    +   * is the honest answer — callers treat `null` as no-indexer rather than an
    +   * empty signer set).
    +   */
    +  static forNetwork(
    +    config: Omit,
    +    networkPassphrase: string
    +  ): StellarIndexerBackend | null {
    +    if (networkPassphrase !== STELLAR_INDEXER_NETWORK) return null;
    +    return new StellarIndexerBackend({ ...config, networkPassphrase });
    +  }
    +
    +  private nowSeconds(): number {
    +    return this.config.now ? this.config.now() : Math.floor(Date.now() / 1000);
    +  }
    +
    +  private async query(body: {
    +    contracts: string[];
    +    key?: unknown;
    +  }): Promise {
    +    const controller = new AbortController();
    +    const timeout = setTimeout(
    +      () => controller.abort(),
    +      DEFAULT_INDEXER_TIMEOUT_MS
    +    );
    +    try {
    +      const response = await fetch(`${this.config.url}/v1/contract-data/`, {
    +        method: "POST",
    +        headers: {
    +          "Content-Type": "application/json",
    +          Authorization: `Bearer ${this.config.accessToken}`,
    +        },
    +        body: JSON.stringify(body),
    +        signal: controller.signal,
    +      });
    +      if (!response.ok) {
    +        const text = await response.text().catch(() => "");
    +        throw new IndexerError(
    +          `Stellar Indexer request failed (${response.status}): ${text.slice(0, 200)}`,
    +          PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +          { status: response.status }
    +        );
    +      }
    +      const json = (await response.json()) as unknown;
    +      return extractEntries(json);
    +    } catch (err) {
    +      if (err instanceof IndexerError) throw err;
    +      const aborted = err instanceof Error && err.name === "AbortError";
    +      throw new IndexerError(
    +        aborted ? "Stellar Indexer request timed out" : String(err),
    +        PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +        undefined,
    +        err instanceof Error ? err : undefined
    +      );
    +    } finally {
    +      clearTimeout(timeout);
    +    }
    +  }
    +
    +  async getSigners(wallet: string): Promise {
    +    const entries = await this.query({ contracts: [wallet] });
    +    return this.entriesToSigners(entries);
    +  }
    +
    +  /**
    +   * Group entries by signer key and resolve status. A key with any live entry
    +   * is live/expired; a key whose every entry is tombstoned (no live counterpart
    +   * in either durability) is `removed`.
    +   */
    +  entriesToSigners(entries: StellarIndexerEntry[]): WalletSigner[] {
    +    const groups = new Map();
    +    for (const entry of entries) {
    +      // Group on the raw JSON ScVal of the key (identical signer keys serialize
    +      // identically); decode once per group below.
    +      const groupKey = JSON.stringify(entry.key);
    +      const list = groups.get(groupKey) ?? [];
    +      list.push(entry);
    +      groups.set(groupKey, list);
    +    }
    +
    +    const signers: WalletSigner[] = [];
    +    for (const [, group] of groups) {
    +      const live = group.find((e) => e.deleted_at == null);
    +      const chosen = live ?? group[0]!;
    +      const key = scValToSignerKey(jsonScValToXdr(chosen.key));
    +      const decoded = decodeSignerVal(jsonScValToXdr(chosen.val));
    +      const storage: SignerStorageClass = chosen.durability;
    +      const status = live
    +        ? deriveStatus({
    +            expiration: decoded.expiration,
    +            nowSeconds: this.nowSeconds(),
    +          })
    +        : "removed";
    +      signers.push(buildWalletSigner(key, decoded, storage, status));
    +    }
    +    return signers;
    +  }
    +
    +  /**
    +   * Reverse lookup via derive-then-confirm. Only Secp256r1 keyIds are
    +   * derivable; Ed25519/Policy signers are not discoverable through this backend
    +   * (use Mercury's event-driven index for those).
    +   */
    +  async findWallets(key: SignerKey): Promise {
    +    if (key.key !== "Secp256r1") {
    +      return [];
    +    }
    +    if (!this.config.hardening) {
    +      throw new IndexerError(
    +        "findWallets requires hardening deps (networkPassphrase + deployerPublicKey) to derive candidates",
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
    +    }
    +
    +    const candidate = deriveContractAddress(
    +      Buffer.from(key.value, "base64url"),
    +      this.config.hardening.deployerPublicKey,
    +      this.config.hardening.networkPassphrase
    +    );
    +
    +    const entries = await this.query({
    +      contracts: [candidate],
    +      key: signerKeyToIndexerJson(key),
    +    });
    +
    +    // Confirmed only if the derived wallet holds a live entry for this key.
    +    const live = entries.some((e) => e.deleted_at == null);
    +    return live ? [candidate] : [];
    +  }
    +
    +  async health(): Promise {
    +    try {
    +      await this.query({
    +        contracts: ["CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"],
    +      });
    +      return { ok: true, backend: "stellar-indexer" };
    +    } catch (err) {
    +      return {
    +        ok: false,
    +        backend: "stellar-indexer",
    +        detail: err instanceof Error ? err.message : String(err),
    +      };
    +    }
    +  }
    +}
    +
    +/**
    + * Pull the entries array out of a few known response shapes.
    + *
    + * A genuinely empty response (`null`/`undefined` or `{}`) yields `[]`, but an
    + * UNRECOGNIZED non-empty envelope throws rather than silently degrading to `[]`:
    + * conflating "shape changed" with "wallet has no signers" would burn the whole
    + * retry budget and then report a valid wallet as signer-less (audit LOW).
    + */
    +function extractEntries(json: unknown): StellarIndexerEntry[] {
    +  if (Array.isArray(json)) return json as StellarIndexerEntry[];
    +  if (json == null) return [];
    +  if (typeof json === "object") {
    +    const obj = json as { entries?: unknown; data?: unknown };
    +    if (Array.isArray(obj.entries)) return obj.entries as StellarIndexerEntry[];
    +    if (Array.isArray(obj.data)) return obj.data as StellarIndexerEntry[];
    +    if (Object.keys(obj).length === 0) return []; // genuinely-empty object
    +  }
    +  throw new IndexerError(
    +    "Unrecognized Stellar Indexer response shape (expected an array or { entries | data: [...] })",
    +    PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +  );
    +}
    +
    +/**
    + * Convert a Stellar JSON-ScVal object into an `xdr.ScVal`.
    + *
    + * The Stellar Indexer serializes ledger-entry `key`/`val` as JSON ScVal — a
    + * single-key object per value (`{"vec":[...]}`, `{"symbol":"…"}`, …). This is
    + * the inverse of that serialization for the variants a smart-wallet
    + * SignerKey/SignerVal can contain, letting the contract-spec decoders take over.
    + *
    + * Unknown variant keys THROW (never silently misdecode): an unexpected wire
    + * shape must surface as an error, not a wrong signer.
    + */
    +export function jsonScValToXdr(json: unknown): xdr.ScVal {
    +  // Some encoders represent `void`/None as a bare null.
    +  if (json === null || json === undefined) return xdr.ScVal.scvVoid();
    +  if (typeof json !== "object") {
    +    throw new IndexerError(
    +      `Unrecognized JSON ScVal (not an object): ${JSON.stringify(json)}`,
    +      PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +    );
    +  }
    +
    +  const obj = json as Record;
    +  const keys = Object.keys(obj);
    +  if (keys.length !== 1) {
    +    throw new IndexerError(
    +      `Unrecognized JSON ScVal (expected exactly one type key): ${JSON.stringify(keys)}`,
    +      PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +    );
    +  }
    +  const tag = keys[0]!;
    +  const v = obj[tag];
    +
    +  switch (tag) {
    +    case "void":
    +      return xdr.ScVal.scvVoid();
    +    case "bool":
    +      return xdr.ScVal.scvBool(Boolean(v));
    +    case "u32":
    +      return xdr.ScVal.scvU32(Number(v));
    +    case "i32":
    +      return xdr.ScVal.scvI32(Number(v));
    +    case "u64":
    +      return nativeToScVal(BigInt(v as string | number), { type: "u64" });
    +    case "i64":
    +      return nativeToScVal(BigInt(v as string | number), { type: "i64" });
    +    case "timepoint":
    +      return nativeToScVal(BigInt(v as string | number), { type: "timepoint" });
    +    case "duration":
    +      return nativeToScVal(BigInt(v as string | number), { type: "duration" });
    +    case "u128":
    +      return nativeToScVal(bigIntOfParts(v), { type: "u128" });
    +    case "i128":
    +      return nativeToScVal(bigIntOfParts(v), { type: "i128" });
    +    case "u256":
    +      return nativeToScVal(bigIntOfParts(v), { type: "u256" });
    +    case "i256":
    +      return nativeToScVal(bigIntOfParts(v), { type: "i256" });
    +    case "bytes":
    +      return xdr.ScVal.scvBytes(Buffer.from(String(v), "hex"));
    +    case "string":
    +      return xdr.ScVal.scvString(String(v));
    +    case "symbol":
    +      return xdr.ScVal.scvSymbol(String(v));
    +    case "address":
    +      return Address.fromString(String(v)).toScVal();
    +    case "vec":
    +      return xdr.ScVal.scvVec((v as unknown[]).map(jsonScValToXdr));
    +    case "map":
    +      return xdr.ScVal.scvMap(
    +        (v as Array<{ key: unknown; val: unknown }>).map(
    +          (e) =>
    +            new xdr.ScMapEntry({
    +              key: jsonScValToXdr(e.key),
    +              val: jsonScValToXdr(e.val),
    +            })
    +        )
    +      );
    +    default:
    +      throw new IndexerError(
    +        `Unsupported JSON ScVal type "${tag}"`,
    +        PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +      );
    +  }
    +}
    +
    +/**
    + * Coerce a large-integer JSON ScVal value to a bigint, accepting either a
    + * decimal string/number or a `{ hi, lo }` multi-part object (the exact 128/256
    + * wire shape is undocumented and unused by SignerVal — u64 covers expiration —
    + * so this stays tolerant).
    + */
    +function bigIntOfParts(v: unknown): bigint {
    +  if (typeof v === "string" || typeof v === "number") return BigInt(v);
    +  if (v && typeof v === "object" && "hi" in v && "lo" in v) {
    +    const { hi, lo } = v as { hi: string | number; lo: string | number };
    +    return (BigInt(hi) << 64n) | BigInt(lo);
    +  }
    +  throw new IndexerError(
    +    `Unrecognized large-integer JSON ScVal value: ${JSON.stringify(v)}`,
    +    PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +  );
    +}
    diff --git a/src/indexer/types.ts b/src/indexer/types.ts
    new file mode 100644
    index 0000000..25a3c21
    --- /dev/null
    +++ b/src/indexer/types.ts
    @@ -0,0 +1,80 @@
    +/**
    + * Indexer abstraction: one interface, two interchangeable backends (Mercury and
    + * Stellar Indexer). Both resolve the same {@link WalletSigner} shape so a signer
    + * added through the demo is discoverable through either backend and asserted
    + * equal in the F2 cross-backend e2e.
    + *
    + * @packageDocumentation
    + */
    +
    +import type { SignerKey, SignerLimits } from "../types.js";
    +
    +/** Storage durability of a signer's ledger entry. */
    +export type SignerStorageClass = "persistent" | "temporary";
    +
    +/**
    + * Lifecycle status of a signer, derived from the entry + expiration:
    + * - `live` — present and not expired.
    + * - `expired` — present but past its expiration timestamp.
    + * - `evicted` — a temporary entry whose ledger state was evicted (TTL).
    + * - `removed` — tombstoned (remove_signer), no live counterpart in either
    + *   durability.
    + */
    +export type SignerStatus = "live" | "expired" | "evicted" | "removed";
    +
    +/** A signer as resolved by an indexer backend. */
    +export interface WalletSigner {
    +  /** The signer's key (kind + value). */
    +  key: SignerKey;
    +  /** 65-byte secp256r1 public key, for Secp256r1 signers. */
    +  publicKey?: Uint8Array;
    +  /**
    +   * Expiration as a UNIX timestamp in seconds (inclusive), if set. The reworked
    +   * contract stores expiration as a timestamp, not a ledger sequence (#602).
    +   */
    +  expiration?: number;
    +  /** Per-contract limits (`undefined` = unlimited). */
    +  limits?: SignerLimits;
    +  /** Storage durability of the entry. */
    +  storage: SignerStorageClass;
    +  /** Derived lifecycle status. */
    +  status: SignerStatus;
    +}
    +
    +/** Health of an indexer backend. */
    +export interface IndexerHealth {
    +  /** Whether the backend answered a health probe. */
    +  ok: boolean;
    +  /** Backend identifier (e.g. "mercury", "stellar-indexer"). */
    +  backend: string;
    +  /** Optional human-readable detail. */
    +  detail?: string;
    +}
    +
    +/**
    + * A pluggable signer indexer.
    + *
    + * Null-tolerant seam (per SAK): a backend that is *not configured* is
    + * represented as `null` at the call site (see `forNetwork`); a backend that is
    + * configured but *fails* throws; a health/404 degrades to `{ ok: false }`
    + * rather than throwing.
    + */
    +export interface SignerIndexer {
    +  /** Enumerate all signers currently indexed for a wallet. */
    +  getSigners(wallet: string): Promise;
    +  /** Reverse lookup: the wallet contract ids a signer key belongs to. */
    +  findWallets(key: SignerKey): Promise;
    +  /** Probe backend health (degrades to `{ ok: false }` rather than throwing). */
    +  health(): Promise;
    +}
    +
    +/**
    + * Deps for the SDK-side `findWallets` hardening (#598 F3): before trusting a
    + * reverse-lookup result, the candidate wallet is confirmed by re-deriving its
    + * address from the keyId (deterministic derivation) — no unverified `res[0]`.
    + */
    +export interface FindWalletsHardeningDeps {
    +  networkPassphrase: string;
    +  /** The canonical deployer `G…` public key used for derivation. */
    +  deployerPublicKey: string;
    +}
    diff --git a/src/kit.ts b/src/kit.ts
    index b1086b8..3103df8 100644
    --- a/src/kit.ts
    +++ b/src/kit.ts
    @@ -1,698 +1,470 @@
    -import { Client as PasskeyClient, type Signature, type SignerKey as SDKSignerKey, type SignerLimits as SDKSignerLimits } from 'passkey-kit-sdk'
    -import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk/minimal'
    -import { startRegistration, startAuthentication, type AuthenticationResponseJSON, type AuthenticatorAttestationResponseJSON, type AuthenticatorSelectionCriteria } from "@simplewebauthn/browser"
    -import { Buffer } from 'buffer'
    -import base64url from 'base64url'
    -import type { SignerKey, SignerLimits, SignerStore } from './types'
    -import { PasskeyBase } from './base'
    -import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/minimal/contract'
    -import type { Server } from '@stellar/stellar-sdk/minimal/rpc'
    -
    -// TODO we should allow setting the rpId in the constructor so we don't have to keep setting it for every call
    -// e.g. if you want to sign with a smol.xyz key on next.smol.xyz key you have to set rpId over and over again
    -
    -export class PasskeyKit extends PasskeyBase {
    -    declare rpc: Server
    -    declare rpcUrl: string
    -
    -    private walletKeypair: Keypair
    -    private walletPublicKey: string
    -    private walletWasmHash: string
    -    private timeoutInSeconds: number
    -    private WebAuthn: {
    -        startRegistration: typeof startRegistration,
    -        startAuthentication: typeof startAuthentication
    +/**
    + * `PasskeyKit` — the browser-side facade for creating and using smart-wallet
    + * accounts with WebAuthn passkeys.
    + *
    + * This is a ground-up rewrite of the old monolithic `kit.ts`. The signing
    + * pipeline, WebAuthn ceremonies, deploy path, and signer writes now live in
    + * dependency-injected managers ({@link CredentialManager}, {@link SignerManager},
    + * {@link SubmissionManager}) wired here with late-bound closures. All Protocol-27
    + * probe shims, dead commented experiments, and the legacy factory-address
    + * fallback are gone — the kit targets stellar-sdk >= 16 and the current wallet.
    + *
    + * @packageDocumentation
    + */
    +
    +import { xdr } from "@stellar/stellar-sdk";
    +import { Server } from "@stellar/stellar-sdk/rpc";
    +import {
    +  startAuthentication,
    +  startRegistration,
    +  type AuthenticationResponseJSON,
    +  type AuthenticatorSelectionCriteria,
    +} from "@simplewebauthn/browser";
    +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract";
    +import { Client as PasskeyClient } from "passkey-kit-sdk";
    +import base64url from "./base64url.js";
    +import {
    +  SignerKey,
    +  SignerStore,
    +  type ConnectWalletResult,
    +  type CreateWalletResult,
    +  type SignerLimits,
    +  type StorageAdapter,
    +} from "./types.js";
    +import {
    +  ConfigurationError,
    +  PasskeyKitError,
    +  PasskeyKitErrorCode,
    +  WalletNotConnectedError,
    +  WalletOwnershipError,
    +} from "./errors.js";
    +import { PasskeyEventEmitter } from "./events.js";
    +import { DEFAULT_TIMEOUT_SECONDS } from "./constants.js";
    +import { PasskeySigner, type Signer, type SignerContext } from "./signers.js";
    +import type { WebAuthnClient } from "./kit/webauthn-ops.js";
    +import type { CreatedPasskey } from "./kit/webauthn-ops.js";
    +import type { SignOptions } from "./kit/tx-ops.js";
    +import { calculateExpiration } from "./kit/tx-ops.js";
    +import {
    +  CredentialManager,
    +  SignerManager,
    +  SubmissionManager,
    +} from "./managers/index.js";
    +import { resolveDeployer } from "./kit/deploy-ops.js";
    +import type { WalletTx } from "./kit/wallet-ops.js";
    +
    +/** Configuration for a {@link PasskeyKit} client. */
    +export interface PasskeyKitConfig {
    +  /** Stellar RPC URL. */
    +  rpcUrl: string;
    +  /** Network passphrase. */
    +  networkPassphrase: string;
    +  /** Smart-wallet WASM hash (hex) used to deploy new wallets. */
    +  walletWasmHash: string;
    +  /** WebAuthn Relying Party id (domain); defaults to the current origin. */
    +  rpId?: string;
    +  /**
    +   * Secret key (`S…`) for the fee-paying deployer. Defaults to the canonical
    +   * deterministic deployer (see {@link resolveDeployer}); overriding it changes
    +   * derived wallet addresses.
    +   */
    +  deploySource?: string;
    +  /** Transaction timeout, in seconds (default 30). */
    +  timeoutInSeconds?: number;
    +  /** Optional passkey-record storage adapter (see `passkey-kit/storage`). */
    +  storage?: StorageAdapter;
    +  /** Custom WebAuthn implementation (for testing). */
    +  WebAuthn?: WebAuthnClient;
    +}
    +
    +/** Options for {@link PasskeyKit.createKey}/{@link PasskeyKit.createWallet}. */
    +export interface CreateOptions {
    +  authenticatorSelection?: AuthenticatorSelectionCriteria;
    +}
    +
    +/** Options for {@link PasskeyKit.connectWallet}. */
    +export interface ConnectOptions {
    +  /** A specific keyId to connect (skips the discovery ceremony). */
    +  keyId?: string | Uint8Array;
    +  /** Indexer-backed keyId → contract lookup, used when derivation misses. */
    +  getContractId?: (keyId: string) => Promise;
    +  /**
    +   * Also assert the wallet's on-chain WASM hash equals `walletWasmHash`.
    +   * Off by default: an upgraded wallet legitimately runs a different hash.
    +   */
    +  verifyWasmHash?: boolean;
    +}
    +
    +export class PasskeyKit {
    +  readonly rpc: Server;
    +  readonly rpcUrl: string;
    +  readonly networkPassphrase: string;
    +  readonly walletWasmHash: string;
    +  readonly rpId?: string;
    +
    +  /** Lifecycle event emitter (walletCreated, walletConnected, …). */
    +  readonly events = new PasskeyEventEmitter();
    +
    +  private readonly timeoutInSeconds: number;
    +  private readonly webAuthn: WebAuthnClient;
    +
    +  private readonly credentialManager: CredentialManager;
    +  private readonly signerManager: SignerManager;
    +  private readonly submissionManager: SubmissionManager;
    +
    +  /** The connected passkey's base64url keyId, if any. */
    +  keyId: string | undefined;
    +  /** The connected wallet client, if any. */
    +  wallet: PasskeyClient | undefined;
    +
    +  constructor(config: PasskeyKitConfig) {
    +    if (!config.rpcUrl) {
    +      throw new ConfigurationError(
    +        "rpcUrl is required",
    +        PasskeyKitErrorCode.MISSING_CONFIG
    +      );
         }
    -
    -    public keyId: string | undefined
    -    public networkPassphrase: string
    -    public wallet: PasskeyClient | undefined
    -
    -    constructor(options: {
    -        rpcUrl: string,
    -        networkPassphrase: string,
    -        walletWasmHash: string,
    -        timeoutInSeconds?: number,
    -        WebAuthn?: {
    -            startRegistration: typeof startRegistration,
    -            startAuthentication: typeof startAuthentication
    -        }
    -    }) {
    -        const { rpcUrl, networkPassphrase, walletWasmHash, WebAuthn } = options
    -
    -        super(rpcUrl)
    -
    -        this.networkPassphrase = networkPassphrase
    -        // this account exists as the seed source for deploying new wallets
    -        // not using the genesis wallet as on mainnet the account has no usable signers
    -        // there's a chance this isn't the best move and should instead be a constructor variable
    -        // alternatively when we create a new wallet we shouldn't inherit the source as the auth entry signer
    -        // Keypair.fromRawEd25519Seed(hash(Buffer.from(this.networkPassphrase)))
    -        this.walletKeypair = Keypair.fromRawEd25519Seed(hash(Buffer.from('kalepail')));
    -        this.walletPublicKey = this.walletKeypair.publicKey()
    -        this.walletWasmHash = walletWasmHash
    -        this.timeoutInSeconds = options.timeoutInSeconds || 30 // OpenZeppelin Relayer requires <= 30 second timeout so let's default to that
    -        this.WebAuthn = WebAuthn || { startRegistration, startAuthentication }
    +    if (!config.networkPassphrase) {
    +      throw new ConfigurationError(
    +        "networkPassphrase is required",
    +        PasskeyKitErrorCode.MISSING_CONFIG
    +      );
         }
    -
    -    public async createWallet(
    -        app: string, 
    -        user: string, 
    -        settings?: {
    -            rpId?: string
    -            authenticatorSelection?: AuthenticatorSelectionCriteria
    -        }
    -    ) {
    -        const { rawResponse, keyId, keyIdBase64, publicKey } = await this.createKey(app, user, settings)
    -
    -        const at = await PasskeyClient.deploy(
    -            {
    -                signer: {
    -                    tag: 'Secp256r1',
    -                    values: [
    -                        keyId,
    -                        publicKey,
    -                        [undefined],
    -                        [undefined],
    -                        { tag: 'Persistent', values: undefined },
    -                    ]
    -                }
    -            },
    -            {
    -                rpcUrl: this.rpcUrl,
    -                wasmHash: this.walletWasmHash,
    -                networkPassphrase: this.networkPassphrase,
    -                publicKey: this.walletPublicKey,
    -                salt: hash(keyId),
    -                timeoutInSeconds: this.timeoutInSeconds,
    -            }
    -        )
    -
    -        const contractId = at.result.options.contractId
    -
    -        this.wallet = new PasskeyClient({
    -            contractId,
    -            networkPassphrase: this.networkPassphrase,
    -            rpcUrl: this.rpcUrl
    -        })
    -
    -        await at.sign({
    -            signTransaction: basicNodeSigner(this.walletKeypair, this.networkPassphrase).signTransaction
    -        })
    -
    -        return {
    -            rawResponse,
    -            keyId,
    -            keyIdBase64,
    -            contractId,
    -            signedTx: at.signed!
    -        }
    +    if (!config.walletWasmHash) {
    +      throw new ConfigurationError(
    +        "walletWasmHash is required",
    +        PasskeyKitErrorCode.MISSING_CONFIG
    +      );
         }
     
    -    public async createKey(app: string, user: string, settings?: {
    -        rpId?: string
    -        authenticatorSelection?: AuthenticatorSelectionCriteria
    -    }) {
    -        const now = new Date()
    -        const displayName = `${user} — ${now.toLocaleString()}`
    -        const { rpId, authenticatorSelection = {
    -            residentKey: "preferred",
    -            userVerification: "preferred",
    -        } } = settings || {}
    -
    -        // TODO discover the contract id before creating the key so we can use it in the key name
    -        // TODO it's possible for the creation to fail in which case we've created a passkey but it's not onchain.
    -        // In this case we should save the passkey info and retry uploading it async vs asking the user to create another passkey
    -        // This does introduce a storage dependency though so it likely needs to be a function with some logic for choosing how to store the passkey data
    -
    -        const rawResponse = await this.WebAuthn.startRegistration({
    -            optionsJSON: {
    -                challenge: base64url("stellaristhebetterblockchain"),
    -                rp: {
    -                    id: rpId,
    -                    name: app,
    -                },
    -                user: {
    -                    id: base64url(`${user}:${now.getTime()}:${Math.random()}`),
    -                    name: displayName,
    -                    displayName
    -                },
    -                authenticatorSelection,
    -                pubKeyCredParams: [{ alg: -7, type: "public-key" }],
    -            }
    -        });
    -        const { id, response } = rawResponse
    -
    -        if (!this.keyId)
    -            this.keyId = id;
    -
    -        return {
    -            rawResponse,
    -            keyId: base64url.toBuffer(id),
    -            keyIdBase64: id,
    -            publicKey: await this.getPublicKey(response),
    -        }
    +    this.rpc = new Server(config.rpcUrl);
    +    this.rpcUrl = config.rpcUrl;
    +    this.networkPassphrase = config.networkPassphrase;
    +    this.walletWasmHash = config.walletWasmHash;
    +    this.rpId = config.rpId;
    +    this.timeoutInSeconds = config.timeoutInSeconds ?? DEFAULT_TIMEOUT_SECONDS;
    +    this.webAuthn =
    +      config.WebAuthn ?? ({ startRegistration, startAuthentication } as WebAuthnClient);
    +
    +    const deployerKeypair = resolveDeployer(config.deploySource);
    +
    +    this.credentialManager = new CredentialManager({
    +      rpId: this.rpId,
    +      webAuthn: this.webAuthn,
    +      storage: config.storage,
    +    });
    +
    +    this.signerManager = new SignerManager({
    +      networkPassphrase: this.networkPassphrase,
    +      timeoutInSeconds: this.timeoutInSeconds,
    +      rpc: this.rpc,
    +      getWallet: () => this.wallet,
    +      getContractId: () => this.wallet?.options.contractId,
    +      getSignerContext: () => this.signerContext(),
    +      calculateExpiration: () =>
    +        calculateExpiration({ rpc: this.rpc, timeoutInSeconds: this.timeoutInSeconds }),
    +    });
    +
    +    this.submissionManager = new SubmissionManager({
    +      rpc: this.rpc,
    +      rpcUrl: config.rpcUrl,
    +      networkPassphrase: this.networkPassphrase,
    +      walletWasmHash: this.walletWasmHash,
    +      deployerKeypair,
    +      timeoutInSeconds: this.timeoutInSeconds,
    +    });
    +  }
    +
    +  /** The connected wallet's contract id, if any. */
    +  get contractId(): string | undefined {
    +    return this.wallet?.options.contractId;
    +  }
    +
    +  /** The fee-paying deployer's `G…` public key. */
    +  get deployerPublicKey(): string {
    +    return this.submissionManager.deployerPublicKey;
    +  }
    +
    +  private signerContext(): SignerContext {
    +    return { rpId: this.rpId, webAuthn: this.webAuthn, defaultKeyId: this.keyId };
    +  }
    +
    +  // -- Passkey / wallet lifecycle ---------------------------------------------
    +
    +  /** Run a passkey registration ceremony without deploying a wallet. */
    +  createKey(
    +    appName: string,
    +    userName: string,
    +    options?: CreateOptions
    +  ): Promise {
    +    return this.credentialManager.createKey(
    +      appName,
    +      userName,
    +      options?.authenticatorSelection
    +    );
    +  }
    +
    +  /**
    +   * Register a passkey and deploy a smart wallet initialized with it as the
    +   * first signer. Returns the signed deploy transaction (submit it via
    +   * `PasskeyServer`).
    +   */
    +  async createWallet(
    +    appName: string,
    +    userName: string,
    +    options?: CreateOptions
    +  ): Promise {
    +    const created = await this.createKey(appName, userName, options);
    +
    +    const deployTx = await this.submissionManager.buildDeployTransaction(
    +      created.keyIdBuffer,
    +      created.publicKey
    +    );
    +    const contractId = deployTx.result.options.contractId;
    +
    +    this.wallet = new PasskeyClient({
    +      contractId,
    +      rpcUrl: this.rpcUrl,
    +      networkPassphrase: this.networkPassphrase,
    +    });
    +    this.keyId = created.keyId;
    +
    +    const signedTx = await this.submissionManager.signDeploy(deployTx);
    +
    +    await this.credentialManager.rememberPasskey({
    +      keyId: created.keyId,
    +      publicKey: created.publicKey,
    +      contractId,
    +      createdAt: Date.now(),
    +    });
    +
    +    this.events.emit("walletCreated", { contractId, keyId: created.keyId });
    +
    +    return {
    +      rawResponse: created.rawResponse,
    +      keyId: created.keyIdBuffer,
    +      keyIdBase64: created.keyId,
    +      contractId,
    +      signedTx,
    +    };
    +  }
    +
    +  /**
    +   * Connect an existing wallet from a passkey.
    +   *
    +   * Resolves the wallet address by (1) deterministic derivation from the keyId,
    +   * (2) local storage, then (3) an injected indexer lookup — and then VERIFIES
    +   * ownership: the keyId must resolve to a live signer on the wallet (#601 F7).
    +   * This closes the unverified reverse-lookup hole (#598 F3) at the SDK layer.
    +   *
    +   * @throws {WalletOwnershipError} If the keyId is not a signer on the wallet.
    +   */
    +  async connectWallet(options?: ConnectOptions): Promise {
    +    let rawResponse: AuthenticationResponseJSON | undefined;
    +    let keyId = options?.keyId;
    +
    +    if (!keyId) {
    +      const auth = await this.credentialManager.authenticate();
    +      keyId = auth.keyId;
    +      rawResponse = auth.rawResponse;
         }
     
    -    public async connectWallet(opts?: {
    -        rpId?: string,
    -        keyId?: string | Uint8Array,
    -        getContractId?: (keyId: string) => Promise,
    -        // TEMP for backwards compatibility for when we seeded wallets from a factory address
    -        // Consider putting this somewhere else??
    -        walletPublicKey?: string
    -    }) {
    -        let { rpId, keyId, getContractId, walletPublicKey } = opts || {}
    -        let keyIdBuffer: Buffer
    -        let rawResponse: AuthenticationResponseJSON | undefined;
    -
    -        if (!keyId) {
    -            rawResponse = await this.WebAuthn.startAuthentication({
    -                optionsJSON: {
    -                    challenge: base64url("stellaristhebetterblockchain"),
    -                    rpId,
    -                    userVerification: "preferred",
    -                }
    -            });
    -
    -            keyId = rawResponse.id
    -        }
    -
    -        if (keyId instanceof Uint8Array) {
    -            keyIdBuffer = Buffer.from(keyId)
    -            keyId = base64url(keyIdBuffer)
    -        } else {
    -            keyIdBuffer = base64url.toBuffer(keyId)
    -        }
    -
    -        if (!this.keyId)
    -            this.keyId = keyId
    -
    -        // Check for the contractId on-chain as a derivation from the keyId. This is the easiest and "cheapest" check however it will only work for the initially deployed passkey if it was used as derivation
    -        let contractId: string | undefined = this.encodeContract(this.walletPublicKey, keyIdBuffer);
    -
    -        // attempt passkey id derivation
    -        try {
    -            // TODO what is the error if the entry exists but is archived?
    -            await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance())
    -        }
    -        // if that fails look up from the `getContractId` function
    -        catch {
    -            contractId = getContractId && await getContractId(keyId)
    -        }
    -
    -        ////
    -        // TEMP for backwards compatibility for when we seeded wallets from a factory address
    -        // Consider putting this in the constructor
    -        if (!contractId && walletPublicKey) {
    -            contractId = this.encodeContract(walletPublicKey, keyIdBuffer);
    -
    -            try {
    -                await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance())
    -            } catch {
    -                contractId = undefined
    -            }
    -        }
    -        ////
    -
    -        if (!contractId)
    -            throw new Error('Failed to connect wallet')
    -
    -        this.wallet = new PasskeyClient({
    -            contractId,
    -            rpcUrl: this.rpcUrl,
    -            networkPassphrase: this.networkPassphrase,
    -        })
    -
    -        return {
    -            rawResponse,
    -            keyId: keyIdBuffer,
    -            keyIdBase64: keyId,
    -            contractId
    -        }
    +    const keyIdBase64 =
    +      keyId instanceof Uint8Array ? base64url(Buffer.from(keyId)) : keyId;
    +    const keyIdBuffer =
    +      keyId instanceof Uint8Array ? Buffer.from(keyId) : base64url.toBuffer(keyId);
    +
    +    // 1) Deterministic derivation, confirmed by an on-chain instance read.
    +    let contractId: string | undefined =
    +      this.submissionManager.deriveWalletAddress(keyIdBuffer);
    +    try {
    +      await this.rpc.getContractData(
    +        contractId,
    +        xdr.ScVal.scvLedgerKeyContractInstance()
    +      );
    +    } catch {
    +      // 2) local storage, then 3) injected indexer lookup.
    +      contractId =
    +        (await this.credentialManager.lookupContractId(keyIdBase64)) ??
    +        (options?.getContractId
    +          ? await options.getContractId(keyIdBase64)
    +          : undefined);
         }
     
    -    public async signAuthEntry(
    -        entry: xdr.SorobanAuthorizationEntry,
    -        options?: {
    -            rpId?: string,
    -            keyId?: 'any' | string | Uint8Array
    -            keypair?: Keypair,
    -            policy?: string,
    -            expiration?: number
    -        }
    -    ) {
    -        let { rpId, keyId, keypair, policy, expiration } = options || {}
    -
    -        if ([keyId, keypair, policy].filter((arg) => !!arg).length > 1)
    -            throw new Error('Exactly one of `options.keyId`, `options.keypair`, or `options.policy` must be provided.');
    -
    -        const credentials = entry.credentials().address();
    -
    -        if (!expiration) {
    -            expiration = credentials.signatureExpirationLedger()
    -
    -            if (!expiration) {
    -                const { sequence } = await this.rpc.getLatestLedger()
    -                expiration = sequence + this.timeoutInSeconds / 5; // assumes 5 second ledger time
    -            }
    -        }
    -
    -        credentials.signatureExpirationLedger(expiration)
    -
    -        const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
    -            new xdr.HashIdPreimageSorobanAuthorization({
    -                networkId: hash(Buffer.from(this.networkPassphrase)),
    -                nonce: credentials.nonce(),
    -                signatureExpirationLedger: credentials.signatureExpirationLedger(),
    -                invocation: entry.rootInvocation()
    -            })
    -        )
    -
    -        const payload = hash(preimage.toXDR())
    -
    -        // let signatures: Signatures
    -        let key: SDKSignerKey
    -        let val: Signature | undefined
    -
    -        // const scSpecTypeDefSignatures = xdr.ScSpecTypeDef.scSpecTypeUdt(
    -        //     new xdr.ScSpecTypeUdt({ name: "Signatures" }),
    -        // );
    -
    -        // switch (credentials.signature().switch()) {
    -        //     case xdr.ScValType.scvVoid():
    -        //         signatures = [new Map()]
    -        //         break;
    -        //     default: {
    -        //         signatures = this.wallet!.spec.scValToNative(credentials.signature(), scSpecTypeDefSignatures)
    -        //     }
    -        // }
    -
    -        // Sign with a policy
    -        if (policy) {
    -            key = {
    -                tag: "Policy",
    -                values: [policy]
    -            }
    -            val = {
    -                tag: "Policy",
    -                values: undefined,
    -            }
    -        }
    -
    -        // Sign with the keypair as an ed25519 signer
    -        else if (keypair) {
    -            const signature = keypair.sign(payload);
    -
    -            key = {
    -                tag: "Ed25519",
    -                values: [keypair.rawPublicKey()]
    -            }
    -            val = {
    -                tag: "Ed25519",
    -                values: [signature],
    -            }
    -        }
    -
    -        // Default, use passkey
    -        else {
    -            const authenticationResponse = await this.WebAuthn.startAuthentication({
    -                optionsJSON: keyId === 'any'
    -                    || (!keyId && !this.keyId)
    -                    ? {
    -                        challenge: base64url(payload),
    -                        rpId,
    -                        userVerification: "preferred",
    -                    }
    -                    : {
    -                        challenge: base64url(payload),
    -                        rpId,
    -                        allowCredentials: [
    -                            {
    -                                id: keyId instanceof Uint8Array
    -                                    ? base64url(Buffer.from(keyId))
    -                                    : keyId || this.keyId!,
    -                                type: "public-key",
    -                            },
    -                        ],
    -                        userVerification: "preferred",
    -                    }
    -            });
    -
    -            key = {
    -                tag: "Secp256r1",
    -                values: [base64url.toBuffer(authenticationResponse.id)]
    -            }
    -            val = {
    -                tag: "Secp256r1",
    -                values: [
    -                    {
    -                        authenticator_data: base64url.toBuffer(
    -                            authenticationResponse.response.authenticatorData,
    -                        ),
    -                        client_data_json: base64url.toBuffer(
    -                            authenticationResponse.response.clientDataJSON,
    -                        ),
    -                        signature: this.compactSignature(
    -                            base64url.toBuffer(authenticationResponse.response.signature)
    -                        ),
    -                    },
    -                ],
    -            }
    -        }
    -
    -        const scKeyType = xdr.ScSpecTypeDef.scSpecTypeUdt(
    -            new xdr.ScSpecTypeUdt({ name: "SignerKey" }),
    -        );
    -        const scValType = xdr.ScSpecTypeDef.scSpecTypeUdt(
    -            new xdr.ScSpecTypeUdt({ name: "Signature" }),
    -        );
    -        const scKey = this.wallet!.spec.nativeToScVal(key, scKeyType);
    -        const scVal = val ? this.wallet!.spec.nativeToScVal(val, scValType) : xdr.ScVal.scvVoid();
    -        const scEntry = new xdr.ScMapEntry({
    -            key: scKey,
    -            val: scVal,
    -        })
    -
    -        switch (credentials.signature().switch().name) {
    -            case 'scvVoid':
    -                credentials.signature(xdr.ScVal.scvVec([
    -                    xdr.ScVal.scvMap([scEntry])
    -                ]))
    -                break;
    -            case 'scvVec':
    -                // Add the new signature to the existing map
    -                credentials.signature().vec()?.[0].map()?.push(scEntry)
    -
    -                // Order the map by key
    -                // Not using Buffer.compare because Symbols are 9 bytes and unused bytes _append_ 0s vs prepending them, which is too bad
    -                credentials.signature().vec()?.[0].map()?.sort((a, b) => {
    -                    return (
    -                        a.key().vec()![0].sym() +
    -                        a.key().vec()![1].toXDR().join('')
    -                    ).localeCompare(
    -                        b.key().vec()![0].sym() +
    -                        b.key().vec()![1].toXDR().join('')
    -                    )
    -                })
    -                break;
    -            default:
    -                throw new Error('Unsupported signature')
    -        }
    -
    -        // Insert the new signature into the signatures Map
    -        // signatures[0].set(key, val)
    -
    -        // Insert the new signatures Map into the credentials
    -        // credentials.signature(
    -        //     this.wallet!.spec.nativeToScVal(signatures, scSpecTypeDefSignatures)
    -        // )
    -
    -        // Order the signatures map
    -        // credentials.signature().vec()?.[0].map()?.sort((a, b) => {
    -        //     return (
    -        //         a.key().vec()![0].sym() +
    -        //         a.key().vec()![1].toXDR().join('')
    -        //     ).localeCompare(
    -        //         b.key().vec()![0].sym() +
    -        //         b.key().vec()![1].toXDR().join('')
    -        //     )
    -        // })
    -
    -        return entry
    +    if (!contractId) {
    +      throw new PasskeyKitError(
    +        "Could not resolve a wallet for the given passkey",
    +        PasskeyKitErrorCode.WALLET_NOT_FOUND,
    +        { context: { keyId: keyIdBase64 } }
    +      );
         }
     
    -    public async sign(
    -        txn: AssembledTransaction | Tx | string,
    -        options?: {
    -            rpId?: string,
    -            keyId?: 'any' | string | Uint8Array
    -            keypair?: Keypair,
    -            policy?: string,
    -            expiration?: number
    -        }
    -    ) {
    -        if (!(txn instanceof AssembledTransaction)) {
    -            try {
    -                txn = AssembledTransaction.fromXDR(this.wallet!.options, typeof txn === 'string' ? txn : txn.toXDR(), this.wallet!.spec);
    -            } catch {
    -                if (!(txn instanceof AssembledTransaction)) {
    -                    const built = TransactionBuilder.fromXDR(typeof txn === 'string' ? txn : txn.toXDR(), this.networkPassphrase);
    -                    const operation = built.operations[0] as Operation.InvokeHostFunction;
    -
    -                    txn = await AssembledTransaction.buildWithOp(
    -                        Operation.invokeHostFunction({ func: operation.func }),
    -                        this.wallet!.options as AssembledTransactionOptions
    -                    );
    -                }
    -            }
    -        }
    -
    -        await txn.signAuthEntries({
    -            address: this.wallet!.options.contractId,
    -            authorizeEntry: (entry) => {
    -                const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR())
    -                return this.signAuthEntry(clone, options)
    -            },
    -        })
    -
    -        return txn
    -    }
    -
    -    public addSecp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, expiration?: number) {
    -        return this.secp256r1(keyId, publicKey, limits, store, 'add_signer', expiration)
    -    }
    -    public addEd25519(publicKey: string, limits: SignerLimits, store: SignerStore, expiration?: number) {
    -        return this.ed25519(publicKey, limits, store, 'add_signer', expiration)
    -    }
    -    public addPolicy(policy: string, limits: SignerLimits, store: SignerStore, expiration?: number) {
    -        return this.policy(policy, limits, store, 'add_signer', expiration)
    -    }
    -
    -    public updateSecp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, expiration?: number) {
    -        return this.secp256r1(keyId, publicKey, limits, store, 'update_signer', expiration)
    -    }
    -    public updateEd25519(publicKey: string, limits: SignerLimits, store: SignerStore, expiration?: number) {
    -        return this.ed25519(publicKey, limits, store, 'update_signer', expiration)
    +    this.wallet = new PasskeyClient({
    +      contractId,
    +      rpcUrl: this.rpcUrl,
    +      networkPassphrase: this.networkPassphrase,
    +    });
    +    this.keyId = keyIdBase64;
    +
    +    // 3) Ownership verification (F7): the keyId must be a live signer.
    +    // A thrown error here is a transport failure, not proof of non-ownership —
    +    // surface it as-is (a valid passkey must not read as an ownership mismatch
    +    // because the RPC hiccuped); only a definitive not-found (`null`) does.
    +    let signerVal;
    +    try {
    +      signerVal = await this.signerManager.getSigner(
    +        SignerKey.Secp256r1(keyIdBase64)
    +      );
    +    } catch (err) {
    +      this.wallet = undefined;
    +      this.keyId = undefined;
    +      throw err;
         }
    -    public updatePolicy(policy: string, limits: SignerLimits, store: SignerStore, expiration?: number) {
    -        return this.policy(policy, limits, store, 'update_signer', expiration)
    +    if (!signerVal) {
    +      this.wallet = undefined;
    +      this.keyId = undefined;
    +      throw new WalletOwnershipError(
    +        "The passkey is not a signer on the resolved wallet",
    +        { contractId, keyId: keyIdBase64 }
    +      );
         }
     
    -    public remove(signer: SignerKey) {
    -        return this.wallet!.remove_signer({
    -            signer_key: this.getSignerKey(signer)
    -        }, {
    -            timeoutInSeconds: this.timeoutInSeconds,
    -        });
    -    }
    -
    -    private secp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) {
    -        keyId = typeof keyId === 'string' ? base64url.toBuffer(keyId) : keyId
    -        publicKey = typeof publicKey === 'string' ? base64url.toBuffer(publicKey) : publicKey
    -
    -        return this.wallet![fn]({
    -            signer: {
    -                tag: "Secp256r1",
    -                values: [
    -                    Buffer.from(keyId),
    -                    Buffer.from(publicKey),
    -                    [expiration],
    -                    this.getSignerLimits(limits),
    -                    { tag: store, values: undefined },
    -                ],
    -            },
    -        }, {
    -            timeoutInSeconds: this.timeoutInSeconds,
    -        });
    -    }
    -    private ed25519(publicKey: string, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) {
    -        return this.wallet![fn]({
    -            signer: {
    -                tag: "Ed25519",
    -                values: [
    -                    Keypair.fromPublicKey(publicKey).rawPublicKey(),
    -                    [expiration],
    -                    this.getSignerLimits(limits),
    -                    { tag: store, values: undefined },
    -                ],
    -            },
    -        }, {
    -            timeoutInSeconds: this.timeoutInSeconds,
    -        });
    -    }
    -    private policy(policy: string, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) {
    -        return this.wallet![fn]({
    -            signer: {
    -                tag: "Policy",
    -                values: [
    -                    policy,
    -                    [expiration],
    -                    this.getSignerLimits(limits),
    -                    { tag: store, values: undefined },
    -                ],
    -            },
    -        }, {
    -            timeoutInSeconds: this.timeoutInSeconds,
    -        });
    -    }
    -
    -    /* LATER 
    -        - Add a getKeyInfo action to get info about a specific passkey
    -            Specifically looking for name, type, etc. data so a user could grok what signer mapped to what passkey
    -    */
    -
    -    private getSignerLimits(limits: SignerLimits) {
    -        if (!limits)
    -            return [undefined] as SDKSignerLimits
    -
    -        const sdk_limits: SDKSignerLimits = [new Map()]
    -
    -        for (const [contract, signer_keys] of limits.entries()) {
    -            let sdk_signer_keys: SDKSignerKey[] | undefined
    -
    -            if (signer_keys?.length) {
    -                sdk_signer_keys = []
    -
    -                for (const signer_key of signer_keys) {
    -                    sdk_signer_keys.push(
    -                        this.getSignerKey(signer_key)
    -                    )
    -                }
    -            }
    -
    -            sdk_limits[0]?.set(contract, sdk_signer_keys)
    -        }
    -
    -        return sdk_limits
    +    // 4) Optional wasm-hash check (opt-in; upgraded wallets differ by design).
    +    if (options?.verifyWasmHash) {
    +      await this.assertWalletWasmHash(contractId);
         }
     
    -    private getSignerKey({ key: tag, value }: SignerKey) {
    -        let signer_key: SDKSignerKey
    -
    -        switch (tag) {
    -            case 'Policy':
    -                signer_key = {
    -                    tag,
    -                    values: [value]
    -                }
    -                break;
    -            case 'Ed25519':
    -                signer_key = {
    -                    tag,
    -                    values: [Keypair.fromPublicKey(value).rawPublicKey()]
    -                }
    -                break;
    -            case 'Secp256r1':
    -                signer_key = {
    -                    tag,
    -                    values: [base64url.toBuffer(value)]
    -                }
    -                break;
    -        }
    -
    -        return signer_key
    +    this.events.emit("walletConnected", { contractId, keyId: keyIdBase64 });
    +
    +    return { rawResponse, keyId: keyIdBuffer, keyIdBase64, contractId };
    +  }
    +
    +  private async assertWalletWasmHash(contractId: string): Promise {
    +    const instance = await this.rpc.getContractData(
    +      contractId,
    +      xdr.ScVal.scvLedgerKeyContractInstance()
    +    );
    +    const executable = instance.val
    +      .contractData()
    +      .val()
    +      .instance()
    +      .executable();
    +    if (executable.switch().name !== "contractExecutableWasm") {
    +      throw new WalletOwnershipError("Wallet is not a WASM contract", {
    +        contractId,
    +      });
         }
    -
    -    private encodeContract(walletPublicKey: string, keyIdBuffer: Buffer) {
    -        let contractId: string | undefined = StrKey.encodeContract(hash(xdr.HashIdPreimage.envelopeTypeContractId(
    -            new xdr.HashIdPreimageContractId({
    -                networkId: hash(Buffer.from(this.networkPassphrase)),
    -                contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress(
    -                    new xdr.ContractIdPreimageFromAddress({
    -                        address: Address.fromString(walletPublicKey).toScAddress(),
    -                        salt: hash(keyIdBuffer),
    -                    })
    -                )
    -            })
    -        ).toXDR()));
    -
    -        return contractId
    +    const wasmHash = executable.wasmHash().toString("hex");
    +    if (wasmHash !== this.walletWasmHash.toLowerCase()) {
    +      throw new WalletOwnershipError("Wallet WASM hash does not match", {
    +        contractId,
    +        expected: this.walletWasmHash,
    +        actual: wasmHash,
    +      });
         }
    -
    -    private async getPublicKey(response: AuthenticatorAttestationResponseJSON) {
    -        let publicKey: Buffer | undefined
    -
    -        if (response.publicKey) {
    -            publicKey = base64url.toBuffer(response.publicKey)
    -            publicKey = publicKey?.slice(publicKey.length - 65)
    -        }
    -
    -        if (
    -            !publicKey
    -            || publicKey[0] !== 0x04
    -            || publicKey.length !== 65
    -        ) {
    -            let x: Buffer
    -            let y: Buffer
    -
    -            if (response.authenticatorData) {
    -                const authenticatorData = base64url.toBuffer(response.authenticatorData)
    -                const credentialIdLength = (authenticatorData[53] << 8) | authenticatorData[54]
    -
    -                x = authenticatorData.slice(65 + credentialIdLength, 97 + credentialIdLength)
    -                y = authenticatorData.slice(100 + credentialIdLength, 132 + credentialIdLength)
    -            } else {
    -                const attestationObject = base64url.toBuffer(response.attestationObject)
    -
    -                let publicKeykPrefixSlice = Buffer.from([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20])
    -                let startIndex = attestationObject.indexOf(publicKeykPrefixSlice)
    -                startIndex = startIndex + publicKeykPrefixSlice.length
    -
    -                x = attestationObject.slice(startIndex, 32 + startIndex)
    -                y = attestationObject.slice(35 + startIndex, 67 + startIndex)
    -            }
    -
    -            publicKey = Buffer.from([
    -                0x04, // (0x04 prefix) https://en.bitcoin.it/wiki/Elliptic_Curve_Digital_Signature_Algorithm
    -                ...x,
    -                ...y
    -            ])
    -        }
    -
    -        /* TODO
    -            - We're doing some pretty "smart" public key decoding stuff so we should verify the signature against this final public key before assuming it's safe to use and save on-chain
    -                Hmm...Given that `startRegistration` doesn't produce a signature, verifying we've got the correct public key isn't really possible
    -            - This probably needs to be an onchain check, even if just a simulation, just to ensure everything looks good before we get too far adding value etc.
    -        */
    -
    -        return publicKey
    +  }
    +
    +  /** Disconnect the current wallet. */
    +  disconnect(): void {
    +    const contractId = this.contractId;
    +    this.wallet = undefined;
    +    this.keyId = undefined;
    +    if (contractId) {
    +      this.events.emit("walletDisconnected", { contractId });
         }
    -
    -    private compactSignature(signature: Buffer) {
    -        // Decode the DER signature
    -        let offset = 2;
    -
    -        const rLength = signature[offset + 1];
    -        const r = signature.slice(offset + 2, offset + 2 + rLength);
    -
    -        offset += 2 + rLength;
    -
    -        const sLength = signature[offset + 1];
    -        const s = signature.slice(offset + 2, offset + 2 + sLength);
    -
    -        // Convert r and s to BigInt
    -        const rBigInt = BigInt('0x' + r.toString('hex'));
    -        let sBigInt = BigInt('0x' + s.toString('hex'));
    -
    -        // Ensure s is in the low-S form
    -        // https://github.com/stellar/stellar-protocol/discussions/1435#discussioncomment-8809175
    -        // https://discord.com/channels/897514728459468821/1233048618571927693
    -        // Define the order of the curve secp256r1
    -        // https://github.com/RustCrypto/elliptic-curves/blob/master/p256/src/lib.rs#L72
    -        const n = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551');
    -        const halfN = n / 2n;
    -
    -        if (sBigInt > halfN)
    -            sBigInt = n - sBigInt;
    -
    -        // Convert back to buffers and ensure they are 32 bytes
    -        const rPadded = Buffer.from(rBigInt.toString(16).padStart(64, '0'), 'hex');
    -        const sLowS = Buffer.from(sBigInt.toString(16).padStart(64, '0'), 'hex');
    -
    -        // Concatenate r and low-s
    -        const concatSignature = Buffer.concat([rPadded, sLowS]);
    -
    -        return concatSignature;
    +  }
    +
    +  // -- Signing -----------------------------------------------------------------
    +
    +  /** Sign a single auth entry (defaults to the connected passkey signer). */
    +  signAuthEntry(
    +    entry: xdr.SorobanAuthorizationEntry,
    +    signer?: Signer,
    +    options?: SignOptions
    +  ): Promise {
    +    return this.signerManager.signAuthEntry(entry, signer, options);
    +  }
    +
    +  /** Sign an assembled transaction's wallet auth entries. */
    +  sign(
    +    txn: AssembledTransaction,
    +    signer?: Signer,
    +    options?: SignOptions
    +  ): Promise> {
    +    return this.signerManager.sign(txn, signer, options);
    +  }
    +
    +  // -- Signer management -------------------------------------------------------
    +
    +  addSecp256r1(
    +    keyId: string | Uint8Array,
    +    publicKey: string | Uint8Array,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return this.signerManager.addSecp256r1(keyId, publicKey, limits, store, expiration);
    +  }
    +  updateSecp256r1(
    +    keyId: string | Uint8Array,
    +    publicKey: string | Uint8Array,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return this.signerManager.updateSecp256r1(keyId, publicKey, limits, store, expiration);
    +  }
    +  addEd25519(
    +    publicKey: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return this.signerManager.addEd25519(publicKey, limits, store, expiration);
    +  }
    +  updateEd25519(
    +    publicKey: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return this.signerManager.updateEd25519(publicKey, limits, store, expiration);
    +  }
    +  addPolicy(
    +    policy: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return this.signerManager.addPolicy(policy, limits, store, expiration);
    +  }
    +  updatePolicy(
    +    policy: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return this.signerManager.updatePolicy(policy, limits, store, expiration);
    +  }
    +  remove(signerKey: SignerKey): Promise {
    +    return this.signerManager.remove(signerKey);
    +  }
    +
    +  /** Build an `upgrade(new_wasm_hash)` transaction for the connected wallet. */
    +  upgrade(newWasmHash: Buffer | Uint8Array): Promise {
    +    return this.signerManager.upgrade(newWasmHash);
    +  }
    +
    +  /** Read a signer entry from the ledger (temporary before persistent). */
    +  getSigner(signerKey: SignerKey) {
    +    return this.signerManager.getSigner(signerKey);
    +  }
    +
    +  /** Require a connected wallet, or throw. */
    +  requireWallet(): PasskeyClient {
    +    if (!this.wallet) {
    +      throw new WalletNotConnectedError();
         }
    -}
    \ No newline at end of file
    +    return this.wallet;
    +  }
    +}
    diff --git a/src/kit/auth-payload.test.ts b/src/kit/auth-payload.test.ts
    new file mode 100644
    index 0000000..166b268
    --- /dev/null
    +++ b/src/kit/auth-payload.test.ts
    @@ -0,0 +1,188 @@
    +/**
    + * Ported from the old `bun_tests/9.index.ts` Protocol-27 auth-helper vectors.
    + *
    + * The old test also exercised the "does this SDK support P27?" fallback shims;
    + * on stellar-sdk >= 16 those are dead, so this keeps the golden-vector coverage
    + * (address / addressV2 / with-delegates preimages match the SDK's own
    + * `buildAuthorizationEntryPreimage`) and drops the shim branches.
    + */
    +
    +import { describe, expect, it } from "vitest";
    +import {
    +  Address,
    +  Keypair,
    +  Networks,
    +  buildAuthorizationEntryPreimage,
    +  buildWithDelegatesEntry,
    +  hash,
    +  xdr,
    +} from "@stellar/stellar-sdk";
    +import {
    +  assertSignatureExpirationLedger,
    +  buildSignaturePayload,
    +  getAddressCredentials,
    +  usesAddressBoundPayload,
    +} from "./auth-payload.js";
    +
    +const networkPassphrase = Networks.TESTNET;
    +
    +function makeAccount(seedByte: number): string {
    +  return Keypair.fromRawEd25519Seed(Buffer.alloc(32, seedByte)).publicKey();
    +}
    +
    +function makeInvocation(): xdr.SorobanAuthorizedInvocation {
    +  return new xdr.SorobanAuthorizedInvocation({
    +    function:
    +      xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(
    +        new xdr.InvokeContractArgs({
    +          contractAddress: xdr.ScAddress.scAddressTypeContract(
    +            hash(Buffer.from("contract"))
    +          ),
    +          functionName: "do_it",
    +          args: [],
    +        })
    +      ),
    +    subInvocations: [],
    +  });
    +}
    +
    +function makeAddressCredentials(
    +  address: string,
    +  expiration = 1
    +): xdr.SorobanAddressCredentials {
    +  return new xdr.SorobanAddressCredentials({
    +    address: Address.fromString(address).toScAddress(),
    +    nonce: xdr.Int64.fromString("7"),
    +    signatureExpirationLedger: expiration,
    +    signature: xdr.ScVal.scvVoid(),
    +  });
    +}
    +
    +function makeAuthEntry(address: string): xdr.SorobanAuthorizationEntry {
    +  return new xdr.SorobanAuthorizationEntry({
    +    credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(
    +      makeAddressCredentials(address)
    +    ),
    +    rootInvocation: makeInvocation(),
    +  });
    +}
    +
    +describe("getAddressCredentials", () => {
    +  it("returns the address credentials for a plain address entry", () => {
    +    const account = makeAccount(1);
    +    const entry = makeAuthEntry(account);
    +    const credentials = getAddressCredentials(entry.credentials());
    +
    +    expect(entry.credentials().switch().name).toBe("sorobanCredentialsAddress");
    +    expect(Address.fromScAddress(credentials.address()).toString()).toBe(account);
    +    expect(usesAddressBoundPayload(entry.credentials())).toBe(false);
    +  });
    +
    +  it("throws for non-address (source-account) credentials", () => {
    +    const sourceAccount = xdr.SorobanCredentials.sorobanCredentialsSourceAccount();
    +    expect(() => getAddressCredentials(sourceAccount)).toThrow(
    +      /do not contain address credentials/
    +    );
    +  });
    +});
    +
    +describe("buildSignaturePayload", () => {
    +  it("matches the SDK preimage hash for a plain address entry", () => {
    +    const entry = makeAuthEntry(makeAccount(2));
    +    const payload = buildSignaturePayload(networkPassphrase, entry, 123);
    +    const credentials = getAddressCredentials(entry.credentials());
    +
    +    const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR());
    +    const expectedPreimage = buildAuthorizationEntryPreimage(
    +      expectedEntry,
    +      123,
    +      networkPassphrase
    +    );
    +
    +    expect(payload).toEqual(hash(expectedPreimage.toXDR()));
    +    expect(credentials.signatureExpirationLedger()).toBe(123);
    +  });
    +
    +  it("matches the SDK preimage hash for an addressV2 (address-bound) entry", () => {
    +    const baseEntry = makeAuthEntry(makeAccount(3));
    +    const credentials = getAddressCredentials(baseEntry.credentials());
    +    const addressV2Entry = new xdr.SorobanAuthorizationEntry({
    +      credentials:
    +        xdr.SorobanCredentials.sorobanCredentialsAddressV2(credentials),
    +      rootInvocation: baseEntry.rootInvocation(),
    +    });
    +
    +    expect(addressV2Entry.credentials().switch().name).toBe(
    +      "sorobanCredentialsAddressV2"
    +    );
    +    expect(usesAddressBoundPayload(addressV2Entry.credentials())).toBe(true);
    +
    +    const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(
    +      addressV2Entry.toXDR()
    +    );
    +    const expectedPreimage = buildAuthorizationEntryPreimage(
    +      expectedEntry,
    +      123,
    +      networkPassphrase
    +    );
    +
    +    expect(buildSignaturePayload(networkPassphrase, addressV2Entry, 123)).toEqual(
    +      hash(expectedPreimage.toXDR())
    +    );
    +  });
    +
    +  it("matches the SDK preimage hash for a with-delegates entry", () => {
    +    const delegatedEntry = buildWithDelegatesEntry({
    +      entry: makeAuthEntry(makeAccount(4)),
    +      validUntilLedgerSeq: 123,
    +      delegates: [{ address: makeAccount(5) }],
    +    });
    +
    +    expect(delegatedEntry.credentials().switch().name).toBe(
    +      "sorobanCredentialsAddressWithDelegates"
    +    );
    +    expect(usesAddressBoundPayload(delegatedEntry.credentials())).toBe(true);
    +
    +    const expectedEntry = xdr.SorobanAuthorizationEntry.fromXDR(
    +      delegatedEntry.toXDR()
    +    );
    +    const expectedPreimage = buildAuthorizationEntryPreimage(
    +      expectedEntry,
    +      123,
    +      networkPassphrase
    +    );
    +
    +    expect(buildSignaturePayload(networkPassphrase, delegatedEntry, 123)).toEqual(
    +      hash(expectedPreimage.toXDR())
    +    );
    +    expect(
    +      getAddressCredentials(
    +        delegatedEntry.credentials()
    +      ).signatureExpirationLedger()
    +    ).toBe(123);
    +  });
    +
    +  it("rejects a non-u32 expiration without mutating the entry", () => {
    +    const entry = makeAuthEntry(makeAccount(4));
    +    const credentials = getAddressCredentials(entry.credentials());
    +    expect(() => buildSignaturePayload(networkPassphrase, entry, 1.5)).toThrow(
    +      /uint32 integer/
    +    );
    +    expect(credentials.signatureExpirationLedger()).toBe(1);
    +  });
    +});
    +
    +describe("assertSignatureExpirationLedger", () => {
    +  it("accepts u32 bounds and rejects everything else", () => {
    +    expect(() => assertSignatureExpirationLedger(0)).not.toThrow();
    +    expect(() => assertSignatureExpirationLedger(0xffffffff)).not.toThrow();
    +    expect(() => assertSignatureExpirationLedger(-1)).toThrow(/uint32 integer/);
    +    expect(() => assertSignatureExpirationLedger(0x100000000)).toThrow(
    +      /uint32 integer/
    +    );
    +    expect(() => assertSignatureExpirationLedger(1.5)).toThrow(/uint32 integer/);
    +    expect(() => assertSignatureExpirationLedger(Number.NaN)).toThrow(
    +      /uint32 integer/
    +    );
    +  });
    +});
    diff --git a/src/kit/auth-payload.ts b/src/kit/auth-payload.ts
    new file mode 100644
    index 0000000..659a502
    --- /dev/null
    +++ b/src/kit/auth-payload.ts
    @@ -0,0 +1,280 @@
    +/**
    + * Soroban authorization-entry payload helpers + the flat `Signatures` map codec.
    + *
    + * These are thin wrappers over the stellar-sdk's native Protocol-27 auth
    + * primitives (`buildAuthorizationEntryPreimage`). The old kit hand-rolled a
    + * stack of "does this SDK support P27?" probe shims; on stellar-sdk >= 16 those
    + * are all dead, so this module simply uses the SDK.
    + *
    + * The smart wallet's `__check_auth` reads a `Signatures` value — a single
    + * `Map` — from the address credentials. Each signer
    + * contributes one entry keyed by its on-chain `SignerKey`. The host rejects an
    + * `ScMap` whose keys are not in the host's canonical sort order, so the map is
    + * sorted with {@link compareScVal} (NOT a byte/`localeCompare` sort — see below).
    + *
    + * @packageDocumentation
    + */
    +
    +import {
    +  buildAuthorizationEntryPreimage,
    +  hash,
    +  xdr,
    +} from "@stellar/stellar-sdk";
    +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract";
    +import type {
    +  Signature as SDKSignature,
    +  SignerKey as SDKSignerKey,
    +} from "passkey-kit-sdk";
    +import { SigningError, PasskeyKitErrorCode } from "../errors.js";
    +
    +// ============================================================================
    +// Address credentials + signature payload
    +// ============================================================================
    +
    +/**
    + * Extract the {@link xdr.SorobanAddressCredentials} from a credentials union,
    + * for the address / addressV2 / addressWithDelegates variants.
    + *
    + * @throws {SigningError} If the credentials are not address-based.
    + */
    +export function getAddressCredentials(
    +  credentials: xdr.SorobanCredentials
    +): xdr.SorobanAddressCredentials {
    +  switch (credentials.switch().name) {
    +    case "sorobanCredentialsAddress":
    +      return credentials.address();
    +    case "sorobanCredentialsAddressV2":
    +      return credentials.addressV2();
    +    case "sorobanCredentialsAddressWithDelegates":
    +      return credentials.addressWithDelegates().addressCredentials();
    +    default:
    +      throw new SigningError(
    +        `Soroban credentials do not contain address credentials: ${credentials.switch().name}`,
    +        PasskeyKitErrorCode.UNSUPPORTED_CREDENTIALS
    +      );
    +  }
    +}
    +
    +/**
    + * Whether a credentials union uses an address-bound signature payload (the P27
    + * V2 / with-delegates variants), whose preimage binds the invoker address.
    + */
    +export function usesAddressBoundPayload(
    +  credentials: xdr.SorobanCredentials
    +): boolean {
    +  const name = credentials.switch().name;
    +  return (
    +    name === "sorobanCredentialsAddressV2" ||
    +    name === "sorobanCredentialsAddressWithDelegates"
    +  );
    +}
    +
    +/**
    + * Assert that a signature-expiration ledger is a valid `u32`.
    + *
    + * @throws {SigningError} If not a `u32` integer.
    + */
    +export function assertSignatureExpirationLedger(expiration: number): void {
    +  if (
    +    !Number.isInteger(expiration) ||
    +    expiration < 0 ||
    +    expiration > 0xffffffff
    +  ) {
    +    throw new SigningError(
    +      "Soroban signature expiration ledger must be a uint32 integer",
    +      PasskeyKitErrorCode.INVALID_SIGNATURE_EXPIRATION,
    +      { expiration }
    +    );
    +  }
    +}
    +
    +/**
    + * Build the signature payload (the 32-byte hash a signer signs) for an
    + * authorization entry at a given expiration ledger.
    + *
    + * Sets the expiration on the entry's address credentials, then hashes the SDK's
    + * canonical authorization-entry preimage. Works for the address, addressV2, and
    + * addressWithDelegates credential variants.
    + *
    + * @throws {SigningError} If `expiration` is not a valid `u32`.
    + */
    +export function buildSignaturePayload(
    +  networkPassphrase: string,
    +  entry: xdr.SorobanAuthorizationEntry,
    +  expiration: number
    +): Buffer {
    +  assertSignatureExpirationLedger(expiration);
    +
    +  const credentials = getAddressCredentials(entry.credentials());
    +  credentials.signatureExpirationLedger(expiration);
    +
    +  return hash(
    +    buildAuthorizationEntryPreimage(
    +      entry,
    +      expiration,
    +      networkPassphrase
    +    ).toXDR()
    +  );
    +}
    +
    +// ============================================================================
    +// Host-order ScVal comparison
    +// ============================================================================
    +
    +/**
    + * Compare two ScVals in the order the Soroban host uses when it validates that
    + * an ScMap is sorted by key (host `metered_map::from_map`, which rejects an
    + * unsorted map with `InvalidInput`).
    + *
    + * This is deliberately NOT a comparison of the XDR byte encodings. XDR prefixes
    + * every variable-length payload (Bytes, Symbol, String, Vec) with a 4-byte
    + * length, so `toXDR("hex")` sorting is length-major; the host instead compares
    + * those payloads element-wise by content (Rust slice `Ord`), where a shorter
    + * value that is a prefix of a longer one sorts first. The old kit's
    + * `localeCompare` on a symbol+hex string approximated this and could diverge
    + * from the host, producing a map the wallet rejects at `__check_auth`.
    + *
    + * Only the shapes that appear as `SignerKey` map keys are handled precisely
    + * (Vec, Symbol, String, Bytes, Address). All other shapes are same-type here
    + * (the discriminant is compared first), so their fixed-width XDR encodings sort
    + * order-equivalently to the host.
    + */
    +export function compareScVal(a: xdr.ScVal, b: xdr.ScVal): number {
    +  const aType = a.switch().value;
    +  const bType = b.switch().value;
    +  if (aType !== bType) {
    +    return aType < bType ? -1 : 1;
    +  }
    +
    +  switch (a.switch().name) {
    +    case "scvVec": {
    +      const av = a.vec() ?? [];
    +      const bv = b.vec() ?? [];
    +      const len = Math.min(av.length, bv.length);
    +      for (let i = 0; i < len; i += 1) {
    +        const cmp = compareScVal(av[i]!, bv[i]!);
    +        if (cmp !== 0) return cmp;
    +      }
    +      return av.length - bv.length;
    +    }
    +    case "scvMap": {
    +      const am = a.map() ?? [];
    +      const bm = b.map() ?? [];
    +      const len = Math.min(am.length, bm.length);
    +      for (let i = 0; i < len; i += 1) {
    +        const keyCmp = compareScVal(am[i]!.key(), bm[i]!.key());
    +        if (keyCmp !== 0) return keyCmp;
    +        const valCmp = compareScVal(am[i]!.val(), bm[i]!.val());
    +        if (valCmp !== 0) return valCmp;
    +      }
    +      return am.length - bm.length;
    +    }
    +    case "scvSymbol":
    +      return Buffer.compare(
    +        Buffer.from(a.sym().toString(), "utf8"),
    +        Buffer.from(b.sym().toString(), "utf8")
    +      );
    +    case "scvString":
    +      return Buffer.compare(
    +        Buffer.from(a.str().toString(), "utf8"),
    +        Buffer.from(b.str().toString(), "utf8")
    +      );
    +    case "scvBytes":
    +      return Buffer.compare(Buffer.from(a.bytes()), Buffer.from(b.bytes()));
    +    case "scvAddress":
    +      // ScAddress is a type discriminant plus a fixed-width key, so a byte
    +      // comparison of the encoding matches the host's (type, key) ordering.
    +      return Buffer.compare(a.address().toXDR(), b.address().toXDR());
    +    default:
    +      // Same-type scalar (or any unhandled shape): its XDR encoding is
    +      // fixed-width for a given type, so a byte comparison is order-equivalent.
    +      return Buffer.compare(a.toXDR(), b.toXDR());
    +  }
    +}
    +
    +// ============================================================================
    +// SignerKey / Signature encoding + the flat Signatures map
    +// ============================================================================
    +
    +const SIGNER_KEY_UDT = xdr.ScSpecTypeDef.scSpecTypeUdt(
    +  new xdr.ScSpecTypeUdt({ name: "SignerKey" })
    +);
    +const SIGNATURE_UDT = xdr.ScSpecTypeDef.scSpecTypeUdt(
    +  new xdr.ScSpecTypeUdt({ name: "Signature" })
    +);
    +
    +/** The `SignerVal` UDT type def, for decoding a stored signer entry's value. */
    +export const SIGNER_VAL_UDT = xdr.ScSpecTypeDef.scSpecTypeUdt(
    +  new xdr.ScSpecTypeUdt({ name: "SignerVal" })
    +);
    +
    +/** Encode a native `SignerKey` union into its contract ScVal via the spec. */
    +export function signerKeyToScVal(
    +  spec: ContractSpec,
    +  key: SDKSignerKey
    +): xdr.ScVal {
    +  return spec.nativeToScVal(key, SIGNER_KEY_UDT);
    +}
    +
    +/**
    + * Encode a native `Signature` union into its contract ScVal via the spec, or
    + * `scvVoid` when there is no signature value (policy signers).
    + */
    +export function signatureToScVal(
    +  spec: ContractSpec,
    +  val: SDKSignature | undefined
    +): xdr.ScVal {
    +  return val ? spec.nativeToScVal(val, SIGNATURE_UDT) : xdr.ScVal.scvVoid();
    +}
    +
    +/**
    + * Upsert a `(SignerKey, Signature)` pair into the entry's `Signatures` value,
    + * which is encoded as `scvVec([ scvMap([...]) ])`.
    + *
    + * A signer key already present in the map is replaced (co-signing is idempotent
    + * per signer). The map is re-sorted by key in host order after every insert, so
    + * the wallet's `__check_auth` accepts it.
    + *
    + * @throws {SigningError} If the existing signature is neither void nor a
    + *   Signatures vec.
    + */
    +export function upsertSignatureEntry(
    +  credentials: xdr.SorobanAddressCredentials,
    +  scKey: xdr.ScVal,
    +  scVal: xdr.ScVal
    +): void {
    +  const newEntry = new xdr.ScMapEntry({ key: scKey, val: scVal });
    +
    +  switch (credentials.signature().switch().name) {
    +    case "scvVoid": {
    +      credentials.signature(xdr.ScVal.scvVec([xdr.ScVal.scvMap([newEntry])]));
    +      break;
    +    }
    +    case "scvVec": {
    +      const map = credentials.signature().vec()?.[0]?.map();
    +      if (!map) {
    +        throw new SigningError(
    +          "Malformed Signatures value: expected a map inside the signatures vector",
    +          PasskeyKitErrorCode.SIGNING_FAILED
    +        );
    +      }
    +      const existingIndex = map.findIndex(
    +        (entry) => compareScVal(entry.key(), scKey) === 0
    +      );
    +      if (existingIndex >= 0) {
    +        map[existingIndex] = newEntry;
    +      } else {
    +        map.push(newEntry);
    +      }
    +      // Host order: the wallet converts this map to a host object and rejects
    +      // it if the keys are not in host-sort order.
    +      map.sort((x, y) => compareScVal(x.key(), y.key()));
    +      break;
    +    }
    +    default:
    +      throw new SigningError(
    +        `Unsupported existing signature value: ${credentials.signature().switch().name}`,
    +        PasskeyKitErrorCode.SIGNING_FAILED
    +      );
    +  }
    +}
    diff --git a/src/kit/deploy-ops.ts b/src/kit/deploy-ops.ts
    new file mode 100644
    index 0000000..295f22c
    --- /dev/null
    +++ b/src/kit/deploy-ops.ts
    @@ -0,0 +1,95 @@
    +/**
    + * Smart-wallet deployment operations.
    + *
    + * The deployer keypair is now configurable (`config.deploySource`) instead of a
    + * hard-coded `Keypair.fromRawEd25519Seed(hash("kalepail"))`. The default still
    + * derives from {@link DEFAULT_DEPLOYER_SEED} = `"kalepail"` so contract-id
    + * determinism (and indexer reverse-lookup) is preserved out of the box.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Keypair, hash } from "@stellar/stellar-sdk";
    +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract";
    +import {
    +  Client as PasskeyClient,
    +  type Signer as SDKSigner,
    +} from "passkey-kit-sdk";
    +import { DEFAULT_DEPLOYER_SEED } from "../constants.js";
    +import { deriveContractAddress } from "../utils.js";
    +import { ConfigurationError, PasskeyKitErrorCode } from "../errors.js";
    +
    +/**
    + * Resolve the fee-paying deployer keypair from an optional secret.
    + *
    + * With no secret, derives the canonical deterministic deployer from
    + * {@link DEFAULT_DEPLOYER_SEED}. The deployer only pays fees and salts the
    + * deploy; it never controls the wallet. Supplying a different `deploySource`
    + * changes the derived contract addresses (and breaks keyId → contract
    + * discovery), so this is documented as an advanced option.
    + *
    + * @throws {ConfigurationError} If `deploySource` is not a valid secret key.
    + */
    +export function resolveDeployer(deploySource?: string): Keypair {
    +  if (deploySource) {
    +    try {
    +      return Keypair.fromSecret(deploySource);
    +    } catch {
    +      throw new ConfigurationError(
    +        "deploySource must be a valid Stellar secret key (S…)",
    +        PasskeyKitErrorCode.INVALID_CONFIG
    +      );
    +    }
    +  }
    +  return Keypair.fromRawEd25519Seed(hash(Buffer.from(DEFAULT_DEPLOYER_SEED)));
    +}
    +
    +/** Deterministically derive a smart-wallet address for a passkey credential. */
    +export function deriveWalletAddress(
    +  deps: { networkPassphrase: string; deployerPublicKey: string },
    +  keyId: Buffer
    +): string {
    +  return deriveContractAddress(keyId, deps.deployerPublicKey, deps.networkPassphrase);
    +}
    +
    +/**
    + * Build the smart-wallet deploy transaction, initializing it with the passkey as
    + * the first (unlimited, persistent) Secp256r1 signer via `__constructor`.
    + *
    + * The returned {@link AssembledTransaction} still needs to be signed by the
    + * deployer keypair (the fee source) before submission.
    + */
    +export async function buildDeployTransaction(
    +  deps: {
    +    rpcUrl: string;
    +    networkPassphrase: string;
    +    walletWasmHash: string;
    +    deployerPublicKey: string;
    +    timeoutInSeconds: number;
    +  },
    +  keyId: Buffer,
    +  publicKey: Uint8Array
    +): Promise> {
    +  const signer: SDKSigner = {
    +    tag: "Secp256r1",
    +    values: [
    +      Buffer.from(keyId),
    +      Buffer.from(publicKey),
    +      [undefined], // SignerExpiration: none
    +      [undefined], // SignerLimits: unlimited
    +      { tag: "Persistent", values: undefined },
    +    ],
    +  };
    +
    +  return PasskeyClient.deploy(
    +    { signer },
    +    {
    +      rpcUrl: deps.rpcUrl,
    +      wasmHash: deps.walletWasmHash,
    +      networkPassphrase: deps.networkPassphrase,
    +      publicKey: deps.deployerPublicKey,
    +      salt: hash(keyId),
    +      timeoutInSeconds: deps.timeoutInSeconds,
    +    }
    +  );
    +}
    diff --git a/src/kit/signatures.test.ts b/src/kit/signatures.test.ts
    new file mode 100644
    index 0000000..c965216
    --- /dev/null
    +++ b/src/kit/signatures.test.ts
    @@ -0,0 +1,131 @@
    +import { describe, expect, it } from "vitest";
    +import { Address, xdr } from "@stellar/stellar-sdk";
    +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract";
    +import { Client as PasskeyClient } from "passkey-kit-sdk";
    +import type { SignerKey as SDKSignerKey } from "passkey-kit-sdk";
    +import {
    +  compareScVal,
    +  signerKeyToScVal,
    +  signatureToScVal,
    +  upsertSignatureEntry,
    +} from "./auth-payload.js";
    +
    +const TESTNET = "Test SDF Network ; September 2015";
    +const CONTRACT = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +function walletSpec(): ContractSpec {
    +  const client = new PasskeyClient({
    +    contractId: CONTRACT,
    +    networkPassphrase: TESTNET,
    +    rpcUrl: "https://rpc.example",
    +  });
    +  return (client as unknown as { spec: ContractSpec }).spec;
    +}
    +
    +describe("compareScVal", () => {
    +  it("orders by type discriminant first", () => {
    +    // scvBool (0) sorts before scvU32 (4).
    +    expect(
    +      compareScVal(xdr.ScVal.scvBool(true), xdr.ScVal.scvU32(0))
    +    ).toBeLessThan(0);
    +  });
    +
    +  it("orders bytes by content, with a shorter prefix first (host order)", () => {
    +    const short = xdr.ScVal.scvBytes(Buffer.from([0xff]));
    +    const long = xdr.ScVal.scvBytes(Buffer.from([0xff, 0x00]));
    +    // Host compares element-wise then by length: [ff] is a prefix of [ff,00].
    +    expect(compareScVal(short, long)).toBeLessThan(0);
    +    expect(compareScVal(long, short)).toBeGreaterThan(0);
    +    expect(compareScVal(short, short)).toBe(0);
    +  });
    +
    +  it("recurses into vectors (SignerKey encoding shape)", () => {
    +    const a = xdr.ScVal.scvVec([
    +      xdr.ScVal.scvSymbol("Secp256r1"),
    +      xdr.ScVal.scvBytes(Buffer.from([0x01])),
    +    ]);
    +    const b = xdr.ScVal.scvVec([
    +      xdr.ScVal.scvSymbol("Secp256r1"),
    +      xdr.ScVal.scvBytes(Buffer.from([0x01, 0x00])),
    +    ]);
    +    expect(compareScVal(a, b)).toBeLessThan(0);
    +  });
    +
    +  it("orders addresses by their encoding", () => {
    +    const addr = xdr.ScVal.scvAddress(
    +      Address.fromString(CONTRACT).toScAddress()
    +    );
    +    expect(compareScVal(addr, addr)).toBe(0);
    +  });
    +});
    +
    +describe("signerKeyToScVal", () => {
    +  it("encodes a Secp256r1 signer key as a tagged vector", () => {
    +    const spec = walletSpec();
    +    const key: SDKSignerKey = {
    +      tag: "Secp256r1",
    +      values: [Buffer.from([0xaa, 0xbb])],
    +    };
    +    const scVal = signerKeyToScVal(spec, key);
    +    expect(scVal.switch().name).toBe("scvVec");
    +    const vec = scVal.vec()!;
    +    expect(vec[0]!.sym().toString()).toBe("Secp256r1");
    +    expect(Buffer.from(vec[1]!.bytes())).toEqual(Buffer.from([0xaa, 0xbb]));
    +  });
    +});
    +
    +describe("upsertSignatureEntry", () => {
    +  function credentials(): xdr.SorobanAddressCredentials {
    +    return new xdr.SorobanAddressCredentials({
    +      address: Address.fromString(CONTRACT).toScAddress(),
    +      nonce: xdr.Int64.fromString("1"),
    +      signatureExpirationLedger: 1,
    +      signature: xdr.ScVal.scvVoid(),
    +    });
    +  }
    +
    +  function keyBytesOf(entry: xdr.ScMapEntry): Buffer {
    +    return Buffer.from(entry.key().vec()![1]!.bytes());
    +  }
    +
    +  it("creates the Signatures vec+map from a void signature", () => {
    +    const spec = walletSpec();
    +    const creds = credentials();
    +    const scKey = signerKeyToScVal(spec, {
    +      tag: "Secp256r1",
    +      values: [Buffer.from([0x01])],
    +    });
    +    upsertSignatureEntry(creds, scKey, signatureToScVal(spec, undefined));
    +
    +    expect(creds.signature().switch().name).toBe("scvVec");
    +    const map = creds.signature().vec()![0]!.map()!;
    +    expect(map).toHaveLength(1);
    +  });
    +
    +  it("keeps map keys in host order across inserts", () => {
    +    const spec = walletSpec();
    +    const creds = credentials();
    +    // Insert out of order: [ff] then [01]; expect sorted [01] before [ff].
    +    for (const b of [0xff, 0x01]) {
    +      const scKey = signerKeyToScVal(spec, {
    +        tag: "Secp256r1",
    +        values: [Buffer.from([b])],
    +      });
    +      upsertSignatureEntry(creds, scKey, xdr.ScVal.scvVoid());
    +    }
    +    const map = creds.signature().vec()![0]!.map()!;
    +    expect(map.map(keyBytesOf)).toEqual([Buffer.from([0x01]), Buffer.from([0xff])]);
    +  });
    +
    +  it("replaces (dedupes) an existing key rather than duplicating it", () => {
    +    const spec = walletSpec();
    +    const creds = credentials();
    +    const scKey = signerKeyToScVal(spec, {
    +      tag: "Secp256r1",
    +      values: [Buffer.from([0x07])],
    +    });
    +    upsertSignatureEntry(creds, scKey, xdr.ScVal.scvVoid());
    +    upsertSignatureEntry(creds, scKey, xdr.ScVal.scvVoid());
    +    expect(creds.signature().vec()![0]!.map()!).toHaveLength(1);
    +  });
    +});
    diff --git a/src/kit/tx-ops.ts b/src/kit/tx-ops.ts
    new file mode 100644
    index 0000000..6c16714
    --- /dev/null
    +++ b/src/kit/tx-ops.ts
    @@ -0,0 +1,210 @@
    +/**
    + * Transaction-signing operations: the auth-entry signing pipeline and the
    + * `AssembledTransaction` signing entrypoint.
    + *
    + * Two deliberate changes from the old kit:
    + * - `sign` takes a single explicit `AssembledTransaction` instead of the
    + *   lossy `AssembledTransaction | Tx | string` tri-input that silently dropped
    + *   memo/fee/operations on its fallback path (#599 §6). Callers holding XDR use
    + *   `AssembledTransaction.fromXDR` first.
    + * - The `Signatures` map is sorted with the host-order `compareScVal`, not the
    + *   old `localeCompare` string approximation.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Operation, TransactionBuilder, xdr } from "@stellar/stellar-sdk";
    +import type { Keypair } from "@stellar/stellar-sdk";
    +import type { Server } from "@stellar/stellar-sdk/rpc";
    +import type {
    +  AssembledTransaction,
    +  Spec as ContractSpec,
    +} from "@stellar/stellar-sdk/contract";
    +import type { Signer, SignerContext } from "../signers.js";
    +import {
    +  buildSignaturePayload,
    +  getAddressCredentials,
    +  signatureToScVal,
    +  signerKeyToScVal,
    +  upsertSignatureEntry,
    +} from "./auth-payload.js";
    +import { SigningError, PasskeyKitError, PasskeyKitErrorCode } from "../errors.js";
    +
    +/** Deps for computing a default signature-expiration ledger. */
    +export interface ExpirationDeps {
    +  rpc: Server;
    +  timeoutInSeconds: number;
    +}
    +
    +/**
    + * Compute a default signature-expiration ledger: the latest ledger plus the
    + * timeout window (assuming ~5s ledgers), rounded up.
    + */
    +export async function calculateExpiration(deps: ExpirationDeps): Promise {
    +  const { sequence } = await deps.rpc.getLatestLedger();
    +  return Math.ceil(sequence + deps.timeoutInSeconds / 5);
    +}
    +
    +/** Deps for signing a single auth entry. */
    +export interface SignAuthEntryDeps {
    +  networkPassphrase: string;
    +  spec: ContractSpec;
    +  signerContext: SignerContext;
    +  calculateExpiration: () => Promise;
    +}
    +
    +/** Per-call signing options. */
    +export interface SignOptions {
    +  /** Signature expiration ledger (defaults to the configured window). */
    +  expiration?: number;
    +}
    +
    +/**
    + * Sign a single Soroban authorization entry with a {@link Signer}, merging the
    + * resulting `(SignerKey, Signature)` pair into the entry's flat `Signatures`
    + * map (host-ordered).
    + *
    + * Mutates and returns the passed entry.
    + */
    +export async function signAuthEntry(
    +  deps: SignAuthEntryDeps,
    +  entry: xdr.SorobanAuthorizationEntry,
    +  signer: Signer,
    +  options?: SignOptions
    +): Promise {
    +  if (
    +    entry.credentials().switch().name === "sorobanCredentialsAddressWithDelegates"
    +  ) {
    +    throw new SigningError(
    +      "ADDRESS_WITH_DELEGATES auth entries are not supported by passkey signing",
    +      PasskeyKitErrorCode.UNSUPPORTED_CREDENTIALS
    +    );
    +  }
    +
    +  const credentials = getAddressCredentials(entry.credentials());
    +
    +  // `== null`, not `!expiration`: an explicit `expiration: 0` is a caller-chosen
    +  // value, not "unset" — only undefined/null falls through to the entry's
    +  // existing ledger or a freshly computed default.
    +  let expiration = options?.expiration;
    +  if (expiration == null) {
    +    expiration = credentials.signatureExpirationLedger();
    +    if (!expiration) {
    +      expiration = await deps.calculateExpiration();
    +    }
    +  }
    +
    +  // Sets credentials.signatureExpirationLedger(expiration) as a side effect.
    +  const payload = buildSignaturePayload(deps.networkPassphrase, entry, expiration);
    +
    +  const prepared = await signer.sign(payload, deps.signerContext);
    +  const scKey = signerKeyToScVal(deps.spec, prepared.key);
    +  const scVal = signatureToScVal(deps.spec, prepared.value);
    +  upsertSignatureEntry(credentials, scKey, scVal);
    +
    +  return entry;
    +}
    +
    +/** Deps for signing an assembled transaction's auth entries. */
    +export interface SignTxDeps extends SignAuthEntryDeps {
    +  getContractId: () => string | undefined;
    +}
    +
    +/**
    + * Sign every auth entry of an {@link AssembledTransaction} that is authorized by
    + * the connected wallet, using `signer`. Returns the same transaction with its
    + * auth entries signed.
    + *
    + * @throws {SigningError} If no wallet is connected.
    + */
    +export async function sign(
    +  deps: SignTxDeps,
    +  txn: AssembledTransaction,
    +  signer: Signer,
    +  options?: SignOptions
    +): Promise> {
    +  const contractId = deps.getContractId();
    +  if (!contractId) {
    +    throw new SigningError(
    +      "A wallet must be connected to sign a transaction",
    +      PasskeyKitErrorCode.SIGNING_FAILED
    +    );
    +  }
    +
    +  await txn.signAuthEntries({
    +    address: contractId,
    +    authorizeEntry: async (entry) => {
    +      const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR());
    +      return signAuthEntry(deps, clone, signer, options);
    +    },
    +  });
    +
    +  return txn;
    +}
    +
    +/**
    + * The `restorePreamble` shape Soroban simulation returns when a transaction
    + * touches archived entries (a subset of the SDK's `SimulateTransactionResponse`).
    + */
    +export interface RestorePreamble {
    +  minResourceFee: string;
    +  transactionData: { build(): xdr.SorobanTransactionData };
    +}
    +
    +/**
    + * Restore an archived contract-data footprint reported by simulation, paying
    + * with the deployer keypair.
    + *
    + * Soroban simulation returns a `restorePreamble` when the transaction touches
    + * archived entries; the footprint must be restored (a separate, fee-bearing
    + * transaction) before the real transaction can succeed. The deployer keypair
    + * pays because it is the on-chain fee source the kit already controls.
    + *
    + * Returns the restore transaction hash. This path is exercised live in F2 (it
    + * requires archived on-chain state to trigger).
    + *
    + * @throws {SigningError} On submission/confirmation failure.
    + */
    +export async function restoreFootprint(
    +  deps: {
    +    rpc: Server;
    +    networkPassphrase: string;
    +    deployerKeypair: Keypair;
    +    timeoutInSeconds: number;
    +  },
    +  restorePreamble: RestorePreamble
    +): Promise {
    +  const account = await deps.rpc.getAccount(deps.deployerKeypair.publicKey());
    +  const fee = (Number(restorePreamble.minResourceFee) + 100_000).toString();
    +
    +  const restoreTx = new TransactionBuilder(account, {
    +    fee,
    +    networkPassphrase: deps.networkPassphrase,
    +  })
    +    .addOperation(Operation.restoreFootprint({}))
    +    .setSorobanData(restorePreamble.transactionData.build())
    +    .setTimeout(deps.timeoutInSeconds)
    +    .build();
    +
    +  restoreTx.sign(deps.deployerKeypair);
    +
    +  const sendResult = await deps.rpc.sendTransaction(restoreTx);
    +  if (sendResult.status === "ERROR") {
    +    throw new PasskeyKitError(
    +      `Footprint restore submission failed: ${sendResult.errorResult?.toXDR("base64") ?? "unknown"}`,
    +      PasskeyKitErrorCode.RESTORE_REQUIRED,
    +      { context: { hash: sendResult.hash } }
    +    );
    +  }
    +
    +  const result = await deps.rpc.pollTransaction(sendResult.hash, { attempts: 10 });
    +  if (result.status !== "SUCCESS") {
    +    throw new PasskeyKitError(
    +      `Footprint restore did not confirm (status ${result.status})`,
    +      PasskeyKitErrorCode.RESTORE_REQUIRED,
    +      { context: { hash: sendResult.hash } }
    +    );
    +  }
    +
    +  return sendResult.hash;
    +}
    diff --git a/src/kit/wallet-ops.ts b/src/kit/wallet-ops.ts
    new file mode 100644
    index 0000000..9a704e9
    --- /dev/null
    +++ b/src/kit/wallet-ops.ts
    @@ -0,0 +1,235 @@
    +/**
    + * Wallet signer operations: build add/update/remove signer transactions, encode
    + * the SDK-side {@link SignerKey}/{@link SignerLimits} helpers into the contract's
    + * union types, and read a signer entry back from the ledger.
    + *
    + * `getSigner` reads the flat top-level `SignerKey → SignerVal` entry directly via
    + * RPC (temporary durability before persistent, per the contract's lookup
    + * invariant). This doubles as `connectWallet`'s ownership check (#601 F7): a
    + * keyId that resolves to a live signer entry proves the wallet actually knows
    + * that passkey. When the contract's `get_signer` view lands (A3) this can move
    + * onto the view; the ledger read is equivalent and works today.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Keypair } from "@stellar/stellar-sdk";
    +import { Durability, type Server } from "@stellar/stellar-sdk/rpc";
    +import type {
    +  AssembledTransaction,
    +  Result,
    +  Spec as ContractSpec,
    +} from "@stellar/stellar-sdk/contract";
    +import {
    +  Client as PasskeyClient,
    +  type Signer as SDKSigner,
    +  type SignerExpiration,
    +  type SignerKey as SDKSignerKey,
    +  type SignerLimits as SDKSignerLimits,
    +  type SignerVal,
    +} from "passkey-kit-sdk";
    +import base64url from "../base64url.js";
    +import { SignerStore, type SignerKey, type SignerLimits } from "../types.js";
    +import { readContractData } from "../rpc-data.js";
    +import { signerKeyToScVal, SIGNER_VAL_UDT } from "./auth-payload.js";
    +
    +/**
    + * The result of a signer-write / upgrade transaction. The reworked contract's
    + * admin functions are typed `Result` (they return `Ok(())` or a typed
    + * contract error), so the AssembledTransaction is generic over that.
    + */
    +export type WalletTx = AssembledTransaction>;
    +
    +/** Encode an optional expiration into the contract's `Option` timestamp. */
    +function toSignerExpiration(expiration?: number): SignerExpiration {
    +  return [expiration == null ? undefined : BigInt(expiration)];
    +}
    +
    +/** Which write the builder targets. */
    +type SignerFn = "add_signer" | "update_signer";
    +
    +/** Encode a SDK-side {@link SignerKey} helper into the contract's union. */
    +export function toContractSignerKey({ key, value }: SignerKey): SDKSignerKey {
    +  switch (key) {
    +    case "Policy":
    +      return { tag: "Policy", values: [value] };
    +    case "Ed25519":
    +      return { tag: "Ed25519", values: [Keypair.fromPublicKey(value).rawPublicKey()] };
    +    case "Secp256r1":
    +      return { tag: "Secp256r1", values: [base64url.toBuffer(value)] };
    +  }
    +}
    +
    +/** Encode SDK-side {@link SignerLimits} into the contract's `SignerLimits`. */
    +export function toContractSignerLimits(limits: SignerLimits): SDKSignerLimits {
    +  if (!limits) {
    +    return [undefined];
    +  }
    +
    +  const map = new Map();
    +  for (const [contract, signerKeys] of limits.entries()) {
    +    map.set(
    +      contract,
    +      signerKeys?.length ? signerKeys.map(toContractSignerKey) : undefined
    +    );
    +  }
    +  return [map];
    +}
    +
    +function buildSecp256r1Signer(
    +  keyId: string | Uint8Array,
    +  publicKey: string | Uint8Array,
    +  limits: SignerLimits,
    +  store: SignerStore,
    +  expiration?: number
    +): SDKSigner {
    +  const keyIdBuffer =
    +    typeof keyId === "string" ? base64url.toBuffer(keyId) : Buffer.from(keyId);
    +  const publicKeyBuffer =
    +    typeof publicKey === "string"
    +      ? base64url.toBuffer(publicKey)
    +      : Buffer.from(publicKey);
    +  return {
    +    tag: "Secp256r1",
    +    values: [
    +      keyIdBuffer,
    +      publicKeyBuffer,
    +      toSignerExpiration(expiration),
    +      toContractSignerLimits(limits),
    +      { tag: store, values: undefined },
    +    ],
    +  };
    +}
    +
    +function buildEd25519Signer(
    +  publicKey: string,
    +  limits: SignerLimits,
    +  store: SignerStore,
    +  expiration?: number
    +): SDKSigner {
    +  return {
    +    tag: "Ed25519",
    +    values: [
    +      Keypair.fromPublicKey(publicKey).rawPublicKey(),
    +      toSignerExpiration(expiration),
    +      toContractSignerLimits(limits),
    +      { tag: store, values: undefined },
    +    ],
    +  };
    +}
    +
    +function buildPolicySigner(
    +  policy: string,
    +  limits: SignerLimits,
    +  store: SignerStore,
    +  expiration?: number
    +): SDKSigner {
    +  return {
    +    tag: "Policy",
    +    values: [
    +      policy,
    +      toSignerExpiration(expiration),
    +      toContractSignerLimits(limits),
    +      { tag: store, values: undefined },
    +    ],
    +  };
    +}
    +
    +/** Shared deps for building signer-write transactions. */
    +export interface WalletWriteDeps {
    +  wallet: PasskeyClient;
    +  timeoutInSeconds: number;
    +}
    +
    +export function buildSecp256r1SignerTx(
    +  deps: WalletWriteDeps,
    +  fn: SignerFn,
    +  keyId: string | Uint8Array,
    +  publicKey: string | Uint8Array,
    +  limits: SignerLimits,
    +  store: SignerStore,
    +  expiration?: number
    +): Promise {
    +  return deps.wallet[fn](
    +    { signer: buildSecp256r1Signer(keyId, publicKey, limits, store, expiration) },
    +    { timeoutInSeconds: deps.timeoutInSeconds }
    +  );
    +}
    +
    +export function buildEd25519SignerTx(
    +  deps: WalletWriteDeps,
    +  fn: SignerFn,
    +  publicKey: string,
    +  limits: SignerLimits,
    +  store: SignerStore,
    +  expiration?: number
    +): Promise {
    +  return deps.wallet[fn](
    +    { signer: buildEd25519Signer(publicKey, limits, store, expiration) },
    +    { timeoutInSeconds: deps.timeoutInSeconds }
    +  );
    +}
    +
    +export function buildPolicySignerTx(
    +  deps: WalletWriteDeps,
    +  fn: SignerFn,
    +  policy: string,
    +  limits: SignerLimits,
    +  store: SignerStore,
    +  expiration?: number
    +): Promise {
    +  return deps.wallet[fn](
    +    { signer: buildPolicySigner(policy, limits, store, expiration) },
    +    { timeoutInSeconds: deps.timeoutInSeconds }
    +  );
    +}
    +
    +export function buildRemoveSignerTx(
    +  deps: WalletWriteDeps,
    +  signerKey: SignerKey
    +): Promise {
    +  return deps.wallet.remove_signer(
    +    { signer_key: toContractSignerKey(signerKey) },
    +    { timeoutInSeconds: deps.timeoutInSeconds }
    +  );
    +}
    +
    +/** Build an `upgrade(new_wasm_hash)` transaction (renamed from update_contract_code). */
    +export function buildUpgradeTx(
    +  deps: WalletWriteDeps,
    +  newWasmHash: Buffer | Uint8Array
    +): Promise {
    +  return deps.wallet.upgrade(
    +    { new_wasm_hash: Buffer.from(newWasmHash) },
    +    { timeoutInSeconds: deps.timeoutInSeconds }
    +  );
    +}
    +
    +/**
    + * Read a signer entry from the ledger, or `null` if absent.
    + *
    + * Checks temporary durability before persistent (the contract's lookup order).
    + * A non-null result proves the wallet holds this signer — the basis of
    + * `connectWallet`'s ownership verification.
    + *
    + * `null` means a genuine not-found in BOTH durabilities. A transport error
    + * (RPC 429/5xx/timeout) is NOT swallowed — it propagates, so `connectWallet`
    + * never mistakes a flaky RPC for "the passkey is not a signer" and throws a
    + * misleading `WalletOwnershipError` for a valid credential.
    + */
    +export async function getSigner(
    +  deps: { rpc: Server; spec: ContractSpec },
    +  contractId: string,
    +  signerKey: SignerKey
    +): Promise {
    +  const scKey = signerKeyToScVal(deps.spec, toContractSignerKey(signerKey));
    +
    +  for (const durability of [Durability.Temporary, Durability.Persistent]) {
    +    const scVal = await readContractData(deps.rpc, contractId, scKey, durability);
    +    if (scVal) {
    +      return deps.spec.scValToNative(scVal, SIGNER_VAL_UDT) as SignerVal;
    +    }
    +  }
    +
    +  return null;
    +}
    diff --git a/src/kit/webauthn-ops.ts b/src/kit/webauthn-ops.ts
    new file mode 100644
    index 0000000..7d29ffb
    --- /dev/null
    +++ b/src/kit/webauthn-ops.ts
    @@ -0,0 +1,141 @@
    +/**
    + * WebAuthn ceremony operations: passkey registration and authentication.
    + *
    + * Pure functions over an injected WebAuthn implementation and RP config, so the
    + * ceremonies are unit-testable with a fake authenticator. The old kit hard-coded
    + * a static challenge (`"stellaristhebetterblockchain"`) and read `rpId` per
    + * call; here the challenge is random (see {@link generateChallenge}) and the RP
    + * config comes from the kit constructor.
    + *
    + * @packageDocumentation
    + */
    +
    +import type {
    +  AuthenticationResponseJSON,
    +  RegistrationResponseJSON,
    +  PublicKeyCredentialCreationOptionsJSON,
    +  PublicKeyCredentialRequestOptionsJSON,
    +  AuthenticatorSelectionCriteria,
    +} from "@simplewebauthn/browser";
    +import base64url from "../base64url.js";
    +import { extractPublicKeyFromAttestation, generateChallenge } from "../utils.js";
    +import { WEBAUTHN_TIMEOUT_MS } from "../constants.js";
    +import { WebAuthnError, PasskeyKitErrorCode } from "../errors.js";
    +
    +/** The WebAuthn ceremony surface the kit depends on (overridable for tests). */
    +export interface WebAuthnClient {
    +  startRegistration(args: {
    +    optionsJSON: PublicKeyCredentialCreationOptionsJSON;
    +  }): Promise;
    +  startAuthentication(args: {
    +    optionsJSON: PublicKeyCredentialRequestOptionsJSON;
    +  }): Promise;
    +}
    +
    +/** RP config + WebAuthn implementation for the ceremonies. */
    +export interface WebAuthnDeps {
    +  rpId?: string;
    +  webAuthn: WebAuthnClient;
    +}
    +
    +/** A freshly registered passkey. */
    +export interface CreatedPasskey {
    +  rawResponse: RegistrationResponseJSON;
    +  /** Base64URL credential id. */
    +  keyId: string;
    +  /** Raw credential id bytes. */
    +  keyIdBuffer: Buffer;
    +  /** 65-byte uncompressed secp256r1 public key. */
    +  publicKey: Uint8Array;
    +}
    +
    +/**
    + * Run a WebAuthn registration ceremony and extract the passkey public key.
    + *
    + * `pubKeyCredParams` is ES256-only (the smart wallet verifies secp256r1).
    + */
    +export async function createPasskey(
    +  deps: WebAuthnDeps,
    +  appName: string,
    +  userName: string,
    +  authenticatorSelection: AuthenticatorSelectionCriteria = {
    +    residentKey: "preferred",
    +    userVerification: "preferred",
    +  }
    +): Promise {
    +  const now = new Date();
    +  const displayName = `${userName} — ${now.toLocaleString()}`;
    +
    +  const optionsJSON: PublicKeyCredentialCreationOptionsJSON = {
    +    challenge: generateChallenge(),
    +    rp: { id: deps.rpId, name: appName },
    +    user: {
    +      id: generateChallenge(),
    +      name: displayName,
    +      displayName,
    +    },
    +    authenticatorSelection,
    +    pubKeyCredParams: [{ alg: -7, type: "public-key" }],
    +    timeout: WEBAUTHN_TIMEOUT_MS,
    +  };
    +
    +  let rawResponse: RegistrationResponseJSON;
    +  try {
    +    rawResponse = await deps.webAuthn.startRegistration({ optionsJSON });
    +  } catch (err) {
    +    throw new WebAuthnError(
    +      "Passkey registration failed",
    +      PasskeyKitErrorCode.WEBAUTHN_REGISTRATION_FAILED,
    +      err instanceof Error ? err : undefined
    +    );
    +  }
    +
    +  const publicKey = await extractPublicKeyFromAttestation(rawResponse.response);
    +
    +  return {
    +    rawResponse,
    +    keyId: rawResponse.id,
    +    keyIdBuffer: base64url.toBuffer(rawResponse.id),
    +    publicKey,
    +  };
    +}
    +
    +/** A discoverable-credential authentication result. */
    +export interface AuthenticatedPasskey {
    +  keyId: string;
    +  keyIdBuffer: Buffer;
    +  rawResponse: AuthenticationResponseJSON;
    +}
    +
    +/**
    + * Run a WebAuthn authentication ceremony against a discoverable credential (no
    + * `allowCredentials`), returning the selected credential id. Used by
    + * `connectWallet` to discover which passkey the user picked.
    + */
    +export async function authenticatePasskey(
    +  deps: WebAuthnDeps
    +): Promise {
    +  const optionsJSON: PublicKeyCredentialRequestOptionsJSON = {
    +    challenge: generateChallenge(),
    +    rpId: deps.rpId,
    +    userVerification: "preferred",
    +    timeout: WEBAUTHN_TIMEOUT_MS,
    +  };
    +
    +  let rawResponse: AuthenticationResponseJSON;
    +  try {
    +    rawResponse = await deps.webAuthn.startAuthentication({ optionsJSON });
    +  } catch (err) {
    +    throw new WebAuthnError(
    +      "Passkey authentication failed",
    +      PasskeyKitErrorCode.WEBAUTHN_AUTHENTICATION_FAILED,
    +      err instanceof Error ? err : undefined
    +    );
    +  }
    +
    +  return {
    +    keyId: rawResponse.id,
    +    keyIdBuffer: base64url.toBuffer(rawResponse.id),
    +    rawResponse,
    +  };
    +}
    diff --git a/src/managers/credential-manager.ts b/src/managers/credential-manager.ts
    new file mode 100644
    index 0000000..2f23bd2
    --- /dev/null
    +++ b/src/managers/credential-manager.ts
    @@ -0,0 +1,76 @@
    +/**
    + * Credential manager: WebAuthn passkey ceremonies + optional passkey-record
    + * persistence.
    + *
    + * Constructed from a plain {@link CredentialManagerDeps} object so it is fully
    + * unit-testable with `vi.fn()` fakes (no live authenticator or RPC needed).
    + *
    + * @packageDocumentation
    + */
    +
    +import type { AuthenticatorSelectionCriteria } from "@simplewebauthn/browser";
    +import type { StorageAdapter, StoredPasskey } from "../types.js";
    +import {
    +  authenticatePasskey,
    +  createPasskey,
    +  type AuthenticatedPasskey,
    +  type CreatedPasskey,
    +  type WebAuthnClient,
    +} from "../kit/webauthn-ops.js";
    +
    +export interface CredentialManagerDeps {
    +  rpId?: string;
    +  webAuthn: WebAuthnClient;
    +  /** Optional passkey-record store; when absent, remember/lookup are no-ops. */
    +  storage?: StorageAdapter;
    +}
    +
    +export class CredentialManager {
    +  constructor(private readonly deps: CredentialManagerDeps) {}
    +
    +  private get webAuthnDeps() {
    +    return { rpId: this.deps.rpId, webAuthn: this.deps.webAuthn };
    +  }
    +
    +  /** Run a registration ceremony, returning the new passkey + its public key. */
    +  createKey(
    +    appName: string,
    +    userName: string,
    +    authenticatorSelection?: AuthenticatorSelectionCriteria
    +  ): Promise {
    +    return createPasskey(
    +      this.webAuthnDeps,
    +      appName,
    +      userName,
    +      authenticatorSelection
    +    );
    +  }
    +
    +  /** Run a discoverable-credential authentication ceremony. */
    +  authenticate(): Promise {
    +    return authenticatePasskey(this.webAuthnDeps);
    +  }
    +
    +  /** Persist a passkey → wallet association (no-op without a storage adapter). */
    +  async rememberPasskey(record: StoredPasskey): Promise {
    +    await this.deps.storage?.save(record);
    +  }
    +
    +  /** Remove a persisted passkey record (no-op without a storage adapter). */
    +  async forgetPasskey(keyId: string): Promise {
    +    await this.deps.storage?.delete(keyId);
    +  }
    +
    +  /** Look up a stored passkey record by keyId. */
    +  async getPasskey(keyId: string): Promise {
    +    return (await this.deps.storage?.get(keyId)) ?? null;
    +  }
    +
    +  /**
    +   * Resolve a wallet contract id for a keyId from local storage, if a record
    +   * exists. Used as an indexer-free `connectWallet` fallback.
    +   */
    +  async lookupContractId(keyId: string): Promise {
    +    return (await this.getPasskey(keyId))?.contractId;
    +  }
    +}
    diff --git a/src/managers/index.ts b/src/managers/index.ts
    new file mode 100644
    index 0000000..a70f9fd
    --- /dev/null
    +++ b/src/managers/index.ts
    @@ -0,0 +1,13 @@
    +/**
    + * Dependency-injected managers that compose the kit facade.
    + */
    +
    +export {
    +  CredentialManager,
    +  type CredentialManagerDeps,
    +} from "./credential-manager.js";
    +export { SignerManager, type SignerManagerDeps } from "./signer-manager.js";
    +export {
    +  SubmissionManager,
    +  type SubmissionManagerDeps,
    +} from "./submission-manager.js";
    diff --git a/src/managers/signer-manager.test.ts b/src/managers/signer-manager.test.ts
    new file mode 100644
    index 0000000..3222932
    --- /dev/null
    +++ b/src/managers/signer-manager.test.ts
    @@ -0,0 +1,144 @@
    +import { describe, expect, it, vi } from "vitest";
    +import { Keypair } from "@stellar/stellar-sdk";
    +import type { Spec as ContractSpec } from "@stellar/stellar-sdk/contract";
    +import { Client as PasskeyClient } from "passkey-kit-sdk";
    +import { SignerManager, type SignerManagerDeps } from "./signer-manager.js";
    +import { SignerKey, SignerStore } from "../types.js";
    +import { WalletNotConnectedError } from "../errors.js";
    +import base64url from "../base64url.js";
    +
    +const TESTNET = "Test SDF Network ; September 2015";
    +const CONTRACT = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +function realSpec(): ContractSpec {
    +  return (
    +    new PasskeyClient({
    +      contractId: CONTRACT,
    +      networkPassphrase: TESTNET,
    +      rpcUrl: "https://rpc.example",
    +    }) as unknown as { spec: ContractSpec }
    +  ).spec;
    +}
    +
    +function makeDeps(overrides: Partial = {}) {
    +  const wallet = {
    +    spec: realSpec(),
    +    options: { contractId: CONTRACT },
    +    add_signer: vi.fn(async () => "AT_ADD"),
    +    update_signer: vi.fn(async () => "AT_UPDATE"),
    +    remove_signer: vi.fn(async () => "AT_REMOVE"),
    +  };
    +  const rpc = {
    +    // getSigner probes via getLedgerEntries: empty entries = genuine not-found.
    +    getLedgerEntries: vi.fn(async () => ({ entries: [] as unknown[] })),
    +  };
    +  const deps: SignerManagerDeps = {
    +    networkPassphrase: TESTNET,
    +    timeoutInSeconds: 30,
    +    rpc: rpc as never,
    +    getWallet: () => wallet as never,
    +    getContractId: () => CONTRACT,
    +    getSignerContext: () => ({ webAuthn: { startAuthentication: vi.fn() } as never }),
    +    calculateExpiration: vi.fn(async () => 100),
    +    ...overrides,
    +  };
    +  return { deps, wallet, rpc };
    +}
    +
    +describe("SignerManager signer writes", () => {
    +  it("addEd25519 calls wallet.add_signer with the encoded Ed25519 signer", async () => {
    +    const { deps, wallet } = makeDeps();
    +    const manager = new SignerManager(deps);
    +    const pk = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 3)).publicKey();
    +
    +    const result = await manager.addEd25519(pk, undefined, SignerStore.Persistent);
    +
    +    expect(result).toBe("AT_ADD");
    +    expect(wallet.add_signer).toHaveBeenCalledTimes(1);
    +    const [{ signer }, opts] = wallet.add_signer.mock.calls[0]!;
    +    expect(signer.tag).toBe("Ed25519");
    +    expect(signer.values[3]).toEqual({ tag: "Persistent", values: undefined });
    +    expect(opts).toEqual({ timeoutInSeconds: 30 });
    +  });
    +
    +  it("addSecp256r1 encodes keyId/publicKey and expiration/limits", async () => {
    +    const { deps, wallet } = makeDeps();
    +    const manager = new SignerManager(deps);
    +    const keyId = base64url.encode(Buffer.alloc(16, 1));
    +    const publicKey = base64url.encode(Buffer.alloc(65, 4));
    +
    +    await manager.addSecp256r1(keyId, publicKey, undefined, SignerStore.Temporary, 777);
    +
    +    const [{ signer }] = wallet.add_signer.mock.calls[0]!;
    +    expect(signer.tag).toBe("Secp256r1");
    +    expect(signer.values[2]).toEqual([777n]); // SignerExpiration (Option)
    +    expect(signer.values[3]).toEqual([undefined]); // unlimited SignerLimits
    +    expect(signer.values[4]).toEqual({ tag: "Temporary", values: undefined });
    +  });
    +
    +  it("remove calls wallet.remove_signer with the encoded key", async () => {
    +    const { deps, wallet } = makeDeps();
    +    const manager = new SignerManager(deps);
    +
    +    await manager.remove(SignerKey.Ed25519(
    +      Keypair.fromRawEd25519Seed(Buffer.alloc(32, 5)).publicKey()
    +    ));
    +
    +    expect(wallet.remove_signer).toHaveBeenCalledTimes(1);
    +    const [{ signer_key }] = wallet.remove_signer.mock.calls[0]!;
    +    expect(signer_key.tag).toBe("Ed25519");
    +  });
    +
    +  it("throws WalletNotConnectedError when no wallet is connected", () => {
    +    const { deps } = makeDeps({ getWallet: () => undefined });
    +    const manager = new SignerManager(deps);
    +    expect(() =>
    +      manager.addPolicy(CONTRACT, undefined, SignerStore.Persistent)
    +    ).toThrow(WalletNotConnectedError);
    +  });
    +});
    +
    +describe("SignerManager.sign", () => {
    +  it("signs the transaction's auth entries against the connected contract", async () => {
    +    const { deps } = makeDeps();
    +    const manager = new SignerManager(deps);
    +    const signAuthEntries = vi.fn(async () => {});
    +    const txn = { signAuthEntries } as never;
    +
    +    const returned = await manager.sign(txn, { sign: vi.fn() });
    +
    +    expect(returned).toBe(txn);
    +    expect(signAuthEntries).toHaveBeenCalledTimes(1);
    +    expect(signAuthEntries.mock.calls[0]![0].address).toBe(CONTRACT);
    +  });
    +});
    +
    +describe("SignerManager.getSigner", () => {
    +  it("returns null when the signer entry is absent in both durabilities", async () => {
    +    const { deps, rpc } = makeDeps();
    +    const manager = new SignerManager(deps);
    +
    +    const result = await manager.getSigner(
    +      SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 9)))
    +    );
    +
    +    expect(result).toBeNull();
    +    expect(rpc.getLedgerEntries).toHaveBeenCalledTimes(2); // temporary + persistent
    +  });
    +
    +  it("propagates a transport error instead of reporting the signer absent", async () => {
    +    // A transient RPC failure must NOT be swallowed into a null (which
    +    // connectWallet would read as a false ownership mismatch, audit LOW).
    +    const rpc = {
    +      getLedgerEntries: vi.fn(async () => {
    +        throw new Error("503 upstream request timeout");
    +      }),
    +    };
    +    const { deps } = makeDeps({ rpc: rpc as never });
    +    const manager = new SignerManager(deps);
    +
    +    await expect(
    +      manager.getSigner(SignerKey.Secp256r1(base64url.encode(Buffer.alloc(16, 9))))
    +    ).rejects.toThrow("503 upstream");
    +  });
    +});
    diff --git a/src/managers/signer-manager.ts b/src/managers/signer-manager.ts
    new file mode 100644
    index 0000000..1a41d16
    --- /dev/null
    +++ b/src/managers/signer-manager.ts
    @@ -0,0 +1,188 @@
    +/**
    + * Signer manager: the signing pipeline and signer-write builders for the
    + * connected wallet.
    + *
    + * Owns nothing directly — every wallet-dependent value is a late-bound closure
    + * ({@link SignerManagerDeps.getWallet} / `getContractId` / `getSpec`) supplied by
    + * the kit facade, so the manager is constructed once and always sees current
    + * state. That indirection is also what makes it `vi.fn()`-testable.
    + *
    + * @packageDocumentation
    + */
    +
    +import { xdr } from "@stellar/stellar-sdk";
    +import type { Server } from "@stellar/stellar-sdk/rpc";
    +import type {
    +  AssembledTransaction,
    +  Spec as ContractSpec,
    +} from "@stellar/stellar-sdk/contract";
    +import type { Client as PasskeyClient, SignerVal } from "passkey-kit-sdk";
    +import type { Signer, SignerContext } from "../signers.js";
    +import { PasskeySigner } from "../signers.js";
    +import {
    +  SignerStore,
    +  type SignerKey,
    +  type SignerLimits,
    +} from "../types.js";
    +import { WalletNotConnectedError } from "../errors.js";
    +import {
    +  signAuthEntry as signAuthEntryOp,
    +  sign as signOp,
    +  type SignOptions,
    +} from "../kit/tx-ops.js";
    +import {
    +  buildEd25519SignerTx,
    +  buildPolicySignerTx,
    +  buildRemoveSignerTx,
    +  buildSecp256r1SignerTx,
    +  buildUpgradeTx,
    +  getSigner as getSignerOp,
    +  type WalletTx,
    +} from "../kit/wallet-ops.js";
    +
    +/** Extract the contract `Spec` from a generated client (not in the public type). */
    +function specOf(wallet: PasskeyClient): ContractSpec {
    +  return (wallet as unknown as { spec: ContractSpec }).spec;
    +}
    +
    +export interface SignerManagerDeps {
    +  networkPassphrase: string;
    +  timeoutInSeconds: number;
    +  rpc: Server;
    +  getWallet: () => PasskeyClient | undefined;
    +  getContractId: () => string | undefined;
    +  getSignerContext: () => SignerContext;
    +  calculateExpiration: () => Promise;
    +}
    +
    +export class SignerManager {
    +  constructor(private readonly deps: SignerManagerDeps) {}
    +
    +  private requireWallet(): { wallet: PasskeyClient; spec: ContractSpec } {
    +    const wallet = this.deps.getWallet();
    +    if (!wallet) {
    +      throw new WalletNotConnectedError("perform a signer operation");
    +    }
    +    return { wallet, spec: specOf(wallet) };
    +  }
    +
    +  private writeDeps() {
    +    return {
    +      wallet: this.requireWallet().wallet,
    +      timeoutInSeconds: this.deps.timeoutInSeconds,
    +    };
    +  }
    +
    +  // -- Signing pipeline --------------------------------------------------------
    +
    +  private signAuthEntryDeps() {
    +    return {
    +      networkPassphrase: this.deps.networkPassphrase,
    +      spec: this.requireWallet().spec,
    +      signerContext: this.deps.getSignerContext(),
    +      calculateExpiration: this.deps.calculateExpiration,
    +    };
    +  }
    +
    +  /** Sign a single auth entry with `signer` (defaults to the connected passkey). */
    +  signAuthEntry(
    +    entry: xdr.SorobanAuthorizationEntry,
    +    signer: Signer = new PasskeySigner(),
    +    options?: SignOptions
    +  ): Promise {
    +    return signAuthEntryOp(this.signAuthEntryDeps(), entry, signer, options);
    +  }
    +
    +  /** Sign an assembled transaction's wallet auth entries. */
    +  sign(
    +    txn: AssembledTransaction,
    +    signer: Signer = new PasskeySigner(),
    +    options?: SignOptions
    +  ): Promise> {
    +    return signOp(
    +      { ...this.signAuthEntryDeps(), getContractId: this.deps.getContractId },
    +      txn,
    +      signer,
    +      options
    +    );
    +  }
    +
    +  // -- Signer writes -----------------------------------------------------------
    +
    +  addSecp256r1(
    +    keyId: string | Uint8Array,
    +    publicKey: string | Uint8Array,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return buildSecp256r1SignerTx(this.writeDeps(), "add_signer", keyId, publicKey, limits, store, expiration);
    +  }
    +
    +  updateSecp256r1(
    +    keyId: string | Uint8Array,
    +    publicKey: string | Uint8Array,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return buildSecp256r1SignerTx(this.writeDeps(), "update_signer", keyId, publicKey, limits, store, expiration);
    +  }
    +
    +  addEd25519(
    +    publicKey: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return buildEd25519SignerTx(this.writeDeps(), "add_signer", publicKey, limits, store, expiration);
    +  }
    +
    +  updateEd25519(
    +    publicKey: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return buildEd25519SignerTx(this.writeDeps(), "update_signer", publicKey, limits, store, expiration);
    +  }
    +
    +  addPolicy(
    +    policy: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return buildPolicySignerTx(this.writeDeps(), "add_signer", policy, limits, store, expiration);
    +  }
    +
    +  updatePolicy(
    +    policy: string,
    +    limits: SignerLimits,
    +    store: SignerStore,
    +    expiration?: number
    +  ): Promise {
    +    return buildPolicySignerTx(this.writeDeps(), "update_signer", policy, limits, store, expiration);
    +  }
    +
    +  remove(signerKey: SignerKey): Promise {
    +    return buildRemoveSignerTx(this.writeDeps(), signerKey);
    +  }
    +
    +  /** Build an `upgrade(new_wasm_hash)` transaction. */
    +  upgrade(newWasmHash: Buffer | Uint8Array): Promise {
    +    return buildUpgradeTx(this.writeDeps(), newWasmHash);
    +  }
    +
    +  // -- Reads -------------------------------------------------------------------
    +
    +  /** Read a signer entry from the ledger (temporary before persistent). */
    +  getSigner(signerKey: SignerKey): Promise {
    +    const { spec } = this.requireWallet();
    +    const contractId = this.deps.getContractId();
    +    if (!contractId) {
    +      throw new WalletNotConnectedError("read a signer");
    +    }
    +    return getSignerOp({ rpc: this.deps.rpc, spec }, contractId, signerKey);
    +  }
    +}
    diff --git a/src/managers/submission-manager.ts b/src/managers/submission-manager.ts
    new file mode 100644
    index 0000000..e983728
    --- /dev/null
    +++ b/src/managers/submission-manager.ts
    @@ -0,0 +1,116 @@
    +/**
    + * Submission manager: builds and signs the deploy transaction with the fee-
    + * paying deployer keypair, derives deterministic wallet addresses, and drives
    + * the footprint-restore flow.
    + *
    + * Network submission itself is the server's job (`PasskeyServer` / relayer); this
    + * manager owns the deployer keypair and the fee-payer signature that make a
    + * deploy transaction submittable.
    + *
    + * @packageDocumentation
    + */
    +
    +import { TransactionBuilder } from "@stellar/stellar-sdk";
    +import type { Keypair, Transaction } from "@stellar/stellar-sdk";
    +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract";
    +import type { Server } from "@stellar/stellar-sdk/rpc";
    +import type { Client as PasskeyClient } from "passkey-kit-sdk";
    +import {
    +  buildDeployTransaction as buildDeployTransactionOp,
    +  deriveWalletAddress as deriveWalletAddressOp,
    +} from "../kit/deploy-ops.js";
    +import {
    +  restoreFootprint as restoreFootprintOp,
    +  type RestorePreamble,
    +} from "../kit/tx-ops.js";
    +
    +export interface SubmissionManagerDeps {
    +  rpc: Server;
    +  rpcUrl: string;
    +  networkPassphrase: string;
    +  walletWasmHash: string;
    +  deployerKeypair: Keypair;
    +  timeoutInSeconds: number;
    +}
    +
    +export class SubmissionManager {
    +  constructor(private readonly deps: SubmissionManagerDeps) {}
    +
    +  /** The deployer's `G…` public key (fee source + derivation deployer). */
    +  get deployerPublicKey(): string {
    +    return this.deps.deployerKeypair.publicKey();
    +  }
    +
    +  /** Deterministically derive the wallet address for a passkey credential. */
    +  deriveWalletAddress(keyId: Buffer): string {
    +    return deriveWalletAddressOp(
    +      {
    +        networkPassphrase: this.deps.networkPassphrase,
    +        deployerPublicKey: this.deployerPublicKey,
    +      },
    +      keyId
    +    );
    +  }
    +
    +  /** Build the wallet deploy transaction (initial Secp256r1 signer). */
    +  buildDeployTransaction(
    +    keyId: Buffer,
    +    publicKey: Uint8Array
    +  ): Promise> {
    +    return buildDeployTransactionOp(
    +      {
    +        rpcUrl: this.deps.rpcUrl,
    +        networkPassphrase: this.deps.networkPassphrase,
    +        walletWasmHash: this.deps.walletWasmHash,
    +        deployerPublicKey: this.deployerPublicKey,
    +        timeoutInSeconds: this.deps.timeoutInSeconds,
    +      },
    +      keyId,
    +      publicKey
    +    );
    +  }
    +
    +  /**
    +   * Sign a deploy transaction with the deployer keypair (the fee source) and
    +   * return the signed transaction XDR.
    +   *
    +   * The deploy carries source-account auth, so it is submitted through the
    +   * relayer's fee-bump (`{ xdr }`) path. A fee-bumped Soroban inner transaction
    +   * must have `fee == resourceFee` — the fee-bump supplies the inclusion fee —
    +   * or the relayer rejects it with a fee mismatch ("Transaction fee must be
    +   * equal to the resource fee"). The assembled fee is `inclusion + resource`,
    +   * so pin it to the resource fee BEFORE the deployer signs (the signature
    +   * commits to the fee). `TransactionBuilder.cloneFrom` drops
    +   * `SorobanTransactionData` (→ txMalformed), so set the fee field surgically on
    +   * the envelope and rebuild.
    +   */
    +  async signDeploy(tx: AssembledTransaction): Promise {
    +    if (!tx.built) {
    +      throw new Error("deploy transaction has not been built/simulated");
    +    }
    +    const envelope = tx.built.toEnvelope();
    +    const inner = envelope.v1().tx();
    +    const resourceFee = inner.ext().sorobanData().resourceFee().toString();
    +    inner.fee(Number(resourceFee));
    +
    +    const feeAdjusted = TransactionBuilder.fromXDR(
    +      envelope.toXDR("base64"),
    +      this.deps.networkPassphrase
    +    ) as Transaction;
    +    feeAdjusted.sign(this.deps.deployerKeypair);
    +    return feeAdjusted.toXDR();
    +  }
    +
    +  /** Restore an archived footprint reported by simulation. */
    +  restoreFootprint(restorePreamble: RestorePreamble): Promise {
    +    return restoreFootprintOp(
    +      {
    +        rpc: this.deps.rpc,
    +        networkPassphrase: this.deps.networkPassphrase,
    +        deployerKeypair: this.deps.deployerKeypair,
    +        timeoutInSeconds: this.deps.timeoutInSeconds,
    +      },
    +      restorePreamble
    +    );
    +  }
    +}
    diff --git a/src/relayer.test.ts b/src/relayer.test.ts
    new file mode 100644
    index 0000000..a612178
    --- /dev/null
    +++ b/src/relayer.test.ts
    @@ -0,0 +1,139 @@
    +import { describe, expect, it, vi } from "vitest";
    +import { PluginExecutionError } from "@openzeppelin/relayer-plugin-channels";
    +import { RelayerClient } from "./relayer.js";
    +import { ContractError, RelayerError, PasskeyKitErrorCode } from "./errors.js";
    +
    +/** Build a RelayerClient with its private ChannelsClient replaced by a fake. */
    +function withChannels(fake: Record): RelayerClient {
    +  const relayer = new RelayerClient({ baseUrl: "https://relayer.test", apiKey: "k" });
    +  (relayer as unknown as { channels: unknown }).channels = fake;
    +  return relayer;
    +}
    +
    +describe("RelayerClient config", () => {
    +  it("throws when baseUrl or apiKey is missing", () => {
    +    expect(() => new RelayerClient({ baseUrl: "", apiKey: "k" })).toThrow(
    +      RelayerError
    +    );
    +    expect(() => new RelayerClient({ baseUrl: "u", apiKey: "" })).toThrow(
    +      RelayerError
    +    );
    +  });
    +});
    +
    +describe("RelayerClient.send", () => {
    +  it("returns a success result for a confirmed submission", async () => {
    +    const submitSorobanTransaction = vi.fn(async () => ({
    +      transactionId: "tx-1",
    +      hash: "abc123",
    +      status: "confirmed",
    +    }));
    +    const relayer = withChannels({ submitSorobanTransaction });
    +
    +    const result = await relayer.send("FUNC", ["AUTH"]);
    +
    +    expect(result).toEqual({ success: true, hash: "abc123", transactionId: "tx-1" });
    +    expect(submitSorobanTransaction).toHaveBeenCalledWith({
    +      func: "FUNC",
    +      auth: ["AUTH"],
    +      skipWait: undefined,
    +      fundRelayerId: undefined,
    +    });
    +  });
    +
    +  it("returns a typed failure when the relayer reports a failed status", async () => {
    +    const relayer = withChannels({
    +      submitSorobanTransaction: vi.fn(async () => ({
    +        transactionId: "tx-2",
    +        hash: "def",
    +        status: "failed",
    +      })),
    +    });
    +
    +    const result = await relayer.send("FUNC", []);
    +    expect(result.success).toBe(false);
    +    if (!result.success) {
    +      expect(result.error).toBeInstanceOf(RelayerError);
    +      expect(result.hash).toBe("def");
    +    }
    +  });
    +
    +  it("reports a non-terminal (pending) status as a distinct non-success result", async () => {
    +    // A `skipWait` submit returns status "pending" with no hash yet. It must NOT
    +    // map to success:true (which a poll loop would treat as confirmed).
    +    const relayer = withChannels({
    +      submitSorobanTransaction: vi.fn(async () => ({
    +        transactionId: "tx-p",
    +        hash: "",
    +        status: "pending",
    +      })),
    +    });
    +
    +    const result = await relayer.send("FUNC", [], { skipWait: true });
    +    expect(result.success).toBe(false);
    +    if (!result.success) {
    +      expect(result.error).toBeInstanceOf(RelayerError);
    +      expect(result.error.code).toBe(PasskeyKitErrorCode.RELAYER_PENDING);
    +      expect(result.error.context).toMatchObject({
    +        pending: true,
    +        status: "pending",
    +        transactionId: "tx-p",
    +      });
    +    }
    +  });
    +
    +  it("never throws on a PluginClientError — maps it to a RelayerError", async () => {
    +    const relayer = withChannels({
    +      submitSorobanTransaction: vi.fn(async () => {
    +        throw new PluginExecutionError("insufficient balance", {
    +          code: "FEE_LIMIT_EXCEEDED",
    +        });
    +      }),
    +    });
    +
    +    const result = await relayer.send("FUNC", []);
    +    expect(result.success).toBe(false);
    +    if (!result.success) {
    +      expect(result.error).toBeInstanceOf(RelayerError);
    +      expect(result.error.code).toBe(PasskeyKitErrorCode.RELAYER_REQUEST_FAILED);
    +      expect(result.error.context).toMatchObject({ category: "execution" });
    +    }
    +  });
    +
    +  it("decodes an on-chain contract error surfaced by the relayer", async () => {
    +    const relayer = withChannels({
    +      submitSorobanTransaction: vi.fn(async () => {
    +        throw new PluginExecutionError("HostError: Error(Contract, #4)");
    +      }),
    +    });
    +
    +    const result = await relayer.send("FUNC", []);
    +    expect(result.success).toBe(false);
    +    if (!result.success) {
    +      expect(result.error).toBeInstanceOf(ContractError);
    +      expect((result.error as ContractError).contractErrorName).toBe(
    +        "SignerExpired"
    +      );
    +    }
    +  });
    +});
    +
    +describe("RelayerClient.sendTransaction", () => {
    +  it("submits an envelope via submitTransaction", async () => {
    +    const submitTransaction = vi.fn(async () => ({
    +      transactionId: "tx-9",
    +      hash: "hh",
    +      status: "success",
    +    }));
    +    const relayer = withChannels({ submitTransaction });
    +
    +    const result = await relayer.sendTransaction("ENVELOPE_XDR", { skipWait: true });
    +
    +    expect(result.success).toBe(true);
    +    expect(submitTransaction).toHaveBeenCalledWith({
    +      xdr: "ENVELOPE_XDR",
    +      skipWait: true,
    +      fundRelayerId: undefined,
    +    });
    +  });
    +});
    diff --git a/src/relayer.ts b/src/relayer.ts
    new file mode 100644
    index 0000000..391c943
    --- /dev/null
    +++ b/src/relayer.ts
    @@ -0,0 +1,207 @@
    +/**
    + * Server-side relayer client.
    + *
    + * A thin, typed wrapper over the OpenZeppelin Channels client
    + * (`@openzeppelin/relayer-plugin-channels` ^0.20) for fee-sponsored submission.
    + * It holds the relayer API key, so it MUST run server-side only (it is reached
    + * through `PasskeyServer`, exported from the `passkey-kit/server` subpath).
    + *
    + * Two submission modes:
    + * - {@link RelayerClient.send} — `{ func, auth }` for invokeHostFunction flows
    + *   (the preferred Soroban path; the relayer builds the envelope + pays fees).
    + * - {@link RelayerClient.sendTransaction} — `{ xdr }` for a signed envelope
    + *   (e.g. a deploy transaction that needs source-account auth + a fee bump).
    + *
    + * Every method returns a discriminated {@link TransactionResult} and NEVER
    + * throws for expected relayer/on-chain failures — a `PluginClientError` is
    + * mapped to a typed {@link RelayerError} (or a {@link ContractError} when a
    + * contract code can be decoded from its details).
    + *
    + * @packageDocumentation
    + */
    +
    +import {
    +  ChannelsClient,
    +  PluginClientError,
    +  type ChannelsTransactionResponse,
    +} from "@openzeppelin/relayer-plugin-channels";
    +import type { TransactionResult, TransactionFailure } from "./types.js";
    +import { RelayerError, PasskeyKitErrorCode } from "./errors.js";
    +import {
    +  decodeContractError,
    +  failedTransaction,
    +} from "./contract-errors.js";
    +import { DEFAULT_RELAYER_TIMEOUT_MS } from "./constants.js";
    +
    +/** Configuration for a {@link RelayerClient}. */
    +export interface RelayerClientConfig {
    +  /** Base URL of the Channels relayer service. */
    +  baseUrl: string;
    +  /** API key for the relayer service (server-side secret). */
    +  apiKey: string;
    +  /** Optional admin secret for management operations. */
    +  adminSecret?: string;
    +  /** Request timeout in ms (default {@link DEFAULT_RELAYER_TIMEOUT_MS}). */
    +  timeout?: number;
    +}
    +
    +/** Per-submission relayer options. */
    +export interface RelayerSubmitOptions {
    +  /** Return immediately after submission; poll {@link RelayerClient.getTransaction}. */
    +  skipWait?: boolean;
    +  /** Alternative fund-relayer id for the fee bump (must be allow-listed). */
    +  fundRelayerId?: string;
    +}
    +
    +/**
    + * Terminal-success statuses (ALLOWLIST). Only a status that positively confirms
    + * on-chain inclusion counts as success — anything else is failure or still
    + * pending. An allowlist (not a failure denylist) is deliberate: an unrecognized
    + * or non-terminal status must never be mistaken for a confirmed transaction.
    + */
    +const SUCCESS_STATUS = /confirm|success/i;
    +
    +/** Terminal-failure statuses. */
    +const FAILURE_STATUS = /fail|error|revert|reject/i;
    +
    +export class RelayerClient {
    +  private readonly channels: ChannelsClient;
    +
    +  constructor(config: RelayerClientConfig) {
    +    if (!config.baseUrl || !config.apiKey) {
    +      throw new RelayerError(
    +        "RelayerClient requires both baseUrl and apiKey",
    +        PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED
    +      );
    +    }
    +    this.channels = new ChannelsClient({
    +      baseUrl: config.baseUrl,
    +      apiKey: config.apiKey,
    +      adminSecret: config.adminSecret,
    +      timeout: config.timeout ?? DEFAULT_RELAYER_TIMEOUT_MS,
    +    });
    +  }
    +
    +  /**
    +   * Submit an invokeHostFunction via `{ func, auth }` (the preferred Soroban
    +   * path). The relayer builds the transaction envelope with a channel account
    +   * and pays the fees.
    +   */
    +  async send(
    +    func: string,
    +    auth: string[],
    +    options?: RelayerSubmitOptions
    +  ): Promise {
    +    return this.run(() =>
    +      this.channels.submitSorobanTransaction({
    +        func,
    +        auth,
    +        skipWait: options?.skipWait,
    +        fundRelayerId: options?.fundRelayerId,
    +      })
    +    );
    +  }
    +
    +  /**
    +   * Submit a signed transaction envelope via `{ xdr }` for a fee bump (preserves
    +   * the inner signature; use for deploys / source-account-auth transactions).
    +   */
    +  async sendTransaction(
    +    xdr: string,
    +    options?: RelayerSubmitOptions
    +  ): Promise {
    +    return this.run(() =>
    +      this.channels.submitTransaction({
    +        xdr,
    +        skipWait: options?.skipWait,
    +        fundRelayerId: options?.fundRelayerId,
    +      })
    +    );
    +  }
    +
    +  /** Poll a previously-submitted (`skipWait`) transaction by its relayer id. */
    +  async getTransaction(transactionId: string): Promise {
    +    return this.run(() => this.channels.getTransaction({ transactionId }));
    +  }
    +
    +  private async run(
    +    fn: () => Promise
    +  ): Promise {
    +    try {
    +      return this.toResult(await fn());
    +    } catch (err) {
    +      return this.mapError(err);
    +    }
    +  }
    +
    +  private toResult(res: ChannelsTransactionResponse): TransactionResult {
    +    const status = res.status ?? "";
    +
    +    if (SUCCESS_STATUS.test(status)) {
    +      return {
    +        success: true,
    +        hash: res.hash ?? "",
    +        ...(res.transactionId ? { transactionId: res.transactionId } : {}),
    +      };
    +    }
    +
    +    if (FAILURE_STATUS.test(status)) {
    +      return failedTransaction(
    +        new RelayerError(
    +          `Relayer reported status "${res.status}"`,
    +          PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
    +          { status: res.status, transactionId: res.transactionId }
    +        ),
    +        res.hash ?? undefined
    +      );
    +    }
    +
    +    // Neither terminal-success nor terminal-failure: the transaction is still
    +    // pending (e.g. a `skipWait` submit not yet polled to confirmation). Surface
    +    // it as a distinct, NON-success result carrying RELAYER_PENDING — never as
    +    // `success: true` — so a poll loop keeps polling instead of treating an
    +    // unconfirmed (or later-reverted) transaction as done.
    +    return failedTransaction(
    +      new RelayerError(
    +        `Relayer status "${res.status ?? "unknown"}" is not terminal (pending)`,
    +        PasskeyKitErrorCode.RELAYER_PENDING,
    +        { status: res.status, transactionId: res.transactionId, pending: true }
    +      ),
    +      res.hash ?? undefined
    +    );
    +  }
    +
    +  private mapError(err: unknown): TransactionFailure {
    +    // Prefer a decoded contract error when the relayer surfaced one.
    +    const details =
    +      err instanceof PluginClientError
    +        ? (err as { errorDetails?: unknown }).errorDetails
    +        : undefined;
    +    const contractError =
    +      (err instanceof Error && decodeContractError(err.message)) ||
    +      decodeContractError(details);
    +    if (contractError) {
    +      return failedTransaction(contractError);
    +    }
    +
    +    if (err instanceof PluginClientError) {
    +      return failedTransaction(
    +        new RelayerError(
    +          err.message,
    +          PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
    +          { category: err.category, details },
    +          err
    +        )
    +      );
    +    }
    +
    +    return failedTransaction(
    +      new RelayerError(
    +        err instanceof Error ? err.message : String(err),
    +        PasskeyKitErrorCode.RELAYER_REQUEST_FAILED,
    +        undefined,
    +        err instanceof Error ? err : undefined
    +      )
    +    );
    +  }
    +}
    diff --git a/src/rpc-data.ts b/src/rpc-data.ts
    new file mode 100644
    index 0000000..b555c1c
    --- /dev/null
    +++ b/src/rpc-data.ts
    @@ -0,0 +1,71 @@
    +/**
    + * Direct contract-data ledger reads that distinguish a genuine not-found from a
    + * transport failure.
    + *
    + * `Server.getContractData` collapses BOTH a real not-found AND any transport
    + * error (429/5xx/timeout, aborted fetch) into an identical `{ code: 404 }`
    + * throw, so a caller can't tell "the entry is gone" from "the RPC hiccuped".
    + * These helpers go one level lower: `getLedgerEntries` resolves with an empty
    + * `entries` array for a genuine not-found and only throws on an actual transport
    + * failure. That lets eviction detection and `getSigner`'s ownership check treat
    + * the two cases correctly instead of false-evicting live signers (audit H2) or
    + * reporting a valid passkey as "not a signer" on a flaky RPC.
    + *
    + * @packageDocumentation
    + */
    +
    +import { Address, xdr } from "@stellar/stellar-sdk";
    +import { Durability, type Server } from "@stellar/stellar-sdk/rpc";
    +
    +/** Build the contract-data `LedgerKey` for a (contract, key, durability) triple. */
    +export function contractDataLedgerKey(
    +  contractId: string,
    +  key: xdr.ScVal,
    +  durability: Durability
    +): xdr.LedgerKey {
    +  return xdr.LedgerKey.contractData(
    +    new xdr.LedgerKeyContractData({
    +      contract: Address.fromString(contractId).toScAddress(),
    +      key,
    +      durability:
    +        durability === Durability.Temporary
    +          ? xdr.ContractDataDurability.temporary()
    +          : xdr.ContractDataDurability.persistent(),
    +    })
    +  );
    +}
    +
    +/**
    + * Read a contract-data entry's stored ScVal, or `null` when the entry genuinely
    + * does not exist in that durability.
    + *
    + * Transport errors are NOT swallowed — they propagate so a caller never mistakes
    + * a flaky RPC for an absent entry.
    + */
    +export async function readContractData(
    +  rpc: Server,
    +  contractId: string,
    +  key: xdr.ScVal,
    +  durability: Durability
    +): Promise {
    +  const { entries } = await rpc.getLedgerEntries(
    +    contractDataLedgerKey(contractId, key, durability)
    +  );
    +  const entry = entries[0];
    +  return entry ? entry.val.contractData().val() : null;
    +}
    +
    +/**
    + * Whether a contract-data entry exists in the given durability.
    + *
    + * Returns `false` only on a genuine not-found; a transport error throws (so
    + * callers conclude eviction exclusively from an authoritative not-found).
    + */
    +export async function contractDataExists(
    +  rpc: Server,
    +  contractId: string,
    +  key: xdr.ScVal,
    +  durability: Durability
    +): Promise {
    +  return (await readContractData(rpc, contractId, key, durability)) !== null;
    +}
    diff --git a/src/sac.ts b/src/sac.ts
    index 63f3f10..401dd70 100644
    --- a/src/sac.ts
    +++ b/src/sac.ts
    @@ -1,29 +1,58 @@
    -import { Client as SacClient } from 'sac-sdk'
    -import { PasskeyBase } from "./base"
    -import type { Server } from '@stellar/stellar-sdk/minimal/rpc'
    +/**
    + * Stellar Asset Contract (SEP-41) helpers.
    + *
    + * DECISION (todo 950): keep the generated `sac-sdk` client rather than collapsing
    + * it to a bare `buildTokenTransferHostFunction` helper. The demo needs the full
    + * SEP-41 surface (balance, name/symbol/decimals reads, transfer) for token UX,
    + * which a transfer-only host-function builder cannot provide. We ALSO expose
    + * {@link buildTokenTransferHostFunction} for the low-level relayer `{func,auth}`
    + * path. (`sac-sdk` regeneration from a canonical source is handled in B4.)
    + *
    + * @packageDocumentation
    + */
     
    -export class SACClient extends PasskeyBase {
    -    declare rpc: Server
    -    declare rpcUrl: string
    +import { Address, xdr, nativeToScVal } from "@stellar/stellar-sdk";
    +import { Client as SacClient } from "sac-sdk";
     
    -    public networkPassphrase: string
    -    
    -    constructor(options: {
    -        networkPassphrase: string,
    -        rpcUrl: string
    -    }) {
    -        const { networkPassphrase, rpcUrl } = options
    +/** A thin factory around the generated SEP-41 `sac-sdk` client. */
    +export class SACClient {
    +  readonly networkPassphrase: string;
    +  readonly rpcUrl: string;
     
    -        super(rpcUrl)
    +  constructor(options: { networkPassphrase: string; rpcUrl: string }) {
    +    this.networkPassphrase = options.networkPassphrase;
    +    this.rpcUrl = options.rpcUrl;
    +  }
     
    -        this.networkPassphrase = networkPassphrase
    -    }
    +  /** Build a SEP-41 client for a specific SAC/token contract. */
    +  getSACClient(sacContractId: string): SacClient {
    +    return new SacClient({
    +      contractId: sacContractId,
    +      networkPassphrase: this.networkPassphrase,
    +      rpcUrl: this.rpcUrl,
    +    });
    +  }
    +}
     
    -    public getSACClient(SACContractId: string) {
    -        return new SacClient({
    -            contractId: SACContractId,
    -            networkPassphrase: this.networkPassphrase,
    -            rpcUrl: this.rpcUrl,
    -        })
    -    }
    -}
    \ No newline at end of file
    +/**
    + * Build a raw SEP-41 `transfer(from, to, amount)` host function, for the
    + * low-level relayer `{ func, auth }` submission path (no client instance needed).
    + */
    +export function buildTokenTransferHostFunction(
    +  tokenContract: string,
    +  from: string,
    +  to: string,
    +  amountInStroops: bigint
    +): xdr.HostFunction {
    +  return xdr.HostFunction.hostFunctionTypeInvokeContract(
    +    new xdr.InvokeContractArgs({
    +      contractAddress: Address.fromString(tokenContract).toScAddress(),
    +      functionName: "transfer",
    +      args: [
    +        xdr.ScVal.scvAddress(Address.fromString(from).toScAddress()),
    +        xdr.ScVal.scvAddress(Address.fromString(to).toScAddress()),
    +        nativeToScVal(amountInStroops, { type: "i128" }),
    +      ],
    +    })
    +  );
    +}
    diff --git a/src/server.ts b/src/server.ts
    index be4e2aa..2648413 100644
    --- a/src/server.ts
    +++ b/src/server.ts
    @@ -1,179 +1,348 @@
    -import { xdr } from "@stellar/stellar-sdk/minimal"
    -import { PasskeyBase } from "./base"
    -import base64url from "base64url"
    -import type { Tx } from "@stellar/stellar-sdk/minimal/contract"
    -import type { Signer } from "./types"
    -import { AssembledTransaction } from "@stellar/stellar-sdk/minimal/contract"
    -import { Durability } from "@stellar/stellar-sdk/minimal/rpc"
    -import { ChannelsClient } from "@openzeppelin/relayer-plugin-channels"
    -
    -export class PasskeyServer extends PasskeyBase {
    -    private relayerApiKey: string | undefined
    -    private mercuryJwt: string | undefined
    -    private mercuryKey: string | undefined
    -    private channelsClient: ChannelsClient | undefined
    -
    -    public relayerUrl: string | undefined
    -    public mercuryProjectName: string | undefined
    -    public mercuryUrl: string | undefined
    -
    -    constructor(options: {
    -        rpcUrl?: string,
    -        relayerUrl?: string,
    -        relayerApiKey?: string,
    -        mercuryProjectName?: string,
    -        mercuryUrl?: string,
    -        mercuryJwt?: string,
    -        mercuryKey?: string,
    -    }) {
    -        const {
    -            rpcUrl,
    -            relayerUrl,
    -            relayerApiKey,
    -            mercuryProjectName,
    -            mercuryUrl,
    -            mercuryJwt,
    -            mercuryKey,
    -        } = options
    -
    -        super(rpcUrl)
    -
    -        if (relayerUrl)
    -            this.relayerUrl = relayerUrl
    -
    -        if (relayerApiKey)
    -            this.relayerApiKey = relayerApiKey
    -
    -        if (relayerUrl && relayerApiKey) {
    -            this.channelsClient = new ChannelsClient({
    -                baseUrl: relayerUrl,
    -                apiKey: relayerApiKey,
    -            })
    -        }
    +/**
    + * `PasskeyServer` — the SERVER-ONLY counterpart to {@link PasskeyKit}.
    + *
    + * It holds secrets (the relayer API key, indexer JWTs) and therefore must never
    + * run in a browser bundle: it is exported exclusively from the
    + * `passkey-kit/server` subpath so a bundler can't pull it into client code, and
    + * its config must come from server-side environment variables (never
    + * `VITE_`-prefixed).
    + *
    + * Responsibilities: fee-sponsored submission via {@link RelayerClient} and
    + * signer discovery via an indexer. The indexer methods here are a server-side
    + * Mercury port; B5 (todo 952) replaces them with the `SignerIndexer` abstraction
    + * over both Mercury and Stellar Indexer backends.
    + *
    + * @packageDocumentation
    + */
    +
    +import {
    +  Operation,
    +  TransactionBuilder,
    +  type Transaction,
    +} from "@stellar/stellar-sdk";
    +import { Durability, Server } from "@stellar/stellar-sdk/rpc";
    +import { AssembledTransaction } from "@stellar/stellar-sdk/contract";
    +import { RelayerClient, type RelayerClientConfig, type RelayerSubmitOptions } from "./relayer.js";
    +import {
    +  ConfigurationError,
    +  IndexerError,
    +  RelayerError,
    +  PasskeyKitErrorCode,
    +} from "./errors.js";
    +import { failedTransaction } from "./contract-errors.js";
    +import { contractDataExists } from "./rpc-data.js";
    +import { signerKeyToContractScVal } from "./indexer/codec.js";
    +import { DEFAULT_INDEXER_TIMEOUT_MS } from "./constants.js";
    +import { SignerKey } from "./types.js";
    +import type { IndexedSigner, TransactionResult } from "./types.js";
     
    -        if (mercuryProjectName)
    -            this.mercuryProjectName = mercuryProjectName
    +// Re-export the relayer surface so `passkey-kit/server` is the single
    +// server-only entrypoint (secrets never reach the browser bundle).
    +export {
    +  RelayerClient,
    +  type RelayerClientConfig,
    +  type RelayerSubmitOptions,
    +} from "./relayer.js";
     
    -        if (mercuryUrl)
    -            this.mercuryUrl = mercuryUrl
    +// Token-holding indexer backends (server-side only).
    +export {
    +  MercuryIndexer,
    +  StellarIndexerBackend,
    +  indexerForConfig,
    +  type MercuryIndexerConfig,
    +  type StellarIndexerConfig,
    +} from "./indexer/index.js";
     
    -        if (mercuryJwt)
    -            this.mercuryJwt = mercuryJwt
    +/** Mercury indexer (Zephyr) configuration. */
    +export interface MercuryConfig {
    +  /** Mercury base URL (e.g. https://api.mercurydata.app). */
    +  url: string;
    +  /** Deployed Zephyr project name. */
    +  projectName: string;
    +  /** Bearer JWT (server-side secret). Prefer this over `apiKey`. */
    +  jwt?: string;
    +  /** Raw API key (server-side secret). */
    +  apiKey?: string;
    +}
    +
    +/** Configuration for a {@link PasskeyServer}. */
    +export interface PasskeyServerConfig {
    +  /** Network passphrase (required). */
    +  networkPassphrase: string;
    +  /** Stellar RPC URL (needed for temporary-signer eviction probes). */
    +  rpcUrl?: string;
    +  /** Relayer configuration for fee-sponsored submission. */
    +  relayer?: RelayerClientConfig;
    +  /** Mercury indexer configuration. */
    +  mercury?: MercuryConfig;
    +}
     
    -        if (mercuryKey)
    -            this.mercuryKey = mercuryKey
    +/** Normalize any submittable input to a built {@link Transaction}. */
    +function toBuiltTransaction(
    +  input: AssembledTransaction | Transaction | string,
    +  networkPassphrase: string
    +): Transaction {
    +  if (typeof input === "string") {
    +    return TransactionBuilder.fromXDR(input, networkPassphrase) as Transaction;
    +  }
    +  if (input instanceof AssembledTransaction) {
    +    if (!input.built) {
    +      throw new RelayerError(
    +        "AssembledTransaction has not been built/simulated yet",
    +        PasskeyKitErrorCode.RELAYER_REQUEST_FAILED
    +      );
         }
    +    return input.built;
    +  }
    +  return input;
    +}
     
    -    public async getSigners(contractId: string) {
    -        if (!this.rpc || !this.mercuryProjectName || !this.mercuryUrl || (!this.mercuryJwt && !this.mercuryKey))
    -            throw new Error('Mercury service not configured')
    -
    -        const signers = await fetch(`${this.mercuryUrl}/zephyr/execute`, {
    -            method: 'POST',
    -            headers: {
    -                'Content-Type': 'application/json',
    -                Authorization: this.mercuryJwt ? `Bearer ${this.mercuryJwt}` : this.mercuryKey!
    -            },
    -            body: JSON.stringify({
    -                project_name: this.mercuryProjectName,
    -                mode: {
    -                    Function: {
    -                        fname: "get_signers_by_address",
    -                        arguments: JSON.stringify({
    -                            address: contractId
    -                        })
    -                    }
    -                }
    -            })
    -        })
    -            .then(async (res) => {
    -                if (res.ok)
    -                    return res.json()
    -
    -                throw await res.json()
    -            })
    -
    -        for (const signer of signers) {
    -            if (signer.storage === 'Temporary') {
    -                try {
    -                    await this.rpc.getContractData(contractId, xdr.ScVal.scvBytes(base64url.toBuffer(signer.key)), Durability.Temporary)
    -                } catch {
    -                    signer.evicted = true
    -                }
    -            }
    -        }
    +/** Map an indexer row's `{ kind, key }` to the SDK-side {@link SignerKey}. */
    +function indexedSignerToKey(signer: IndexedSigner): SignerKey {
    +  switch (signer.kind) {
    +    case "Ed25519":
    +      return SignerKey.Ed25519(signer.key);
    +    case "Policy":
    +      return SignerKey.Policy(signer.key);
    +    default:
    +      return SignerKey.Secp256r1(signer.key);
    +  }
    +}
    +
    +/** 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;
    +}
    +
    +export class PasskeyServer {
    +  readonly networkPassphrase: string;
    +  readonly rpc?: Server;
    +
    +  private readonly relayer?: RelayerClient;
    +  private readonly mercury?: MercuryConfig;
    +
    +  constructor(config: PasskeyServerConfig) {
    +    if (!config.networkPassphrase) {
    +      throw new ConfigurationError(
    +        "networkPassphrase is required",
    +        PasskeyKitErrorCode.MISSING_CONFIG
    +      );
    +    }
    +    this.networkPassphrase = config.networkPassphrase;
    +
    +    if (config.rpcUrl) {
    +      this.rpc = new Server(config.rpcUrl);
    +    }
    +    if (config.relayer) {
    +      this.relayer = new RelayerClient(config.relayer);
    +    }
    +    if (config.mercury) {
    +      if (!config.mercury.jwt && !config.mercury.apiKey) {
    +        throw new ConfigurationError(
    +          "Mercury config requires a jwt or apiKey",
    +          PasskeyKitErrorCode.INVALID_CONFIG
    +        );
    +      }
    +      this.mercury = config.mercury;
    +    }
    +  }
    +
    +  // -- Submission --------------------------------------------------------------
    +
    +  /**
    +   * Submit a transaction via the relayer for fee sponsorship.
    +   *
    +   * invokeHostFunction transactions without source-account auth use the preferred
    +   * `{ func, auth }` Soroban path; everything else (deploys, source-account auth)
    +   * is fee-bumped via the `{ xdr }` envelope path. Never throws — returns a typed
    +   * {@link TransactionResult}.
    +   */
    +  async send(
    +    input: AssembledTransaction | Transaction | string,
    +    options?: RelayerSubmitOptions
    +  ): Promise {
    +    if (!this.relayer) {
    +      return failedTransaction(
    +        new RelayerError(
    +          "Relayer is not configured on this PasskeyServer",
    +          PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED
    +        )
    +      );
    +    }
    +
    +    let built: Transaction;
    +    try {
    +      built = toBuiltTransaction(input, this.networkPassphrase);
    +    } catch (err) {
    +      return failedTransaction(
    +        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 this.relayer.send(func, auth, options);
    +    }
    +
    +    return this.relayer.sendTransaction(built.toXDR(), options);
    +  }
     
    -        return signers as Signer[]
    +  /** Poll a `skipWait` submission by its relayer transaction id. */
    +  getTransaction(transactionId: string): Promise {
    +    if (!this.relayer) {
    +      return Promise.resolve(
    +        failedTransaction(
    +          new RelayerError(
    +            "Relayer is not configured on this PasskeyServer",
    +            PasskeyKitErrorCode.RELAYER_NOT_CONFIGURED
    +          )
    +        )
    +      );
         }
    +    return this.relayer.getTransaction(transactionId);
    +  }
     
    -    public async getContractId(options: {
    -        keyId?: string,
    -        publicKey?: string,
    -        policy?: string,
    -    }, index = 0) {
    -        if (!this.mercuryProjectName || !this.mercuryUrl || (!this.mercuryJwt && !this.mercuryKey))
    -            throw new Error('Mercury service not configured')
    -
    -        let { keyId, publicKey, policy } = options || {}
    -
    -        if ([keyId, publicKey, policy].filter((arg) => !!arg).length > 1)
    -            throw new Error('Exactly one of `options.keyId`, `options.publicKey`, or `options.policy` must be provided.');
    -
    -        let args: { key: string, kind: 'Secp256r1' | 'Ed25519' | 'Policy' }
    -
    -        if (keyId)
    -            args = { key: keyId, kind: 'Secp256r1' }
    -        else if (publicKey)
    -            args = { key: publicKey, kind: 'Ed25519' }
    -        else if (policy)
    -            args = { key: policy, kind: 'Policy' }
    -
    -        const res = await fetch(`${this.mercuryUrl}/zephyr/execute`, {
    -            method: 'POST',
    -            headers: {
    -                'Content-Type': 'application/json',
    -                Authorization: this.mercuryJwt ? `Bearer ${this.mercuryJwt}` : this.mercuryKey!
    -            },
    -            body: JSON.stringify({
    -                project_name: this.mercuryProjectName,
    -                mode: {
    -                    Function: {
    -                        fname: "get_addresses_by_signer",
    -                        arguments: JSON.stringify(args!)
    -                    }
    -                }
    -            })
    -        })
    -            .then(async (res) => {
    -                if (res.ok)
    -                    return await res.json() as string[]
    -
    -                throw await res.json()
    -            })
    -
    -        return res[index]
    +  // -- Indexer (server-side Mercury; superseded by SignerIndexer in B5) --------
    +
    +  private async mercuryExecute(fname: string, args: unknown): Promise {
    +    if (!this.mercury) {
    +      throw new IndexerError(
    +        "Mercury indexer is not configured",
    +        PasskeyKitErrorCode.INDEXER_NOT_CONFIGURED
    +      );
         }
     
    -    /* LATER
    -        - Add a method for getting a paginated or filtered list of all a wallet's events
    -    */
    +    const controller = new AbortController();
    +    const timeout = setTimeout(
    +      () => controller.abort(),
    +      DEFAULT_INDEXER_TIMEOUT_MS
    +    );
    +
    +    try {
    +      const response = await fetch(`${this.mercury.url}/zephyr/execute`, {
    +        method: "POST",
    +        headers: {
    +          "Content-Type": "application/json",
    +          Authorization: this.mercury.jwt
    +            ? `Bearer ${this.mercury.jwt}`
    +            : this.mercury.apiKey!,
    +        },
    +        body: JSON.stringify({
    +          project_name: this.mercury.projectName,
    +          mode: { Function: { fname, arguments: JSON.stringify(args) } },
    +        }),
    +        signal: controller.signal,
    +      });
     
    -    public async send(txn: AssembledTransaction | Tx | string) {
    -        if (!this.channelsClient)
    -            throw new Error('Relayer service not configured')
    +      if (!response.ok) {
    +        const body = await response.text().catch(() => "");
    +        throw new IndexerError(
    +          `Mercury request failed (${response.status}): ${body.slice(0, 200)}`,
    +          PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +          { fname, status: response.status }
    +        );
    +      }
     
    -        let txnXdr: string
    +      return (await response.json()) as T;
    +    } catch (err) {
    +      if (err instanceof IndexerError) throw err;
    +      if (err instanceof Error && err.name === "AbortError") {
    +        throw new IndexerError(
    +          "Mercury request timed out",
    +          PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +          { fname }
    +        );
    +      }
    +      throw new IndexerError(
    +        err instanceof Error ? err.message : String(err),
    +        PasskeyKitErrorCode.INDEXER_REQUEST_FAILED,
    +        { fname },
    +        err instanceof Error ? err : undefined
    +      );
    +    } finally {
    +      clearTimeout(timeout);
    +    }
    +  }
     
    -        if (txn instanceof AssembledTransaction) {
    -            txnXdr = txn.built!.toXDR()
    -        } else if (typeof txn !== 'string') {
    -            txnXdr = txn.toXDR()
    -        } else {
    -            txnXdr = txn
    +  /**
    +   * Enumerate a wallet's signers via Mercury, flagging temporary signers whose
    +   * ledger entry has been evicted (Mercury can't observe eviction directly).
    +   */
    +  async getSigners(contractId: string): Promise {
    +    const signers = await this.mercuryExecute(
    +      "get_signers_by_address",
    +      { address: contractId }
    +    );
    +
    +    if (this.rpc) {
    +      for (const signer of signers) {
    +        if (signer.storage === "Temporary" && !signer.evicted) {
    +          try {
    +            // Probe by the SignerKey ScVal the contract stores the entry under,
    +            // NOT the raw keyId bytes (audit H2). A genuine not-found means
    +            // evicted; a transport error is left as reported.
    +            const exists = await contractDataExists(
    +              this.rpc,
    +              contractId,
    +              signerKeyToContractScVal(indexedSignerToKey(signer)),
    +              Durability.Temporary
    +            );
    +            if (!exists) signer.evicted = true;
    +          } catch {
    +            // Transport error (429/5xx/timeout): eviction undeterminable.
    +          }
             }
    +      }
    +    }
     
    -        return this.channelsClient.submitTransaction({
    -            xdr: txnXdr,
    -        })
    +    return signers;
    +  }
    +
    +  /**
    +   * Reverse lookup: find the wallet(s) a signer belongs to via Mercury.
    +   *
    +   * @returns the address at `index`, or `undefined` if there are none.
    +   */
    +  async getContractId(
    +    options: { keyId?: string; publicKey?: string; policy?: string },
    +    index = 0
    +  ): Promise {
    +    const provided = [options.keyId, options.publicKey, options.policy].filter(
    +      Boolean
    +    );
    +    if (provided.length !== 1) {
    +      throw new IndexerError(
    +        "Provide exactly one of keyId, publicKey, or policy",
    +        PasskeyKitErrorCode.INDEXER_REQUEST_FAILED
    +      );
         }
    +
    +    let args: { key: string; kind: "Secp256r1" | "Ed25519" | "Policy" };
    +    if (options.keyId) args = { key: options.keyId, kind: "Secp256r1" };
    +    else if (options.publicKey) args = { key: options.publicKey, kind: "Ed25519" };
    +    else args = { key: options.policy!, kind: "Policy" };
    +
    +    const addresses = await this.mercuryExecute(
    +      "get_addresses_by_signer",
    +      args
    +    );
    +
    +    return addresses[index];
    +  }
     }
    diff --git a/src/signers.test.ts b/src/signers.test.ts
    new file mode 100644
    index 0000000..54f2a37
    --- /dev/null
    +++ b/src/signers.test.ts
    @@ -0,0 +1,114 @@
    +import { describe, expect, it, vi } from "vitest";
    +import { Keypair } from "@stellar/stellar-sdk";
    +import base64url from "./base64url.js";
    +import {
    +  Ed25519Signer,
    +  PasskeySigner,
    +  PolicySigner,
    +  type SignerContext,
    +} from "./signers.js";
    +
    +const POLICY = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +/** Build a minimal DER ECDSA signature for (r, s) = (1, 2). */
    +function derSig(): Buffer {
    +  return Buffer.from([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]);
    +}
    +
    +describe("Ed25519Signer", () => {
    +  it("produces an Ed25519 (key, value) pair with a verifiable signature", () => {
    +    const keypair = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7));
    +    const signer = new Ed25519Signer(keypair);
    +    const payload = Buffer.alloc(32, 9);
    +
    +    const { key, value } = signer.sign(payload);
    +
    +    expect(key.tag).toBe("Ed25519");
    +    expect(Buffer.from(key.values[0])).toEqual(
    +      Buffer.from(keypair.rawPublicKey())
    +    );
    +    expect(value?.tag).toBe("Ed25519");
    +    const signature = (value as { values: readonly [Buffer] }).values[0];
    +    expect(signature).toHaveLength(64);
    +    expect(keypair.verify(payload, Buffer.from(signature))).toBe(true);
    +  });
    +
    +  it("fromSecret rejects an invalid secret key", () => {
    +    expect(() => Ed25519Signer.fromSecret("not-a-secret")).toThrow();
    +  });
    +});
    +
    +describe("PolicySigner", () => {
    +  it("produces a Policy key with a void value", () => {
    +    const { key, value } = new PolicySigner(POLICY).sign();
    +    expect(key).toEqual({ tag: "Policy", values: [POLICY] });
    +    expect(value).toEqual({ tag: "Policy", values: undefined });
    +  });
    +});
    +
    +describe("PasskeySigner", () => {
    +  function fakeAuthenticator(keyId: string) {
    +    return {
    +      startAuthentication: vi.fn(async () => ({
    +        id: keyId,
    +        rawId: keyId,
    +        type: "public-key" as const,
    +        clientExtensionResults: {},
    +        response: {
    +          authenticatorData: base64url.encode(Buffer.alloc(37, 1)),
    +          clientDataJSON: base64url.encode(Buffer.from("{}")),
    +          signature: base64url.encode(derSig()),
    +        },
    +      })),
    +    };
    +  }
    +
    +  it("runs the authentication ceremony and returns a Secp256r1 pair", async () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 0xab));
    +    const authenticator = fakeAuthenticator(keyId);
    +    const context: SignerContext = { webAuthn: authenticator, rpId: "example.com" };
    +
    +    const signer = new PasskeySigner(keyId);
    +    const { key, value } = await signer.sign(Buffer.alloc(32, 5), context);
    +
    +    expect(key.tag).toBe("Secp256r1");
    +    expect(Buffer.from(key.values[0])).toEqual(base64url.toBuffer(keyId));
    +    expect(value?.tag).toBe("Secp256r1");
    +    const sig = (value as { values: readonly [{ signature: Buffer }] }).values[0];
    +    expect(sig.signature).toHaveLength(64);
    +
    +    // Requested a specific credential -> allowCredentials must be set.
    +    const optionsJSON = authenticator.startAuthentication.mock.calls[0]![0]
    +      .optionsJSON;
    +    expect(optionsJSON.allowCredentials).toEqual([
    +      { id: keyId, type: "public-key" },
    +    ]);
    +    expect(optionsJSON.rpId).toBe("example.com");
    +  });
    +
    +  it('omits allowCredentials for a discoverable ("any") credential', async () => {
    +    const keyId = base64url.encode(Buffer.alloc(16, 0xcd));
    +    const authenticator = fakeAuthenticator(keyId);
    +    const context: SignerContext = { webAuthn: authenticator };
    +
    +    await new PasskeySigner("any").sign(Buffer.alloc(32, 1), context);
    +
    +    const optionsJSON = authenticator.startAuthentication.mock.calls[0]![0]
    +      .optionsJSON;
    +    expect(optionsJSON.allowCredentials).toBeUndefined();
    +  });
    +
    +  it("falls back to the context default keyId when none is provided", async () => {
    +    const defaultKeyId = base64url.encode(Buffer.alloc(16, 0x11));
    +    const authenticator = fakeAuthenticator(defaultKeyId);
    +    const context: SignerContext = { webAuthn: authenticator, defaultKeyId };
    +
    +    await new PasskeySigner().sign(Buffer.alloc(32, 1), context);
    +
    +    const optionsJSON = authenticator.startAuthentication.mock.calls[0]![0]
    +      .optionsJSON;
    +    expect(optionsJSON.allowCredentials).toEqual([
    +      { id: defaultKeyId, type: "public-key" },
    +    ]);
    +  });
    +});
    diff --git a/src/signers.ts b/src/signers.ts
    new file mode 100644
    index 0000000..8c3ecff
    --- /dev/null
    +++ b/src/signers.ts
    @@ -0,0 +1,196 @@
    +/**
    + * Typed signer abstraction for the flat `Signatures` map.
    + *
    + * A {@link Signer} knows how to authenticate a signature payload (the 32-byte
    + * hash from `buildSignaturePayload`) and produce the on-chain
    + * `(SignerKey, Signature)` pair the wallet's `__check_auth` expects. This
    + * replaces the old `sign({ keyId | keypair | policy })` option trio, where the
    + * caller had to pass exactly one of three mutually-exclusive fields, with three
    + * explicit signer classes:
    + *
    + * - {@link PasskeySigner} — a WebAuthn secp256r1 passkey (default).
    + * - {@link Ed25519Signer} — a local Stellar keypair.
    + * - {@link PolicySigner} — a policy contract co-signer (no signature bytes).
    + *
    + * @packageDocumentation
    + */
    +
    +import { Keypair } from "@stellar/stellar-sdk";
    +import type {
    +  AuthenticationResponseJSON,
    +  PublicKeyCredentialRequestOptionsJSON,
    +} from "@simplewebauthn/browser";
    +import type {
    +  Signature as SDKSignature,
    +  SignerKey as SDKSignerKey,
    +} from "passkey-kit-sdk";
    +import base64url from "./base64url.js";
    +import { compactSignature } from "./utils.js";
    +import { ValidationError, PasskeyKitErrorCode } from "./errors.js";
    +import { WEBAUTHN_TIMEOUT_MS } from "./constants.js";
    +
    +/** The WebAuthn `startAuthentication` surface a {@link PasskeySigner} needs. */
    +export interface WebAuthnAuthenticator {
    +  startAuthentication(args: {
    +    optionsJSON: PublicKeyCredentialRequestOptionsJSON;
    +  }): Promise;
    +}
    +
    +/** Ambient context passed to every {@link Signer.sign} call by the kit. */
    +export interface SignerContext {
    +  /** WebAuthn Relying Party id (domain); defaults to the current origin. */
    +  rpId?: string;
    +  /** The WebAuthn implementation (overridable for tests). */
    +  webAuthn: WebAuthnAuthenticator;
    +  /** The connected wallet's default passkey keyId (base64url), when known. */
    +  defaultKeyId?: string;
    +}
    +
    +/** The on-chain `(SignerKey, Signature)` pair a signer produces. */
    +export interface PreparedSignature {
    +  key: SDKSignerKey;
    +  /** `undefined` for policy signers (the map stores `scvVoid` for the value). */
    +  value: SDKSignature | undefined;
    +}
    +
    +/** A signer for the wallet's flat `Signatures` map. */
    +export interface Signer {
    +  /** Authenticate a signature payload into an on-chain signature pair. */
    +  sign(
    +    payload: Buffer,
    +    context: SignerContext
    +  ): Promise | PreparedSignature;
    +}
    +
    +/**
    + * An Ed25519 signer backed by a local Stellar keypair.
    + */
    +export class Ed25519Signer implements Signer {
    +  private readonly keypair: Keypair;
    +
    +  constructor(keypair: Keypair) {
    +    this.keypair = keypair;
    +  }
    +
    +  /**
    +   * Build an {@link Ed25519Signer} from a Stellar secret key (`S…`).
    +   *
    +   * @throws {ValidationError} If the secret key is invalid.
    +   */
    +  static fromSecret(secretKey: string): Ed25519Signer {
    +    try {
    +      return new Ed25519Signer(Keypair.fromSecret(secretKey));
    +    } catch {
    +      throw new ValidationError(
    +        "Invalid Ed25519 secret key (must be a valid Stellar secret key, S…)"
    +      );
    +    }
    +  }
    +
    +  /** The signer's `G…` public key. */
    +  get address(): string {
    +    return this.keypair.publicKey();
    +  }
    +
    +  sign(payload: Buffer): PreparedSignature {
    +    const signature = this.keypair.sign(payload);
    +    return {
    +      key: { tag: "Ed25519", values: [Buffer.from(this.keypair.rawPublicKey())] },
    +      value: { tag: "Ed25519", values: [signature] },
    +    };
    +  }
    +}
    +
    +/**
    + * A policy signer: a policy contract co-authorizes the entry. The map value is
    + * `scvVoid`; the wallet calls the policy's `policy__` during `__check_auth`.
    + */
    +export class PolicySigner implements Signer {
    +  private readonly policy: string;
    +
    +  constructor(policy: string) {
    +    this.policy = policy;
    +  }
    +
    +  sign(): PreparedSignature {
    +    return {
    +      key: { tag: "Policy", values: [this.policy] },
    +      value: { tag: "Policy", values: undefined },
    +    };
    +  }
    +}
    +
    +/**
    + * A WebAuthn secp256r1 passkey signer.
    + *
    + * Pass a specific `keyId` to require that credential, `"any"` to let the
    + * authenticator pick a discoverable credential, or omit it to use the kit's
    + * connected passkey ({@link SignerContext.defaultKeyId}).
    + */
    +export class PasskeySigner implements Signer {
    +  private readonly keyId: "any" | string | undefined;
    +
    +  constructor(keyId?: "any" | string | Uint8Array) {
    +    this.keyId =
    +      keyId instanceof Uint8Array ? base64url(Buffer.from(keyId)) : keyId;
    +  }
    +
    +  async sign(
    +    payload: Buffer,
    +    context: SignerContext
    +  ): Promise {
    +    const requestedKeyId =
    +      this.keyId === "any" ? undefined : this.keyId ?? context.defaultKeyId;
    +
    +    // No specific credential → let the authenticator choose a discoverable one.
    +    const discoverable = this.keyId === "any" || requestedKeyId === undefined;
    +
    +    const optionsJSON: PublicKeyCredentialRequestOptionsJSON = {
    +      challenge: base64url(payload),
    +      rpId: context.rpId,
    +      userVerification: "preferred",
    +      timeout: WEBAUTHN_TIMEOUT_MS,
    +      ...(discoverable
    +        ? {}
    +        : {
    +            allowCredentials: [{ id: requestedKeyId!, type: "public-key" }],
    +          }),
    +    };
    +
    +    let response: AuthenticationResponseJSON;
    +    try {
    +      response = await context.webAuthn.startAuthentication({ optionsJSON });
    +    } catch (err) {
    +      throw new ValidationError(
    +        "WebAuthn authentication failed",
    +        PasskeyKitErrorCode.INVALID_INPUT,
    +        { cause: err instanceof Error ? err.message : String(err) }
    +      );
    +    }
    +
    +    return {
    +      key: {
    +        tag: "Secp256r1",
    +        values: [base64url.toBuffer(response.id)],
    +      },
    +      value: {
    +        tag: "Secp256r1",
    +        values: [
    +          {
    +            authenticator_data: base64url.toBuffer(
    +              response.response.authenticatorData
    +            ),
    +            client_data_json: base64url.toBuffer(
    +              response.response.clientDataJSON
    +            ),
    +            signature: Buffer.from(
    +              compactSignature(
    +                base64url.toBuffer(response.response.signature)
    +              )
    +            ),
    +          },
    +        ],
    +      },
    +    };
    +  }
    +}
    diff --git a/src/storage/index.ts b/src/storage/index.ts
    new file mode 100644
    index 0000000..c977778
    --- /dev/null
    +++ b/src/storage/index.ts
    @@ -0,0 +1,14 @@
    +/**
    + * Storage adapters for passkey persistence.
    + *
    + * Import from the `passkey-kit/storage` subpath:
    + * ```typescript
    + * import { IndexedDBStorage, MemoryStorage } from "passkey-kit/storage";
    + * ```
    + */
    +
    +export { MemoryStorage } from "./memory.js";
    +export { LocalStorageAdapter } from "./localStorage.js";
    +export { IndexedDBStorage } from "./indexeddb.js";
    +
    +export type { StorageAdapter, StoredPasskey } from "../types.js";
    diff --git a/src/storage/indexeddb.test.ts b/src/storage/indexeddb.test.ts
    new file mode 100644
    index 0000000..dd529cb
    --- /dev/null
    +++ b/src/storage/indexeddb.test.ts
    @@ -0,0 +1,68 @@
    +import "fake-indexeddb/auto";
    +import { afterEach, describe, expect, it } from "vitest";
    +import { IndexedDBStorage } from "./indexeddb.js";
    +import type { StoredPasskey } from "../types.js";
    +
    +function makePasskey(overrides: Partial = {}): StoredPasskey {
    +  return {
    +    keyId: "key-1",
    +    publicKey: new Uint8Array([0x04, 5, 6, 7]),
    +    contractId: "C1",
    +    createdAt: 1,
    +    ...overrides,
    +  };
    +}
    +
    +let dbCounter = 0;
    +
    +/** A fresh named database per test so cases don't share state. */
    +function freshStore(): IndexedDBStorage {
    +  return new IndexedDBStorage(`passkey-kit-test-${dbCounter++}`);
    +}
    +
    +describe("IndexedDBStorage", () => {
    +  const opened: IndexedDBStorage[] = [];
    +
    +  afterEach(async () => {
    +    await Promise.all(opened.map((s) => s.close()));
    +    opened.length = 0;
    +  });
    +
    +  function track(store: IndexedDBStorage): IndexedDBStorage {
    +    opened.push(store);
    +    return store;
    +  }
    +
    +  it("saves and retrieves a passkey with its Uint8Array intact", async () => {
    +    const store = track(freshStore());
    +    await store.save(makePasskey());
    +    const loaded = await store.get("key-1");
    +    expect(loaded).not.toBeNull();
    +    expect(loaded!.publicKey).toBeInstanceOf(Uint8Array);
    +    expect(Array.from(loaded!.publicKey)).toEqual([0x04, 5, 6, 7]);
    +    expect(await store.get("missing")).toBeNull();
    +  });
    +
    +  it("queries by contract id via the index", async () => {
    +    const store = track(freshStore());
    +    await store.save(makePasskey({ keyId: "a", contractId: "CX" }));
    +    await store.save(makePasskey({ keyId: "b", contractId: "CX" }));
    +    await store.save(makePasskey({ keyId: "c", contractId: "CY" }));
    +    const results = await store.getByContract("CX");
    +    expect(results.map((p) => p.keyId).sort()).toEqual(["a", "b"]);
    +  });
    +
    +  it("updates metadata, deletes, and clears", async () => {
    +    const store = track(freshStore());
    +    await store.save(makePasskey());
    +    await store.update("key-1", { lastUsedAt: 42 });
    +    expect((await store.get("key-1"))!.lastUsedAt).toBe(42);
    +
    +    await store.delete("key-1");
    +    expect(await store.get("key-1")).toBeNull();
    +
    +    await store.save(makePasskey({ keyId: "x" }));
    +    await store.clear();
    +    expect(await store.getAll()).toEqual([]);
    +  });
    +});
    diff --git a/src/storage/indexeddb.ts b/src/storage/indexeddb.ts
    new file mode 100644
    index 0000000..14c47d4
    --- /dev/null
    +++ b/src/storage/indexeddb.ts
    @@ -0,0 +1,143 @@
    +/**
    + * IndexedDB passkey storage adapter.
    + *
    + * Recommended for web apps: larger limits than localStorage, an async API that
    + * doesn't block the main thread, and native `Uint8Array` support.
    + */
    +
    +import type { StorageAdapter, StoredPasskey } from "../types.js";
    +import {
    +  DB_NAME,
    +  DB_VERSION,
    +  IDB_STORE_CREDENTIALS,
    +  IDB_INDEX_CONTRACT_ID,
    +} from "../constants.js";
    +
    +export class IndexedDBStorage implements StorageAdapter {
    +  private dbName: string;
    +  private dbPromise: Promise | null = null;
    +
    +  constructor(dbName: string = DB_NAME) {
    +    this.dbName = dbName;
    +  }
    +
    +  private async getDB(): Promise {
    +    if (this.dbPromise) return this.dbPromise;
    +
    +    this.dbPromise = new Promise((resolve, reject) => {
    +      if (typeof indexedDB === "undefined") {
    +        reject(new Error("IndexedDB is not available in this environment"));
    +        return;
    +      }
    +
    +      const request = indexedDB.open(this.dbName, DB_VERSION);
    +
    +      request.onerror = () =>
    +        reject(new Error(`Failed to open IndexedDB: ${request.error?.message}`));
    +
    +      request.onsuccess = () => resolve(request.result);
    +
    +      request.onupgradeneeded = (event) => {
    +        const db = (event.target as IDBOpenDBRequest).result;
    +        if (!db.objectStoreNames.contains(IDB_STORE_CREDENTIALS)) {
    +          const store = db.createObjectStore(IDB_STORE_CREDENTIALS, {
    +            keyPath: "keyId",
    +          });
    +          store.createIndex(IDB_INDEX_CONTRACT_ID, IDB_INDEX_CONTRACT_ID, {
    +            unique: false,
    +          });
    +        }
    +      };
    +    });
    +
    +    return this.dbPromise;
    +  }
    +
    +  private async withStore(
    +    mode: IDBTransactionMode,
    +    callback: (store: IDBObjectStore) => IDBRequest
    +  ): Promise {
    +    const db = await this.getDB();
    +    return new Promise((resolve, reject) => {
    +      const transaction = db.transaction(IDB_STORE_CREDENTIALS, mode);
    +      const store = transaction.objectStore(IDB_STORE_CREDENTIALS);
    +      const request = callback(store);
    +      request.onsuccess = () => resolve(request.result);
    +      request.onerror = () =>
    +        reject(
    +          new Error(`IndexedDB operation failed: ${request.error?.message}`)
    +        );
    +    });
    +  }
    +
    +  async save(passkey: StoredPasskey): Promise {
    +    await this.withStore("readwrite", (store) => store.put(passkey));
    +  }
    +
    +  async get(keyId: string): Promise {
    +    const result = await this.withStore(
    +      "readonly",
    +      (store) => store.get(keyId)
    +    );
    +    return result ?? null;
    +  }
    +
    +  async getByContract(contractId: string): Promise {
    +    const db = await this.getDB();
    +    return new Promise((resolve, reject) => {
    +      const transaction = db.transaction(IDB_STORE_CREDENTIALS, "readonly");
    +      const index = transaction
    +        .objectStore(IDB_STORE_CREDENTIALS)
    +        .index(IDB_INDEX_CONTRACT_ID);
    +      const request = index.getAll(contractId);
    +      request.onsuccess = () => resolve(request.result);
    +      request.onerror = () =>
    +        reject(
    +          new Error(`Failed to query by contract: ${request.error?.message}`)
    +        );
    +    });
    +  }
    +
    +  async getAll(): Promise {
    +    return this.withStore("readonly", (store) =>
    +      store.getAll()
    +    );
    +  }
    +
    +  async delete(keyId: string): Promise {
    +    await this.withStore("readwrite", (store) => store.delete(keyId));
    +  }
    +
    +  async update(
    +    keyId: string,
    +    updates: Partial>
    +  ): Promise {
    +    const existing = await this.get(keyId);
    +    if (existing) {
    +      await this.save({ ...existing, ...updates });
    +    }
    +  }
    +
    +  async clear(): Promise {
    +    await this.withStore("readwrite", (store) => store.clear());
    +  }
    +
    +  /** Close the database connection. */
    +  async close(): Promise {
    +    if (this.dbPromise) {
    +      const db = await this.dbPromise;
    +      db.close();
    +      this.dbPromise = null;
    +    }
    +  }
    +
    +  /** Delete the entire database. */
    +  static async deleteDatabase(dbName: string = DB_NAME): Promise {
    +    return new Promise((resolve, reject) => {
    +      const request = indexedDB.deleteDatabase(dbName);
    +      request.onsuccess = () => resolve();
    +      request.onerror = () =>
    +        reject(new Error(`Failed to delete database: ${request.error?.message}`));
    +    });
    +  }
    +}
    diff --git a/src/storage/localStorage.test.ts b/src/storage/localStorage.test.ts
    new file mode 100644
    index 0000000..85c015a
    --- /dev/null
    +++ b/src/storage/localStorage.test.ts
    @@ -0,0 +1,75 @@
    +import { beforeEach, describe, expect, it } from "vitest";
    +import { LocalStorageAdapter } from "./localStorage.js";
    +import type { StoredPasskey } from "../types.js";
    +
    +/** Minimal in-memory localStorage shim for the Node test environment. */
    +class LocalStorageShim {
    +  private store = new Map();
    +  getItem(key: string): string | null {
    +    return this.store.has(key) ? this.store.get(key)! : null;
    +  }
    +  setItem(key: string, value: string): void {
    +    this.store.set(key, value);
    +  }
    +  removeItem(key: string): void {
    +    this.store.delete(key);
    +  }
    +  clear(): void {
    +    this.store.clear();
    +  }
    +}
    +
    +function makePasskey(overrides: Partial = {}): StoredPasskey {
    +  return {
    +    keyId: "key-1",
    +    publicKey: new Uint8Array([0x04, 9, 8, 7]),
    +    contractId: "C1",
    +    createdAt: 1,
    +    ...overrides,
    +  };
    +}
    +
    +describe("LocalStorageAdapter", () => {
    +  beforeEach(() => {
    +    (globalThis as { localStorage?: unknown }).localStorage =
    +      new LocalStorageShim();
    +  });
    +
    +  it("persists a passkey across adapter instances (round-trips Uint8Array)", async () => {
    +    const a = new LocalStorageAdapter();
    +    await a.save(makePasskey());
    +
    +    const b = new LocalStorageAdapter();
    +    const loaded = await b.get("key-1");
    +    expect(loaded).not.toBeNull();
    +    expect(loaded!.publicKey).toBeInstanceOf(Uint8Array);
    +    expect(Array.from(loaded!.publicKey)).toEqual([0x04, 9, 8, 7]);
    +    expect(loaded!.contractId).toBe("C1");
    +  });
    +
    +  it("queries by contract, updates, deletes, and clears", async () => {
    +    const store = new LocalStorageAdapter();
    +    await store.save(makePasskey({ keyId: "a", contractId: "CX" }));
    +    await store.save(makePasskey({ keyId: "b", contractId: "CY" }));
    +
    +    expect((await store.getByContract("CX")).map((p) => p.keyId)).toEqual(["a"]);
    +
    +    await store.update("a", { nickname: "renamed" });
    +    expect((await store.get("a"))!.nickname).toBe("renamed");
    +
    +    await store.delete("a");
    +    expect(await store.get("a")).toBeNull();
    +
    +    await store.clear();
    +    expect(await store.getAll()).toEqual([]);
    +  });
    +
    +  it("treats corrupt storage as empty without throwing", async () => {
    +    (globalThis as { localStorage: LocalStorageShim }).localStorage.setItem(
    +      "passkey-kit:credentials",
    +      "{not valid json"
    +    );
    +    const store = new LocalStorageAdapter();
    +    expect(await store.getAll()).toEqual([]);
    +  });
    +});
    diff --git a/src/storage/localStorage.ts b/src/storage/localStorage.ts
    new file mode 100644
    index 0000000..affee21
    --- /dev/null
    +++ b/src/storage/localStorage.ts
    @@ -0,0 +1,114 @@
    +/**
    + * `localStorage` passkey storage adapter.
    + *
    + * Persists passkey records across page reloads in the browser. `localStorage`
    + * has a ~5MB limit and stores data unencrypted; for larger or more structured
    + * needs prefer {@link import("./indexeddb.js").IndexedDBStorage}.
    + */
    +
    +import type { StorageAdapter, StoredPasskey } from "../types.js";
    +import { LOCALSTORAGE_CREDENTIALS_KEY } from "../constants.js";
    +
    +/**
    + * JSON-serializable form of a passkey record (`publicKey` becomes a number[]
    + * since `Uint8Array` is not JSON-serializable).
    + */
    +type SerializedPasskey = Omit & {
    +  publicKey: number[];
    +};
    +
    +function serialize(passkey: StoredPasskey): SerializedPasskey {
    +  return { ...passkey, publicKey: Array.from(passkey.publicKey) };
    +}
    +
    +function deserialize(data: SerializedPasskey): StoredPasskey {
    +  return { ...data, publicKey: new Uint8Array(data.publicKey) };
    +}
    +
    +export class LocalStorageAdapter implements StorageAdapter {
    +  private storageKey: string;
    +
    +  constructor(storageKey: string = LOCALSTORAGE_CREDENTIALS_KEY) {
    +    this.storageKey = storageKey;
    +  }
    +
    +  private read(): Map {
    +    if (typeof localStorage === "undefined") {
    +      throw new Error("localStorage is not available in this environment");
    +    }
    +
    +    const data = localStorage.getItem(this.storageKey);
    +    if (!data) return new Map();
    +
    +    try {
    +      const parsed = JSON.parse(data) as Record;
    +      const map = new Map();
    +      for (const [key, value] of Object.entries(parsed)) {
    +        map.set(key, deserialize(value));
    +      }
    +      return map;
    +    } catch (error) {
    +      // Corrupt storage: warn loudly rather than silently dropping the user's
    +      // passkeys. Return empty so the app keeps working; do not overwrite.
    +      console.error(
    +        `[PasskeyKit] Failed to parse stored passkeys at "${this.storageKey}"; treating storage as empty.`,
    +        error
    +      );
    +      return new Map();
    +    }
    +  }
    +
    +  private write(passkeys: Map): void {
    +    const obj: Record = {};
    +    for (const [key, value] of passkeys.entries()) {
    +      obj[key] = serialize(value);
    +    }
    +    localStorage.setItem(this.storageKey, JSON.stringify(obj));
    +  }
    +
    +  async save(passkey: StoredPasskey): Promise {
    +    const passkeys = this.read();
    +    passkeys.set(passkey.keyId, passkey);
    +    this.write(passkeys);
    +  }
    +
    +  async get(keyId: string): Promise {
    +    return this.read().get(keyId) ?? null;
    +  }
    +
    +  async getByContract(contractId: string): Promise {
    +    const results: StoredPasskey[] = [];
    +    for (const passkey of this.read().values()) {
    +      if (passkey.contractId === contractId) {
    +        results.push(passkey);
    +      }
    +    }
    +    return results;
    +  }
    +
    +  async getAll(): Promise {
    +    return Array.from(this.read().values());
    +  }
    +
    +  async delete(keyId: string): Promise {
    +    const passkeys = this.read();
    +    passkeys.delete(keyId);
    +    this.write(passkeys);
    +  }
    +
    +  async update(
    +    keyId: string,
    +    updates: Partial>
    +  ): Promise {
    +    const passkeys = this.read();
    +    const passkey = passkeys.get(keyId);
    +    if (passkey) {
    +      passkeys.set(keyId, { ...passkey, ...updates });
    +      this.write(passkeys);
    +    }
    +  }
    +
    +  async clear(): Promise {
    +    localStorage.removeItem(this.storageKey);
    +  }
    +}
    diff --git a/src/storage/memory.test.ts b/src/storage/memory.test.ts
    new file mode 100644
    index 0000000..9dbc3ea
    --- /dev/null
    +++ b/src/storage/memory.test.ts
    @@ -0,0 +1,58 @@
    +import { describe, expect, it } from "vitest";
    +import { MemoryStorage } from "./memory.js";
    +import type { StoredPasskey } from "../types.js";
    +
    +function makePasskey(overrides: Partial = {}): StoredPasskey {
    +  return {
    +    keyId: "key-1",
    +    publicKey: new Uint8Array([0x04, 1, 2, 3]),
    +    contractId: "C1",
    +    createdAt: 1,
    +    ...overrides,
    +  };
    +}
    +
    +describe("MemoryStorage", () => {
    +  it("saves and retrieves a passkey by keyId", async () => {
    +    const store = new MemoryStorage();
    +    const passkey = makePasskey();
    +    await store.save(passkey);
    +    expect(await store.get("key-1")).toEqual(passkey);
    +    expect(await store.get("missing")).toBeNull();
    +  });
    +
    +  it("returns a copy, not the stored reference", async () => {
    +    const store = new MemoryStorage();
    +    await store.save(makePasskey());
    +    const first = await store.get("key-1");
    +    first!.nickname = "mutated";
    +    const second = await store.get("key-1");
    +    expect(second!.nickname).toBeUndefined();
    +  });
    +
    +  it("queries by contract id", async () => {
    +    const store = new MemoryStorage();
    +    await store.save(makePasskey({ keyId: "a", contractId: "CX" }));
    +    await store.save(makePasskey({ keyId: "b", contractId: "CX" }));
    +    await store.save(makePasskey({ keyId: "c", contractId: "CY" }));
    +    const results = await store.getByContract("CX");
    +    expect(results.map((p) => p.keyId).sort()).toEqual(["a", "b"]);
    +  });
    +
    +  it("updates mutable metadata and deletes", async () => {
    +    const store = new MemoryStorage();
    +    await store.save(makePasskey());
    +    await store.update("key-1", { nickname: "Primary" });
    +    expect((await store.get("key-1"))!.nickname).toBe("Primary");
    +    await store.delete("key-1");
    +    expect(await store.get("key-1")).toBeNull();
    +  });
    +
    +  it("clears all records", async () => {
    +    const store = new MemoryStorage();
    +    await store.save(makePasskey({ keyId: "a" }));
    +    await store.save(makePasskey({ keyId: "b" }));
    +    await store.clear();
    +    expect(await store.getAll()).toEqual([]);
    +  });
    +});
    diff --git a/src/storage/memory.ts b/src/storage/memory.ts
    new file mode 100644
    index 0000000..fd42116
    --- /dev/null
    +++ b/src/storage/memory.ts
    @@ -0,0 +1,53 @@
    +/**
    + * In-memory passkey storage adapter.
    + *
    + * Useful for tests and server-side environments. Data is lost when the process
    + * restarts.
    + */
    +
    +import type { StorageAdapter, StoredPasskey } from "../types.js";
    +
    +export class MemoryStorage implements StorageAdapter {
    +  private passkeys: Map = new Map();
    +
    +  async save(passkey: StoredPasskey): Promise {
    +    this.passkeys.set(passkey.keyId, { ...passkey });
    +  }
    +
    +  async get(keyId: string): Promise {
    +    const passkey = this.passkeys.get(keyId);
    +    return passkey ? { ...passkey } : null;
    +  }
    +
    +  async getByContract(contractId: string): Promise {
    +    const results: StoredPasskey[] = [];
    +    for (const passkey of this.passkeys.values()) {
    +      if (passkey.contractId === contractId) {
    +        results.push({ ...passkey });
    +      }
    +    }
    +    return results;
    +  }
    +
    +  async getAll(): Promise {
    +    return Array.from(this.passkeys.values()).map((p) => ({ ...p }));
    +  }
    +
    +  async delete(keyId: string): Promise {
    +    this.passkeys.delete(keyId);
    +  }
    +
    +  async update(
    +    keyId: string,
    +    updates: Partial>
    +  ): Promise {
    +    const passkey = this.passkeys.get(keyId);
    +    if (passkey) {
    +      this.passkeys.set(keyId, { ...passkey, ...updates });
    +    }
    +  }
    +
    +  async clear(): Promise {
    +    this.passkeys.clear();
    +  }
    +}
    diff --git a/src/types.ts b/src/types.ts
    index 984ce3f..b2005ea 100644
    --- a/src/types.ts
    +++ b/src/types.ts
    @@ -1,32 +1,217 @@
    -export type Signer = {
    -    kind: string
    -    key: string 
    -    val: string 
    -    expiration: number | null
    -    storage: "Persistent" | "Temporary"
    -    limits: string
    -    evicted?: boolean 
    -}
    +/**
    + * Core type definitions for the Passkey Kit SDK.
    + *
    + * @packageDocumentation
    + */
    +
    +import type {
    +  AuthenticationResponseJSON,
    +  RegistrationResponseJSON,
    +} from "@simplewebauthn/browser";
    +import type { PasskeyKitError } from "./errors.js";
    +
    +// ============================================================================
    +// Signer-key helpers (SDK-side mirror of the contract's SignerKey union)
    +// ============================================================================
     
    +/**
    + * The three signer kinds the smart wallet understands.
    + */
    +export type SignerKeyTag = "Policy" | "Ed25519" | "Secp256r1";
    +
    +/**
    + * A convenience wrapper describing which signer a key/limits entry refers to.
    + *
    + * The `value` is the string form the caller works with: a `C…`/`G…` address for
    + * `Policy`, a `G…` public key for `Ed25519`, or a base64url credential id for
    + * `Secp256r1`. The signing pipeline converts these into the contract's
    + * `SignerKey` ScVals.
    + */
     export class SignerKey {
    -    private constructor(public key: "Policy" | "Ed25519" | "Secp256r1", public value: string) { }
    +  private constructor(
    +    public key: SignerKeyTag,
    +    public value: string
    +  ) {}
     
    -    static Policy(policy: string): SignerKey {
    -        return new SignerKey("Policy", policy);
    -    }
    +  static Policy(policy: string): SignerKey {
    +    return new SignerKey("Policy", policy);
    +  }
     
    -    static Ed25519(publicKey: string): SignerKey {
    -        return new SignerKey("Ed25519", publicKey);
    -    }
    +  static Ed25519(publicKey: string): SignerKey {
    +    return new SignerKey("Ed25519", publicKey);
    +  }
     
    -    static Secp256r1(id: string): SignerKey {
    -        return new SignerKey("Secp256r1", id);
    -    }
    +  static Secp256r1(keyId: string): SignerKey {
    +    return new SignerKey("Secp256r1", keyId);
    +  }
     }
     
    -export type SignerLimits = Map | undefined
    +/**
    + * Per-signer limits: a map of contract address → the signer keys that signer may
    + * co-authorize on that contract (`undefined` value = unrestricted on that
    + * contract). `undefined` for the whole map = a fully unlimited signer.
    + */
    +export type SignerLimits = Map | undefined;
     
    +/**
    + * Storage durability for a signer entry.
    + */
     export enum SignerStore {
    -    Persistent = 'Persistent',
    -    Temporary = 'Temporary',
    -}
    \ No newline at end of file
    +  Persistent = "Persistent",
    +  Temporary = "Temporary",
    +}
    +
    +// ============================================================================
    +// Indexer row (renamed from the old `Signer` to avoid colliding with the
    +// bindings' `Signer` union — #599)
    +// ============================================================================
    +
    +/**
    + * A signer row as returned by an indexer backend (Mercury or Stellar Indexer).
    + *
    + * Renamed from the old `Signer` type, which name-collided with the generated
    + * bindings' `Signer` union. The richer cross-backend shape lives in
    + * `indexer/types.ts` (`WalletSigner`); this remains the wire-row shape the
    + * current server helpers return.
    + */
    +export interface IndexedSigner {
    +  kind: string;
    +  key: string;
    +  val: string;
    +  expiration: number | null;
    +  storage: "Persistent" | "Temporary";
    +  limits: string;
    +  evicted?: boolean;
    +}
    +
    +// ============================================================================
    +// Stored passkey records
    +// ============================================================================
    +
    +/**
    + * A persisted passkey → smart-wallet association.
    + *
    + * The `keyId` (base64url credential id) is essential for re-authenticating the
    + * passkey later; the `contractId` lets the kit reconnect a wallet from a keyId
    + * without an indexer round-trip.
    + */
    +export interface StoredPasskey {
    +  /** Base64URL-encoded credential id (the passkey keyId) — the unique key. */
    +  keyId: string;
    +
    +  /** 65-byte uncompressed secp256r1 public key (0x04 prefix + x + y). */
    +  publicKey: Uint8Array;
    +
    +  /** Smart-wallet contract address this passkey controls. */
    +  contractId: string;
    +
    +  /** User-friendly label for this passkey. */
    +  nickname?: string;
    +
    +  /** Unix-ms timestamp when the passkey was created. */
    +  createdAt: number;
    +
    +  /** Unix-ms timestamp when the passkey was last used. */
    +  lastUsedAt?: number;
    +}
    +
    +/**
    + * Interface for passkey storage adapters.
    + *
    + * Implementations persist the passkey → wallet association in various backends
    + * (IndexedDB, localStorage, memory). See `./storage`.
    + */
    +export interface StorageAdapter {
    +  /** Save a new passkey record or overwrite an existing one (keyed by keyId). */
    +  save(passkey: StoredPasskey): Promise;
    +
    +  /** Get a passkey record by its keyId. */
    +  get(keyId: string): Promise;
    +
    +  /** Get all passkey records associated with a contract. */
    +  getByContract(contractId: string): Promise;
    +
    +  /** Get every stored passkey record. */
    +  getAll(): Promise;
    +
    +  /** Delete a passkey record by its keyId. */
    +  delete(keyId: string): Promise;
    +
    +  /** Update mutable metadata on a stored passkey record. */
    +  update(
    +    keyId: string,
    +    updates: Partial>
    +  ): Promise;
    +
    +  /** Remove every stored passkey record. */
    +  clear(): Promise;
    +}
    +
    +// ============================================================================
    +// Result types
    +// ============================================================================
    +
    +/** Result of creating (deploying) a new smart wallet. */
    +export interface CreateWalletResult {
    +  /** The raw WebAuthn registration response. */
    +  rawResponse: RegistrationResponseJSON;
    +
    +  /** Raw credential id bytes. */
    +  keyId: Uint8Array;
    +
    +  /** Base64URL-encoded credential id. */
    +  keyIdBase64: string;
    +
    +  /** Deployed smart-wallet contract address. */
    +  contractId: string;
    +
    +  /** Signed deployment transaction, ready to submit (base64 XDR). */
    +  signedTx: string;
    +}
    +
    +/** Result of connecting to an existing smart wallet. */
    +export interface ConnectWalletResult {
    +  /** Raw WebAuthn authentication response, when a ceremony was performed. */
    +  rawResponse?: AuthenticationResponseJSON;
    +
    +  /** Raw credential id bytes. */
    +  keyId: Uint8Array;
    +
    +  /** Base64URL-encoded credential id. */
    +  keyIdBase64: string;
    +
    +  /** Connected smart-wallet contract address. */
    +  contractId: string;
    +}
    +
    +/** Successful result of a submission operation. */
    +export interface TransactionSuccess {
    +  success: true;
    +  /** Transaction hash (may be empty for a `skipWait` submission not yet polled). */
    +  hash: string;
    +  /** Ledger the transaction was included in, when known. */
    +  ledger?: number;
    +  /** Relayer transaction id, for polling a `skipWait` submission. */
    +  transactionId?: string;
    +}
    +
    +/**
    + * Failed result of a submission operation.
    + *
    + * Submission methods never throw for expected on-chain/relayer failures; they
    + * return this shape with a typed {@link PasskeyKitError} (a `ContractError` when
    + * an on-chain contract code was decoded). All other SDK methods throw.
    + */
    +export interface TransactionFailure {
    +  success: false;
    +  /** The typed error describing the failure. Branch on `error.code`. */
    +  error: PasskeyKitError;
    +  /** Transaction hash, when one was assigned before the failure. */
    +  hash?: string;
    +}
    +
    +/** Result of a submission operation: a discriminated union on `success`. */
    +export type TransactionResult = TransactionSuccess | TransactionFailure;
    +
    +/** How a transaction is submitted to the network. */
    +export type SubmissionMethod = "relayer" | "rpc";
    diff --git a/src/utils.test.ts b/src/utils.test.ts
    new file mode 100644
    index 0000000..cf2277e
    --- /dev/null
    +++ b/src/utils.test.ts
    @@ -0,0 +1,122 @@
    +import { describe, expect, it } from "vitest";
    +import base64url from "./base64url.js";
    +import {
    +  compactSignature,
    +  deriveContractAddress,
    +  extractPublicKeyFromAttestation,
    +  generateChallenge,
    +} from "./utils.js";
    +import { SECP256R1_CURVE_ORDER } from "./constants.js";
    +
    +const TESTNET = "Test SDF Network ; September 2015";
    +const MAINNET = "Public Global Stellar Network ; September 2015";
    +
    +// The canonical passkey-kit deployer: Keypair.fromRawEd25519Seed(sha256("kalepail")).
    +const DEPLOYER = "GC2C7AWLS2FMFTQAHW3IBUB4ZXVP4E37XNLEF2IK7IVXBB6CMEPCSXFO";
    +
    +// Golden vectors pinning the deterministic contract-address derivation
    +// (salt = sha256(keyId), deployer = the canonical kalepail keypair). Any change
    +// to the derivation breaks these instead of silently shifting deployed wallet
    +// addresses — and breaking indexer reverse-lookup.
    +const DERIVE_VECTORS = [
    +  { name: "zeros-16", credHex: "00".repeat(16), passphrase: TESTNET, expected: "CD7HUBQP46MGSX3J2U5VLMU3UCAKO7KWFGUCZZQXJCOXCWRMSDDK6J2A" },
    +  { name: "zeros-32", credHex: "00".repeat(32), passphrase: TESTNET, expected: "CBQU3NIOXC3IDGERJWV3YVMSQSIOU2S6NSMH35OS3GPG6XARZFAAT2NL" },
    +  { name: "ff-16", credHex: "ff".repeat(16), passphrase: TESTNET, expected: "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL" },
    +  { name: "random-16", credHex: "0123456789abcdef0123456789abcdef", passphrase: TESTNET, expected: "CARFE5CAZUVN7NWEYKQHFD55SXPJ2IZNT3HQDI22PKNTLOHTAP6DENDJ" },
    +  { name: "random-32", credHex: "deadbeefcafef00dfeedfacebaadf00d0123456789abcdef0123456789abcdef", passphrase: TESTNET, expected: "CAWJ3E3FTZPDQPHWYNRTDAWPW6SEUV4TFPIGW2VOSZHILKQG4N7JHK3P" },
    +  { name: "mainnet-random-16", credHex: "0123456789abcdef0123456789abcdef", passphrase: MAINNET, expected: "CBTNGSWMKSGVQ5YGEUHG2YJIZEGSTBZKSVUMWXSRODNQ732JRC5AYV6Z" },
    +] as const;
    +
    +describe("deriveContractAddress", () => {
    +  it.each(DERIVE_VECTORS)(
    +    "derives $name to the pinned contract address",
    +    ({ credHex, passphrase, expected }) => {
    +      const keyId = Buffer.from(credHex, "hex");
    +      expect(deriveContractAddress(keyId, DEPLOYER, passphrase)).toBe(expected);
    +    }
    +  );
    +
    +  it("derives a different address on mainnet than testnet for the same keyId", () => {
    +    const keyId = Buffer.from("0123456789abcdef0123456789abcdef", "hex");
    +    expect(deriveContractAddress(keyId, DEPLOYER, TESTNET)).not.toBe(
    +      deriveContractAddress(keyId, DEPLOYER, MAINNET)
    +    );
    +  });
    +});
    +
    +describe("extractPublicKeyFromAttestation", () => {
    +  it("normalizes an SPKI public key from a WebAuthn registration response", async () => {
    +    const keyPair = await crypto.subtle.generateKey(
    +      { name: "ECDSA", namedCurve: "P-256" },
    +      true,
    +      ["sign", "verify"]
    +    );
    +
    +    const spki = Buffer.from(
    +      new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey))
    +    );
    +    const raw = Buffer.from(
    +      new Uint8Array(await crypto.subtle.exportKey("raw", keyPair.publicKey))
    +    );
    +
    +    const extracted = await extractPublicKeyFromAttestation({
    +      clientDataJSON: "",
    +      attestationObject: "",
    +      publicKey: base64url.encode(spki),
    +    });
    +
    +    expect(Buffer.from(extracted)).toEqual(raw);
    +    expect(extracted).toHaveLength(65);
    +    expect(extracted[0]).toBe(0x04);
    +  });
    +});
    +
    +describe("compactSignature", () => {
    +  function toBuf32(x: bigint): Buffer {
    +    return Buffer.from(x.toString(16).padStart(64, "0"), "hex");
    +  }
    +
    +  /** DER-encode an (r, s) pair, adding the leading 0x00 pad when needed. */
    +  function der(r: Buffer, s: Buffer): Buffer {
    +    const pad = (b: Buffer) =>
    +      b[0]! & 0x80 ? Buffer.concat([Buffer.from([0x00]), b]) : b;
    +    const rp = pad(r);
    +    const sp = pad(s);
    +    const content = Buffer.concat([
    +      Buffer.from([0x02, rp.length]),
    +      rp,
    +      Buffer.from([0x02, sp.length]),
    +      sp,
    +    ]);
    +    return Buffer.concat([Buffer.from([0x30, content.length]), content]);
    +  }
    +
    +  it("returns a 64-byte r||s signature", () => {
    +    const sig = compactSignature(der(Buffer.from([0x01]), Buffer.from([0x02])));
    +    expect(sig).toHaveLength(64);
    +    expect(Buffer.from(sig.subarray(0, 32)).toString("hex")).toBe(
    +      "00".repeat(31) + "01"
    +    );
    +    expect(Buffer.from(sig.subarray(32)).toString("hex")).toBe(
    +      "00".repeat(31) + "02"
    +    );
    +  });
    +
    +  it("normalizes a high-S value to low-S", () => {
    +    // s = n - 1 is > n/2; low-S form is n - (n - 1) = 1.
    +    const highS = toBuf32(SECP256R1_CURVE_ORDER - 1n);
    +    const sig = compactSignature(der(Buffer.from([0x01]), highS));
    +    expect(Buffer.from(sig.subarray(32)).toString("hex")).toBe(
    +      "00".repeat(31) + "01"
    +    );
    +  });
    +});
    +
    +describe("generateChallenge", () => {
    +  it("returns 32 random bytes, base64url-encoded", () => {
    +    const a = generateChallenge();
    +    const b = generateChallenge();
    +    expect(base64url.toBuffer(a)).toHaveLength(32);
    +    expect(a).not.toBe(b);
    +  });
    +});
    diff --git a/src/utils.ts b/src/utils.ts
    new file mode 100644
    index 0000000..0109b54
    --- /dev/null
    +++ b/src/utils.ts
    @@ -0,0 +1,216 @@
    +/**
    + * Cryptographic and derivation helpers for the Passkey Kit SDK.
    + *
    + * @packageDocumentation
    + */
    +
    +import { StrKey, hash, xdr, Address } from "@stellar/stellar-sdk";
    +import type { RegistrationResponseJSON } from "@simplewebauthn/browser";
    +import base64url from "./base64url.js";
    +import {
    +  SECP256R1_PUBLIC_KEY_SIZE,
    +  SECP256R1_CURVE_ORDER,
    +  UNCOMPRESSED_PUBKEY_PREFIX,
    +  WEBAUTHN_CHALLENGE_SIZE,
    +} from "./constants.js";
    +import { WebAuthnError, PasskeyKitErrorCode } from "./errors.js";
    +
    +// ============================================================================
    +// Deterministic contract-address derivation
    +// ============================================================================
    +
    +/**
    + * Derive a smart-wallet contract address from a passkey credential id.
    + *
    + * The wallet's contract id is deterministic: `salt = sha256(keyId)` and the
    + * deployer address salts a `ContractIdPreimageFromAddress`. This determinism is
    + * load-bearing — the indexer reverse-lookup (keyId → wallet) re-derives the
    + * address the same way — so this function is pinned with golden vectors in
    + * `utils.test.ts`. Any change to the derivation breaks the tests instead of
    + * silently shifting deployed addresses.
    + *
    + * @param keyId - The raw credential id bytes
    + * @param deployerPublicKey - The deployer's `G…` public key
    + * @param networkPassphrase - The network passphrase
    + * @returns The derived contract address (`C…`)
    + */
    +export function deriveContractAddress(
    +  keyId: Buffer,
    +  deployerPublicKey: string,
    +  networkPassphrase: string
    +): string {
    +  const preimage = xdr.HashIdPreimage.envelopeTypeContractId(
    +    new xdr.HashIdPreimageContractId({
    +      networkId: hash(Buffer.from(networkPassphrase)),
    +      contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress(
    +        new xdr.ContractIdPreimageFromAddress({
    +          address: Address.fromString(deployerPublicKey).toScAddress(),
    +          salt: hash(keyId),
    +        })
    +      ),
    +    })
    +  );
    +
    +  return StrKey.encodeContract(hash(preimage.toXDR()));
    +}
    +
    +// ============================================================================
    +// WebAuthn public-key extraction
    +// ============================================================================
    +
    +/**
    + * Extract the 65-byte uncompressed secp256r1 public key from a WebAuthn
    + * registration (attestation) response.
    + *
    + * Tries, in order:
    + * 1. `response.publicKey` when it is already a raw uncompressed EC point.
    + * 2. `response.publicKey` decoded from SPKI via WebCrypto (`crypto.subtle`),
    + *    which is the correct, non-magic-offset way to normalize the DER-encoded
    + *    SubjectPublicKeyInfo most authenticators return.
    + * 3. Parsing the COSE key out of `authenticatorData` / `attestationObject`.
    + *
    + * @throws {WebAuthnError} If the public key cannot be extracted.
    + */
    +export async function extractPublicKeyFromAttestation(
    +  response: RegistrationResponseJSON["response"]
    +): Promise {
    +  let publicKey: Buffer | undefined;
    +
    +  if (response.publicKey) {
    +    const encodedPublicKey = base64url.toBuffer(response.publicKey);
    +
    +    if (
    +      encodedPublicKey.length === SECP256R1_PUBLIC_KEY_SIZE &&
    +      encodedPublicKey[0] === UNCOMPRESSED_PUBKEY_PREFIX
    +    ) {
    +      publicKey = encodedPublicKey;
    +    } else if (typeof crypto?.subtle !== "undefined") {
    +      try {
    +        const imported = await crypto.subtle.importKey(
    +          "spki",
    +          new Uint8Array(encodedPublicKey),
    +          { name: "ECDSA", namedCurve: "P-256" },
    +          true,
    +          []
    +        );
    +        const rawKey = await crypto.subtle.exportKey("raw", imported);
    +        publicKey = Buffer.from(new Uint8Array(rawKey));
    +      } catch {
    +        publicKey = encodedPublicKey.subarray(
    +          encodedPublicKey.length - SECP256R1_PUBLIC_KEY_SIZE
    +        );
    +      }
    +    } else {
    +      publicKey = encodedPublicKey.subarray(
    +        encodedPublicKey.length - SECP256R1_PUBLIC_KEY_SIZE
    +      );
    +    }
    +  }
    +
    +  // Validate it is a proper uncompressed EC point; otherwise fall back to the
    +  // COSE key embedded in authenticatorData / attestationObject.
    +  if (
    +    !publicKey ||
    +    publicKey[0] !== UNCOMPRESSED_PUBKEY_PREFIX ||
    +    publicKey.length !== SECP256R1_PUBLIC_KEY_SIZE
    +  ) {
    +    let x: Buffer;
    +    let y: Buffer;
    +
    +    if (response.authenticatorData) {
    +      const authenticatorData = base64url.toBuffer(response.authenticatorData);
    +      const credentialIdLength =
    +        (authenticatorData[53]! << 8) | authenticatorData[54]!;
    +
    +      x = authenticatorData.subarray(
    +        65 + credentialIdLength,
    +        97 + credentialIdLength
    +      );
    +      y = authenticatorData.subarray(
    +        100 + credentialIdLength,
    +        132 + credentialIdLength
    +      );
    +    } else if (response.attestationObject) {
    +      const attestationObject = base64url.toBuffer(response.attestationObject);
    +
    +      // COSE key structure prefix for an ES256 (P-256) key.
    +      const publicKeyPrefixSlice = Buffer.from([
    +        0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20,
    +      ]);
    +      let startIndex = attestationObject.indexOf(publicKeyPrefixSlice);
    +      if (startIndex === -1) {
    +        throw new WebAuthnError(
    +          "Could not locate the COSE public key in the attestation object",
    +          PasskeyKitErrorCode.PUBLIC_KEY_EXTRACTION_FAILED
    +        );
    +      }
    +      startIndex = startIndex + publicKeyPrefixSlice.length;
    +
    +      x = attestationObject.subarray(startIndex, 32 + startIndex);
    +      y = attestationObject.subarray(35 + startIndex, 67 + startIndex);
    +    } else {
    +      throw new WebAuthnError(
    +        "Could not extract a public key from the attestation response",
    +        PasskeyKitErrorCode.PUBLIC_KEY_EXTRACTION_FAILED
    +      );
    +    }
    +
    +    publicKey = Buffer.from([UNCOMPRESSED_PUBKEY_PREFIX, ...x, ...y]);
    +  }
    +
    +  return new Uint8Array(publicKey);
    +}
    +
    +// ============================================================================
    +// Signature encoding
    +// ============================================================================
    +
    +/**
    + * Convert a DER-encoded ECDSA signature to Stellar's compact `r || s` form with
    + * a low-S value (S <= n/2), as required by the secp256r1 host function.
    + *
    + * @param derSignature - The DER-encoded signature
    + * @returns 64-byte compact signature (`r || s`)
    + */
    +export function compactSignature(derSignature: Buffer): Uint8Array {
    +  // DER: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
    +  let offset = 2; // skip 0x30 and the total length
    +
    +  const rLength = derSignature[offset + 1]!;
    +  const r = derSignature.subarray(offset + 2, offset + 2 + rLength);
    +
    +  offset += 2 + rLength;
    +
    +  const sLength = derSignature[offset + 1]!;
    +  const s = derSignature.subarray(offset + 2, offset + 2 + sLength);
    +
    +  const rBigInt = BigInt("0x" + r.toString("hex"));
    +  let sBigInt = BigInt("0x" + s.toString("hex"));
    +
    +  // Enforce low-S.
    +  const halfN = SECP256R1_CURVE_ORDER / 2n;
    +  if (sBigInt > halfN) {
    +    sBigInt = SECP256R1_CURVE_ORDER - sBigInt;
    +  }
    +
    +  const rPadded = Buffer.from(rBigInt.toString(16).padStart(64, "0"), "hex");
    +  const sLowS = Buffer.from(sBigInt.toString(16).padStart(64, "0"), "hex");
    +
    +  return new Uint8Array(Buffer.concat([rPadded, sLowS]));
    +}
    +
    +// ============================================================================
    +// WebAuthn challenge
    +// ============================================================================
    +
    +/**
    + * Generate a cryptographically-random base64url challenge for a WebAuthn
    + * ceremony.
    + *
    + * Replaces the old hard-coded `"stellaristhebetterblockchain"` challenge.
    + */
    +export function generateChallenge(): string {
    +  const bytes = new Uint8Array(WEBAUTHN_CHALLENGE_SIZE);
    +  crypto.getRandomValues(bytes);
    +  return base64url.encode(Buffer.from(bytes));
    +}
    diff --git a/src/validation.test.ts b/src/validation.test.ts
    new file mode 100644
    index 0000000..993e95d
    --- /dev/null
    +++ b/src/validation.test.ts
    @@ -0,0 +1,87 @@
    +import { describe, expect, it } from "vitest";
    +import { Keypair } from "@stellar/stellar-sdk";
    +import {
    +  validateAddress,
    +  validateAmount,
    +  validateExpiration,
    +  validateSecp256r1PublicKey,
    +} from "./validation.js";
    +import { PasskeyKitErrorCode, ValidationError } from "./errors.js";
    +
    +const G_ADDRESS = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 1)).publicKey();
    +const C_ADDRESS = "CAXCIOHU357VIEDAWOU6YZUL3IU3CDFLCHA6O4PT2VDICDX4PNGSVZDL";
    +
    +describe("validateAddress", () => {
    +  it("accepts G… and C… addresses", () => {
    +    expect(() => validateAddress(G_ADDRESS)).not.toThrow();
    +    expect(() => validateAddress(C_ADDRESS)).not.toThrow();
    +  });
    +
    +  it("rejects malformed or empty addresses with INVALID_ADDRESS", () => {
    +    expect(() => validateAddress("not-an-address")).toThrow(ValidationError);
    +    try {
    +      validateAddress("");
    +    } catch (err) {
    +      expect((err as ValidationError).code).toBe(
    +        PasskeyKitErrorCode.INVALID_ADDRESS
    +      );
    +    }
    +  });
    +});
    +
    +describe("validateAmount", () => {
    +  it("accepts positive finite numbers", () => {
    +    expect(() => validateAmount(1)).not.toThrow();
    +    expect(() => validateAmount(0.5)).not.toThrow();
    +  });
    +
    +  it("rejects zero, negatives, and non-finite values", () => {
    +    expect(() => validateAmount(0)).toThrow(ValidationError);
    +    expect(() => validateAmount(-1)).toThrow(ValidationError);
    +    expect(() => validateAmount(Number.NaN)).toThrow(ValidationError);
    +    expect(() => validateAmount(Number.POSITIVE_INFINITY)).toThrow(
    +      ValidationError
    +    );
    +  });
    +});
    +
    +describe("validateExpiration", () => {
    +  it("accepts undefined and valid u32 values", () => {
    +    expect(() => validateExpiration(undefined)).not.toThrow();
    +    expect(() => validateExpiration(0)).not.toThrow();
    +    expect(() => validateExpiration(0xffffffff)).not.toThrow();
    +  });
    +
    +  it("rejects negatives, non-integers, and out-of-range values", () => {
    +    expect(() => validateExpiration(-1)).toThrow(ValidationError);
    +    expect(() => validateExpiration(1.5)).toThrow(ValidationError);
    +    expect(() => validateExpiration(0x100000000)).toThrow(ValidationError);
    +  });
    +});
    +
    +describe("validateSecp256r1PublicKey", () => {
    +  it("accepts a 65-byte uncompressed point", () => {
    +    const key = new Uint8Array(65);
    +    key[0] = 0x04;
    +    expect(() => validateSecp256r1PublicKey(key)).not.toThrow();
    +  });
    +
    +  it("rejects wrong length or wrong prefix with INVALID_PUBLIC_KEY", () => {
    +    const wrongLength = new Uint8Array(64);
    +    wrongLength[0] = 0x04;
    +    const wrongPrefix = new Uint8Array(65);
    +    wrongPrefix[0] = 0x02;
    +
    +    for (const bad of [wrongLength, wrongPrefix]) {
    +      try {
    +        validateSecp256r1PublicKey(bad);
    +        throw new Error("expected to throw");
    +      } catch (err) {
    +        expect(err).toBeInstanceOf(ValidationError);
    +        expect((err as ValidationError).code).toBe(
    +          PasskeyKitErrorCode.INVALID_PUBLIC_KEY
    +        );
    +      }
    +    }
    +  });
    +});
    diff --git a/src/validation.ts b/src/validation.ts
    new file mode 100644
    index 0000000..100038b
    --- /dev/null
    +++ b/src/validation.ts
    @@ -0,0 +1,121 @@
    +/**
    + * Client-side input validation.
    + *
    + * Produces clear {@link ValidationError}s before submission instead of opaque
    + * on-chain or WebCrypto failures. The smart wallet imposes no signer-count caps
    + * (unlike a policy-based account), so validation here is about shapes and
    + * ranges: addresses, amounts, expirations, and secp256r1 public keys.
    + *
    + * @packageDocumentation
    + */
    +
    +import { StrKey } from "@stellar/stellar-sdk";
    +import {
    +  SECP256R1_PUBLIC_KEY_SIZE,
    +  UNCOMPRESSED_PUBKEY_PREFIX,
    +} from "./constants.js";
    +import { PasskeyKitErrorCode, ValidationError } from "./errors.js";
    +
    +/**
    + * Validate that a string is a Stellar account (`G…`) or contract (`C…`) address.
    + *
    + * @throws {ValidationError} If the address is missing or malformed.
    + */
    +export function validateAddress(
    +  address: string,
    +  fieldName = "address"
    +): void {
    +  if (!address || typeof address !== "string") {
    +    throw new ValidationError(
    +      `${fieldName} is required`,
    +      PasskeyKitErrorCode.INVALID_ADDRESS,
    +      { field: fieldName }
    +    );
    +  }
    +
    +  if (
    +    !StrKey.isValidEd25519PublicKey(address) &&
    +    !StrKey.isValidContract(address)
    +  ) {
    +    throw new ValidationError(
    +      `Invalid ${fieldName}: must be a valid Stellar account (G…) or contract (C…) address`,
    +      PasskeyKitErrorCode.INVALID_ADDRESS,
    +      { field: fieldName, value: address.slice(0, 10) + "…" }
    +    );
    +  }
    +}
    +
    +/**
    + * Validate that an amount is a positive, finite number.
    + *
    + * @throws {ValidationError} If the amount is not a positive number.
    + */
    +export function validateAmount(amount: number, fieldName = "amount"): void {
    +  if (typeof amount !== "number" || !Number.isFinite(amount)) {
    +    throw new ValidationError(
    +      `${fieldName} must be a number`,
    +      PasskeyKitErrorCode.INVALID_AMOUNT,
    +      { field: fieldName }
    +    );
    +  }
    +  if (amount <= 0) {
    +    throw new ValidationError(
    +      `${fieldName} must be positive`,
    +      PasskeyKitErrorCode.INVALID_AMOUNT,
    +      { field: fieldName, value: amount }
    +    );
    +  }
    +}
    +
    +/**
    + * Validate a signer expiration.
    + *
    + * Must be a non-negative integer within `u32` bounds when provided; `undefined`
    + * means "no expiration". (Unix-second timestamps fit in `u32` through the year
    + * 2106, so this bound holds whether the contract interprets the value as a
    + * ledger sequence or a UNIX timestamp.)
    + *
    + * @throws {ValidationError} If the expiration is out of range.
    + */
    +export function validateExpiration(
    +  expiration: number | undefined,
    +  fieldName = "expiration"
    +): void {
    +  if (expiration === undefined) return;
    +  if (
    +    !Number.isInteger(expiration) ||
    +    expiration < 0 ||
    +    expiration > 0xffffffff
    +  ) {
    +    throw new ValidationError(
    +      `${fieldName} must be a non-negative integer within u32 bounds`,
    +      PasskeyKitErrorCode.INVALID_INPUT,
    +      { field: fieldName, value: expiration }
    +    );
    +  }
    +}
    +
    +/**
    + * Validate a secp256r1 public key: 65 bytes, uncompressed (`0x04`) prefix.
    + *
    + * @throws {ValidationError} If the key is not a valid uncompressed EC point.
    + */
    +export function validateSecp256r1PublicKey(
    +  publicKey: Uint8Array,
    +  fieldName = "publicKey"
    +): void {
    +  if (publicKey.length !== SECP256R1_PUBLIC_KEY_SIZE) {
    +    throw new ValidationError(
    +      `${fieldName} must be ${SECP256R1_PUBLIC_KEY_SIZE} bytes (got ${publicKey.length})`,
    +      PasskeyKitErrorCode.INVALID_PUBLIC_KEY,
    +      { field: fieldName, length: publicKey.length }
    +    );
    +  }
    +  if (publicKey[0] !== UNCOMPRESSED_PUBKEY_PREFIX) {
    +    throw new ValidationError(
    +      `${fieldName} must be an uncompressed EC point (0x04 prefix)`,
    +      PasskeyKitErrorCode.INVALID_PUBLIC_KEY,
    +      { field: fieldName, prefix: publicKey[0] }
    +    );
    +  }
    +}
    diff --git a/src/version.ts b/src/version.ts
    new file mode 100644
    index 0000000..f5b0fa5
    --- /dev/null
    +++ b/src/version.ts
    @@ -0,0 +1,10 @@
    +/**
    + * Package version — auto-generated from package.json.
    + * @packageDocumentation
    + */
    +
    +// This file is auto-generated. Do not edit manually.
    +// Run `pnpm run sync-version` (or `pnpm build`) to update.
    +
    +export const VERSION = "0.13.0";
    +export const NAME = "passkey-kit";
    diff --git a/tsconfig.json b/tsconfig.json
    index 5c30dc8..9056806 100644
    --- a/tsconfig.json
    +++ b/tsconfig.json
    @@ -1,28 +1,23 @@
     {
       "compilerOptions": {
    -    "outDir": "./types",
    -    "rootDir": "./src",
    -    // Enable latest features
    -    "target": "ESNext",
    -    "module": "ESNext",
    -    "moduleDetection": "force",
    -    // Bundler mode
    -    "moduleResolution": "Bundler",
    -    "allowImportingTsExtensions": true,
    -    "verbatimModuleSyntax": true,
    +    "target": "ES2022",
    +    "module": "NodeNext",
    +    "moduleResolution": "NodeNext",
    +    "lib": ["ES2022", "DOM"],
         "declaration": true,
    -    "emitDeclarationOnly": true,
    -    // Best practices
    +    "declarationMap": true,
    +    "sourceMap": true,
    +    "outDir": "dist",
    +    "rootDir": "src",
         "strict": true,
    +    "esModuleInterop": true,
         "skipLibCheck": true,
    -    "noFallthroughCasesInSwitch": true,
    -    // Some stricter flags (disabled by default)
    -    "noUnusedLocals": false,
    -    "noUnusedParameters": false,
    -    "noPropertyAccessFromIndexSignature": false,
    -    "strictNullChecks": true,
    +    "forceConsistentCasingInFileNames": true,
    +    "resolveJsonModule": true,
    +    "isolatedModules": true,
    +    "noEmit": false,
    +    "noFallthroughCasesInSwitch": true
       },
    -  "include": [
    -    "src",
    -  ],
    -}
    \ No newline at end of file
    +  "include": ["src/**/*"],
    +  "exclude": ["node_modules", "dist", "**/*.test.ts"]
    +}
    diff --git a/wrangler.toml b/wrangler.toml
    new file mode 100644
    index 0000000..a8c0fd5
    --- /dev/null
    +++ b/wrangler.toml
    @@ -0,0 +1,24 @@
    +# Cloudflare Pages config for the demo (audit #597: "deploy broken-as-scripted —
    +# no wrangler.toml anywhere; deploy:demo has no project/output dir").
    +#
    +# `deploy:demo` in the root package.json runs `npx wrangler pages deploy` FROM
    +# THE REPO ROOT with no arguments, so the config it needs must live here. With
    +# `name` + `pages_build_output_dir` set, that script now resolves the project
    +# and the built assets directory without extra flags.
    +#
    +# Build the demo first (`pnpm run build:demo` -> demo/dist), then deploy.
    +name = "passkey-kit-demo"
    +pages_build_output_dir = "demo/dist"
    +compatibility_date = "2024-12-01"
    +
    +# `deploy:demo:prod` runs `wrangler pages deploy --env production`. NOTE:
    +# `wrangler pages deploy` has no first-class `--env`; Pages selects
    +# production-vs-preview by BRANCH (`--branch`), not environment. This section
    +# only exists so `--env production` resolves instead of erroring. The correct
    +# fix belongs in the root package.json (owned by the demo/root, not services):
    +# change `deploy:demo:prod` to `wrangler pages deploy --branch production` (or
    +# drop it — a plain `pages deploy` publishes to production). See the C2 services
    +# todo comment for the handoff to the demo agent.
    +[env.production]
    +name = "passkey-kit-demo"
    +pages_build_output_dir = "demo/dist"
    diff --git a/zephyr/Cargo.lock b/zephyr/Cargo.lock
    index af0f773..ae9224c 100644
    --- a/zephyr/Cargo.lock
    +++ b/zephyr/Cargo.lock
    @@ -4,22 +4,16 @@ version = 4
     
     [[package]]
     name = "ahash"
    -version = "0.8.11"
    +version = "0.8.12"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
    +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
     dependencies = [
    - "cfg-if 1.0.0",
    + "cfg-if 1.0.4",
      "once_cell",
      "version_check",
      "zerocopy",
     ]
     
    -[[package]]
    -name = "android-tzdata"
    -version = "0.1.1"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
    -
     [[package]]
     name = "android_system_properties"
     version = "0.1.5"
    @@ -31,9 +25,9 @@ dependencies = [
     
     [[package]]
     name = "anyhow"
    -version = "1.0.94"
    +version = "1.0.103"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7"
    +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
     
     [[package]]
     name = "ark-bls12-381"
    @@ -75,7 +69,7 @@ dependencies = [
      "ark-serialize",
      "ark-std",
      "derivative",
    - "digest",
    + "digest 0.10.7",
      "itertools",
      "num-bigint",
      "num-traits",
    @@ -128,7 +122,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5"
     dependencies = [
      "ark-serialize-derive",
      "ark-std",
    - "digest",
    + "digest 0.10.7",
      "num-bigint",
     ]
     
    @@ -155,9 +149,9 @@ dependencies = [
     
     [[package]]
     name = "autocfg"
    -version = "1.4.0"
    +version = "1.5.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
    +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
     
     [[package]]
     name = "base16ct"
    @@ -185,9 +179,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
     
     [[package]]
     name = "base64ct"
    -version = "1.6.0"
    +version = "1.8.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
    +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
     
     [[package]]
     name = "bincode"
    @@ -208,22 +202,34 @@ dependencies = [
     ]
     
     [[package]]
    -name = "bumpalo"
    -version = "3.16.0"
    +name = "block-buffer"
    +version = "0.12.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
    +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
    +dependencies = [
    + "hybrid-array",
    +]
     
     [[package]]
    -name = "byteorder"
    -version = "1.5.0"
    +name = "bs58"
    +version = "0.5.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
    +dependencies = [
    + "tinyvec",
    +]
    +
    +[[package]]
    +name = "bumpalo"
    +version = "3.20.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
    +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
     
     [[package]]
     name = "bytes"
    -version = "1.9.0"
    +version = "1.12.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
    +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
     
     [[package]]
     name = "bytes-lit"
    @@ -234,15 +240,16 @@ dependencies = [
      "num-bigint",
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "cc"
    -version = "1.2.4"
    +version = "1.2.67"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf"
    +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
     dependencies = [
    + "find-msvc-tools",
      "shlex",
     ]
     
    @@ -254,9 +261,9 @@ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
     
     [[package]]
     name = "cfg-if"
    -version = "1.0.0"
    +version = "1.0.4"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
    +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
     
     [[package]]
     name = "cfg_eval"
    @@ -266,7 +273,7 @@ checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
    @@ -282,15 +289,14 @@ dependencies = [
     
     [[package]]
     name = "chrono"
    -version = "0.4.39"
    +version = "0.4.45"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
    +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
     dependencies = [
    - "android-tzdata",
      "iana-time-zone",
      "num-traits",
      "serde",
    - "windows-targets",
    + "windows-link",
     ]
     
     [[package]]
    @@ -307,9 +313,18 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
     
     [[package]]
     name = "cpufeatures"
    -version = "0.2.16"
    +version = "0.2.17"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3"
    +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
    +dependencies = [
    + "libc",
    +]
    +
    +[[package]]
    +name = "cpufeatures"
    +version = "0.3.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
     dependencies = [
      "libc",
     ]
    @@ -332,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
     dependencies = [
      "generic-array",
    - "rand_core",
    + "rand_core 0.6.4",
      "subtle",
      "zeroize",
     ]
    @@ -347,17 +362,44 @@ dependencies = [
      "typenum",
     ]
     
    +[[package]]
    +name = "crypto-common"
    +version = "0.2.2"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
    +dependencies = [
    + "hybrid-array",
    + "rand_core 0.10.1",
    +]
    +
     [[package]]
     name = "curve25519-dalek"
     version = "4.1.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
     dependencies = [
    - "cfg-if 1.0.0",
    - "cpufeatures",
    + "cfg-if 1.0.4",
    + "cpufeatures 0.2.17",
      "curve25519-dalek-derive",
    - "digest",
    - "fiat-crypto",
    + "digest 0.10.7",
    + "fiat-crypto 0.2.9",
    + "rustc_version",
    + "subtle",
    + "zeroize",
    +]
    +
    +[[package]]
    +name = "curve25519-dalek"
    +version = "5.0.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
    +dependencies = [
    + "cfg-if 1.0.4",
    + "cpufeatures 0.3.0",
    + "curve25519-dalek-derive",
    + "digest 0.11.3",
    + "fiat-crypto 0.3.0",
    + "rand_core 0.10.1",
      "rustc_version",
      "subtle",
      "zeroize",
    @@ -371,55 +413,89 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
    +]
    +
    +[[package]]
    +name = "darling"
    +version = "0.20.11"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
    +dependencies = [
    + "darling_core 0.20.11",
    + "darling_macro 0.20.11",
     ]
     
     [[package]]
     name = "darling"
    -version = "0.20.10"
    +version = "0.23.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
    +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
     dependencies = [
    - "darling_core",
    - "darling_macro",
    + "darling_core 0.23.0",
    + "darling_macro 0.23.0",
     ]
     
     [[package]]
     name = "darling_core"
    -version = "0.20.10"
    +version = "0.20.11"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
    +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
     dependencies = [
      "fnv",
      "ident_case",
      "proc-macro2",
      "quote",
      "strsim",
    - "syn 2.0.104",
    + "syn 2.0.118",
    +]
    +
    +[[package]]
    +name = "darling_core"
    +version = "0.23.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
    +dependencies = [
    + "ident_case",
    + "proc-macro2",
    + "quote",
    + "strsim",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "darling_macro"
    -version = "0.20.10"
    +version = "0.20.11"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
    +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
     dependencies = [
    - "darling_core",
    + "darling_core 0.20.11",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
    +]
    +
    +[[package]]
    +name = "darling_macro"
    +version = "0.23.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
    +dependencies = [
    + "darling_core 0.23.0",
    + "quote",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "data-encoding"
    -version = "2.6.0"
    +version = "2.11.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
    +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
     
     [[package]]
     name = "der"
    -version = "0.7.9"
    +version = "0.7.10"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
    +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
     dependencies = [
      "const-oid",
      "zeroize",
    @@ -427,12 +503,11 @@ dependencies = [
     
     [[package]]
     name = "deranged"
    -version = "0.3.11"
    +version = "0.5.8"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
    +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
     dependencies = [
    - "powerfmt",
    - "serde",
    + "serde_core",
     ]
     
     [[package]]
    @@ -452,12 +527,22 @@ version = "0.10.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
     dependencies = [
    - "block-buffer",
    + "block-buffer 0.10.4",
      "const-oid",
    - "crypto-common",
    + "crypto-common 0.1.6",
      "subtle",
     ]
     
    +[[package]]
    +name = "digest"
    +version = "0.11.3"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
    +dependencies = [
    + "block-buffer 0.12.1",
    + "crypto-common 0.2.2",
    +]
    +
     [[package]]
     name = "downcast-rs"
     version = "1.2.1"
    @@ -466,9 +551,9 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
     
     [[package]]
     name = "dyn-clone"
    -version = "1.0.19"
    +version = "1.0.20"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
    +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
     
     [[package]]
     name = "ecdsa"
    @@ -477,10 +562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
     dependencies = [
      "der",
    - "digest",
    + "digest 0.10.7",
      "elliptic-curve",
      "rfc6979",
    - "signature",
    + "signature 2.2.0",
     ]
     
     [[package]]
    @@ -490,29 +575,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
     dependencies = [
      "pkcs8",
    - "signature",
    + "signature 2.2.0",
    +]
    +
    +[[package]]
    +name = "ed25519"
    +version = "3.0.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
    +dependencies = [
    + "signature 3.0.0",
     ]
     
     [[package]]
     name = "ed25519-dalek"
    -version = "2.1.1"
    +version = "2.2.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
    +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
     dependencies = [
    - "curve25519-dalek",
    - "ed25519",
    - "rand_core",
    + "curve25519-dalek 4.1.3",
    + "ed25519 2.2.3",
      "serde",
    - "sha2",
    + "sha2 0.10.9",
    + "subtle",
    + "zeroize",
    +]
    +
    +[[package]]
    +name = "ed25519-dalek"
    +version = "3.0.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
    +dependencies = [
    + "curve25519-dalek 5.0.0",
    + "ed25519 3.0.0",
    + "rand_core 0.10.1",
    + "sha2 0.11.0",
    + "signature 3.0.0",
      "subtle",
      "zeroize",
     ]
     
     [[package]]
     name = "either"
    -version = "1.13.0"
    +version = "1.16.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
    +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
     
     [[package]]
     name = "elliptic-curve"
    @@ -522,11 +630,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
     dependencies = [
      "base16ct",
      "crypto-bigint",
    - "digest",
    + "digest 0.10.7",
      "ff",
      "generic-array",
      "group",
    - "rand_core",
    + "rand_core 0.6.4",
      "sec1",
      "subtle",
      "zeroize",
    @@ -534,9 +642,9 @@ dependencies = [
     
     [[package]]
     name = "equivalent"
    -version = "1.0.1"
    +version = "1.0.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
    +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
     
     [[package]]
     name = "escape-bytes"
    @@ -546,17 +654,17 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2"
     
     [[package]]
     name = "ethnum"
    -version = "1.5.0"
    +version = "1.5.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c"
    +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f"
     
     [[package]]
     name = "ff"
    -version = "0.13.0"
    +version = "0.13.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449"
    +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
     dependencies = [
    - "rand_core",
    + "rand_core 0.6.4",
      "subtle",
     ]
     
    @@ -566,17 +674,53 @@ version = "0.2.9"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
     
    +[[package]]
    +name = "fiat-crypto"
    +version = "0.3.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
    +
    +[[package]]
    +name = "find-msvc-tools"
    +version = "0.1.9"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
    +
     [[package]]
     name = "fnv"
     version = "1.0.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
     
    +[[package]]
    +name = "futures-core"
    +version = "0.3.32"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
    +
    +[[package]]
    +name = "futures-task"
    +version = "0.3.32"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
    +
    +[[package]]
    +name = "futures-util"
    +version = "0.3.32"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
    +dependencies = [
    + "futures-core",
    + "futures-task",
    + "pin-project-lite",
    + "slab",
    +]
    +
     [[package]]
     name = "generic-array"
    -version = "0.14.7"
    +version = "0.14.9"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
    +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
     dependencies = [
      "typenum",
      "version_check",
    @@ -585,11 +729,11 @@ dependencies = [
     
     [[package]]
     name = "getrandom"
    -version = "0.2.15"
    +version = "0.2.17"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
    +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
     dependencies = [
    - "cfg-if 1.0.0",
    + "cfg-if 1.0.4",
      "js-sys",
      "libc",
      "wasi",
    @@ -603,7 +747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
     dependencies = [
      "ff",
    - "rand_core",
    + "rand_core 0.6.4",
      "subtle",
     ]
     
    @@ -618,7 +762,7 @@ dependencies = [
      "pest_derive",
      "serde",
      "serde_json",
    - "thiserror 1.0.69",
    + "thiserror",
     ]
     
     [[package]]
    @@ -638,15 +782,9 @@ dependencies = [
     
     [[package]]
     name = "hashbrown"
    -version = "0.15.2"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
    -
    -[[package]]
    -name = "heck"
    -version = "0.5.0"
    +version = "0.17.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
    +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
     
     [[package]]
     name = "hex"
    @@ -669,30 +807,39 @@ version = "0.12.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
     dependencies = [
    - "digest",
    + "digest 0.10.7",
     ]
     
     [[package]]
     name = "http"
    -version = "1.2.0"
    +version = "1.4.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea"
    +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
     dependencies = [
      "bytes",
    - "fnv",
      "itoa",
     ]
     
    +[[package]]
    +name = "hybrid-array"
    +version = "0.4.13"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
    +dependencies = [
    + "typenum",
    +]
    +
     [[package]]
     name = "iana-time-zone"
    -version = "0.1.61"
    +version = "0.1.65"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220"
    +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
     dependencies = [
      "android_system_properties",
      "core-foundation-sys",
      "iana-time-zone-haiku",
      "js-sys",
    + "log",
      "wasm-bindgen",
      "windows-core",
     ]
    @@ -725,13 +872,14 @@ dependencies = [
     
     [[package]]
     name = "indexmap"
    -version = "2.7.0"
    +version = "2.14.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f"
    +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
     dependencies = [
      "equivalent",
    - "hashbrown 0.15.2",
    + "hashbrown 0.17.1",
      "serde",
    + "serde_core",
     ]
     
     [[package]]
    @@ -751,17 +899,18 @@ dependencies = [
     
     [[package]]
     name = "itoa"
    -version = "1.0.14"
    +version = "1.0.18"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
    +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
     
     [[package]]
     name = "js-sys"
    -version = "0.3.76"
    +version = "0.3.103"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7"
    +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
     dependencies = [
    - "once_cell",
    + "cfg-if 1.0.4",
    + "futures-util",
      "wasm-bindgen",
     ]
     
    @@ -771,55 +920,44 @@ version = "0.13.4"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
     dependencies = [
    - "cfg-if 1.0.0",
    + "cfg-if 1.0.4",
      "ecdsa",
      "elliptic-curve",
    - "sha2",
    + "sha2 0.10.9",
     ]
     
     [[package]]
     name = "keccak"
    -version = "0.1.5"
    +version = "0.1.6"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654"
    +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
     dependencies = [
    - "cpufeatures",
    + "cpufeatures 0.2.17",
     ]
     
     [[package]]
     name = "libc"
    -version = "0.2.168"
    +version = "0.2.186"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d"
    +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
     
     [[package]]
     name = "libm"
    -version = "0.2.11"
    +version = "0.2.16"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
    +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
     
     [[package]]
     name = "log"
    -version = "0.4.22"
    +version = "0.4.33"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
    -
    -[[package]]
    -name = "macro-string"
    -version = "0.1.4"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3"
    -dependencies = [
    - "proc-macro2",
    - "quote",
    - "syn 2.0.104",
    -]
    +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
     
     [[package]]
     name = "memchr"
    -version = "2.7.4"
    +version = "2.8.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
    +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
     
     [[package]]
     name = "memory_units"
    @@ -829,9 +967,9 @@ checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
     
     [[package]]
     name = "num-bigint"
    -version = "0.4.6"
    +version = "0.4.8"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
    +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
     dependencies = [
      "num-integer",
      "num-traits",
    @@ -839,9 +977,9 @@ dependencies = [
     
     [[package]]
     name = "num-conv"
    -version = "0.1.0"
    +version = "0.2.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
    +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
     
     [[package]]
     name = "num-derive"
    @@ -851,7 +989,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
    @@ -874,9 +1012,9 @@ dependencies = [
     
     [[package]]
     name = "once_cell"
    -version = "1.20.2"
    +version = "1.21.4"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
    +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
     
     [[package]]
     name = "p256"
    @@ -887,7 +1025,19 @@ dependencies = [
      "ecdsa",
      "elliptic-curve",
      "primeorder",
    - "sha2",
    + "sha2 0.10.9",
    +]
    +
    +[[package]]
    +name = "passkey-kit-indexer"
    +version = "1.0.0"
    +dependencies = [
    + "base64 0.22.1",
    + "hex",
    + "serde",
    + "soroban-sdk",
    + "stellar-strkey 0.0.13",
    + "zephyr-sdk",
     ]
     
     [[package]]
    @@ -898,20 +1048,19 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
     
     [[package]]
     name = "pest"
    -version = "2.7.15"
    +version = "2.8.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
    +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
     dependencies = [
      "memchr",
    - "thiserror 2.0.8",
      "ucd-trie",
     ]
     
     [[package]]
     name = "pest_derive"
    -version = "2.7.15"
    +version = "2.8.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e"
    +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
     dependencies = [
      "pest",
      "pest_generator",
    @@ -919,28 +1068,32 @@ dependencies = [
     
     [[package]]
     name = "pest_generator"
    -version = "2.7.15"
    +version = "2.8.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b"
    +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
     dependencies = [
      "pest",
      "pest_meta",
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "pest_meta"
    -version = "2.7.15"
    +version = "2.8.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea"
    +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
     dependencies = [
    - "once_cell",
      "pest",
    - "sha2",
     ]
     
    +[[package]]
    +name = "pin-project-lite"
    +version = "0.2.17"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
    +
     [[package]]
     name = "pkcs8"
     version = "0.10.2"
    @@ -959,21 +1112,21 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
     
     [[package]]
     name = "ppv-lite86"
    -version = "0.2.20"
    +version = "0.2.21"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
    +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
     dependencies = [
      "zerocopy",
     ]
     
     [[package]]
     name = "prettyplease"
    -version = "0.2.25"
    +version = "0.2.37"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033"
    +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
     dependencies = [
      "proc-macro2",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
    @@ -987,31 +1140,31 @@ dependencies = [
     
     [[package]]
     name = "proc-macro2"
    -version = "1.0.92"
    +version = "1.0.106"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
    +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
     dependencies = [
      "unicode-ident",
     ]
     
     [[package]]
     name = "quote"
    -version = "1.0.37"
    +version = "1.0.46"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
    +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
     dependencies = [
      "proc-macro2",
     ]
     
     [[package]]
     name = "rand"
    -version = "0.8.5"
    +version = "0.8.7"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
    +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
     dependencies = [
      "libc",
      "rand_chacha",
    - "rand_core",
    + "rand_core 0.6.4",
     ]
     
     [[package]]
    @@ -1021,7 +1174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
     dependencies = [
      "ppv-lite86",
    - "rand_core",
    + "rand_core 0.6.4",
     ]
     
     [[package]]
    @@ -1033,24 +1186,30 @@ dependencies = [
      "getrandom",
     ]
     
    +[[package]]
    +name = "rand_core"
    +version = "0.10.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
    +
     [[package]]
     name = "ref-cast"
    -version = "1.0.24"
    +version = "1.0.25"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
    +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
     dependencies = [
      "ref-cast-impl",
     ]
     
     [[package]]
     name = "ref-cast-impl"
    -version = "1.0.24"
    +version = "1.0.25"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
    +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
    @@ -1066,13 +1225,13 @@ dependencies = [
     [[package]]
     name = "rs-zephyr-common"
     version = "0.1.8"
    -source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=7e9e458#7e9e4580cb5918db9f088126c89e496c09b6c649"
    +source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=3b832840b25289af243326fc3be8a29108891ec2#3b832840b25289af243326fc3be8a29108891ec2"
     dependencies = [
      "anyhow",
      "http",
      "serde",
    - "stellar-xdr 22.1.0",
    - "thiserror 1.0.69",
    + "stellar-xdr 27.0.0",
    + "thiserror",
     ]
     
     [[package]]
    @@ -1085,10 +1244,10 @@ dependencies = [
     ]
     
     [[package]]
    -name = "ryu"
    -version = "1.0.18"
    +name = "rustversion"
    +version = "1.0.23"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
    +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
     
     [[package]]
     name = "schemars"
    @@ -1115,9 +1274,9 @@ dependencies = [
     
     [[package]]
     name = "schemars"
    -version = "1.0.4"
    +version = "1.2.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
    +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
     dependencies = [
      "dyn-clone",
      "ref-cast",
    @@ -1140,58 +1299,69 @@ dependencies = [
     
     [[package]]
     name = "semver"
    -version = "1.0.24"
    +version = "1.0.28"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba"
    +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
     
     [[package]]
     name = "serde"
    -version = "1.0.216"
    +version = "1.0.228"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
    +dependencies = [
    + "serde_core",
    + "serde_derive",
    +]
    +
    +[[package]]
    +name = "serde_core"
    +version = "1.0.228"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e"
    +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
     dependencies = [
      "serde_derive",
     ]
     
     [[package]]
     name = "serde_derive"
    -version = "1.0.216"
    +version = "1.0.228"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e"
    +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "serde_json"
    -version = "1.0.133"
    +version = "1.0.150"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
    +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
     dependencies = [
      "itoa",
      "memchr",
    - "ryu",
      "serde",
    + "serde_core",
    + "zmij",
     ]
     
     [[package]]
     name = "serde_with"
    -version = "3.14.0"
    +version = "3.21.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
    +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
     dependencies = [
      "base64 0.22.1",
    + "bs58",
      "chrono",
      "hex",
      "indexmap 1.9.3",
    - "indexmap 2.7.0",
    + "indexmap 2.14.0",
      "schemars 0.8.22",
      "schemars 0.9.0",
    - "schemars 1.0.4",
    - "serde",
    - "serde_derive",
    + "schemars 1.2.1",
    + "serde_core",
      "serde_json",
      "serde_with_macros",
      "time",
    @@ -1199,14 +1369,14 @@ dependencies = [
     
     [[package]]
     name = "serde_with_macros"
    -version = "3.14.0"
    +version = "3.21.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
    +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
     dependencies = [
    - "darling",
    + "darling 0.23.0",
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
    @@ -1215,26 +1385,37 @@ version = "0.10.9"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
     dependencies = [
    - "cfg-if 1.0.0",
    - "cpufeatures",
    - "digest",
    + "cfg-if 1.0.4",
    + "cpufeatures 0.2.17",
    + "digest 0.10.7",
    +]
    +
    +[[package]]
    +name = "sha2"
    +version = "0.11.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
    +dependencies = [
    + "cfg-if 1.0.4",
    + "cpufeatures 0.3.0",
    + "digest 0.11.3",
     ]
     
     [[package]]
     name = "sha3"
    -version = "0.10.8"
    +version = "0.10.9"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
    +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
     dependencies = [
    - "digest",
    + "digest 0.10.7",
      "keccak",
     ]
     
     [[package]]
     name = "shlex"
    -version = "1.3.0"
    +version = "2.0.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
    +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
     
     [[package]]
     name = "signature"
    @@ -1242,58 +1423,41 @@ version = "2.2.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
     dependencies = [
    - "digest",
    - "rand_core",
    + "digest 0.10.7",
    + "rand_core 0.6.4",
     ]
     
     [[package]]
    -name = "smallvec"
    -version = "1.13.2"
    +name = "signature"
    +version = "3.0.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
    -
    -[[package]]
    -name = "smart-wallet-interface"
    -version = "0.5.0"
    +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
     dependencies = [
    - "soroban-sdk 23.0.2",
    + "rand_core 0.10.1",
     ]
     
     [[package]]
    -name = "smart-wallets-next-dima"
    -version = "0.0.0"
    -dependencies = [
    - "base64 0.22.1",
    - "hex",
    - "serde",
    - "serde_json",
    - "smart-wallet-interface",
    - "stellar-strkey 0.0.13",
    - "zephyr-sdk",
    -]
    +name = "slab"
    +version = "0.4.12"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
     
     [[package]]
    -name = "soroban-builtin-sdk-macros"
    -version = "22.1.3"
    +name = "smallvec"
    +version = "1.15.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f"
    -dependencies = [
    - "itertools",
    - "proc-macro2",
    - "quote",
    - "syn 2.0.104",
    -]
    +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
     
     [[package]]
     name = "soroban-builtin-sdk-macros"
    -version = "23.0.1"
    +version = "22.1.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85"
    +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f"
     dependencies = [
      "itertools",
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
    @@ -1307,47 +1471,20 @@ dependencies = [
      "num-derive",
      "num-traits",
      "serde",
    - "soroban-env-macros 22.1.3",
    + "soroban-env-macros",
      "soroban-wasmi",
      "static_assertions",
      "stellar-xdr 22.1.0",
      "wasmparser",
     ]
     
    -[[package]]
    -name = "soroban-env-common"
    -version = "23.0.1"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3"
    -dependencies = [
    - "crate-git-revision",
    - "ethnum",
    - "num-derive",
    - "num-traits",
    - "soroban-env-macros 23.0.1",
    - "soroban-wasmi",
    - "static_assertions",
    - "stellar-xdr 23.0.0",
    - "wasmparser",
    -]
    -
     [[package]]
     name = "soroban-env-guest"
     version = "22.1.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e"
     dependencies = [
    - "soroban-env-common 22.1.3",
    - "static_assertions",
    -]
    -
    -[[package]]
    -name = "soroban-env-guest"
    -version = "23.0.1"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6"
    -dependencies = [
    - "soroban-env-common 23.0.1",
    + "soroban-env-common",
      "static_assertions",
     ]
     
    @@ -1361,9 +1498,9 @@ dependencies = [
      "ark-ec",
      "ark-ff",
      "ark-serialize",
    - "curve25519-dalek",
    + "curve25519-dalek 5.0.0",
      "ecdsa",
    - "ed25519-dalek",
    + "ed25519-dalek 3.0.0",
      "elliptic-curve",
      "generic-array",
      "getrandom",
    @@ -1377,52 +1514,16 @@ dependencies = [
      "rand",
      "rand_chacha",
      "sec1",
    - "sha2",
    + "sha2 0.10.9",
      "sha3",
    - "soroban-builtin-sdk-macros 22.1.3",
    - "soroban-env-common 22.1.3",
    + "soroban-builtin-sdk-macros",
    + "soroban-env-common",
      "soroban-wasmi",
      "static_assertions",
      "stellar-strkey 0.0.9",
      "wasmparser",
     ]
     
    -[[package]]
    -name = "soroban-env-host"
    -version = "23.0.1"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725"
    -dependencies = [
    - "ark-bls12-381",
    - "ark-ec",
    - "ark-ff",
    - "ark-serialize",
    - "curve25519-dalek",
    - "ecdsa",
    - "ed25519-dalek",
    - "elliptic-curve",
    - "generic-array",
    - "getrandom",
    - "hex-literal",
    - "hmac",
    - "k256",
    - "num-derive",
    - "num-integer",
    - "num-traits",
    - "p256",
    - "rand",
    - "rand_chacha",
    - "sec1",
    - "sha2",
    - "sha3",
    - "soroban-builtin-sdk-macros 23.0.1",
    - "soroban-env-common 23.0.1",
    - "soroban-wasmi",
    - "static_assertions",
    - "stellar-strkey 0.0.13",
    - "wasmparser",
    -]
    -
     [[package]]
     name = "soroban-env-macros"
     version = "22.1.3"
    @@ -1435,36 +1536,21 @@ dependencies = [
      "serde",
      "serde_json",
      "stellar-xdr 22.1.0",
    - "syn 2.0.104",
    -]
    -
    -[[package]]
    -name = "soroban-env-macros"
    -version = "23.0.1"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c"
    -dependencies = [
    - "itertools",
    - "proc-macro2",
    - "quote",
    - "serde",
    - "serde_json",
    - "stellar-xdr 23.0.0",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "soroban-ledger-snapshot"
    -version = "22.0.7"
    +version = "22.0.11"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "80bbe59497cb50e81861187e6bd2a2c805df253573d44ed56e7d373f79530758"
    +checksum = "c30035cf1e8f02f65de3e594b6da113ecdaf1cd134d8480961d62568bb15adaf"
     dependencies = [
      "serde",
      "serde_json",
      "serde_with",
    - "soroban-env-common 22.1.3",
    - "soroban-env-host 22.1.3",
    - "thiserror 1.0.69",
    + "soroban-env-common",
    + "soroban-env-host",
    + "thiserror",
     ]
     
     [[package]]
    @@ -1478,125 +1564,59 @@ dependencies = [
      "rustc_version",
      "serde",
      "serde_json",
    - "soroban-env-guest 22.1.3",
    - "soroban-env-host 22.1.3",
    + "soroban-env-guest",
    + "soroban-env-host",
      "soroban-ledger-snapshot",
    - "soroban-sdk-macros 22.0.7",
    + "soroban-sdk-macros",
      "stellar-strkey 0.0.9",
     ]
     
    -[[package]]
    -name = "soroban-sdk"
    -version = "23.0.2"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "af0e5bf6702f5952d78c5b2bcd05b0349f9a570cc62028d90dac3710b40cbe65"
    -dependencies = [
    - "bytes-lit",
    - "crate-git-revision",
    - "rand",
    - "rustc_version",
    - "serde",
    - "serde_json",
    - "soroban-env-guest 23.0.1",
    - "soroban-env-host 23.0.1",
    - "soroban-sdk-macros 23.0.2",
    - "stellar-strkey 0.0.13",
    -]
    -
     [[package]]
     name = "soroban-sdk-macros"
    -version = "22.0.7"
    +version = "22.0.11"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "a141230aa65006d4b3eeee9d0589172d734a2abfbe15b84670e38e76e200b370"
    +checksum = "42b205cd86b34d530db87667bd287fbb194166d79b368227fd842110a914fde8"
     dependencies = [
      "crate-git-revision",
    - "darling",
    + "darling 0.20.11",
      "itertools",
      "proc-macro2",
      "quote",
      "rustc_version",
    - "sha2",
    - "soroban-env-common 22.1.3",
    - "soroban-spec 22.0.7",
    - "soroban-spec-rust 22.0.7",
    + "sha2 0.10.9",
    + "soroban-env-common",
    + "soroban-spec",
    + "soroban-spec-rust",
      "stellar-xdr 22.1.0",
    - "syn 2.0.104",
    -]
    -
    -[[package]]
    -name = "soroban-sdk-macros"
    -version = "23.0.2"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920"
    -dependencies = [
    - "darling",
    - "heck",
    - "itertools",
    - "macro-string",
    - "proc-macro2",
    - "quote",
    - "sha2",
    - "soroban-env-common 23.0.1",
    - "soroban-spec 23.0.2",
    - "soroban-spec-rust 23.0.2",
    - "stellar-xdr 23.0.0",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "soroban-spec"
    -version = "22.0.7"
    +version = "22.0.11"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "b54326e9516b33be99c701b37242b27efb8e66cc1f1eff994b9d9a013a4be136"
    +checksum = "cb6a16f2de28852c759f4da5f28cda54ec0d8dfa4c0e6e8cb3495234a72b0cea"
     dependencies = [
      "base64 0.13.1",
      "stellar-xdr 22.1.0",
    - "thiserror 1.0.69",
    - "wasmparser",
    -]
    -
    -[[package]]
    -name = "soroban-spec"
    -version = "23.0.2"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "72526d30f8825b859afa7e0b94549dad05c58a6c928b0763620412744512d7e2"
    -dependencies = [
    - "base64 0.22.1",
    - "stellar-xdr 23.0.0",
    - "thiserror 1.0.69",
    + "thiserror",
      "wasmparser",
     ]
     
     [[package]]
     name = "soroban-spec-rust"
    -version = "22.0.7"
    +version = "22.0.11"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "f009cab4dfd653bc94a06c5022f1ca9d30e198b0e451f84cf307231563d11de2"
    +checksum = "cdc6db5902ab21290dddf63fec4ee95703fe59891a947646e7b8607536f043fc"
     dependencies = [
      "prettyplease",
      "proc-macro2",
      "quote",
    - "sha2",
    - "soroban-spec 22.0.7",
    + "sha2 0.10.9",
    + "soroban-spec",
      "stellar-xdr 22.1.0",
    - "syn 2.0.104",
    - "thiserror 1.0.69",
    -]
    -
    -[[package]]
    -name = "soroban-spec-rust"
    -version = "23.0.2"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "9088cb8307dad026cda494971c4f13c76f9427ab26becb7cd691da95dc5e9b1d"
    -dependencies = [
    - "prettyplease",
    - "proc-macro2",
    - "quote",
    - "sha2",
    - "soroban-spec 23.0.2",
    - "stellar-xdr 23.0.0",
    - "syn 2.0.104",
    - "thiserror 1.0.69",
    + "syn 2.0.118",
    + "thiserror",
     ]
     
     [[package]]
    @@ -1642,7 +1662,7 @@ checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd"
     dependencies = [
      "base32",
      "crate-git-revision",
    - "thiserror 1.0.69",
    + "thiserror",
     ]
     
     [[package]]
    @@ -1653,7 +1673,7 @@ checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144"
     dependencies = [
      "crate-git-revision",
      "data-encoding",
    - "thiserror 1.0.69",
    + "thiserror",
     ]
     
     [[package]]
    @@ -1683,9 +1703,9 @@ dependencies = [
     
     [[package]]
     name = "stellar-xdr"
    -version = "23.0.0"
    +version = "27.0.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b"
    +checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a"
     dependencies = [
      "base64 0.22.1",
      "cfg_eval",
    @@ -1695,7 +1715,7 @@ dependencies = [
      "hex",
      "serde",
      "serde_with",
    - "sha2",
    + "sha2 0.10.9",
      "stellar-strkey 0.0.13",
     ]
     
    @@ -1724,9 +1744,9 @@ dependencies = [
     
     [[package]]
     name = "syn"
    -version = "2.0.104"
    +version = "2.0.118"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
    +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
     dependencies = [
      "proc-macro2",
      "quote",
    @@ -1739,16 +1759,7 @@ version = "1.0.69"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
     dependencies = [
    - "thiserror-impl 1.0.69",
    -]
    -
    -[[package]]
    -name = "thiserror"
    -version = "2.0.8"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a"
    -dependencies = [
    - "thiserror-impl 2.0.8",
    + "thiserror-impl",
     ]
     
     [[package]]
    @@ -1759,56 +1770,59 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    -]
    -
    -[[package]]
    -name = "thiserror-impl"
    -version = "2.0.8"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943"
    -dependencies = [
    - "proc-macro2",
    - "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "time"
    -version = "0.3.37"
    +version = "0.3.53"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
    +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
     dependencies = [
      "deranged",
    - "itoa",
      "num-conv",
      "powerfmt",
    - "serde",
    + "serde_core",
      "time-core",
      "time-macros",
     ]
     
     [[package]]
     name = "time-core"
    -version = "0.1.2"
    +version = "0.1.9"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
    +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
     
     [[package]]
     name = "time-macros"
    -version = "0.2.19"
    +version = "0.2.31"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
    +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
     dependencies = [
      "num-conv",
      "time-core",
     ]
     
    +[[package]]
    +name = "tinyvec"
    +version = "1.12.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
    +dependencies = [
    + "tinyvec_macros",
    +]
    +
    +[[package]]
    +name = "tinyvec_macros"
    +version = "0.1.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
    +
     [[package]]
     name = "typenum"
    -version = "1.17.0"
    +version = "1.20.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
    +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
     
     [[package]]
     name = "ucd-trie"
    @@ -1818,9 +1832,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
     
     [[package]]
     name = "unicode-ident"
    -version = "1.0.14"
    +version = "1.0.24"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
    +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
     
     [[package]]
     name = "version_check"
    @@ -1830,40 +1844,28 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
     
     [[package]]
     name = "wasi"
    -version = "0.11.0+wasi-snapshot-preview1"
    +version = "0.11.1+wasi-snapshot-preview1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
    +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
     
     [[package]]
     name = "wasm-bindgen"
    -version = "0.2.99"
    +version = "0.2.126"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396"
    +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
     dependencies = [
    - "cfg-if 1.0.0",
    + "cfg-if 1.0.4",
      "once_cell",
    + "rustversion",
      "wasm-bindgen-macro",
    -]
    -
    -[[package]]
    -name = "wasm-bindgen-backend"
    -version = "0.2.99"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79"
    -dependencies = [
    - "bumpalo",
    - "log",
    - "proc-macro2",
    - "quote",
    - "syn 2.0.104",
      "wasm-bindgen-shared",
     ]
     
     [[package]]
     name = "wasm-bindgen-macro"
    -version = "0.2.99"
    +version = "0.2.126"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe"
    +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
     dependencies = [
      "quote",
      "wasm-bindgen-macro-support",
    @@ -1871,22 +1873,25 @@ dependencies = [
     
     [[package]]
     name = "wasm-bindgen-macro-support"
    -version = "0.2.99"
    +version = "0.2.126"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
    +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
     dependencies = [
    + "bumpalo",
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    - "wasm-bindgen-backend",
    + "syn 2.0.118",
      "wasm-bindgen-shared",
     ]
     
     [[package]]
     name = "wasm-bindgen-shared"
    -version = "0.2.99"
    +version = "0.2.126"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
    +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
    +dependencies = [
    + "unicode-ident",
    +]
     
     [[package]]
     name = "wasmi_arena"
    @@ -1912,7 +1917,7 @@ version = "0.116.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50"
     dependencies = [
    - "indexmap 2.7.0",
    + "indexmap 2.14.0",
      "semver",
     ]
     
    @@ -1961,76 +1966,62 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
     
     [[package]]
     name = "windows-core"
    -version = "0.52.0"
    +version = "0.62.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
    +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
     dependencies = [
    - "windows-targets",
    + "windows-implement",
    + "windows-interface",
    + "windows-link",
    + "windows-result",
    + "windows-strings",
     ]
     
     [[package]]
    -name = "windows-targets"
    -version = "0.52.6"
    +name = "windows-implement"
    +version = "0.60.2"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
    +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
     dependencies = [
    - "windows_aarch64_gnullvm",
    - "windows_aarch64_msvc",
    - "windows_i686_gnu",
    - "windows_i686_gnullvm",
    - "windows_i686_msvc",
    - "windows_x86_64_gnu",
    - "windows_x86_64_gnullvm",
    - "windows_x86_64_msvc",
    + "proc-macro2",
    + "quote",
    + "syn 2.0.118",
     ]
     
     [[package]]
    -name = "windows_aarch64_gnullvm"
    -version = "0.52.6"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
    -
    -[[package]]
    -name = "windows_aarch64_msvc"
    -version = "0.52.6"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
    -
    -[[package]]
    -name = "windows_i686_gnu"
    -version = "0.52.6"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
    -
    -[[package]]
    -name = "windows_i686_gnullvm"
    -version = "0.52.6"
    -source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
    -
    -[[package]]
    -name = "windows_i686_msvc"
    -version = "0.52.6"
    +name = "windows-interface"
    +version = "0.59.3"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
    +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "syn 2.0.118",
    +]
     
     [[package]]
    -name = "windows_x86_64_gnu"
    -version = "0.52.6"
    +name = "windows-link"
    +version = "0.2.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
    +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
     
     [[package]]
    -name = "windows_x86_64_gnullvm"
    -version = "0.52.6"
    +name = "windows-result"
    +version = "0.4.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
    +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
    +dependencies = [
    + "windows-link",
    +]
     
     [[package]]
    -name = "windows_x86_64_msvc"
    -version = "0.52.6"
    +name = "windows-strings"
    +version = "0.5.1"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
    +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
    +dependencies = [
    + "windows-link",
    +]
     
     [[package]]
     name = "zephyr-macros"
    @@ -2039,66 +2030,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
     checksum = "79b82b4e5e981bb7345e0ae5e55230e21323df21539ac9e11aa9efe8cca0bbd9"
     dependencies = [
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "zephyr-sdk"
     version = "0.2.5"
    -source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=7e9e458#7e9e4580cb5918db9f088126c89e496c09b6c649"
    +source = "git+https://github.com/xycloo/rs-zephyr-toolkit?rev=3b832840b25289af243326fc3be8a29108891ec2#3b832840b25289af243326fc3be8a29108891ec2"
     dependencies = [
      "bincode",
      "charming-fork-zephyr",
    - "ed25519-dalek",
    + "ed25519-dalek 2.2.0",
      "rs-zephyr-common",
      "serde",
      "serde_json",
    - "sha2",
    - "soroban-sdk 22.0.7",
    + "sha2 0.10.9",
    + "soroban-sdk",
      "stellar-strkey 0.0.8",
      "stellar-xdr 22.1.0",
    - "thiserror 1.0.69",
    + "thiserror",
      "wee_alloc",
      "zephyr-macros",
     ]
     
     [[package]]
     name = "zerocopy"
    -version = "0.7.35"
    +version = "0.8.54"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
    +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
     dependencies = [
    - "byteorder",
      "zerocopy-derive",
     ]
     
     [[package]]
     name = "zerocopy-derive"
    -version = "0.7.35"
    +version = "0.8.54"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
    +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
     
     [[package]]
     name = "zeroize"
    -version = "1.8.1"
    +version = "1.9.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
    +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
     dependencies = [
      "zeroize_derive",
     ]
     
     [[package]]
     name = "zeroize_derive"
    -version = "1.4.2"
    +version = "1.5.0"
     source = "registry+https://github.com/rust-lang/crates.io-index"
    -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
    +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
     dependencies = [
      "proc-macro2",
      "quote",
    - "syn 2.0.104",
    + "syn 2.0.118",
     ]
    +
    +[[package]]
    +name = "zmij"
    +version = "1.0.21"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
    diff --git a/zephyr/Cargo.toml b/zephyr/Cargo.toml
    index ea06232..a30849b 100644
    --- a/zephyr/Cargo.toml
    +++ b/zephyr/Cargo.toml
    @@ -1,19 +1,35 @@
     [package]
    -name = "smart-wallets-next-dima"
    -version = "0.0.0"
    +name = "passkey-kit-indexer"
    +version = "1.0.0"
     edition = "2021"
    +publish = false
     
     [lib]
     crate-type = ["cdylib"]
     
     [dependencies]
    -zephyr-sdk = { version = "0.2.4", git="https://github.com/xycloo/rs-zephyr-toolkit", rev="7e9e458" }
    +# Current (Protocol 27) Zephyr SDK. The `stellar-main-2` branch is upstream's
    +# live default branch; it pins a single soroban-sdk (22.x) internally, which is
    +# the ONLY soroban-sdk in this program's tree (see README "Toolchain & F1").
    +zephyr-sdk = { git = "https://github.com/xycloo/rs-zephyr-toolkit", rev = "3b832840b25289af243326fc3be8a29108891ec2" }
    +
    +# Pinned to EXACTLY the soroban-sdk that zephyr-sdk resolves, so Cargo unifies
    +# to a single crate instance (this is the ONLY soroban-sdk in the tree — we do
    +# NOT depend on `smart-wallet-interface`/soroban-sdk 27, which caused the
    +# dual-soroban-sdk build failure, audit F1). Used only for the local
    +# `#[contracttype]` mirror types that decode the wallet's ScVal events/keys.
    +#
    +# `=22.0.7` is required: 22.0.7 gates its wasm `#[panic_handler]` behind
    +# `not(feature = "alloc")` (and zephyr-sdk enables `alloc`), so it emits no
    +# panic handler and doesn't clash with `std` (which stellar-xdr pulls in). 22.0.8+
    +# made the panic handler unconditional on wasm, which duplicates `std`'s
    +# `panic_impl` lang item and fails the build. See zephyr/README "Toolchain & F1".
    +soroban-sdk = { version = "=22.0.7", features = ["alloc"] }
    +
     stellar-strkey = "0.0.13"
     serde = { version = "1.0", features = ["derive"] }
    -serde_json = "1.0"
    -smart-wallet-interface = { path = "../contracts/smart-wallet-interface" }
    -hex = "0.4.3"
     base64 = "0.22"
    +hex = "0.4.3"
     
     [profile.release]
     opt-level = "z"
    diff --git a/zephyr/README.md b/zephyr/README.md
    new file mode 100644
    index 0000000..d4d66e7
    --- /dev/null
    +++ b/zephyr/README.md
    @@ -0,0 +1,231 @@
    +# passkey-kit Mercury (Zephyr) indexer
    +
    +Mercury Zephyr program that indexes passkey-kit **v1** smart-wallet signers for
    +two query patterns:
    +
    +- **Enumerate a wallet's signers** — `get_signers_by_address(address)`.
    +- **Reverse lookup (keyId → wallets)** — `get_addresses_by_signer(key, kind)`.
    +
    +It is the `MercuryIndexer` backend behind the SDK's `SignerIndexer`
    +abstraction; the Stellar Indexer backend is the interchangeable second one.
    +
    +This is a ground-up rewrite (overhaul v1). It consumes the reworked contract's
    +`#[contractevent]` event schema, indexes on a single soroban-sdk, enforces a
    +WASM-hash allowlist on ingestion, and uses consistent UNIX-timestamp expiration
    +semantics. See "Audit fixes" below.
    +
    +---
    +
    +## Build
    +
    +```sh
    +cd zephyr
    +cargo build --release --target wasm32-unknown-unknown
    +# or, equivalently, the Mercury wrapper:
    +mercury-cli build
    +```
    +
    +Artifact: `target/wasm32-unknown-unknown/release/passkey_kit_indexer.wasm`.
    +
    +### Toolchain & the dual-soroban-sdk trap (audit F1)
    +
    +The legacy program did not build: it pulled **two** soroban-sdks — one from the
    +old `zephyr-sdk` (22.x) and one from `smart-wallet-interface` (23.x) — whose
    +types don't unify, plus a stale rustc pin. This rewrite fixes it structurally:
    +
    +- **No `smart-wallet-interface` dependency.** The event/signer types are
    +  mirrored locally in `src/wallet.rs` as `#[contracttype]` definitions on THIS
    +  crate's soroban-sdk. The ScVal enum/struct wire encoding is protocol-stable
    +  across soroban-sdk versions, so the mirrors decode the contract's
    +  soroban-sdk-27-emitted events byte-for-byte while keeping the indexer
    +  decoupled from the contract crate's toolchain.
    +- **Exactly one soroban-sdk in the tree**, pinned `=22.0.7` to match what the
    +  current `zephyr-sdk` resolves. `22.0.7` is required, not incidental:
    +  - `22.0.7` gates its wasm `#[panic_handler]` behind
    +    `cfg(all(not(feature = "alloc"), target_family = "wasm"))`. `zephyr-sdk`
    +    enables the `alloc` feature, so soroban-sdk emits **no** panic handler.
    +  - `22.0.8+` made the panic handler unconditional on wasm. Because the current
    +    `zephyr-sdk` graph links `std` (via `rs-zephyr-common`'s `stellar-xdr 27`),
    +    that duplicates `std`'s `panic_impl` lang item and the build fails with
    +    `E0152: found duplicate lang item`.
    +- **`zephyr-sdk` is pinned to the `stellar-main-2` branch** (the current, P27
    +  default branch), rev `3b832840…`. `Cargo.toml` records the exact rev.
    +- **rustc** is pinned to `1.93.0` (`rust-toolchain.toml`). The old
    +  `stable-2024-09-05` (rustc 1.81) predates `stellar-xdr 27`, which
    +  `rs-zephyr-common` now pulls in.
    +
    +> Note on "soroban-sdk 27": the overhaul plan targeted soroban-sdk 27 here, but
    +> upstream `zephyr-sdk` (even on `stellar-main-2`) is still built on soroban-sdk
    +> 22.x — no soroban-sdk-27 `zephyr-sdk` exists yet. The plan's real intent (F1)
    +> was "a single soroban-sdk that builds", which this achieves. Ledger-meta XDR
    +> parsing is handled by `zephyr-sdk` itself; the program only decodes ScVals,
    +> which are wire-stable.
    +
    +---
    +
    +## Events consumed (v1 `#[contractevent]` schema)
    +
    +The contract emits `#[contractevent]` structs (default `data_format = "map"`):
    +topic 0 is the snake_case struct name; `#[topic]` fields follow; the remaining
    +fields are an `ScVal::Map` keyed by field name.
    +
    +| Event | Topics | Data (`Map`) | Indexer action |
    +|---|---|---|---|
    +| `signer_added` | `[sym, SignerKey]` | `{ storage, val }` | upsert signer, `active=true` |
    +| `signer_updated` | `[sym, SignerKey]` | `{ old_storage, storage, val }` | upsert signer (rewrite in place) |
    +| `signer_removed` | `[sym, SignerKey]` | `{ storage }` | soft-delete (`active=false`) |
    +| `upgraded` | `[sym]` | `{ new_hash, old_hash }` | refresh wallet WASM hash |
    +
    +`SignerVal` carries `SignerExpiration(Option)` (UNIX seconds) and
    +`SignerLimits`; the secp256r1 variant also carries the 65-byte SEC-1 public key.
    +The mirror definitions live in `src/wallet.rs`.
    +
    +---
    +
    +## Storage schema
    +
    +`signers` — one row per `(wallet address, signer key)`:
    +
    +| Column | Type | Notes |
    +|---|---|---|
    +| `address` | BYTEA (indexed) | wallet `ScVal::Address` XDR |
    +| `key` | BYTEA (indexed) | `SignerKey` ScVal XDR |
    +| `val` | BYTEA | secp256r1 pubkey `ScVal::Bytes(65)`, else `ScVal::Void` |
    +| `limits` | BYTEA | `SignerLimits` ScVal XDR |
    +| `exp` | BIGINT | UNIX seconds, or `i64::MAX` = never |
    +| `storage` | BYTEA | `SignerStorage` ScVal XDR |
    +| `active` | BYTEA | native `bool` (bincode) — live/removed |
    +
    +`wallets` — trusted wallet set (ingestion gate + latest known WASM hash):
    +
    +| Column | Type | Notes |
    +|---|---|---|
    +| `address` | BYTEA (indexed) | wallet `ScVal::Address` XDR |
    +| `wasm_hash` | BYTEA | `ScVal::Bytes(32)` of the instance executable |
    +
    +There is no row-delete host op, so removals are a soft-delete flag and expired
    +rows are never pruned on-chain (they are flagged, not deleted). The `wallets`
    +table doubles as the WASM-hash allowlist gate.
    +
    +---
    +
    +## WASM-hash allowlist (audit F3)
    +
    +Any contract can emit well-formed `signer_added` events; without a gate an
    +attacker could inject rows and poison the reverse lookup. On each ledger close
    +the program scans **successful** contract-instance ledger entries and records a
    +wallet as trusted only when its instance executable hash is a known passkey-kit
    +WASM (`src/wallet.rs::ALLOWLISTED_WASM_HASHES` — the FINAL post-audit-gate v1
    +hash `84924c53…` + legacy `e45c42b9…`, sourced from
    +`docs/deployments-testnet-2026-07-11.md` — never rebuilt locally). Signer events
    +are ingested only from wallets in the trusted set. The SDK still verifies
    +ownership client-side (audit F7) as defence in depth.
    +
    +---
    +
    +## Expiration semantics (audit F4/F5)
    +
    +`SignerExpiration` is a UNIX timestamp in seconds, **inclusive**: a signer is
    +valid while `ledger_timestamp <= exp` and expired once `ledger_timestamp > exp`.
    +Both read functions apply the identical rule and **return** expired signers with
    +an `expired` flag rather than silently filtering them (the old code filtered
    +expiration in one function and ignored it in the other, using an off-by-one
    +ledger-sequence comparison). Removed signers are omitted (soft-deleted); surface
    +removals via the Stellar Indexer tombstone backend.
    +
    +The unauthenticated `debug_signers` full-table dump was removed.
    +
    +---
    +
    +## Function contracts
    +
    +Invoke via `POST {mercuryBackend}/zephyr/execute` with the function name and a
    +JSON body. Responses are JSON.
    +
    +### `get_signers_by_address`
    +
    +Request: `{ "address": "C..." }`
    +
    +Response: `SignerResponse[]`
    +
    +```jsonc
    +{
    +  "kind": "Policy" | "Ed25519" | "Secp256r1",
    +  "key":  "C… | G… | base64url(keyId)",   // by kind
    +  "val":  "base64url(65-byte pubkey)" | null, // secp256r1 only
    +  "expiration": 1752192000 | null,        // UNIX seconds; null = never
    +  "expired": false,                        // now > expiration
    +  "storage": "Persistent" | "Temporary",
    +  "limits": "base64(SignerLimits XDR)"
    +}
    +```
    +
    +### `get_addresses_by_signer`
    +
    +Request: `{ "key": "…", "kind": "Policy" | "Ed25519" | "Secp256r1" }`
    +(`key` encoding matches the `key` field above for that kind.)
    +
    +Response: `WalletMatch[]`
    +
    +```jsonc
    +{
    +  "address": "C…",
    +  "expiration": 1752192000 | null,
    +  "expired": false,
    +  "storage": "Persistent" | "Temporary"
    +}
    +```
    +
    +The SDK's `findWallets` maps this to a plain address list; the expiration flag
    +is exposed for parity with `get_signers_by_address`.
    +
    +---
    +
    +## Deploy (Mercury) — tooling status (audit F2 / F2b, re-verified 2026-07-12)
    +
    +**The published self-serve standard-Zephyr flow this program targets is RETIRED
    +on Mercury's current backend. `passkey_kit_indexer.wasm` builds clean but cannot
    +be deployed/queried via public tooling today.** Live read-only probes:
    +
    +- `POST …/zephyr/execute` (the serverless-function query path) → **404 even with
    +  a valid JWT** (the JWT is good: `POST …/rest/retroshade/query {"query":"SELECT 1"}`
    +  → `200 [{"?column?":1}]`). Standard-Zephyr `execute` is gone.
    +- Old deploy routes `/zephyr_upload`, `/zephyr_table_new` → **404**. Published
    +  `mercury-cli 0.2.1` (crates.io, latest) hardcodes these and is dead (it still
    +  prints `Successfully deployed` after the 404s — its exit code is NOT
    +  trustworthy).
    +- The current backend is under `…/rest` (testnet `https://testnet.mercurydata.app/rest`
    +  == `https://api.mercurydata.app/rest`; mainnet `https://mainnet.mercurydata.app/rest`).
    +  The live self-serve model is **Retroshades** (`POST /rest/retroshade/deploy`,
    +  query via `POST /rest/retroshade/query` SQL) — a different programming model
    +  than this program (`on_close` + `DatabaseDerive` tables + `env.conclude`
    +  serverless functions).
    +- The `--base`-capable `mercury-cli` (`mercury-cli --base …/rest deploy …`) is
    +  **source-built and not in any public repo** (the `MercuryTechnologies/mercury-cli`
    +  on GitHub is Mercury's unrelated *banking* CLI). Obtaining it needs Mercury.
    +
    +### Mercury now hosts a Smart Account Indexer (but not passkey-kit — yet)
    +
    +`GET {base}/rest/smart-account-indexer` is a free, no-auth, hosted reverse-lookup
    +service on both networks: `GET /api/lookup/:credentialId` (passkey keyId →
    +wallets), `GET /api/lookup/address/:address` (Ed25519/Policy signer → wallets),
    +`GET /api/contract/:contractId` (a wallet's signers). **It indexes OpenZeppelin
    +smart-account-kit, NOT passkey-kit** — a real passkey-kit wallet/signer returns
    +`count:0` (verified). passkey-kit's `signer_added/updated/removed/upgraded`
    +events are a different schema/wasm.
    +
    +### How to get passkey-kit indexed on Mercury (all paths are USER / Mercury-gated)
    +
    +1. **Preferred — ask Mercury to add passkey-kit to the hosted Smart Account
    +   Indexer** (same product they already run). Give them the v1 wasm hash
    +   `84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319` (+ legacy
    +   `e45c42b9…`). Then NO deploy — the SDK just queries the `/api/lookup/*` routes
    +   above. Contact: xyclooLabs Discord / Telegram.
    +2. **Self-host** via the current `--base` CLI (from Mercury) or the dashboard
    +   (`test.mercurydata.app/custom-ingestion`, browser login) — likely requires
    +   migrating this program to Retroshades (SQL query) and reconciling the SDK's
    +   `mercury.ts` accordingly.
    +
    +Secrets rule: Mercury JWTs come from the repo-root `.env` (`MERCURY_TESTNET_JWT`
    +/ `MERCURY_MAINNET_JWT`) via shell substitution only — never printed or
    +committed. Full research + probe evidence: Solo todo 967 comment.
    diff --git a/zephyr/rust-toolchain.toml b/zephyr/rust-toolchain.toml
    index 74a50d8..0aaad0e 100644
    --- a/zephyr/rust-toolchain.toml
    +++ b/zephyr/rust-toolchain.toml
    @@ -1,4 +1,8 @@
    +# Modern stable toolchain that builds the current (P27) zephyr-sdk graph. The
    +# old pin (stable-2024-09-05 / rustc 1.81) predates stellar-xdr 27, which
    +# rs-zephyr-common now pulls in. `wasm32-unknown-unknown` is the Zephyr VM
    +# target (built with the +multivalue / stack-size flags in .cargo/config.toml).
     [toolchain]
    -channel = "stable-2024-09-05"
    +channel = "1.93.0"
     targets = ["wasm32-unknown-unknown"]
    -components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"]
    \ No newline at end of file
    +components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"]
    diff --git a/zephyr/src/lib.rs b/zephyr/src/lib.rs
    index 8a05564..6488cde 100644
    --- a/zephyr/src/lib.rs
    +++ b/zephyr/src/lib.rs
    @@ -1,104 +1,82 @@
    +//! passkey-kit Mercury (Zephyr) indexer.
    +//!
    +//! Ingests the v1 smart-wallet `#[contractevent]` events (`signer_added`,
    +//! `signer_updated`, `signer_removed`, `upgraded`) into two tables and exposes
    +//! two serverless read functions:
    +//!
    +//! - `get_signers_by_address(address)` — enumerate a wallet's signers.
    +//! - `get_addresses_by_signer(key, kind)` — reverse lookup (keyId -> wallets).
    +//!
    +//! Design notes (see `zephyr/README.md` for the full story):
    +//! - Single soroban-sdk (22.x, via zephyr-sdk); NO `smart-wallet-interface`
    +//!   dependency — the mirror types in `wallet.rs` decode the events instead.
    +//!   This kills the dual-soroban-sdk build failure (audit F1).
    +//! - WASM-hash allowlist gate on ingestion: only events from contracts whose
    +//!   on-chain instance executable is a known passkey-kit wallet are ingested
    +//!   (audit F3).
    +//! - Expiration is a UNIX timestamp, inclusive (`now <= exp`); it is stored and
    +//!   returned with an `expired` flag rather than filtered, and BOTH read
    +//!   functions apply the identical semantic (audit F4/F5).
    +//! - `debug_signers` (an unauthenticated full-table dump) is gone.
    +
     use base64::{
    -    engine::general_purpose::URL_SAFE, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _,
    +    engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
    +    Engine as _,
     };
     use serde::{Deserialize, Serialize};
    -use smart_wallet_interface::types::{
    -    SignerExpiration, SignerKey, SignerLimits, SignerStorage, SignerVal,
    +use soroban_sdk::{
    +    xdr::{
    +        ContractExecutable, Hash, LedgerEntryData, Limits, ScAddress, ScSymbol, ScVal, WriteXdr,
    +    },
    +    Address, Bytes, BytesN,
     };
     use stellar_strkey::{ed25519, Strkey};
    -use types::{
    -    Signers, SignersActive, SignersAddress, SignersKeyValLimitsExpStorage,
    -    SignersValLimitsExpStorageActive,
    -};
     use zephyr_sdk::{
    -    soroban_sdk::{
    -        self, symbol_short,
    -        xdr::{Hash, ScAddress, ScVal, ToXdr},
    -        Address, Bytes, BytesN, Symbol,
    -    },
    +    bincode,
         utils::{address_from_str, address_to_alloc_string},
         EnvClient,
     };
     
     mod types;
    +mod wallet;
     
    -const SW_V1: Symbol = symbol_short!("sw_v1");
    -const ADD: Symbol = symbol_short!("add");
    -const UPDATE: Symbol = symbol_short!("update");
    -const REMOVE: Symbol = symbol_short!("remove");
    +use types::{
    +    Signer, SignerActiveMutation, SignerKeyOnly, SignerMutation, SignerReadRow, Wallet,
    +    WalletAddrOnly, WalletHashMutation, WalletMatchRow, EXP_NEVER,
    +};
    +use wallet::{
    +    is_allowlisted, SignerAddedData, SignerKey, SignerRemovedData, SignerStorage, SignerUpdatedData,
    +    SignerVal, UpgradedData,
    +};
    +
    +// ---------------------------------------------------------------------------
    +// Ingestion
    +// ---------------------------------------------------------------------------
     
     #[no_mangle]
     pub extern "C" fn on_close() {
         let env = EnvClient::new();
     
    -    for event in env.reader().pretty().soroban_events() {
    -        if let Some(topic0) = event.topics.get(0) {
    -            if let Ok(t0) = env.try_from_scval::(topic0) {
    -                if t0 == SW_V1 {
    -                    if let Some(topic1) = event.topics.get(1) {
    -                        if let Ok(t1) = env.try_from_scval::(topic1) {
    -                            if let Some(key) = event.topics.get(2) {
    -                                let address =
    -                                    ScVal::Address(ScAddress::Contract(Hash::from(event.contract)));
    -
    -                                if t1 == ADD || t1 == UPDATE {
    -                                    let mut older: Vec = env
    -                                        .read_filter()
    -                                        .column_equal_to_xdr("address", &address)
    -                                        .column_equal_to_xdr("key", key)
    -                                        .read()
    -                                        .unwrap();
    -
    -                                    let (signer_val, signer_storage): (SignerVal, SignerStorage) =
    -                                        env.from_scval(&event.data);
    -
    -                                    if let Some(older) = older.get_mut(0) {
    -                                        let (public_key, signer_expiration, signer_limits) =
    -                                            get_signer_expiration_limits(signer_val);
    -
    -                                        older.val = env.to_scval(public_key);
    -                                        older.exp = signer_expiration.0.unwrap_or(u32::MAX);
    -                                        older.limits = env.to_scval(signer_limits);
    -                                        older.storage = env.to_scval(signer_storage);
    -                                        older.active = ScVal::Bool(true);
    -
    -                                        env.update()
    -                                            .column_equal_to_xdr("address", &address)
    -                                            .column_equal_to_xdr("key", key)
    -                                            .execute(older)
    -                                            .unwrap();
    -                                    } else {
    -                                        let (public_key, signer_expiration, signer_limits) =
    -                                            get_signer_expiration_limits(signer_val);
    -                                        let signer = Signers {
    -                                            address,
    -                                            key: key.clone(),
    -                                            val: env.to_scval(public_key),
    -                                            limits: env.to_scval(signer_limits),
    -                                            exp: signer_expiration.0.unwrap_or(u32::MAX),
    -                                            storage: env.to_scval(signer_storage),
    -                                            active: ScVal::Bool(true),
    -                                        };
    -
    -                                        env.put(&signer);
    -                                    }
    -                                } else if t1 == REMOVE {
    -                                    let mut older: Vec = env
    -                                        .read_filter()
    -                                        .column_equal_to_xdr("address", &address)
    -                                        .column_equal_to_xdr("key", key)
    -                                        .read()
    -                                        .unwrap();
    -
    -                                    if let Some(older) = older.get_mut(0) {
    -                                        older.active = ScVal::Bool(false);
    -
    -                                        env.update()
    -                                            .column_equal_to_xdr("address", &address)
    -                                            .column_equal_to_xdr("key", key)
    -                                            .execute(older)
    -                                            .unwrap();
    -                                    }
    +    // Pass 1 — refresh the trusted-wallet set from this close's contract
    +    // instances. A wallet is trusted iff an allowlisted smart-wallet WASM was
    +    // observed as its instance executable (audit F3). `trusted` also serves as
    +    // an in-memory cache so same-close events (e.g. the constructor's
    +    // `signer_added`) are accepted before the `wallets` write is committed.
    +    let mut trusted: Vec<[u8; 32]> = Vec::new();
    +    let entries = env.reader().v1_success_ledger_entries();
    +    for entry in entries.created.iter().chain(entries.updated.iter()) {
    +        if let LedgerEntryData::ContractData(cd) = &entry.data {
    +            if let ScVal::LedgerKeyContractInstance = cd.key {
    +                if let ScVal::ContractInstance(instance) = &cd.val {
    +                    if let ContractExecutable::Wasm(Hash(hash)) = &instance.executable {
    +                        if is_allowlisted(hash) {
    +                            if let ScAddress::Contract(Hash(id)) = &cd.contract {
    +                                let address = ScVal::Address(ScAddress::Contract(Hash(*id)));
    +                                let wasm_hash =
    +                                    env.to_scval(BytesN::from_array(env.soroban(), hash));
    +                                upsert_wallet(&env, address, wasm_hash);
    +                                if !trusted.contains(id) {
    +                                    trusted.push(*id);
                                     }
                                 }
                             }
    @@ -107,106 +85,352 @@ pub extern "C" fn on_close() {
                 }
             }
         }
    +
    +    // Pass 2 — fold signer events from trusted wallets into one pending state
    +    // per (address, key). Event names are matched as raw `ScVal::Symbol` values
    +    // (deterministic structural byte comparison) rather than via soroban
    +    // `Symbol` equality, which for >9-char symbols routes through env object
    +    // comparison.
    +    //
    +    // Folding is load-bearing (review M4): host db writes commit only AFTER
    +    // `on_close`, so a db_read inside this loop cannot observe this close's own
    +    // writes. Handling each event with its own read+write would let two
    +    // lifecycle events on the same key in one ledger corrupt the index — an
    +    // add+remove would leave the signer `active=true` forever, and two updates
    +    // would insert duplicate rows. Collapsing to a single write per key (in the
    +    // flush pass below) fixes both. This mirrors the in-memory `trusted` cache
    +    // the wallets table already uses for the same reason.
    +    let sym_added = sym_scval("signer_added");
    +    let sym_updated = sym_scval("signer_updated");
    +    let sym_removed = sym_scval("signer_removed");
    +    let sym_upgraded = sym_scval("upgraded");
    +
    +    let mut pending: Vec = Vec::new();
    +
    +    for event in env.reader().pretty().soroban_events() {
    +        let topic0 = match event.topics.get(0) {
    +            Some(t) => t,
    +            None => continue,
    +        };
    +        if topic0 != &sym_added
    +            && topic0 != &sym_updated
    +            && topic0 != &sym_removed
    +            && topic0 != &sym_upgraded
    +        {
    +            continue;
    +        }
    +
    +        // Trust gate: this-close instance set, or a previously-recorded wallet.
    +        let contract_id = event.contract;
    +        let address = ScVal::Address(ScAddress::Contract(Hash(contract_id)));
    +        let is_trusted = trusted.contains(&contract_id) || wallet_is_trusted(&env, &address);
    +        if !is_trusted {
    +            continue;
    +        }
    +        if !trusted.contains(&contract_id) {
    +            trusted.push(contract_id);
    +        }
    +
    +        if topic0 == &sym_added {
    +            let key = match event.topics.get(1) {
    +                Some(key) => key.clone(),
    +                None => continue,
    +            };
    +            if let Ok(data) = env.try_from_scval::(&event.data) {
    +                fold_upsert(&mut pending, &env, address, key, data.val, data.storage);
    +            }
    +        } else if topic0 == &sym_updated {
    +            let key = match event.topics.get(1) {
    +                Some(key) => key.clone(),
    +                None => continue,
    +            };
    +            // `old_storage` (the pre-update durability) is available on
    +            // `SignerUpdatedData` but needs no separate handling: the index
    +            // keeps one row per (wallet, key) and rewrites `storage` in place,
    +            // so a durability flip is captured by the new `storage` alone.
    +            if let Ok(data) = env.try_from_scval::(&event.data) {
    +                fold_upsert(&mut pending, &env, address, key, data.val, data.storage);
    +            }
    +        } else if topic0 == &sym_removed {
    +            let key = match event.topics.get(1) {
    +                Some(key) => key.clone(),
    +                None => continue,
    +            };
    +            // Decode to validate the v1 `signer_removed` data shape (Map{storage}).
    +            if env.try_from_scval::(&event.data).is_ok() {
    +                fold_remove(&mut pending, address, key);
    +            }
    +        } else {
    +            // `upgraded`: refresh the (already-trusted) wallet's WASM hash.
    +            if let Ok(data) = env.try_from_scval::(&event.data) {
    +                refresh_wallet_hash(&env, &address, env.to_scval(data.new_hash));
    +            }
    +        }
    +    }
    +
    +    // Pass 3 — flush each key's net state: one prior-ledger existence read and
    +    // one write per (address, key).
    +    for signer in pending {
    +        flush_signer(&env, signer);
    +    }
    +}
    +
    +// --- ingestion helpers ------------------------------------------------------
    +
    +/// Build an `ScVal::Symbol` for an event-name comparison (event topic 0).
    +fn sym_scval(name: &str) -> ScVal {
    +    ScVal::Symbol(ScSymbol(name.to_string().try_into().unwrap()))
    +}
    +
    +fn wallet_is_trusted(env: &EnvClient, address: &ScVal) -> bool {
    +    let rows: Vec = env
    +        .read_filter()
    +        .column_equal_to_xdr("address", address)
    +        .read()
    +        .unwrap();
    +    !rows.is_empty()
    +}
    +
    +fn upsert_wallet(env: &EnvClient, address: ScVal, wasm_hash: ScVal) {
    +    let existing: Vec = env
    +        .read_filter()
    +        .column_equal_to_xdr("address", &address)
    +        .read()
    +        .unwrap();
    +    if existing.is_empty() {
    +        env.put(&Wallet { address, wasm_hash });
    +    } else {
    +        env.update()
    +            .column_equal_to_xdr("address", &address)
    +            .execute(&WalletHashMutation { wasm_hash })
    +            .unwrap();
    +    }
    +}
    +
    +fn refresh_wallet_hash(env: &EnvClient, address: &ScVal, wasm_hash: ScVal) {
    +    env.update()
    +        .column_equal_to_xdr("address", address)
    +        .execute(&WalletHashMutation { wasm_hash })
    +        .unwrap();
    +}
    +
    +/// The folded net state of one (address, key) signer across a single ledger
    +/// close (review M4). Written exactly once, in `flush_signer`.
    +struct PendingSigner {
    +    address: ScVal,
    +    key: ScVal,
    +    /// Columns from the last add/update seen this close. `None` when only
    +    /// removals were seen — the row, if any, lives in a prior ledger and only
    +    /// its `active` flag is flipped.
    +    cols: Option,
    +    active: bool,
    +}
    +
    +struct SignerCols {
    +    val: ScVal,
    +    limits: ScVal,
    +    exp: i64,
    +    storage: ScVal,
     }
     
    -fn get_signer_expiration_limits(
    +/// Get the pending entry for (address, key), creating it if absent. Linear scan
    +/// is fine: the number of distinct signers touched in one ledger is tiny.
    +fn pending_entry<'a>(
    +    pending: &'a mut Vec,
    +    address: &ScVal,
    +    key: &ScVal,
    +) -> &'a mut PendingSigner {
    +    if let Some(i) = pending
    +        .iter()
    +        .position(|p| &p.address == address && &p.key == key)
    +    {
    +        &mut pending[i]
    +    } else {
    +        pending.push(PendingSigner {
    +            address: address.clone(),
    +            key: key.clone(),
    +            cols: None,
    +            active: false,
    +        });
    +        let last = pending.len() - 1;
    +        &mut pending[last]
    +    }
    +}
    +
    +/// Fold an add/update into the pending state: last write wins on columns, and
    +/// the signer becomes active.
    +fn fold_upsert(
    +    pending: &mut Vec,
    +    env: &EnvClient,
    +    address: ScVal,
    +    key: ScVal,
         signer_val: SignerVal,
    -) -> (Option>, SignerExpiration, SignerLimits) {
    -    match signer_val {
    -        SignerVal::Policy(signer_expiration, signer_limits) => {
    -            (None, signer_expiration, signer_limits)
    -        }
    -        SignerVal::Ed25519(signer_expiration, signer_limits) => {
    -            (None, signer_expiration, signer_limits)
    +    storage: SignerStorage,
    +) {
    +    let (public_key, expiration, limits) = signer_val.into_parts();
    +    let cols = SignerCols {
    +        val: match public_key {
    +            Some(public_key) => env.to_scval(public_key),
    +            None => ScVal::Void,
    +        },
    +        limits: env.to_scval(limits),
    +        exp: expiration.0.map(|t| t as i64).unwrap_or(EXP_NEVER),
    +        storage: env.to_scval(storage),
    +    };
    +    let entry = pending_entry(pending, &address, &key);
    +    entry.cols = Some(cols);
    +    entry.active = true;
    +}
    +
    +/// Fold a removal into the pending state: the signer becomes inactive. Any
    +/// columns from an earlier same-close add/update are retained so a
    +/// same-close add+remove still writes a complete (inactive) row.
    +fn fold_remove(pending: &mut Vec, address: ScVal, key: ScVal) {
    +    pending_entry(pending, &address, &key).active = false;
    +}
    +
    +/// Commit one folded signer with a single existence read + single write.
    +fn flush_signer(env: &EnvClient, signer: PendingSigner) {
    +    let exists = signer_exists(env, &signer.address, &signer.key);
    +    match signer.cols {
    +        Some(cols) => {
    +            if exists {
    +                env.update()
    +                    .column_equal_to_xdr("address", &signer.address)
    +                    .column_equal_to_xdr("key", &signer.key)
    +                    .execute(&SignerMutation {
    +                        val: cols.val,
    +                        limits: cols.limits,
    +                        exp: cols.exp,
    +                        storage: cols.storage,
    +                        active: signer.active,
    +                    })
    +                    .unwrap();
    +            } else {
    +                env.put(&Signer {
    +                    address: signer.address,
    +                    key: signer.key,
    +                    val: cols.val,
    +                    limits: cols.limits,
    +                    exp: cols.exp,
    +                    storage: cols.storage,
    +                    active: signer.active,
    +                });
    +            }
             }
    -        SignerVal::Secp256r1(public_key, signer_expiration, signer_limits) => {
    -            (Some(public_key), signer_expiration, signer_limits)
    +        // Only removals seen this close (so `active` is false). Deactivate an
    +        // existing prior-ledger row; nothing to do if none exists.
    +        None => {
    +            if exists {
    +                env.update()
    +                    .column_equal_to_xdr("address", &signer.address)
    +                    .column_equal_to_xdr("key", &signer.key)
    +                    .execute(&SignerActiveMutation {
    +                        active: signer.active,
    +                    })
    +                    .unwrap();
    +            }
             }
         }
     }
     
    +fn signer_exists(env: &EnvClient, address: &ScVal, key: &ScVal) -> bool {
    +    let rows: Vec = env
    +        .read_filter()
    +        .column_equal_to_xdr("address", address)
    +        .column_equal_to_xdr("key", key)
    +        .read()
    +        .unwrap();
    +    !rows.is_empty()
    +}
    +
    +// ---------------------------------------------------------------------------
    +// Serverless read functions
    +// ---------------------------------------------------------------------------
    +
     #[derive(Deserialize)]
     pub struct SignersByAddressRequest {
         address: String,
     }
     
     #[derive(Serialize)]
    -pub struct SignersByAddressResponse {
    +pub struct SignerResponse {
    +    /// "Policy" | "Ed25519" | "Secp256r1".
         kind: String,
    +    /// Policy: C-address; Ed25519: G-address; Secp256r1: base64url(keyId).
         key: String,
    +    /// Secp256r1: base64url(65-byte SEC-1 public key); null otherwise.
         val: Option,
    -    expiration: Option,
    +    /// Expiration UNIX timestamp (seconds), or null for never.
    +    expiration: Option,
    +    /// Whether the signer is expired at the current ledger timestamp.
    +    expired: bool,
    +    /// "Persistent" | "Temporary".
         storage: String,
    +    /// base64 (standard) of the `SignerLimits` XDR.
         limits: String,
     }
     
    +/// Enumerate a wallet's live (non-removed) signers, expired ones included and
    +/// flagged. Removed signers are omitted (soft-deleted); surface removals via
    +/// the Stellar Indexer tombstone backend.
     #[no_mangle]
     pub extern "C" fn get_signers_by_address() {
         let env = EnvClient::empty();
         let SignersByAddressRequest { address } = env.read_request_body();
    +    let now = env.soroban().ledger().timestamp();
     
    -    let address = address_from_str(&env, address.as_str());
    -    let address = env.to_scval(address);
    +    // Validate the wallet C-address before building it: `address_from_str`
    +    // panics on malformed input and this profile aborts on panic (review LOW).
    +    if stellar_strkey::Contract::from_string(&address).is_err() {
    +        env.conclude::>(Vec::new());
    +        return;
    +    }
    +    let address = env.to_scval(address_from_str(&env, address.as_str()));
    +    let active_true = bincode::serialize(&true).unwrap();
     
    -    let signers: Vec = env
    +    let rows: Vec = env
             .read_filter()
             .column_equal_to_xdr("address", &address)
    -        .column_equal_to_xdr("active", &ScVal::Bool(true))
    -        .column_gt("exp", env.soroban().ledger().sequence())
    +        .column_equal_to_bytes("active", &active_true)
             .read()
             .unwrap();
     
    -    let mut response: Vec = vec![];
    -
    -    for SignersKeyValLimitsExpStorage {
    -        key,
    -        val,
    -        limits,
    -        exp,
    -        storage,
    -    } in signers
    -    {
    -        let signer_key = env.from_scval::(&key);
    -        let signer_limits = env.from_scval::(&limits);
    -        let signer_storage = env.from_scval::(&storage);
    -
    -        let (kind_parsed, key_parsed) = match signer_key {
    -            SignerKey::Policy(policy) => (
    -                String::from("Policy"),
    -                address_to_alloc_string(&env, policy),
    -            ),
    -            SignerKey::Ed25519(ed25519) => (
    -                String::from("Ed25519"),
    -                Strkey::PublicKeyEd25519(ed25519::PublicKey(ed25519.to_array())).to_string(),
    +    let mut response: Vec = Vec::new();
    +    for row in rows {
    +        let (kind, key) = match env.from_scval::(&row.key) {
    +            SignerKey::Policy(policy) => {
    +                ("Policy".to_string(), address_to_alloc_string(&env, policy))
    +            }
    +            SignerKey::Ed25519(public_key) => (
    +                "Ed25519".to_string(),
    +                Strkey::PublicKeyEd25519(ed25519::PublicKey(public_key.to_array())).to_string(),
                 ),
    -            SignerKey::Secp256r1(secp256r1) => (
    -                String::from("Secp256r1"),
    -                URL_SAFE_NO_PAD.encode(secp256r1.to_alloc_vec()),
    +            SignerKey::Secp256r1(key_id) => (
    +                "Secp256r1".to_string(),
    +                URL_SAFE_NO_PAD.encode(key_id.to_alloc_vec()),
                 ),
             };
     
    -        let mut val_parsed: Option = None;
    -
    -        if let Some(public_key) = env.from_scval::>>(&val) {
    -            val_parsed = Some(URL_SAFE_NO_PAD.encode(public_key.to_array()));
    -        }
    -
    -        let storage_parsed = match signer_storage {
    -            SignerStorage::Persistent => String::from("Persistent"),
    -            SignerStorage::Temporary => String::from("Temporary"),
    -        };
    -
    -        response.push(SignersByAddressResponse {
    -            kind: kind_parsed,
    -            key: key_parsed,
    -            val: val_parsed,
    -            expiration: if exp == u32::MAX { None } else { Some(exp) },
    -            storage: storage_parsed,
    -            limits: URL_SAFE.encode(signer_limits.to_xdr(&env.soroban()).to_alloc_vec()),
    -        })
    +        let val = env
    +            .from_scval::>>(&row.val)
    +            .map(|public_key| URL_SAFE_NO_PAD.encode(public_key.to_array()));
    +
    +        let storage = storage_string(env.from_scval::(&row.storage));
    +        let (expiration, expired) = expiry(row.exp, now);
    +
    +        response.push(SignerResponse {
    +            kind,
    +            key,
    +            val,
    +            expiration,
    +            expired,
    +            storage,
    +            limits: URL_SAFE.encode(row.limits.to_xdr(Limits::none()).unwrap()),
    +        });
         }
     
    -    env.conclude(response)
    +    env.conclude(response);
     }
     
     #[derive(Deserialize)]
    @@ -215,58 +439,100 @@ pub struct AddressBySignerRequest {
         kind: String,
     }
     
    +#[derive(Serialize)]
    +pub struct WalletMatch {
    +    /// Wallet contract C-address.
    +    address: String,
    +    expiration: Option,
    +    expired: bool,
    +    storage: String,
    +}
    +
    +/// Reverse lookup: wallets on which a given signer key is live. Matches include
    +/// expired signers, flagged via `expired`. The SDK's `findWallets` maps this to
    +/// a plain address list; the expiration flag is exposed for parity with
    +/// `get_signers_by_address` (audit F5).
     #[no_mangle]
     pub extern "C" fn get_addresses_by_signer() {
         let env = EnvClient::empty();
         let AddressBySignerRequest { key, kind } = env.read_request_body();
    +    let now = env.soroban().ledger().timestamp();
     
    -    let key_scval: ScVal;
    -
    -    if kind == "Policy" {
    -        let key = address_from_str(&env, &key.as_str());
    -        key_scval = env.to_scval(SignerKey::Policy(key));
    -    } else if kind == "Ed25519" {
    -        // This is pretty verbose and manual but there's no easy way to go from a G-address to it's 32 bytes of public key
    -        let key = address_from_str(&env, &key.as_str()).to_xdr(&env.soroban());
    -        let key = key.slice(key.len() - 32..);
    -        let mut slice = [0u8; 32];
    -        key.copy_into_slice(&mut slice);
    -        let key = BytesN::from_array(&env.soroban(), &slice);
    -        key_scval = env.to_scval(SignerKey::Ed25519(key));
    -    } else if kind == "Secp256r1" {
    -        let key = URL_SAFE_NO_PAD.decode(key).unwrap();
    -        let key = Bytes::from_slice(&env.soroban(), key.as_slice());
    -        key_scval = env.to_scval(SignerKey::Secp256r1(key));
    -    } else {
    -        panic!("Invalid signer type");
    -    }
    +    // Parse caller-supplied key material fallibly: unparseable input concludes
    +    // empty rather than panicking (which would abort under panic=abort). (LOW)
    +    let key_scval = match kind.as_str() {
    +        "Policy" if is_valid_address_str(&key) => {
    +            env.to_scval(SignerKey::Policy(address_from_str(&env, key.as_str())))
    +        }
    +        "Ed25519" => match stellar_strkey::ed25519::PublicKey::from_string(&key) {
    +            Ok(public_key) => env.to_scval(SignerKey::Ed25519(BytesN::from_array(
    +                env.soroban(),
    +                &public_key.0,
    +            ))),
    +            Err(_) => {
    +                env.conclude::>(Vec::new());
    +                return;
    +            }
    +        },
    +        "Secp256r1" => match URL_SAFE_NO_PAD.decode(&key) {
    +            Ok(raw) => env.to_scval(SignerKey::Secp256r1(Bytes::from_slice(env.soroban(), &raw))),
    +            Err(_) => {
    +                env.conclude::>(Vec::new());
    +                return;
    +            }
    +        },
    +        _ => {
    +            env.conclude::>(Vec::new());
    +            return;
    +        }
    +    };
     
    -    let signers: Vec = env
    +    let active_true = bincode::serialize(&true).unwrap();
    +    let rows: Vec = env
             .read_filter()
             .column_equal_to_xdr("key", &key_scval)
    -        .column_equal_to_xdr("active", &ScVal::Bool(true))
    +        .column_equal_to_bytes("active", &active_true)
             .read()
             .unwrap();
     
    -    if signers.is_empty() {
    -        env.conclude::>(Vec::default());
    -    } else {
    -        let contracts = signers
    -            .iter()
    -            .map(|SignersAddress { address }| {
    -                address_to_alloc_string(&env, env.from_scval::
    (address)) - }) - .collect::>(); - - env.conclude(contracts); + let mut response: Vec = Vec::new(); + for row in rows { + let address = address_to_alloc_string(&env, env.from_scval::
    (&row.address)); + let storage = storage_string(env.from_scval::(&row.storage)); + let (expiration, expired) = expiry(row.exp, now); + response.push(WalletMatch { + address, + expiration, + expired, + storage, + }); } + + env.conclude(response); } -#[no_mangle] -pub extern "C" fn debug_signers() { - let env = EnvClient::empty(); +// --- read helpers ----------------------------------------------------------- - let signers = env.read::(); +/// Whether a string is a valid Stellar address strkey (contract or ed25519). +/// Guards `address_from_str`, which panics on invalid input. +fn is_valid_address_str(s: &str) -> bool { + stellar_strkey::Contract::from_string(s).is_ok() + || stellar_strkey::ed25519::PublicKey::from_string(s).is_ok() +} + +fn storage_string(storage: SignerStorage) -> String { + match storage { + SignerStorage::Persistent => "Persistent".to_string(), + SignerStorage::Temporary => "Temporary".to_string(), + } +} - env.conclude(signers); +/// Map a stored `exp` and the current ledger timestamp to (expiration, expired). +/// Semantics: valid while `now <= exp` (inclusive); expired once `now > exp`. +fn expiry(exp: i64, now: u64) -> (Option, bool) { + if exp == EXP_NEVER { + (None, false) + } else { + (Some(exp as u64), (now as i64) > exp) + } } diff --git a/zephyr/src/types.rs b/zephyr/src/types.rs index 38483f6..141cfb7 100644 --- a/zephyr/src/types.rs +++ b/zephyr/src/types.rs @@ -1,4 +1,14 @@ -use serde::Serialize; +//! Database row structs for the two indexer tables (`signers`, `wallets`). +//! +//! `DatabaseDerive` maps each field to a column serialization by its Rust type: +//! `ScVal` -> XDR bytes (BYTEA); integers (`i64`) -> ZephyrVal (BIGINT); every +//! other type (here `bool`) -> bincode (BYTEA). Partial structs select a subset +//! of columns by name for reads/updates. +//! +//! NOTE: the `DatabaseDerive` macro panics on ANY struct-level attribute other +//! than `#[with_name]` (a `///` doc comment becomes a `#[doc]` attribute and +//! trips it), so these structs are documented with plain `//` comments. + use zephyr_sdk::{ bincode, prelude::{Limits, ReadXdr, WriteXdr}, @@ -6,46 +16,105 @@ use zephyr_sdk::{ Condition, DatabaseDerive, DatabaseInteract, EnvClient, ZephyrVal, }; -#[derive(DatabaseDerive, Serialize, Clone)] +/// Sentinel `exp` value meaning "never expires" (`SignerExpiration(None)`). +/// Chosen as `i64::MAX` so the inclusive `now <= exp` check is always true and +/// it fits the BIGINT column. +pub const EXP_NEVER: i64 = i64::MAX; + +// --- signers table ---------------------------------------------------------- + +// One row per (wallet address, signer key). `active` is a soft-delete flag: +// there is no row-delete host op, so `remove_signer` flips `active` to false. +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct Signers { +pub struct Signer { + /// Wallet contract address, as an `ScVal::Address`. pub address: ScVal, + /// `SignerKey` ScVal (as emitted in event topic 1). pub key: ScVal, + /// secp256r1 public key as `ScVal::Bytes(65)`, or `ScVal::Void` for + /// Ed25519/Policy signers (which have no separate public key). pub val: ScVal, + /// `SignerLimits` ScVal. pub limits: ScVal, - pub exp: u32, + /// Expiration UNIX timestamp (seconds), or `EXP_NEVER`. + pub exp: i64, + /// `SignerStorage` ScVal (Persistent | Temporary). pub storage: ScVal, - pub active: ScVal, + /// Live (true) or removed (false). + pub active: bool, } -#[derive(DatabaseDerive, Serialize, Clone)] +// Columns written when reactivating/updating an existing signer row (the +// (address, key) pair is supplied as the update filter, not rewritten). +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct SignersKeyValLimitsExpStorage { - pub key: ScVal, +pub struct SignerMutation { pub val: ScVal, pub limits: ScVal, - pub exp: u32, + pub exp: i64, pub storage: ScVal, + pub active: bool, +} + +// Existence probe for a (address, key) pair. +#[derive(DatabaseDerive, Clone)] +#[with_name("signers")] +pub struct SignerKeyOnly { + pub key: ScVal, +} + +// Column written to soft-delete a signer on `remove_signer` (keeps every other +// column intact; the (address, key) pair is the update filter). +#[derive(DatabaseDerive, Clone)] +#[with_name("signers")] +pub struct SignerActiveMutation { + pub active: bool, } -#[derive(DatabaseDerive, Serialize, Clone)] +// Projection for `get_signers_by_address`. +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct SignersValLimitsExpStorageActive { +pub struct SignerReadRow { + pub key: ScVal, pub val: ScVal, pub limits: ScVal, - pub exp: u32, + pub exp: i64, pub storage: ScVal, - pub active: ScVal, } -#[derive(DatabaseDerive, Serialize, Clone)] +// Projection for `get_addresses_by_signer` (reverse lookup). +#[derive(DatabaseDerive, Clone)] #[with_name("signers")] -pub struct SignersAddress { +pub struct WalletMatchRow { pub address: ScVal, + pub exp: i64, + pub storage: ScVal, } -#[derive(DatabaseDerive, Serialize, Clone, Debug)] -#[with_name("signers")] -pub struct SignersActive { - pub active: ScVal, +// --- wallets table ---------------------------------------------------------- + +// One row per trusted wallet: an allowlisted smart-wallet instance observed in +// the ledger. Membership is the ingestion trust gate (audit F3) and records +// the latest known WASM hash (also updated from `upgraded` events). +#[derive(DatabaseDerive, Clone)] +#[with_name("wallets")] +pub struct Wallet { + pub address: ScVal, + /// Latest known executable WASM hash, as `ScVal::Bytes(32)`. + pub wasm_hash: ScVal, +} + +// Existence probe / trust check for a wallet address. +#[derive(DatabaseDerive, Clone)] +#[with_name("wallets")] +pub struct WalletAddrOnly { + pub address: ScVal, +} + +// Column written when refreshing a known wallet's WASM hash. +#[derive(DatabaseDerive, Clone)] +#[with_name("wallets")] +pub struct WalletHashMutation { + pub wasm_hash: ScVal, } diff --git a/zephyr/src/wallet.rs b/zephyr/src/wallet.rs new file mode 100644 index 0000000..6940adf --- /dev/null +++ b/zephyr/src/wallet.rs @@ -0,0 +1,131 @@ +//! Local, version-decoupled mirror of the smart wallet's on-chain types plus +//! the v1 `#[contractevent]` payload shapes the indexer decodes. +//! +//! These `#[contracttype]` definitions are byte-for-byte ScVal-compatible with +//! the contract's `smart-wallet-interface` types, but are built on THIS crate's +//! single soroban-sdk (22.x, brought in by zephyr-sdk) rather than the +//! contract's soroban-sdk 27. Depending on `smart-wallet-interface` directly +//! would pull a SECOND soroban-sdk into the tree and fail the build — that dual +//! soroban-sdk is exactly what audit F1 flagged. Mirroring the few types we +//! decode keeps the indexer decoupled from the contract crate's toolchain while +//! staying wire-compatible (the ScVal enum/struct encoding is protocol-stable +//! across soroban-sdk versions). + +use soroban_sdk::{contracttype, Address, Bytes, BytesN, Map, Vec}; + +// --- Signer model (mirror of smart-wallet-interface::types) ----------------- + +/// UNIX timestamp (seconds) expiration, inclusive; `None` never expires. v1 +/// changed this from a ledger sequence number (audit F12). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerExpiration(pub Option); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerLimits(pub Option>>>); + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignerStorage { + Persistent, + Temporary, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignerKey { + Policy(Address), + Ed25519(BytesN<32>), + Secp256r1(Bytes), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignerVal { + Policy(SignerExpiration, SignerLimits), + Ed25519(SignerExpiration, SignerLimits), + Secp256r1(BytesN<65>, SignerExpiration, SignerLimits), +} + +impl SignerVal { + /// Split a stored signer value into its indexable parts: the secp256r1 + /// public key (only present for passkey signers), expiration, and limits. + pub fn into_parts(self) -> (Option>, SignerExpiration, SignerLimits) { + match self { + SignerVal::Policy(exp, limits) => (None, exp, limits), + SignerVal::Ed25519(exp, limits) => (None, exp, limits), + SignerVal::Secp256r1(public_key, exp, limits) => (Some(public_key), exp, limits), + } + } +} + +// --- Event data payloads ---------------------------------------------------- +// +// The v1 events are `#[contractevent]` structs whose default `data_format` is +// `map`: the non-`#[topic]` fields are emitted as an ScVal::Map keyed by field +// name. The `#[topic]` `key` field is NOT part of the data (it's topic index +// 1). Struct decode is by field name, so the declaration order below is +// irrelevant. + +/// Data of `signer_added` — topics: `[sym("signer_added"), ]`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerAddedData { + pub storage: SignerStorage, + pub val: SignerVal, +} + +/// Data of `signer_updated` — topics: `[sym("signer_updated"), ]`. +/// `old_storage` is the durability the entry lived in before the update (the +/// contract tombstones the old durability if the update flips it; the indexer +/// keeps a single row per (wallet, key) and simply rewrites `storage`). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerUpdatedData { + pub old_storage: SignerStorage, + pub storage: SignerStorage, + pub val: SignerVal, +} + +/// Data of `signer_removed` — topics: `[sym("signer_removed"), ]`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignerRemovedData { + pub storage: SignerStorage, +} + +/// Data of `upgraded` — topics: `[sym("upgraded")]`. `old_hash` is `None` on a +/// wallet's first-ever upgrade. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradedData { + pub new_hash: BytesN<32>, + pub old_hash: Option>, +} + +// --- WASM-hash allowlist (audit F3) ----------------------------------------- + +/// Known passkey-kit smart-wallet WASM hashes (hex), sourced from +/// `docs/deployments-testnet-2026-07-11.md` — the canonical hash manifest. +/// NEVER rebuild these locally; consume the manifest. +/// +/// Only events emitted by a contract whose on-chain instance executable is one +/// of these are ingested. Without this gate any contract emitting well-formed +/// `signer_added`/… events could inject arbitrary signer rows and poison the +/// reverse (keyId -> wallet) lookup (audit F3). +pub const ALLOWLISTED_WASM_HASHES: [&str; 2] = [ + // v1 smart wallet — soroban-sdk 27, #[contractevent] schema. FINAL + // post-audit-gate hash (supersedes the interim pre-audit 9e7fad44…). + "84924c53a413318df2ce753e30de53ec651404c916d30e861718ad155c94b319", + // Legacy pre-1.0 wallet — sw_v1 tuple events (ignored by this indexer), but + // a known smart wallet: a legacy wallet can upgrade in place to a v1 hash + // and keep its address, at which point it starts emitting v1 events. + "e45c42b944a767bd5f37f8c4a469b48917d28e23481dbfd550419c84cdacde92", +]; + +/// Whether a 32-byte WASM hash is a known passkey-kit smart wallet. +pub fn is_allowlisted(wasm_hash: &[u8; 32]) -> bool { + let observed = hex::encode(wasm_hash); + ALLOWLISTED_WASM_HASHES.iter().any(|h| *h == observed) +} diff --git a/zephyr/zephyr.toml b/zephyr/zephyr.toml index 5406a6f..b05ffe5 100644 --- a/zephyr/zephyr.toml +++ b/zephyr/zephyr.toml @@ -1,5 +1,8 @@ -name = "smart-wallets-next-dima" +name = "passkey-kit-indexer" +# Signer index: one row per (wallet address, signer key). `active` is a +# soft-delete flag (no row-delete host op exists). `exp` is a UNIX timestamp +# (seconds) or i64::MAX for "never". [[tables]] name = "signers" force = false @@ -32,4 +35,20 @@ col_type = "BYTEA" [[tables.columns]] name = "active" -col_type = "BYTEA" \ No newline at end of file +col_type = "BYTEA" + +# Trusted wallets: allowlisted smart-wallet instances observed on-chain. Table +# membership is the ingestion trust gate (audit F3); `wasm_hash` records the +# latest known executable (refreshed from `upgraded` events). +[[tables]] +name = "wallets" +force = false + +[[tables.columns]] +name = "address" +col_type = "BYTEA" +index = true + +[[tables.columns]] +name = "wasm_hash" +col_type = "BYTEA"