A passkey-powered smart wallet on the Stellar Soroban blockchain. Users authenticate with their device biometrics (Face ID, fingerprint, Windows Hello) instead of seed phrases or private keys.
Veil's contracts are deployed and transacting on Stellar mainnet, not only testnet.
| Factory contract | CCZ3JLRESNLDADGXWNEH4YQ4NXUUAHRJNCWZHYG6QB4KTDYHOH6OQ7BK |
| Passkey-authorized payment | 626e110b61b709afb2d85c14517e7678b95b07e4d49c34a6caff8ee51148ebcc — a WebAuthn assertion verified on-chain by __check_auth |
| Soroswap aggregator swap | 5e29e3d8cdd27ba25f510e5dbf412e9e6592dabdf761508266a8752bfd096f4f — 1 XLM to Circle USDC through a live third-party DEX |
| Web wallet | app.useveilapp.xyz — passkeys bound to an owned domain, not a preview host |
The first of those is the claim that matters: a payment on mainnet whose only
authorization was a device biometric. No seed phrase, no exported key — the
contract verified the challenge binding and the P-256 signature itself, in
__check_auth, before the transfer moved.
The deployed bytecode matches this repository byte for byte, and you do not have
to take that on faith. contracts/expected-hashes.json records the SHA-256 of
each artifact from a reproducible Docker build (rust:1.85.0-bookworm), and the
same hash is readable from the ledger:
# 1. Pull the bytecode that is actually running on mainnet, and hash it.
# Mainnet has no free public Soroban RPC, so name a provider explicitly.
stellar contract fetch \
--id CCZ3JLRESNLDADGXWNEH4YQ4NXUUAHRJNCWZHYG6QB4KTDYHOH6OQ7BK \
--rpc-url https://mainnet.sorobanrpc.com \
--network-passphrase "Public Global Stellar Network ; September 2015" \
--out-file factory.wasm
sha256sum factory.wasm
# 3a6756d28de795177be28c2359347e57ea4c244cbe609b86facec00ef1abb853
# 2. Rebuild the same artifact from this repository, in Docker, and compare.
./scripts/reproducible-build.shBoth produce 3a6756d28de795177be28c2359347e57ea4c244cbe609b86facec00ef1abb853
— the value committed in contracts/expected-hashes.json. The wallet contract
the factory deploys verifies the same way, as b485f817…59ea5.
A source-verified contract is the difference between "here is our code" and "here is the code that is running."
Being honest about the stage, because a deployed contract is not the same as a
used one: mainnet activity so far is our own verification transactions, not
users. The contracts, the web wallet and the mobile app are real and working;
the traction is not there yet. Testnet remains the default for development —
flip NEXT_PUBLIC_NETWORK=mainnet to point the wallet at production.
Veil combines WebAuthn (the browser passkey standard) with a Soroban custom account contract. When a user registers, a P-256 keypair is created on their device and the public key is stored in the wallet contract. To authorize a transaction, the user's device signs the Soroban authorization payload with their passkey. The contract verifies the full WebAuthn assertion on-chain — including the challenge binding and the ECDSA signature — before approving any action.
User device Stellar network
────────────────────────────── ─────────────────────────────
1. register()
└─ WebAuthn credential create
└─ extract P-256 public key ──► deploy wallet contract
└─ store public key
2. signAuthEntry(payload)
└─ WebAuthn assertion
└─ DER → raw sig conversion
└─ return {pubkey, authData,
clientDataJSON, sig} ────► __check_auth()
└─ verify challenge in clientDataJSON == payload
└─ compute SHA256(authData || SHA256(clientDataJSON))
└─ verify P-256 ECDSA signature
└─ approve / reject
graph TD
subgraph Browser["Browser (WebAuthn)"]
UA["User Agent\n(Face ID / Fingerprint)"]
SDK["invisible-wallet-sdk\n(React hook / Vue composable)"]
end
subgraph Wallet["Veil Wallet PWA (Next.js)"]
UI["Dashboard / Send / Swap UI"]
FP["Fee-Payer G… account\n(HKDF-derived from passkey)"]
end
subgraph Stellar["Stellar Network (Soroban)"]
CONTRACT["Smart Wallet Contract C…\n(__check_auth: P-256 ECDSA verify)"]
FACTORY["Factory Contract\n(deploy wallet instances)"]
SAC["Native XLM SAC\n(token balances)"]
end
subgraph Services["Backend Services"]
LENS["Lens\nPrice oracle (x402 gated)\nGET /price/:assetA/:assetB"]
WRAITH["Wraith\nSAC event indexer\nGET /transfers/:address"]
AGENT["Veil AI Agent\n(Claude + WebSocket)"]
PG[("Postgres")]
end
UA -->|"biometric gesture"| SDK
SDK -->|"passkey credential"| UA
SDK -->|"WebAuthn signature Vec[5]"| CONTRACT
UI -->|"sign envelope"| FP
FP -->|"submit tx"| CONTRACT
CONTRACT -->|"deploy"| FACTORY
CONTRACT -->|"balance query"| SAC
UI -->|"price fetch"| LENS
UI -->|"transfer history"| WRAITH
UI -->|"chat / approve tx"| AGENT
AGENT -->|"get_price"| LENS
AGENT -->|"get_balance"| SAC
LENS --- PG
WRAITH --- PG
See docs/adr/0001-two-account-model.md for the design rationale behind the
C…+G…two-account model. See docs/adr/0002-webauthn-signature-verification.md for the full WebAuthn on-chain verification pipeline.
veil/
├── contracts/
│ ├── invisible_wallet/ # Soroban smart contract (Rust)
│ │ ├── src/
│ │ │ ├── lib.rs # Contract entry points + __check_auth
│ │ │ ├── auth.rs # WebAuthn ES256 verification logic
│ │ │ └── storage.rs # Signer and guardian storage
│ │ └── Cargo.toml
│ └── factory/ # Factory contract — deploys wallet instances
│ ├── src/
│ │ ├── lib.rs # init(wasm_hash) + deploy(pubkey, rp_id, origin)
│ │ ├── storage.rs # WasmHash + Deployed(salt) keys
│ │ └── validation.rs # P-256 public key validation
│ └── Cargo.toml
├── sdk/
│ ├── src/
│ │ ├── core.ts # Framework-agnostic wallet core — register, deploy, login, signAuthEntry, sendPayment, addSigner, removeSigner, setGuardian, initiateRecovery, completeRecovery
│ │ ├── useInvisibleWallet.ts # React hook — binds the core to useSyncExternalStore
│ │ ├── vue/ # Vue 3 composable — binds the same core to refs (invisible-wallet-sdk/vue)
│ │ ├── webauthn.ts # WebAuthn provider interface + web/browser implementation
│ │ ├── webauthn.native.ts # React Native implementation (react-native-passkey) — Metro auto-resolves
│ │ ├── utils.ts # Crypto utilities (DER→raw, pubkey extraction, SHA256, computeWalletAddress)
│ │ └── index.ts # Package exports
│ └── package.json
├── packages/
│ └── agent/ # Veil AI Agent (Node.js / TypeScript)
│ └── src/
│ ├── agent.ts # Claude tool-use loop (get_price, get_balance, build_swap, build_payment, request_user_approval)
│ ├── server.ts # Express + WebSocket server — handles chat messages, conversation history
│ ├── txBuilder.ts # Builds unsigned Stellar XDR transactions (swap, payment)
│ └── x402Client.ts # x402 micropayment client — auto-pays Lens price endpoint calls
└── frontend/
├── website/ # Next.js 14 marketing site (useveilapp.xyz)
│ └── app/
│ ├── page.tsx # Homepage — Hero, HowItWorks, WhyVeil, DevQuickstart
│ └── products/ # /products listing + /wallet /lens /wraith /agent detail pages
├── docs/ # Nextra 3 documentation (docs.useveilapp.xyz)
└── wallet/ # Veil wallet app (Next.js 14, app.useveilapp.xyz)
├── app/
│ ├── dashboard/ # Balance, all token assets with logos, activity feed + filters (All/Transfers/Swaps)
│ ├── send/ # Send XLM or tokens — passkey-gated
│ ├── swap/ # SDEX path payment swap — passkey-gated
│ ├── agent/ # AI chat UI — WebSocket to Agent server
│ ├── token/[code]/ # Individual token page: sparkline chart, balance, filtered txn history, actions
│ ├── contacts/ # Address book
│ ├── settings/ # App settings
│ ├── recover/ # Guardian recovery flow
│ └── lock/ # Inactivity lock screen — biometric re-auth
├── components/ # VeilLogo, TxDetailSheet, ContactPicker, QrScanner
├── hooks/ # useInactivityLock
└── lib/ # txState.ts (mid-tx lock guard), passkeyAuth.ts (shared biometric gate)
| Service | Description | Deployed |
|---|---|---|
| Lens | Price oracle — SDEX + AMM prices, x402 micropayment gated | https://lens-ldtu.onrender.com |
| Wraith | SAC event indexer — transfer history for Soroban wallets | https://wraith-0jo1.onrender.com |
| Agent | Claude AI agent — chat, swap, payments, balance queries | https://veil-agent.onrender.com |
Fastify/Prisma/Postgres oracle that ingests SDEX trades and AMM pool snapshots from Stellar. Exposes GET /price/:assetA/:assetB gated behind x402 micropayments (auto-paid by the agent). Deployed on Render, data stored in Supabase PostgreSQL.
Express/Prisma/Postgres indexer for Stellar Soroban contract events. Fills the Horizon gap for incoming SAC token transfers that classic payment endpoints miss.
Key endpoint: GET /transfers/address/:address?direction=incoming|outgoing|both&limit=N
Additional endpoints: GET /summary/:address, GET /transfers/address/:address with fromDate/toDate/eventType filters.
Claude-powered AI agent embedded in the Veil wallet. Connects via WebSocket. Tools:
| Tool | Description |
|---|---|
get_price |
Fetches live SDEX/AMM price via Lens (x402 auto-paid) |
get_wallet_balance |
Fetches XLM + token balances via Horizon |
get_transfer_history |
Fetches transfer history via Wraith + Horizon payments |
build_swap |
Builds unsigned path payment XDR (auto-adds trustline if missing) |
build_payment |
Builds unsigned payment XDR |
request_user_approval |
Sends transaction to wallet UI for passkey biometric approval |
All transactions built by the agent are returned unsigned to the frontend, where the user approves with Face ID / fingerprint before the transaction is signed and submitted.
| Layer | Technology |
|---|---|
| Smart contract | Rust, Soroban SDK, p256 crate (ECDSA), sha2 |
| Authentication | WebAuthn / FIDO2 (ES256 / P-256) |
| Client SDK | TypeScript, React hooks, @stellar/stellar-sdk v15, Web Crypto API |
| Wallet app | Next.js 14 App Router, next-pwa |
| AI Agent | Node.js, Claude claude-sonnet-4-6, Anthropic SDK, WebSocket |
| Price oracle | Fastify, Prisma, Postgres (Supabase), x402 micropayments |
| Indexer | Express, Prisma, Postgres (Render managed), stellar-sdk v15 |
| Blockchain | Stellar (Soroban smart contracts, testnet) |
- Rust with the
wasm32-unknown-unknowntarget - Stellar CLI
- Node.js 18+
cd contracts/invisible_wallet
cargo build --target wasm32-unknown-unknown --releasecd contracts/invisible_wallet
cargo testcd sdk
npm install
npm run buildThe SDK's public entry points are protected by a size-limit budget so accidental dependency bloat is caught before it ships to integrators. CI runs npm run size on every push and pull request and fails the build if any entry point exceeds its limit.
cd sdk
npm run sizeCurrent budgets (brotli-compressed, includes all transitive deps):
| Entry point | Limit |
|---|---|
dist/index.js |
230 KB |
dist/vanilla.js |
225 KB |
If you legitimately need more headroom, raise the relevant limit in the size-limit block of sdk/package.json in the same PR that introduces the growth, and call out the increase in the PR description so reviewers can sanity-check the cause.
The SDK ships a platform-split WebAuthn layer. Metro automatically resolves
webauthn.native.ts over webauthn.ts when bundling for iOS/Android.
Platform requirements: iOS 16+, Android 13+ (physical device required).
npm install react-native-passkey @react-native-async-storage/async-storage
# iOS only — re-run pod install after adding the native module:
npx pod-installimport AsyncStorage from '@react-native-async-storage/async-storage';
import { useInvisibleWallet } from 'invisible-wallet-sdk';
const wallet = useInvisibleWallet({
factoryAddress: 'CABC...',
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
rpId: 'your-domain.com', // required for React Native (no window.location)
origin: 'https://your-domain.com',
storage: AsyncStorage, // replaces localStorage
});rpId and origin must match your app's associated domain
(apple-app-site-association on iOS / assetlinks.json on Android).
cd examples/expo
cp .env.example .env.local # fill in factory address + RP details
npm install
npx expo run:ios # physical deviceSee examples/expo/README.md for full setup instructions.
The react-native field in sdk/package.json points to the TypeScript source
so Metro can apply its .native.ts extension resolution:
"react-native": "src/index.ts"No extra Babel plugins are needed when using Expo (which handles TypeScript
via babel-preset-expo) or standard @react-native/metro-config.
cd packages/agent
cp .env.example .env # fill in AGENT_KEYPAIR_SECRET, ANTHROPIC_API_KEY, ORACLE_URL, WRAITH_URL
npm install
npm run build
npm startThe agent exposes:
GET /health— health checkWS /— WebSocket chat endpoint (expects{ type: 'chat', walletAddress, feePayerAddress, message })
import { useInvisibleWallet } from 'invisible-wallet-sdk';
function App() {
const wallet = useInvisibleWallet({
factoryAddress: FACTORY_CONTRACT_ID,
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: Networks.TESTNET,
});
// Register a passkey and deploy a wallet contract
await wallet.register('alice');
const { walletAddress } = await wallet.deploy();
// Sign a Soroban authorization entry
const sig = await wallet.signAuthEntry(signaturePayload); // Uint8Array (32 bytes)
// sig = { publicKey, authData, clientDataJSON, signature }
// Encode sig as Vec<Val> and attach to the Soroban auth entry
// Multi-signer management
await wallet.addSigner(newPublicKey);
await wallet.removeSigner(signerIndex);
// Guardian recovery
await wallet.setGuardian(guardianPublicKey);
await wallet.initiateRecovery(newPublicKey);
await wallet.completeRecovery(); // after 3-day timelock
}<script setup lang="ts">
import { useInvisibleWallet } from 'invisible-wallet-sdk/vue';
// Same actions as the React hook — both wrap the same framework-agnostic core.
// State comes back as refs instead of React state.
const { address, isPending, error, register, deploy, login, sendPayment } =
useInvisibleWallet({
factoryAddress: FACTORY_CONTRACT_ID,
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: Networks.TESTNET,
});
</script>
<template>
<p v-if="address">Wallet: {{ address }}</p>
<button v-else :disabled="isPending" @click="register('alice')">Create wallet</button>
</template>vue is an optional peer dependency, so React apps never install it — and the
Vue entry point pulls in no React. See examples/vue/ for a
Vite starter covering register, login and send, and examples/nuxt/
for the SSR flavour.
import { createWalletStore } from 'invisible-wallet-sdk/svelte';
const wallet = createWalletStore({
factoryAddress: FACTORY_CONTRACT_ID,
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
});
// $wallet reactively reflects { address, isDeployed, isPending, error }
await wallet.register('alice');
await wallet.deploy(feePayerSecret);
const sig = await wallet.signAuthEntry(signaturePayload);
await wallet.sendPayment(feePayerSecret, to, amountInStroops);The store binds the same InvisibleWalletCore the React hook and the Vue
composable do, so all three adapters expose an identical set of actions.
See sdk/src/svelte for the adapter and
examples/sveltekit for a full register/dashboard/send
example.
import { useInvisibleWallet } from 'invisible-wallet-sdk/solid';
function App() {
const wallet = useInvisibleWallet({
factoryAddress: FACTORY_CONTRACT_ID,
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
});
return (
<Show when={wallet.address()} fallback={
<button disabled={wallet.isPending()} onClick={() => wallet.register('alice')}>
Create wallet
</button>
}>
<p>Wallet: {wallet.address()}</p>
</Show>
);
}State arrives as Solid accessors — wallet.address(), wallet.isDeployed(),
wallet.isPending(), wallet.error() — over the same InvisibleWalletCore
the React, Vue and Svelte adapters bind, so the actions are identical across
all four. Called inside a component it hydrates on mount and detaches on
cleanup, which keeps it safe through a solid-start server render.
See sdk/src/solid for the adapter and
examples/solid for a Vite starter covering register,
dashboard and send.
import { createInvisibleWallet } from 'invisible-wallet-sdk/vanilla';
// Initialize wallet
const wallet = createInvisibleWallet({
factoryAddress: FACTORY_CONTRACT_ID,
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
});
// Register a passkey and deploy a wallet contract
const { walletAddress } = await wallet.register('alice');
await wallet.deploy(feePayerKeypair);
// Sign a Soroban authorization entry
const sig = await wallet.signAuthEntry(signaturePayload);
// All methods return Promises - no React hooks or JSX requiredThe contract's __check_auth expects the signature field to be a Vec<Val> with four elements:
| Index | Type | Description |
|---|---|---|
| 0 | BytesN<65> |
Uncompressed P-256 public key (0x04 || x || y) |
| 1 | Bytes |
WebAuthn authenticatorData |
| 2 | Bytes |
WebAuthn clientDataJSON (must contain base64url(signature_payload) as challenge) |
| 3 | BytesN<64> |
Raw P-256 ECDSA signature (r || s) |
- Phase 1 — Contract compiles, error types, ECDSA verification, unit tests
- Phase 2 — Full WebAuthn pipeline (DER→raw, real pubkey extraction, challenge binding)
- Phase 3 — Factory contract + deterministic wallet deployment
- Phase 4 — RP ID / origin verification, testnet integration (smoke test)
- Phase 5 — Guardian recovery, multi-signer, nonce/replay protection
- Wallet app — Dashboard, send, swap, contacts, lock screen, PWA, onboarding tutorial
- Token pages — Individual asset pages with sparkline chart, filtered txn history, actions
- Lens oracle — Live SDEX + AMM prices, x402 micropayment gated
- Wraith indexer — Soroban SAC transfer history (combined endpoint PR #6 merged)
- Agent — Claude AI assistant: balance, prices, swaps, payments — all passkey-gated
- Marketing website — Products section with individual pages for all 4 products
- Mainnet — contracts deployed and source-verified; passkey-authorized payment and Soroswap swap settled on-chain
See the Security docs and the Threat Model for the full STRIDE analysis, trust assumptions, and residual risks.
The Soroban contracts build reproducibly, so you can confirm a deployed contract
hash was produced from this source. With Docker installed, run
scripts/reproducible-build.sh to rebuild and compare against the committed
contracts/expected-hashes.json. See docs/reproducible-build.md.
MIT
Thank you