Wisp by Veru Finance
A multi-asset shielded pool on Stellar. Shield Stellar assets into one Merkle tree, move value privately, swap on Soroswap, earn in a DeFindex vault, borrow on Blend, and exit to any address. A zero-knowledge proof gates every step: circuits in Noir, proved with UltraHonk, verified by Soroban contracts.
Principles
- The browser holds every secret. Keys come from one wallet signature and live only in tab memory. Note secrets travel only inside memos encrypted to the receiver.
- The proof fixes every term. The note owner's EdDSA signature covers every public input: recipient, amount, asset, new notes, their memos, the relayer fee and any adapter or money-market spec. Servers can delay or refuse, never change.
- One key per note, one per loan. No two on-chain records share an owner hash.
- Internal moves are asset-blind. Send, split and merge reveal neither amount nor asset. Only boundary operations (shield, withdraw, swap, vault, money market) do, because tokens enter or leave the pool.
- A minimal pool.
wisp_poolholds the tokens, the tree, the nullifiers and the asset registry. Adapters and Positions take a note's value by being its withdrawal's recipient. - Screening with an exit. An ASP admits each deposit into the tree; until then the depositor can always take it back (ragequit).
What you can do
| Feature | What happens | Guide |
|---|---|---|
| Shield | Deposit from your Stellar account into a private note | Shield and screening |
| Send privately | Pay a Wisp receive address; amount, asset and parties stay hidden | Send privately |
| Split · merge | One note into two of your own, or two into one | Split and merge |
| Withdraw | A whole note to any Stellar address | Withdraw |
| Swap | Trade on Soroswap with a live quote and a 1% floor | Swap |
| Vault | Earn in DeFindex, with share price and profit or loss | Vault |
| Money market | Supply and borrow on Blend, with interest per side | Money market |
How it works
Every component and who talks to whom. Solid arrows are calls or requests; the dotted one is Prometheus scraping every service's metrics.
The four layers
- Browser. Holds the keys, finds notes, builds and proves every spend. Nothing secret leaves the tab.
- Off-chain services. The indexer serves public chain data; the relayer submits finished proofs and pays the network fee; the ASP screens deposits. They can stall, never steal or change a transaction.
- Soroban contracts.
wisp_poolholds everything; adapters and Positions reach the venues; verifiers check proofs. - Stellar DeFi. Soroswap, DeFindex and Blend, used unchanged.
wisp_pool, the two adapters and position_manager. Your wallet submits only your own-funds transactions: deposits, ragequits and, when you choose, a trustline.Components
What each piece does, what it exposes, and where its code lives.
In the browser (web/)
| Component | Does | Code |
|---|---|---|
| Wallet adapters | Varial (formerly Freighter, driven by @stellar/freighter-api). Signs the key message (SEP-53) and your own transactions; checks the wallet's network before signing; links to the download when it isn't installed. Stellar Wallets Kit is built in but switched off. | web/src/lib/wallet.ts |
| Key ring | Derives every key from one signature; keeps secrets in tab memory, stores only view-only material. | web/src/lib/keyring.ts |
| Note sync | Stamp matches from the indexer, memos opened locally, spent status via decoy buckets, Merkle paths from public chunks. | web/src/lib/sync.ts |
| Spend engine | Builds every flow: notes, memos, witness, signature, contract call to trusted addresses only. | flows.ts, adapters.ts, positions.ts |
| Prover worker | Noir + bb.js (UltraHonk) in a Web Worker; circuits pinned by SHA-256. | web/src/lib/prover.ts |
| Account helpers | Public XLM balance, trustline checks and adding one from the wallet. | web/src/lib/account.ts |
Off-chain services
| Service | Does | Code |
|---|---|---|
| Indexer ingest | One writer pages getEvents into Postgres with consistency guards and retries. More | indexer/src/ingest.ts |
| Indexer API | Notes by detection key, nullifier buckets, tree chunks, pending deposits, the event feed. API | indexer/src/api.ts |
| Relayer | Allowlist and fee checks, simulation, submission from channel accounts. More | relayer/src/relayer.ts |
| ASP service | Screens pending deposits; admits or rejects through wisp_asp. More | asp/src/service.ts |
| Caddy · Prometheus | TLS and routing with private routes unlogged; metrics and 9 alerts. More | ops/ |
Soroban contracts (contracts/)
| Contract | Entry points | Role |
|---|---|---|
wisp_pool | deposit, admit, ragequit, shielded_transfer, shielded_split_transfer, shielded_merge_transfer, private_transfer, deposit_sealed, timelocked propose / execute | Tokens, tree (depth 20; last 128 roots valid), nullifiers, asset registry, pending deposits |
| Verifiers ×7 | deposit, transfer, shielded_transfer, split_transfer, merge_transfer, sealed_deposit, position_auth | Immutable UltraHonk verifiers, one per circuit |
wisp_asp | admit, reject | The only address the pool accepts admissions from |
swap_adapter | swap, finalize | Note → Soroswap → held output → re-shielded note |
yield_adapter | open, finalize | Note ↔ DeFindex shares, same two phases |
position_manager · Position | fund, outflow, cancel_authorization, sweep_to_market | One Blend account per loan at a deterministic address |
Notes, memos and keys
A note is a private record kept off-chain; only its commitment goes on-chain, with an encrypted memo only its owner can open. Every key comes from one wallet signature.
The note
| Field | Size | Source |
|---|---|---|
secret, nullifier | 32 bytes each | Random, from the sender's browser |
amount | u128 | Raw token units, range-checked in every circuit |
assetId | u32 | Index in the pool's asset registry, from 1 |
owner key index i | u64 | Random in [232, 253); carried in the memo |
ownerPubkeyHash | field | Poseidon2(pkX, pkY) of owner key i |
| Derived value | Formula |
|---|---|
| commitment | Poseidon5(secret, nullifier, amount, assetId, ownerPubkeyHash) |
| nullifier hash | Poseidon2(nullifier, ownerPubkeyHash), published when spent |
| seal | Poseidon2(secret, nullifier), fixes an adapter output in advance |
| memoHash | keccak256(memos) >> 8, binds memos to the proof |
| specHash | keccak256(XDR(spec)) >> 8, binds adapter and money-market terms |
The 172-byte memo
Every new leaf carries exactly one memo: a 32-byte one-time key E, a 16-byte stamp (an HMAC under the receiver's detection key, which the indexer can match but not read), and a 124-byte secret part encrypted to the receiver's view key (nonce + AES-256-GCM of a 96-byte plaintext + tag). The plaintext holds the kind (note or adapter output), secret, nullifier, amount, asset and the owner's key index.
Keys from one wallet signature
| Key | Derived as | Kept |
|---|---|---|
| seed | keccak256("shield-bjj-v1" ‖ sig) mod p, from the SEP-53 signature of a fixed message | Tab memory only |
note owner key i | Poseidon3(seed, 1, i) | Tab memory only |
position key j | Poseidon3(seed, 4, j), j = 0, 1, 2, … | Tab memory only |
| view key | Poseidon3(seed, 5, 0) as X25519 | localStorage (reads, can't spend) |
| detection key | Poseidon3(seed, 6, 0) as X25519 | localStorage, given to the indexer |
| bucket decoys | Poseidon3(seed, 7, i) | localStorage |
Receive address: wisp:<owner hash>:<detectPub>:<viewPub>:<index>. A bare G… or C… address is refused as a private target: a note sent there could never be spent.
Circuits
Seven Noir circuits, proved with UltraHonk (ZK flavor) in the browser and verified on Soroban.
| Circuit | Proves | Signed by |
|---|---|---|
deposit | The commitment encodes exactly this amount, asset and owner | — |
sealed_deposit | As deposit, plus the preimages match a seal fixed earlier | — |
transfer | Membership, nullifier, signature; fee ≤ amount (withdraw, adapters, funding) | Note owner |
shielded_transfer | input = output + fee, same asset | Note owner |
split_transfer | input = receiver + change + fee, both non-zero | Note owner |
merge_transfer | Two notes under one root, same asset, left + right = output + fee | Both owners |
position_auth | The position key signed the spec | Position key |
Measured with bb.js 0.87.0 on 8 threads: transfer 3.5 s, split 5.5 s, merge 7.5 s. On-chain verification costs 113M–122M instructions of the 400M per-transaction limit. Toolchain: nargo 1.0.0-beta.9, pinned in tools/versions.env; artifacts pinned in circuits/manifest.json.
Quick start
Run Wisp locally against the live testnet deployment, then connect a wallet.
Prerequisites
- Node 22, Docker, and the repo's dependencies (
npm ci). - Built contracts (
stellar contract buildincontracts/) and compiled circuits (circuits/target/).
Run it
npm run dev # Postgres, indexer, relayer, ASP service and the web app
# open http://localhost:5173 · Ctrl+C stops everything
Each log line is labelled with its service. It refuses to start while the hosted relayer or ASP run (they share testnet accounts) or a port is taken.
cd web && npm run dev
# wisp indexer → http://127.0.0.1:8088, relayer → http://127.0.0.1:8088
Finds backends that are already running (the Docker stack on 127.0.0.1:8088, or 4200/4300) and forwards to them, so only port 5173 needs forwarding.
docker compose -f ops/compose.yml -f ops/compose.behind-proxy.yml up -d --build
Postgres, indexer, relayer, ASP, Caddy and Prometheus. See Hosting and Deploy.
npm run e2e -w @wisp/web # every flow in a real browser against testnet (~15 min)
Starts its own stack on 4200/4300/4400; stop the dev and Docker relayer and ASP first.
Connect a wallet
- Install Varial (the Stellar Development Foundation's wallet, formerly Freighter) and switch it to Testnet (the network name at the top, or Settings → Network). Wisp checks the network and says so if it's wrong. Without Varial, the app shows Install Varial instead.
- Fund the account:
curl "https://friendbot.stellar.org/?addr=G…". - Click Connect Varial, then Unlock private keys and approve the one signature (no transaction, no fee).
OFFER_OTHER_WALLETS in web/src/lib/wallet.ts). MetaMask's Stellar support only knows mainnet, so it would never be offered on testnet.Using the app
A tour of every screen, and what each number means.
Overview cards
- Shielded balance per asset, from your spendable notes.
- Notes: spendable, screening and spent.
- Public balance: your wallet's XLM, read from Stellar RPC ("Not funded" for a new account).
Shield
Deposit from your account; the wallet signs. The deposit then waits for screening, about a minute on testnet; the page updates on its own when it's admitted, and until then you can take it back.
Send privately, split, merge
Send to a wisp: receive address (the receiver copies it from Receive). Split note previews both resulting notes after the 0.01 XLM fee; Merge notes joins two notes of one asset.
Withdraw
A whole note to any Stellar address. For classic assets such as USDC the recipient needs a trustline; if it's your own account, the app offers Add USDC to your wallet.
Swap
Pick a note and the token to receive; a Soroswap quote shows what you pay, about what you receive, the minimum (quote less 1%) and the rate, refreshed every 15 seconds. If less than the minimum would arrive, nothing is spent.
Vault
Your vault position shows the share price now, its change since your average entry (the index increase), your shares and their value, deposited and withdrawn, and profit or loss.
Money market
Each position shows, per asset: tokens × index = amount now, the net supplied or borrowed, and the interest earned (collateral) or owed (debt), plus net interest at oracle prices. Health and maximums keep a 5% margin above liquidation.
Transaction toasts
Every finished action shows a toast linking its transactions on Stellar Expert (two for swaps and vault moves).
Deploy
The web app is static and goes on Netlify; the backends need a server with a public IP and a domain.
1. Backends
export WISP_API_HOST=api.example.org WISP_ACME_EMAIL=ops@example.org
export WISP_APP_ORIGIN=https://<site>.netlify.app
docker compose -f ops/compose.yml -f ops/compose.public.yml up -d --build
Caddy gets a Let's Encrypt certificate and answers on 80/443.
# ops/.env: WISP_APP_ORIGIN=https://<site>.netlify.app
docker compose -f ops/compose.yml -f ops/compose.behind-proxy.yml up -d --build
sudo cp ops/nginx/wisp-stellar-api.conf /etc/nginx/sites-available/wisp-stellar-api
sudo ln -s /etc/nginx/sites-available/wisp-stellar-api /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d wisp-stellar.213-136-69-252.sslip.io --redirect
Caddy stays on 127.0.0.1:8088; nginx terminates TLS with access logs off for the private routes.
2. Web app on Netlify
VITE_INDEXER_URL=https://<api host>/index/v1 \
VITE_RELAYER_URL=https://<api host>/relay/v1 \
npm run build:netlify -w @wisp/web
npx netlify-cli deploy --prod --dir web/dist-netlify --site <site>
Or drag web/dist-netlify onto app.netlify.com/drop. The build refuses non-https URLs; _headers sets cross-origin isolation (fast proving), no framing, and revalidation for circuits.
3. Check
curl https://<api host>/index/v1/health→"ok": true- The app's bottom left reads Indexer: in sync and Relayer: ready.
- In the console,
crossOriginIsolatedistrue.
docs/deploy.md.Keys and unlock
One SEP-53 signature gives every key the wallet will use. The same account always gives the same seed, so a new device finds everything again.
sequenceDiagram
autonumber
actor U as User
participant UI as Wisp app
participant W as Wallet
participant KR as Key ring (tab memory)
participant LS as localStorage
U->>UI: Connect Varial
UI->>W: requestAccess, then getNetwork (must match)
W-->>UI: G-address
U->>UI: Unlock private keys
UI->>W: signMessage(KEY_DERIVATION_MESSAGE), SEP-53
W-->>UI: 64-byte Ed25519 signature
UI->>W: signMessage again (first unlock on a device)
W-->>UI: the same signature, or the wallet is refused
UI->>KR: seed = keccak256("shield-bjj-v1" ‖ sig) mod p
KR->>KR: note keys (seed,1,i) · position keys (seed,4,j) · view (5) · detection (6) · decoys (7)
KR->>LS: view and detection keys, key book, decoys, receive index
Note over KR: The seed and owner keys never leave tab memory
- The wallet shares its address; Wisp checks its network.
- On a device's first unlock the wallet signs twice and both must match, or its keys wouldn't be reproducible.
- Only view-only material is stored: after a reload notes show ("View only"); spending needs Unlock again.
Note sync
How a wallet finds its notes and learns which are spent, without the indexer reading anything.
sequenceDiagram autonumber participant B as Browser (sync) participant IA as Indexer API participant SW as Stamp workers participant PG as Postgres participant P as wisp_pool B->>IA: POST /notes with the detection key IA->>PG: leaves after this key's watermark, in batches IA->>SW: check each 16-byte stamp under the detection key SW-->>IA: matching leaves IA->>PG: save matches and the new watermark, under an HMAC of the key IA-->>B: matching records with each adapter note's origin, and open outputs B->>B: open memos with the view key, derive the one key each names, recompute commitments B->>IA: POST /buckets with own buckets padded by decoys IA-->>B: every spent nullifier in those buckets B->>IA: GET /tree/chunk/n when building a spend IA-->>B: public leaves B->>P: is the rebuilt root known? P-->>B: yes, or the tree is refused
Shield and screening
Deposits move your own funds, so your wallet signs them. A deposit waits outside the tree until the ASP admits it.
sequenceDiagram
autonumber
participant B as Browser
participant W as Wallet
participant P as wisp_pool
participant I as Indexer
participant AS as ASP service
participant A as wisp_asp
B->>B: new note for a fresh own key, prove deposit
B->>W: sign deposit(depositor, asset, amount, note, proof)
W->>P: transaction, the wallet authorizes the token transfer
P->>P: verify, take the tokens (balance re-checked), store as pending
P-->>I: deposit_pending event
loop every few seconds
AS->>I: GET /deposits/pending, the same list for everyone
AS->>P: confirm it's still pending
AS->>AS: at least 12 ledgers old, depositor not denylisted
end
alt passes
AS->>A: admit(commitments)
A->>P: admit: insert leaves, emit note_memo
B->>I: watcher sees it leave the pending list, syncs
else rejected
AS->>A: reject(commitments, reason)
end
opt before admission
B->>W: sign ragequit(commitment)
W->>P: ragequit: exactly the amount back
end
Send privately
To a receive address. A whole-note send is a shielded_transfer; anything less is a split with change back to you.
sequenceDiagram autonumber participant RB as Receiver's browser participant S as Sender's browser participant R as Relayer participant P as wisp_pool participant I as Indexer RB-->>S: receive address wisp:ownerHash:detectPub:viewPub:index S->>S: receiver note, change note (fresh own key), fee note to the relayer S->>S: memos, memoHash, EdDSA signature, prove split_transfer S->>R: POST /relay/v1/submit R->>R: allowlist, decode, open the fee memo, fee at least 0.01 XLM, simulate R->>P: submit from a channel account, the relayer pays the network fee P->>P: root known, nullifier unused, verify, insert 3 leaves P-->>I: note_memo events RB->>I: POST /notes I-->>RB: the new record, opened locally
Split and merge
Reshape your own notes. On chain both look like any private transfer.
Split
flowchart LR N["Your note
1.99 XLM"] --> SP{{"split_transfer proof
signed by the note's key"}} SP --> A["Note A · 0.49 XLM
fresh own key"] SP --> B2["Note B · 1.49 XLM
fresh own key"] SP --> F["Fee note · 0.01 XLM
to the relayer"]
Merge
flowchart LR
L["Note left"] --> MP{{"merge_transfer proof
signed by both owners' keys"}}
RR["Note right"] --> MP
MP --> O["Merged note
fresh own key"]
MP --> F["Fee note
to the relayer"]
Both merged notes must share a tree root and an asset; the circuit checks left + right = output + fee.
Withdraw
A whole note to any Stellar address, as a public amount of one asset.
sequenceDiagram
autonumber
participant B as Browser
participant RPC as Stellar RPC
participant W as Wallet
participant R as Relayer
participant P as wisp_pool
participant T as Token contract
B->>RPC: classic asset? do the recipient and relayer have its trustline?
alt your own account lacks the trustline
B->>W: sign changeTrust (public, 0.5 XLM reserve)
end
B->>B: prove transfer: recipient, amount, asset, relayer, fee, specHash = 0
B->>R: submit private_transfer
R->>P: fee checked, simulated, submitted
P->>T: amount minus fee to the recipient, fee to the relayer
Swap
The output amount is only known when the swap runs, so the browser fixes the future note's key and preimages in advance (the seal), inside the signed spec.
sequenceDiagram autonumber participant B as Browser participant R as Relayer participant SA as swap_adapter participant P as wisp_pool participant SS as Soroswap router B->>SS: quote: what the note less the fee would give B->>B: SwapSpec: asset out, min_out (quote less 1%), path, deadline, seal, memoHash B->>R: submit swap(proof, w, spec, memo) R->>SA: submit SA->>P: private_transfer, the adapter is caller and recipient SA->>SS: swap, authorizing exactly the router's pull SS-->>SA: output, kept only if at least min_out B->>B: prove sealed_deposit(amount out, asset, key K, seal) B->>R: submit finalize R->>SA: submit SA->>P: deposit_sealed: your new note
The quote
The Swap tab shows Soroswap's quote before you confirm: you pay (the note less the 0.04 XLM fee), you receive about, at least (quote less 1%) and the rate. The swap uses the same quote function. On testnet, the quote, the router and the amount received matched exactly.
Vault
The same two phases with DeFindex. Shares stay shielded as dfXLM notes (pool asset 3).
sequenceDiagram autonumber participant B as Browser participant R as Relayer participant YA as yield_adapter participant P as wisp_pool participant DX as DeFindex vault B->>R: submit open(proof, w, VaultSpec, memo) R->>YA: submit YA->>P: private_transfer to itself YA->>DX: deposit (XLM in, shares out) or withdraw (shares in, XLM out) DX-->>YA: output, held by the adapter B->>R: submit finalize with a sealed_deposit proof YA->>P: deposit_sealed: the output becomes your note
Your vault position
share price = managed funds ÷ shares outstanding (read live)
entry price = XLM deposited ÷ shares received (your vault events)
profit / loss = value of shares now + XLM withdrawn − XLM deposited
Deposits and withdrawals come from each note's origin, the public event that created it, so the numbers are the same on any device. Amounts are before relayer fees.
Money market
Each loan gets its own Position contract and key. Money in is authorized by a note owner; money out only by the position key.
Fund: supply or repay
sequenceDiagram autonumber participant B as Browser participant R as Relayer participant M as position
manager participant Pos as Position (key j) participant P as wisp_pool participant BL as Blend pool B->>B: FundingSpec (key hash, 0 supply or 1 repay), prove transfer to the Position B->>R: submit fund(proof, w, spec) R->>M: submit M->>Pos: deploy on first funding (salt = key hash), then pull Pos->>P: private_transfer, the Position is caller and recipient Pos->>BL: supply collateral, or repay (surplus supplied)
Outflow: borrow or withdraw collateral
sequenceDiagram autonumber participant B as Browser participant R as Relayer participant M as position
manager participant Pos as Position participant BL as Blend pool participant P as wisp_pool B->>B: OutflowSpec with nonce, deadline, fee and the output note fixed in advance B->>B: prove position_auth and sealed_deposit B->>R: submit outflow R->>M: submit M->>M: verify position_auth, check and use the nonce M->>Pos: submit Pos->>BL: borrow, or withdraw collateral (Blend checks health) M->>P: deposit_sealed: the rest becomes your note, the fee goes to the relayer
Interest
amount now = Blend tokens × index (b_rate for collateral, d_rate for debt)
principal = net supplied, or net borrowed (the position's public events)
interest = amount now − principal (earned on collateral, owed on debt)
The browser reads the position events from the indexer's public feed and keeps its own locally, so the indexer can't tell which positions are yours. A repayment that also paid interest counts against principal, a small approximation.
Indexer
One ingest worker turns contract events into Postgres rows; the API serves public data and stamp matches.
flowchart TD A["getEvents from the cursor
up to 500 events, at most 10,000 ledgers"] --> B["decode"] B --> C{"from the contract
that may emit it?"} C -- no --> H["INGEST HALTED"] C -- yes --> D{"leaves contiguous,
roots match?"} D -- no --> H D -- yes --> E["one transaction:
rows + cursor + scanned-to ledger"] E --> F{"events on this page?"} F -- yes --> A F -- no --> G["wait POLL_MS"] --> A A -.->|"RPC or database error"| X["retry, backoff up to 30 s"] X -.-> A
- Only consistency guards stop ingest; everything else is retried.
- The scanned-to ledger is the one each page's cursor reached: a quiet pool shows no lag, a catching-up indexer its real lag.
- First syncs spread stamp checks over worker threads (
STAMP_WORKERS): 11.4 s → 1.7 s p95 at 50,000 leaves.
Relayer
Its checks protect its own fees; the proof already protects the user. It can delay or refuse, never change a term.
flowchart TD
A["POST /relay/v1/submit"] --> B{"contract and function
allowlisted?"}
B -- no --> X["403"]
B -- yes --> C["decode args"]
C --> D{"fee terms"}
D -->|"send · split · merge"| D1["open the fee note's memo,
at least minFeeNote"]
D -->|"withdraw · fund · outflow"| D2["public fee to this relayer"]
D -->|"swap · vault phase 1"| D3["at least minAdapterFee"]
D -->|"finalize · cancel"| D4["no fee"]
D1 & D2 & D3 & D4 --> E["simulateTransaction"]
E --> F{"no authorization needed,
fee under the cap?"}
F -- no --> X2["422"]
F -- yes --> G["margins, least-busy channel account"]
G --> H["send, poll until final, 200"]
Fees (testnet)
| Action | Fee | Paid as |
|---|---|---|
| Send · split · merge | 0.01 XLM | A shielded fee note |
| Withdraw | 0.02 XLM · 0.01 USDC | Public, from the note |
| Swap · vault (both phases) | 0.04 XLM · 0.08 WSPB · 0.000024 dfXLM | Public, from the note |
| Money market | Fund: the withdraw fee; outflow: the fee in its signed spec | Public |
Allowlist: pool shielded_transfer, shielded_split_transfer, shielded_merge_transfer, private_transfer; swap swap, finalize; vault open, finalize; manager fund, outflow, cancel_authorization. Deposits are never relayed.
ASP service
Screens pending deposits and admits or rejects them through wisp_asp. Stateless: every decision is on chain.
flowchart TD
A["every POLL_MS"] --> B["GET /deposits/pending"]
B --> C["confirm each against the pool"]
C --> D{"policy"}
D -->|"denylisted"| R["wisp_asp.reject
(still ragequittable)"]
D -->|"younger than 12 ledgers"| WT["wait"]
D -->|"passes"| AD["wisp_asp.admit, up to 5 per batch"]
Testnet policy: minimum age 12 ledgers, batches of 5, an empty denylist. If it stops, deposits wait, and depositors can still take them back.
API reference
JSON over HTTPS. Amounts are raw token units as strings; hashes and keys are 0x hex. Errors are {"error": {"code", "message"}}.
Indexer /index/v1
Records whose stamps match a detection key, from this key's watermark. Access logs off.
→ { "detectionKey": "0x…64 hex" }
← { "scannedTo": 205,
"notes": [{ "leafIndex": 94, "commitment": "0x…", "ownerHash": "0x…", "memo": "0x…", "ledger": 5462822,
"publicAmount": "…", "publicAssetId": 3,
"origin": { "venue": "vault", "deposit": true, "assetIn": 1, "amountIn": "29600000",
"assetOut": 3, "amountOut": "17749" } }],
"outputs": [{ "id": "0x…", "venue": "swap", "ownerHash": "0x…", "memo": "0x…", "assetId": 4, "amount": "…", "ledger": … }] }
→ { "bits": 7, "buckets": [3, 17, 88] } // bits 0..24, 1..256 buckets
← { "nullifiers": [{ "nullifierHash": "0x…", "ledger": 5452010 }] }
/head ← { "lastLedger", "leaves", "root", "nullifiers", "bucketBits" }
/health ← { "ok": true, "lastLedger", "latestLedger", "lagLedgers": 0 }
/tree/chunk/0 ← { "chunk": 0, "chunkSize": 1024, "leaves": ["0x…", …] }
← { "deposits": [{ "commitment", "depositor", "assetId", "amount", "ledger", "txHash", "rejected": null }], "next": null }
← { "events": [{ "id", "kind": "position_funded", "ledger", "txHash",
"data": { "action": 0, "amount": "199800000", "asset_id": 1, "position": "C…" } }],
"next": "5451551:0023414233257529344-0000000007" }
Relayer /relay/v1
→ { "contract": "C…", "fn": "shielded_split_transfer", "args": ["base64 XDR", …] }
← { "hash": "…64 hex", "ledger": 5465073 }
✕ { "error": { "code": "simulation_failed", "message": "contract error #13", "contractError": 13 } }
Refusals: contract_not_allowed, function_not_allowed (403); bad_args (400); fee_not_ours, fee_unreadable, fee_mismatch, fee_asset, fee_too_low, simulation_failed, needs_authorization, fee_cap (422); send_failed, tx_failed (502); rate_limited (429).
/info ← { "address": "G…", "pool": "C…", "feeReceiveAddress": "wisp:…", "functions": […],
"adapters": { "swap": { "id", "functions" }, … }, "minFeeNote", "minWithdrawFee", "minAdapterFee" }
/health ← { "ok": true, "balance": "99884085482", "floor": "100000000", "channels": 1 }
/status/<hash> ← { "status": "SUCCESS" | "FAILED" | "NOT_FOUND", "ledger"? }
ASP /asp/v1
/policy ← { "asp": "C…", "operator": "G…", "minAgeLedgers": 12, "maxBatch": 5, "denylistSize": 0, "ragequit": "…" }
/health ← { "ok": true, "lastRun": { … } }
/metrics on each service returns 404 from outside; Prometheus scrapes them directly.Testnet deployment
Stellar testnet (passphrase Test SDF Network ; September 2015), RPC https://soroban-testnet.stellar.org. Deployed 2026-10-29 at ledger 5451356; source of truth: deployments/testnet.json.
Contracts
| Contract | Address |
|---|---|
wisp_pool | CCKI65JESNH7YKV364UZMPIBBES4L3WT3V35MEIEIMZZLBYJCV225HBT |
wisp_asp | CCRBFY552A3AH2Q4HHJKYKJ72YDVK57XLM7J6ONURHOAGBVZJCEVLDGI |
swap_adapter | CD5P2UPRIJMVPXENPBKHNUC6NUCUBZFK6KN5HBZROFJRAPRYBYV26A65 |
yield_adapter | CCXWVT7LMXQZSVAW4GDJ57AZDFY5ULWUY7JAGCDQX5A3BP2KU7CE7GQG |
position_manager | CBH5DOXHPKR5KGOO6ZRFFZGVT5F5VWQ3TPCQ3LNQGGMXJWR3GFZTVPI7 |
Verifiers
| Circuit | Verifier |
|---|---|
| deposit | CAFD23GGZTFY52WMW7V4ZNG7KJLESZOZLTQR2IBPIT7M7USU47XHSO47 |
| transfer | CANIGPXDAQYPON6PZGCJC7PQ2XIQD4F3JJQTHZTCKTHCJLWZPQIHOUSB |
| shielded_transfer | CD3UDKN76NRXNIDA7NTXQG2XYXV5N6FPRTFP5YK7TP5ILJCQLU27CWRQ |
| split_transfer | CCL4J7RUXEBNVVBZ4DUIT5ZZCSTKNKDIHDWLLF5FUZPY6TIOLMHAUOOO |
| merge_transfer | CDXII6EFRA6KQWZ2YS2YGMSNPFDG7ZC5I3AI77PJW6FZF26TKKI3QIEH |
| sealed_deposit | CBBGUV5SYKPVDYAWQ5KEX6CXMJHURUY22DEUVN42HBL4HPO6DPGBP6OC |
| position_auth | CDAMYQGXA5GTW5S2ZHBSIDUWW34AYCW6FWKQ4YRAF2EERAUCC5OUFF5I |
Assets
| Id | Asset | Token contract |
|---|---|---|
| 1 | XLM | CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC |
| 2 | WSPB | CAAKUFDD3RSVNP6GDIR2X56F4G5UVBEIY34Q5I6ITLIGGWRKRNHSBSGT |
| 3 | dfXLM (vault shares) | CCLV4H7WTLJQ7ATLHBBQV2WW3OINF3FOY5XZ7VPHZO7NH3D2ZS4GFSF6 |
| 4 | USDC (Blend testnet) | CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU |
Venues and accounts
| What | Address |
|---|---|
| Soroswap router | CCJUD55AG6W5HAI5LRVNKAE5WDP5XGZBUDS5WNTIVDU7O264UZZE7BRD |
| DeFindex XLM vault | CCLV4H7WTLJQ7ATLHBBQV2WW3OINF3FOY5XZ7VPHZO7NH3D2ZS4GFSF6 |
| Blend pool · oracle | CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF · CAZOKR2Y5E2OSWSIBRVZMJ47RUTQPIGVWSAQ2UISGAVC46XKPGDG5PKI |
| Relayer account | GAAVDIB24M4LA2MNXXAFBZPC6E4FJ7Q76FT2PPI2VYSWKMOIXGZY6XNN |
| ASP operator · admin | GDETPDQHFU45GA2A2NBXJI7F62LZ7JVAMCEKFVNGXI3GCUOCKZN7KQOI · GDEHPGG2MZIDRLSJZO7CJXNZDLM3JGMGDIYMBMZHDZ7HLMS2NQSFN6AB |
Limits
- Soroban per-transaction CPU limit 400M; ZK verification about 120M, a depth-20 insert about 22.5M.
- RPC keeps about 7 days of events, scanned at most 10,000 ledgers per request.
- The last 128 roots (plus each full tree's final root) stay valid.
Hosting
The Docker stack
flowchart TB U["Browser on Netlify"] -->|"https://api host"| PX["nginx or Caddy
TLS"] subgraph host["Docker compose · wisp-stellar"] CAD["Caddy · routing"] IA["indexer-api :4200"] IG["indexer-ingest"] RL["relayer :4300"] AS["asp :4400"] PG[("postgres")] PR["prometheus 127.0.0.1:9095"] end PX --> CAD CAD -->|"/index/v1/*"| IA CAD -->|"/relay/v1/*"| RL CAD -->|"/asp/v1/*"| AS IA --> PG IG --> PG PR -.->|"/metrics, internal only"| IA & RL & AS
The dev stack
flowchart LR
L["npm run dev"] --> PGD[("Postgres :5432")]
L --> IGD["ingest, creates tables"]
IGD -->|"then"| IAD["indexer API :4200"]
IGD -->|"then"| RLD["relayer :4300"]
IGD -->|"then"| ASD["ASP :4400"]
IGD -->|"then"| VITE["Vite :5173
proxies the APIs"]
BR["Browser, forward only :5173"] --> VITE
Monitoring and alerts
Every alert has a promtool rule test in CI (ops/prometheus/alerts.test.yml); all but DepositsWaitingLong have fired and resolved in live drills.
| Alert | Fires when | Hold | Runbook |
|---|---|---|---|
| IndexerDown | Indexer API unreachable | 2 min | indexer-stuck |
| IndexerNotIngesting | No ingest write for over 2 minutes | 2 min | indexer-stuck |
| IndexerLagging | More than 60 ledgers behind RPC | 5 min | indexer-stuck |
| RelayerDown | Relayer unreachable | 2 min | relayer-drained |
| RelayerLowBalance | Poorest submitting account below 100 XLM | 1 min | relayer-drained |
| RelayerRejectingMost | Rejections over 3× successes | 15 min | relayer-drained |
| AspDown | ASP unreachable, or no run for 5 minutes | 2 min | asp-down |
| AspFailing | The last ASP run failed | 5 min | asp-down |
| DepositsWaitingLong | An unscreened deposit has waited over a day | 10 min | asp-down |
Runbooks and tools
Runbooks (docs/runbooks/)
- indexer-stuck: halts, lag, slow first syncs, re-ingesting.
- relayer-drained: low balance, trustlines, rejections.
- asp-down: deposits waiting, ragequit as the exit.
- contract-bug: pausing deposits, upgrades, replacing contracts without an upgrade path.
- ttl-and-reset: TTL extensions and testnet resets.
Deployment tools (packages/wisp-testnet/scripts/)
| Script | Does |
|---|---|
deploy.ts | Deploys and wires everything from scratch; archives the old deployment |
verify-deployment.ts | 23 invariants: verifier keys, recorded code, allowlists, assets, venues |
upgrade-pool.ts | Timelocked pool upgrade with state checks |
deploy-positions.ts | Deploys or replaces position_manager, keeping a retired one allowlisted while it holds positions |
ttl-keeper.ts | Checks and extends TTLs |
relayer-trustlines.ts | Opens the relayer's trustlines for classic fee assets |
What is public
| Flow | Public | Private | Submitted by |
|---|---|---|---|
| Shield | Account, amount, asset | Which note is yours | Wallet |
| Send · split · merge | That a private transfer happened | Parties, amounts, asset | Relayer |
| Withdraw | Amount, asset, recipient | Which note paid | Relayer |
| Swap · vault | Amounts and assets in and out, route or vault | Who paid, who holds the output | Relayer |
| Money market | Each position's collateral, debt and movements | Which wallet owns it | Relayer |
| Ragequit · trustline | Account and amount | — | Wallet |
Trust model
| If this is compromised | It can | It cannot |
|---|---|---|
| Indexer | Link a wallet's notes; see times and IPs; withhold notes | Read a memo, spend or fake a note, serve a tree whose root the pool doesn't know |
| Relayer | Delay or refuse; see the finished call and IPs | Change any term of a proof |
| ASP service | Delay or refuse admission | Freeze a deposit (ragequit), move funds |
| A profile with stored view keys | Read that wallet's notes | Spend them without the signature |
| The website's code | Read the seed in an unlocked tab, then spend: the remaining custody risk | Touch a wallet that never unlocks on it |
| Admin key (testnet: one key, timelock 0) | Upgrade the pool, swap verifiers or adapters | Change verifiers in place, pause exits |
Security review
An internal review by the builders, not an audit. Full text: docs/security-review.md.
| # | Severity | Finding | Status |
|---|---|---|---|
| F1 | Low | position_manager treated any non-zero action as repay or withdraw | Fixed, replaced on testnet |
| F2 | Medium (operational) | Adapters and the manager have no upgrade path | Open: retire only when empty |
| F3 | Medium (testnet) | One admin key, timelock 0 | By design for testnet |
| F4 | Low | A bounded root window allows root-churn griefing | Accepted for testnet |
| F5 | Medium | Indexer ordered by a text cast | Fixed |
| F6 | High | Concurrent leaf inserts trapped | Fixed (decision 0010) |
| F7 | Info | The verifier is unaudited PR #38 code | In audit scope |
| F8 | Info | Relayer trustlines and first TTL extension | Operational |
| F9 | Medium | Relayer metric labels taken from the caller | Fixed |
| F10 | Low | A quiet pool read as indexer lag | Fixed |
| F11 | Low | An alert that couldn't fire with no successes | Fixed |
| F12 | Low | Rejected deposits counted as waiting | Fixed |
| F13 | Medium (operational) | Ingest exited on a transient RPC error | Fixed |
Decisions
Settled design choices, in docs/decisions/.
| # | Decision |
|---|---|
| 0001 | Proof system and on-chain verifier (UltraHonk ZK, PR #38) |
| 0002 | No ASP until after T1 (superseded by 0008) |
| 0003 | Spend circuits carry a relayer fee |
| 0004 | Browser (WASM) proving is the default from T1 |
| 0005 | Upgradeability and admin keys |
| 0006 | Merkle tree shape and root history |
| 0007 | Indexer leakage accepted for testnet, with a full-sync escape later |
| 0008 | ASP by admission, with ragequit |
| 0009 | Adapters re-enter through deposit_sealed; real testnet venues |
| 0010 | Valid roots in a fixed-key ring, so concurrent inserts don't collide |
Glossary
| Term | Meaning |
|---|---|
| Note | A private record of value; only its commitment is on chain |
| Commitment | Poseidon5(secret, nullifier, amount, assetId, ownerHash), a leaf of the tree |
| Nullifier hash | Published when a note is spent; stops double spends without saying which note |
| Memo | The 172-byte encrypted record emitted with every new leaf |
| Stamp | The 16-byte HMAC the indexer matches with a detection key, without reading the memo |
| View / detection key | The keys that open memos and match stamps |
| Receive address | wisp:<owner hash>:<detectPub>:<viewPub>:<index> |
| Seal | Poseidon2(secret, nullifier), fixing an adapter output in advance |
| Spec / specHash | Signed terms of an adapter or money-market action, and their hash |
| ASP | Association Set Provider: screens and admits deposits |
| Ragequit | Taking a pending deposit back, with no ASP involved |
| Index (Blend) | b_rate / d_rate: underlying per b-token or d-token; it only rises |
| SEP-53 | Stellar's standard for signing off-chain messages |
docs/architecture.md · last updated 2026-10-30