Threat model
Kalypso's full threat model: the risk profile, the attackers and their goals, and the 57 invariants that define done.
Status: written at the architecture gate on 7 Oct 2026, before any contract code, and kept current as the code lands. Section C is the definition of done: a piece of the system is finished only when every invariant in C that applies to it is upheld and a test shows it.
What the system is: an employer pays a team in confidential USDC on Stellar. The public ledger shows that payments happened, not how much. Each worker reads only their own pay. The company's accountant holds an auditor key that reads every amount the company paid. Workers can withdraw to plain USDC and cash out at an anchor. It is built on OpenZeppelin's confidential token for Stellar (the 31 Jul 2026 revision, commit 98090b3 of OpenZeppelin/stellar-contracts), plus a payroll contract of our own, a web app, a fee sponsor and an event archive. Testnet only.
Upstream references below point at OpenZeppelin/stellar-contracts at commit 98090b3 unless they say otherwise.
A) App-class risk profile
This is a multi-tenant payments system that combines four security classes:
- A delegated money mover: a shared contract moves one party's funds to others under that party's signature, with each company's state in one contract.
- A confidentiality system: secrecy rests on keys derived in the browser and on random salts, and the hidden data (salaries) is easy to guess, because anyone can encrypt a candidate amount and compare.
- A service that pays for and relays user-supplied transactions, and in demo mode may act for anonymous visitors.
- A service that copies upstream data (RPC events) into its own store and serves it back as the history wallets rebuild balances from.
It also renders and exports strings that other tenants wrote on chain, and talks to a third-party anchor through a sign-a-challenge login and a popup.
| # | Category | Applies? | The data flow it rides on |
|---|---|---|---|
| A1 | Broken authorization on contract writes | Yes | Adding and removing workers, opening, paying and closing runs all change company state. Pay moves treasury funds, and the token requires the treasury's own signature (packages/tokens/src/confidential/mod.rs, transfer). |
| A2 | Cross-tenant collision | Yes | One payroll contract, one token and one auditor registry serve every company. A caller-chosen run id that is not scoped to the company lets one company open, close or mark paid another company's run. Auditor ids are the only thing that separates companies inside the token. |
| A3 | Replay and double processing | Yes | Pay marks (run, worker) as paid, the console retries failed batches, and signed auth entries can sit with a relayer until they expire. A double pay is real money. |
| A4 | Ordering and races | Yes | Transfer proofs chain on the treasury's running balance, a relayer may submit in parallel, and demo users may share accounts. Outsiders cannot invalidate a spend proof, because incoming funds land in a separate receiving balance (docs/DESIGN.md). The treasury's own overlapping actions can. |
| A5 | Privileged key abuse | Yes | Verifier keys and auditor keys are set by a manager role (examples/confidential/verifier/src/contract.rs, examples/confidential/auditor/src/contract.rs). A swapped verification key lets forged withdraws drain every company's pooled USDC. |
| A6 | Cryptographic misuse | Yes, strongly | Proofs are built in the browser and keys are derived from a wallet signature or a passkey PRF. A reused salt lets an amount known from one operation decrypt another (docs/DESIGN.md). The host silently reduces non-canonical field values. |
| A7 | Privacy leaks by inference | Yes | Deposit and withdraw amounts are public. Transfer events name the employer and the worker. The roster is on chain. The sender's auditor sees the sender's running balance. The anchor sees cash-out amounts. |
| A8 | Client-side secret exposure | Yes | Derived keys and cached openings live in browser storage, proving libraries hold keys in memory, and any website can ask a wallet to sign our key-derivation message. |
| A9 | Open relay and cost abuse | Yes | The fee sponsor pays for what browsers send, demo actions run on our server, and the archive API is public. |
| A10 | Query injection | Narrowly | The archive API takes contract, account and cursor and reaches the database. |
| A11 | Stored content shown to other users (XSS, spreadsheet formula injection) | Yes | Any admin writes labels and metadata that reach the public page and the accountant's CSV export. Uploaded CSV filenames and cells are displayed. |
| A12 | Trusting upstream replies | Yes | The archive copies RPC, wallets rebuild from the archive, and a console might read statuses from a relayer reply. Balance integrity fails closed against on-chain commitments; which run a payslip belongs to does not, unless we check it. |
| A13 | Misusing a third-party login | Yes | SEP-10 and the SEP-24 popup. Signing an unchecked challenge can mean signing a real transaction. Trusting popup messages lets any window fake a finished withdraw. |
| A14 | Funds locked by lost keys or lost history | Yes | Keys are derived deterministically and the archive can have gaps. After RPC's 7-day window the money stays on chain but cannot be spent without history. |
| A15 | Server-side request forgery | Not as designed | The server only calls fixed hosts. It becomes live if any route takes a URL or domain from a request. C22 keeps it closed. |
| A16 | Reentrancy | Not in the classic form | Soroban blocks a contract from re-entering itself, and payroll only calls our own token. The close cousin does apply: the same worker twice in one batch must not be paid twice (C7). |
| A17 | Upgrade abuse | Depends on the upgrade decision | Soroban signatures bind the nested token call's exact arguments, so even a malicious payroll upgrade cannot move funds the admin did not sign for. It could still forge paid flags and payslip events. |
| A18 | File storage, path traversal, unsafe deserialization | No | The CSV is parsed in the browser and never stored. |
B) Threat model
Attackers considered: an anonymous internet user; a malicious company admin (one tenant); a malicious worker; a compromised or lying upstream (RPC, archive host, anchor, an npm package, a wallet extension); an insider holding our deploy or admin keys.
Trust boundaries
- TB1 Browser to contracts: every argument to the payroll and token functions. The contracts trust only what Soroban signature checks and proof checks establish.
- TB2 Browser to our server: the sponsor route, the demo routes, the archive API. Callers are anonymous.
- TB3 Our server to the relayer, and its reply back.
- TB4 Stellar RPC and Horizon to the archive job and the browsers: events, ledger entries, simulation, transaction status.
- TB5 Archive database to browsers: our stored copy, read back and used to rebuild balances.
- TB6 Wallet to app: the address and signatures the wallet returns, and the passkey PRF output. A malicious extension can pose as the wallet.
- TB7 Browser storage to app: cached openings and any persisted key material, read back.
- TB8 Anchor to browser: stellar.toml, the SEP-10 challenge, the JWT, the SEP-24 URL, popup messages, the transaction record.
- TB9 File to app: the employer's CSV.
- TB10 One tenant's on-chain data read by another tenant or the public: labels, metadata, rosters, auditor ids, events.
- TB11 Contract to contract: payroll to token, token to verifier, token to auditor, token to the USDC contract. A verifier's yes is only as trustworthy as whoever can change its keys.
Attacker-controlled inputs
- On chain: company id, run id, period label, expected count, metadata hash, auditor id (at company creation and at token registration), worker addresses, the pay items list (length, order, duplicates, ciphertext and proof bytes, non-canonical encodings), deposit's recipient and amount, withdraw's destination and amount.
- To our server: the whole transaction envelope (operation types and count, contract ids, functions, arguments, resource fee, fee, time bounds, network passphrase), auth entries (root call, nested calls, expiry), demo action names and parameters, archive query parameters and page size, headers, body size, request rate.
- In the browser: CSV bytes and filename (BOM, encodings, formulas, duplicates, muxed addresses, exponents, thousands separators), URL parameters and invite links, postMessage from any window.
- Indirect: RPC, Horizon, relayer and friendbot replies; archive rows; the anchor's toml, challenge, JWT, interactive URL and transaction record; wallet replies; PRF output; cached openings; events from other tenants and from any contract that copies our event shapes; dust deposits and transfers anyone can send to a treasury or worker (the token's
deposit(from, to, amount)lets anyone credit any account); the key stored under an auditor id; the verification keys.
Privileged position and assets
- The wrapper pools every company's deposited USDC. Forged withdraw proofs would drain all of it.
- The verifier's manager key decides which proofs pass.
- The auditor's manager key decides which public key each auditor id encrypts to, for every future transfer of every account bound to that id.
- The payroll contract holds rosters, runs and paid flags, and emits the payslip events workers and accountants read as proof of pay.
- The server environment holds the relayer key and the database URL.
- The sponsor can pay fees for any transaction it accepts.
- Each browser holds the user's encryption secret, cached openings and a live wallet connection. The employer's browser also holds the plaintext salary table. The accountant's holds the auditor secret: one key opens the company's whole history.
- After 7 days the archive is the only source of the events a wallet needs to spend.
- The public page and the demo are what visitors see first.
Attacker goals
- G1 Drain the pooled USDC: forged withdraws after a verification-key swap, a bug in the verifier or circuits, a replayed withdraw.
- G2 Read salaries they should not see: phish the derivation signature; XSS that reads keys and openings; a demo auditor id shared with real accounts; a worker bound to the wrong auditor id by default or by invite link; a manager rotating an auditor key to one it holds; a weak or reused salt; inference from public deposits and withdrawals.
- G3 Get paid twice, get paid when not owed, or make a worker believe they were paid: run id collision across companies, duplicates in one pay, a reopened run, a missing roster check, paid flags in expiring storage, a direct transfer or injected archive event shown as a payslip, a stale auth entry submitted later.
- G4 Use or seize our servers: an open fee relay; a signing oracle on demo keys; poisoning the archive; the relayer key leaking through the client bundle or logs; scraping the archive without limits.
- G5 Freeze payroll or lock funds: a run closed by someone else; out-of-order relay submissions; demo races; an auditor key rotated mid-run, which fails proofs already in flight; derived keys drifting; archive gaps after 7 days.
C) Defensive-programming standards (the definition of done)
Each line is an outcome the code must uphold, followed by how a test shows it.
Keys and admins
- C1 The token never accepts a proof the published circuits would reject. On-chain verification keys hash to the pinned 31 Jul hashes (register
e01ba872, transferb9c6d437, withdrawd800122c), and no key held on any server can change them. Show: a script hashes the on-chain keys the same way the pins were made, compares them, and lists every admin or manager address. None of those addresses is in the server environment. - C2 The key under a company's auditor id changes only by an act of that company's accountant, or never. No server-held key can register or rotate it. Show: a script lists who holds the role, and a rotation attempted with any server key reverts.
- C3 The wrapper is always solvent: the USDC it holds equals the sum of deposits minus the sum of withdrawals. Show: a script recomputes both sides from events and the USDC balance after the seed run and after every attack test.
Payroll contract
- C4 Every change to a company (add worker, remove worker, open run, pay, close run) needs that company's admin signature, and creating a company needs the named admin's signature. Show: one revert test per function, called by a non-admin, with real auth, not mocked auth.
- C5 Pay moves funds only from the company's stored treasury, only to the item's worker, and only when the treasury signed that exact transfer. Show: tests where an item names a worker not on the roster, and where a caller aims at another company's treasury.
- C6 Every run and paid record belongs to one company. Nothing company B does can create, read, close or mark paid anything of company A's. Keys are (company id, run id[, worker]), and the paid check takes the company id. Show: B opens A's run id, and A's run and flags are unchanged.
- C7 A worker is paid at most once per (company, run), across all transactions and retries, including the same worker listed twice in one call. A run id can be opened once per company, ever. Show: a duplicate inside one batch reverts the whole call; a second pay reverts; reopening a closed run reverts.
- C8 Pay succeeds only if the run exists, belongs to the company and is open, every worker is on the roster at call time, and the batch holds 1 to the measured maximum items. Anything else reverts the whole call with no event and no transfer. The payslip event is emitted only for a transfer that succeeded in the same call. Rosters, runs and paid flags live in persistent storage, never temporary, with their lifetime extended on write. Show: one test per rejection, plus a test that advances past the temporary lifetime and confirms a repeat pay is still blocked.
- C9 A company's auditor has one source of truth: the auditor id its treasury registered under in the token. The treasury is the company admin. It is checked when the company is created and again at every two-step admin handover: a company, or an incoming admin, whose registered auditor id differs from the company's is rejected. Show: a treasury registered under id 2 with a company naming id 1 reverts; a handover to an account registered under another id reverts.
Browser keys, crypto and privacy
- C10 A worker's auditor id never comes from a URL, invite link, archive reply or anything else an attacker can set. It is read from chain or app config, and the worker is shown that id, and who holds it, before signing registration. Show: an invite carrying another id does not change the registration call.
- C11 Every proof uses a fresh salt from the platform's secure random generator, and a retry after a revert rebuilds the proof with a new salt. Show: two proofs for the same amount and recipient have different salts and ciphertexts; a grep finds no
Math.randomon any path into proving. - C12 Plaintext amounts, openings, derived secrets and the signatures they come from never leave the browser: not to our server, not into logs, analytics, error reports or URLs. Show: run the full pay, payslip and accountant flows through a request recorder, then grep the traffic for the amounts in every encoding (decimal, stroops, hex, both byte orders) and for the derivation signature.
- C13 At most one pay transaction per treasury is in flight. The next batch is proved only against chain state after the previous one is confirmed or has definitely failed. Paid, pending and failed labels come from the on-chain paid check, never from a relayer's reply. Show: close the tab after submitting batch 2 of 3 and reopen; the labels match the chain, nobody is paid twice, and no proof was built on an unconfirmed balance.
- C14 What the employer approved is what was paid. After each pay lands, the console decrypts the treasury's own balance checkpoints and confirms each transfer equals the confirmed CSV amount in integer stroops. Any mismatch stops the run and is shown. Show: inject a builder bug that pays 1 stroop extra; the console flags it.
- C15 Derived keys are reproducible and tied to this app. The same wallet always derives the same key here, and no other app or message derives it. The message names our app, our domain, the network and the token contract id, and says to sign it only on our domain. Any change to the message, the key-derivation function or the PRF salt that would change an existing user's key fails CI. If a passkey returns no PRF output, registration is refused, never weakened. Show: pinned test vectors for each path, and a test where a missing PRF refuses.
History and archive
- C16 A wallet never shows or spends a balance that does not open the on-chain commitment. Every opening rebuilt from RPC, the archive or the cache is checked against
confidential_balancebefore it is displayed or used in a proof. A mismatch shows "history incomplete", never a number. One exception, for a treasury that has never been funded: a balance of 0 may be shown without a saved opening only when both on-chain commitments equal the commitment of zero, and "No deposits yet" only when a history that reaches the newest ledger holds exactly this treasury's one registration and nothing else. Show: drop one event from the archive reply; the portal shows incomplete and refuses to build a withdraw proof; an archive whose history stops before the registration never shows "No deposits yet". - C17 The archive never claims history it does not have. It stores events verbatim, with id (ledger, tx hash, index) and application order, only from our configured contract ids, deduplicated by id. It never stores an event RPC marks as from a failed call; since stellar-rpc v30 (Protocol 30, 21 Oct 2026) that mark is gone, because RPC has served no events from failed calls since v23 removed diagnostic events, so an event without it counts as from a successful call and any other value counts as failed. It tracks which ledger ranges it has ingested without gaps, raises an alarm on a gap well before RPC's 7-day window closes, and every reply carries "complete" and "ingested-through" fields. A gap is recorded from what a node says it holds, and a node behind RPC's load balancer that has just restarted can say too little, so recorded gaps are read again on later passes, oldest first. A heal resumes at the first ledger not yet read in full, all heals together take at most half of a pass's pages and half its time, a heal that fails ends with its gap kept and never stops new ledgers being read, and a gap older than RPC's retention window is never tried again. A gap is removed only in the transaction that stores the page proving its last ledger was read in full, once every ledger in it is covered. A heal page vouches only for ledgers at or above the oldest ledger its own node reports, and stores nothing when it cannot show that; the forward read stores nothing from a page whose node reports a floor above the ledgers it would vouch for. Show: stop the job for a range and restart it; replies for that range say complete: false until it is backfilled. A gap recorded from a short node clears on a later pass, and a page from a node missing part of the gap clears nothing (packages/server/test/archive/ingest-heal.test.ts). On 16 Oct, six local copies of the archive holding the real false gap, ledgers 5211974 to 5214910, healed it against testnet RPC while the short node was still behind the load balancer, five on the first pass and one on the second after an RPC timeout; a heal over 3,000 busier ledgers stored the same 443 event ids RPC returns directly.
- C18 A payslip is shown only when all of these hold: a payslip event from our payroll contract, a transfer from our token in the same transaction from the company's stored treasury to that worker, the on-chain paid check true, and an in-range decrypted amount. A direct transfer, a deposit, or any event from another contract never becomes a payslip. The accountant's totals count exactly this set. Show: send the worker a direct confidential transfer and a deposit; neither appears as a payslip or in the totals.
- C19 Decryption is bounded and checked. Any amount outside [0, 2^63), or any sender-auditor balance chain that breaks (previous balance minus amount must equal the new balance), is marked undecryptable and never summed, shown as money or exported. Curve points and field values go through the SDK's canonical, on-curve decoder, and anything else is rejected. Events are deduplicated by event id, never by payload bytes. Show: feed in a ciphertext opened with the wrong key; it is marked and the totals do not change.
Servers
- C20 The sponsor pays only for a transaction that, decoded from the exact bytes it will forward, meets all of these: exactly one host function whose root is one of three things, a call into our payroll or our token,
register_key(owner, point)on our auditor registry whose only authoriser (the envelope source included) is that owner, or the one wallet creation the sponsor pays for; nested calls only into our contracts or the USDC contract; no wasm upload; the testnet passphrase; declared and simulated fees under the cap for their kind (on testnet 2.5 XLM for the wallet creation, set from its measured 1.79 XLM plus margin, and 1 XLM for every other request, where accept_invite reserves 0.61 XLM); and a successful simulation. The one creation is a passkey-kit wallet: a function request (never a prebuilt envelope) creating a contract from passkey-kit's shared deployer that runs the pinned wallet wasm, whose constructor is exactly one Secp256r1 signer with no expiry and no limits plus its genesis proof, whose salt is the hash of that credential id, and whose only authorisation is the deployer's, rooted at that same creation with no sub-calls. Its genesis proof must also come from one of our own sign-up pages: the client data parses as WebAuthn client data of type webauthn.get whose origin, read withURL().originlike each entry of SPONSOR_ALLOWED_ORIGINS, is on that list, and the authenticator data starts with the SHA-256 of that origin's host as the relying party id; with the list empty, every creation is refused. This stops an honest passkey-kit app on another site from spending our budget, because the browser writes the real origin and relying party. It does not stop a determined forger with their own key, who can write our origin and host into the bytes their passkey signs. Any other creation is refused as contract_creation, and any other registry call as root_contract_not_allowed. Limits: per IP per hour (IPv6: per /64), counted before any parsing; per authorising address per day, with a creation counted against the wallet it creates; at most three wallet creations per IP per day (IPv6: per /48) and sixty a day across the service; creations may reserve at most half the daily budget; and a daily budget (200 XLM on testnet), about 48 passkey joins a day. Show: submit a classic payment, a wasm upload, a third-party contract call, a creation running other code, a creation whose passkey was made on another site, a rotate_key, an oversized fee, a 1.2 XLM call, a fourth creation from one IP and a valid pay; only the last is relayed, and a real passkey worker joins, is paid and withdraws holding 0 XLM. - C21 Any demo key held by a server signs only transactions the server built itself, from a fixed menu of demo actions, with server-chosen parameters, to the seeded demo addresses. No route accepts XDR, auth entries or destinations for a demo key to sign. Demo accounts hold no contract role, use a demo-only auditor id that no real account is bound to, and their actions run one at a time per account, rate-limited. Show: post a set-options and a payment XDR to every demo route; nothing is signed. Two concurrent demo pays both end in a defined state.
- C22 The server calls only origins fixed in config (RPC, Horizon, relayer, friendbot, the anchor), never a host or URL taken from a request. Show: grep every server-side fetch for a non-constant URL.
- C23 No secret (relayer key, any demo key, the database URL) appears in client bundles, responses, logs or error text. Show: build, then grep the static output and a captured log run for each secret value.
- C24 The archive API treats its parameters as data. Contract must decode to one of our configured ids, account must decode with the Stellar address parser, cursor must match our format, and page size is capped. Queries are parameterized and the API's database role is read-only. Anything else returns 400. Show: SQL metacharacters, an unknown contract, a muxed address, a 10 MB cursor and a page size of 1e9 all return 400.
Rendering, files and the anchor
- C25 Strings any company writes on chain (labels, metadata) and CSV cells render as plain text, never as HTML or as a link. Explorer links are built only from a validated transaction hash or address on a fixed origin. Exported CSV cells starting with = + - @, a tab or a carriage return are neutralized, and on-chain strings have a length cap. Show: a company with a label of
=HYPERLINK(...)and an<img onerror>tag; the page shows plain text and the export opens inert. - C26 CSV import fails closed. The file has size and row caps. Addresses are decoded with the Stellar address parser, G or C only. Amounts are parsed once, by one decimal parser, into integer stroops, rejecting more than 7 decimals, exponents, separators, negatives and zero. The file uses a dot for decimals in every language, and a cell shaped like "4.200" (one to three digits, a dot, then exactly three) is refused as ambiguous, because a reader whose page groups thousands with a dot means four thousand two hundred. Duplicates are rejected. Every address must be on the on-chain roster (compared after the same decoder) and registered with the token. The confirmation screen, the proof input and the C14 check all use that one parsed value. Show: a fixture with every bad case; each row is rejected with its reason.
- C27 The anchor flow signs nothing the SDK's SEP-10 challenge checker rejects (sequence 0, the server key from the toml, the home domain, time bounds, the network). Popup messages are accepted only from the anchor's exact origin, compared with
URL().originon both sides. The cash-out destination, memo and amount come only from the anchor's authenticated transaction record and are shown to the worker before signing. Show: a challenge with sequence 1 or a payment operation is refused; a message from another origin is ignored. - C28 Every page runs under a Content Security Policy set per request with a fresh nonce: scripts only from our own origin or carrying that nonce, and never from another host even when the page's own code inserts the script element, so the policy carries no strict-dynamic (no inline script without the nonce, never unsafe-inline for scripts, unsafe-eval only on the local development server), connections only to our origin, data: URLs (bb.js fetches its WebAssembly from a data: URL it carries, which never leaves the browser) and the four Stellar services the app uses (RPC, Horizon, friendbot, the test anchor), and framing refused (frame-ancestors none and X-Frame-Options DENY). Every byte the prover uses comes from our own origin or matches a pinned hash: the proving reference data (CRS) is served from /crs with SHA-256 pins and seeded into bb.js's cache before the first proof, bb.js is pinned to the version whose cache layout that relies on (a test fails if it changes), and its wasm loads from its own bundle. Every dependency that touches keys or proving is pinned to an exact version with lockfile integrity, and CI fails on lockfile drift. The one expected policy report is a probe by bb.js's msgpackr dependency, which tries to build a function once, is refused, and falls back to its decoder without eval. Show: the response headers on /, /worker and /docs; a sandbox run from an empty browser cache makes no request to any third-party host; a request to an unlisted host is blocked; a script element pointing at another host, inserted by code running in the page, is blocked before any request leaves the browser.
Added at the backend gate review (8 Oct 2026)
The reviewer attacked section C against the code as built and named what it missed. These are part of the definition of done from here on.
- C29 A balance opening saved for a submitted pay is never overwritten or deleted until that transaction is final, and an in-flight transaction is resolved before any new proof is built. Show: a pay accepted but not yet applied, then a resume, then the pay landing; the run finishes with every row paid once and the treasury still opens.
- C30 Every amount shown, summed or exported equals the payload of the transaction its event names. The transaction hash is recomputed from the envelope, and every ciphertext field in the event must equal the payload's. Show: an archive that serves a re-encrypted transfer makes that payslip undecryptable, not a different number.
- C31 The sponsor pays only when every contract the call can execute is ours, USDC's, the verifier, or a wallet whose code is the pinned passkey wallet wasm. Checked on the simulation footprint, not on the authorization tree alone. For a wallet creation, the footprint may hold only contract data owned by the address the creation's own preimage derives (computed, never read from the footprint), the pinned wallet code, and the deployer's account and nonce; anything else is refused, our own contracts included. A creation reaches this check only after its genesis proof passes the origin rule in C20. Its simulated fee is held to the creation cap and every other call's to the call cap, so a call cannot buy room up to a creation's price. Show: a self-deployed wallet whose check touches a third-party contract is refused, and so is a creation that reaches any entry outside its own new address.
- C32 A run survives a key rotation. Keys are re-read from chain on a failed proof, and a batch that still fails marks only its own rows failed while later batches pay. Show: a worker rotating their auditor key mid-run does not stop the other workers' pay.
- C33 A worker's auditor id is permanent once registered, so the app defaults to a new id the worker owns and shows who owns any other id before the worker signs. (Strengthens C10.)
- C34 No real payroll encrypts to the published demo accountant key. Show: the run engine refuses a company or worker bound to the demo auditor id before any transaction.
- C35 The archive ingests on a schedule that does not depend on visitors, and its alarm reaches a person: health answers 503 on a gap or a stale ingest so an external check fails visibly.
- C36 No log line joins a client IP with a transaction hash, and no single request logs both an IP tag and a hash, since a host's request id would join them.
- C37 A workflow that can write build attestations pins every third-party action by commit, not by tag.
Added at the external audit (8 Oct 2026)
A second audit, run as three independent passes over the contracts, the client money path, and the payslips, sponsor and archive, attacked the code at fb9f2b5 and this standard. Each line below closes a gap it found. Its attack tests stay in the suites as refusing tests.
- C38 A pay the chain may have applied is never forgotten. The device record of a pay in flight is removed only by compare (same hash), and only after an opening that opens the chain is adopted, or the transaction is failed or expired by chain time. No RPC answer deletes a saved opening: every batch opening the device wrote stays a candidate until a later adopted pay passes it, or until the employer rebuilds the treasury from chain history, which any later rebuild can repeat. A reported success counts as landed only when the pay's own opening, or one listed after it, opens the chain, so a stale read can never adopt an older opening. A device with no usable record (a new laptop, cleared storage) rebuilds the same way, and the screen shows what the device expected beside what the chain holds. Show: a balance read one ledger behind, a lying close time, and a second tab each end with the run resuming, never with a locked treasury.
- C39 Every fee the employer is asked to sign is under a fixed cap, enforced where the client library assembles a transaction from its simulation, so no screen can skip it. The caller names the cap, and a pay is always held to the pay cap whatever the caller names: 2 XLM per pay, about four times the measured cost, and 20 XLM for one-off setup calls, about one and a half times the costliest measured one. Show: a simulation naming the largest fee XDR allows is refused before the wallet sees it, for a pay and for a setup call.
- C40 No key is ever derived from a single wallet signature. The client library derives only from two signatures over the same message and refuses unless they are byte-equal, and the app asks the wallet to sign twice, so a key is proven reproducible before anything is registered under it. The library cannot tell one signature passed in twice from two signing calls, so asking twice is the app's part. Show: a signer that randomises its nonce is refused.
- C41 The engine never writes a device record it cannot read back, and a damaged record has a clearing path that removes no opening. A row paid by a transaction this call did not send is reported as that, never as a wrong amount. Show: a damaged record is cleared and the next run proceeds from the chain; the losing tab of a two-tab race is told the rows were paid elsewhere.
- C42 The published demo auditor id is refused by default from a constant in the client library, so a screen that forgets to pass it cannot skip C34. Only the showcase seed opts in. Show: a run bound to the demo id is refused with no options passed.
- C43 An auditor id is never predicted. The treasury registers under the id returned by the accountant's confirmed key registration; before the treasury signs its token registration the app reads the id's owner and key from chain and refuses unless they are the accountant's; and the payroll contract refuses to create a company unless the registry says the named accountant owns the auditor id. An employer who keeps their own books has no confirmation code to compare, because they are checking against themselves; instead the key under the id must equal the key this same wallet derives, a remembered id is used only after the registry confirms its owner and key again. A payroll account is bound to one key number for good by its token registration, so own books reuses the number the chain shows this account is bound to, only while this wallet owns it, and is refused when an outside accountant holds it. Show: an attacker who registers first and takes the predicted id ends with company creation refused, before any salary is encrypted; a second own-books company from the same wallet reuses the bound number, and a wallet bound to an outside accountant cannot switch to own books.
- C44 Every handover offer, in the payroll and the registry alike, expires within the network's longest entry lifetime. Show: an offer one ledger past that limit is refused in both contracts.
- C45 The token's verifier and registry can never change, and the check reads that from the code on chain, not from the hash pin alone: the token exports no setter and cannot replace its own code. Show: check:testnet lists the token's exports and fails on any setter or upgrade path.
- C46 The payroll, registry, token and verifier instances are kept alive. check:testnet reports the days each has left, and a keepalive script extends them. Show: the check fails when any instance has fewer days left than the threshold.
- C47 Screens identify the showcase and every auditor by id, never by label or key bytes, and any company bound to the published demo auditor id is labelled publicly readable wherever it is shown. Who can read a company's pay is shown from the registry's current owner of its auditor id, read at that moment, never from the accountant recorded when the company was created, which goes stale after a handover. The employer's set-up refuses a published demo auditor id before anything is signed, and any company bound to one is labelled publicly readable on the dashboard, the join card and every payslip. These labels fail closed: when a company's settings cannot be read from chain, its payslip says Readers unknown, never nothing, because a missing label looks like the private answer. "You keep your own books" is a who-can-read statement too: it is shown only when the registry's current owner of the company's id is the connected wallet, and not at all when that read fails. A failed pay is explained from the simulation's diagnostic events, never from the top-level error code alone, because a nested host error and a missing signature arrive with the same code.
- C48 A payslip list or an audit is complete only when the chain's own counts agree. The payroll contract counts the companies each worker has joined, the runs each company opened, and the admin changes each company had. A worker view must hold exactly that many companies, each confirmed on the on-chain roster, and exactly that many runs per company, each confirmed by the chain, and must list a payslip for every run the chain marks the worker paid. An audit must hold exactly the counted runs, the on-chain paid count for each, and an admin history that ends at the on-chain admin. Every field a payslip shows (ledger, run, amount, hash) is checked against its transaction. The archive's word that it is complete is never taken alone, and no transfer is ever classified to decide completeness, so a stranger's transfer of any kind cannot change it. Show: an archive that hides a company, the newest run, a payslip or an admin change, swaps in a made-up run, or rewrites a payslip's ledger makes the view incomplete; a stranger's direct or contract-routed transfer leaves it complete.
- C49 The archive proves where its history starts, by one rule the archive and the prove script share: it vouches for history from the deploy only if the first token events it read come from the token's own deploy transaction, whose hash and ledger the deploy recorded. Otherwise its health says which ledger it vouches from. Show: an archive started a few ledgers late reports exactly that instead of healthy.
- C50 The sponsor's budget pays only for relays that may have landed, and new Face ID wallets can never take more than half of it. A provable refusal from the relayer returns the reservation, a creation's share included. Every request is counted against its IP before any parsing or signature work (IPv6: per /64), and against its authorising address per day. Wallet creations are also counted per IP per day with IPv6 by /48, because one site is often handed a /48 of 65,536 /64s, and sixty a day across the service. They may reserve at most CREATION_BUDGET_SHARE_PERCENT (50) of the daily budget; past it a creation is refused as daily_budget_spent while other calls still pass. An envelope's declared fee and resource fee are held to the 1 XLM call cap, so a call cannot declare inflated resources up to a creation's 2.5 XLM. The declared footprint must cover the simulated one by key bytes. A signed authorisation entry, keyed by its address and nonce, is relayed at most once until it expires, whatever body carries it. Not stopped: a caller with many /48s or IPv4 addresses can use up the day's creations, which blocks new sign-ups until the next UTC day. Only creations are held to the share. Every other sponsored call, a new worker's first registrations and invite acceptance among them, and calls from any friendbot-funded account, draws on the whole budget, limited per IP and per address but not by roster, so a caller with many addresses can spend the rest of the day's budget too. Workers then wait for the next UTC day to join, and withdraw by paying their own fee. Show: refused relays, malformed bodies, an empty declared footprint, a resend after two minutes and the same signed entry rewrapped in another body each cost no budget; a creation that would take creations to 51 percent of the day, the 61st creation of a day and a creation from a second /64 of a used /48 are refused, while a worker's call still passes.
Added at the section 13 review (9 Oct 2026)
The finishing review attacked the built screens, the server and this standard. These close what it found.
- C51 A worker's address is shown, copied or handed to anyone only after two chain reads agree. The transaction that created the wallet, read by its hash from RPC or Horizon and hashed again, ran the pinned wallet code with exactly this passkey's signer: no expiry, no limits, persistent storage. And the wallet today runs the pinned code with that same full signer. Today's state alone proves nothing, because an address does not depend on its code: a wallet created first from someone else's code can write this signer beside one of its own, then switch to ours. What leads to the creation (this browser's record, the relay's transaction id, the sponsor's birth index and its record of relayed creations) is a pointer, never proof. The index stores only a transaction the server read from RPC as a successful creation of that address, and the sponsor records every wallet creation it relays before it relays it, so a wallet at the address that Kalypso never relayed was made by someone else, however it was made. A wallet made by someone else is never shown, and the worker is told to make a new pay key. That is known only from the chain or from Kalypso's own empty record: its creation read from chain ran other code or another signer; or every creation Kalypso relayed for that address was read from chain as this address's own creation and failed, with no older relays left unlisted; or Kalypso holds no record of any relay there and this browser holds none of its own. A relayer's status is never enough: a relay it calls failed or expired, with no transaction to read, stays unknown. A wallet whose creation cannot be confirmed just now keeps its address hidden. Its payslips and cash-out still work, and so do those of a wallet known to be taken: only showing the address and joining wait for the proof. That is safe because reading needs this passkey's own keys, a token account registered under someone else's key stops the sign-in (KEYS_MISMATCH), and a withdraw moves pay only to this worker's own cash-out account. Show: a wallet with another key, a wallet born from other code that holds this key today, a squat Kalypso never relayed, and a birth whose signer expires or has limits are each refused before any address appears; a creation whose relay reply was lost is still proven through the sponsor's relay record; on testnet, a wallet created from other code (transaction df348c5f) with this signer and a second one, then switched to the pinned code, passes a check of today's state and is refused by the birth check.
- C52 Every secret kept in the browser is named with its lifetime and its clearing path: the sandbox's five throwaway seeds, four auditor secrets, salaries and openings stay in localStorage until Start over; an employer's treasury openings and the attempts list stay in IndexedDB until the employer rebuilds or clears them, and they are re-derivable from chain history; a worker's and an employer's keys are derived again at each sign-in and never stored; the development-only throwaway wallet lives in sessionStorage and does not exist in production builds. No real account's signing seed is ever stored. Also stored, none of it secret: a worker's record of public pointers (the companies the chain's roster confirmed they joined, the auditor id, the wallet's deploy call, its creation and relay ids), an employer's list of company shortcuts, an accountant's remembered key number, and bb.js's cache of the public proving data. Show: a storage dump after each flow holds exactly this list, and the only Stellar secret keys in it are the sandbox's (production walk, 9 Oct: sandbox, worker create, join, cash-out and a cleared-storage sign-in).
- C53 No total is shown over a history that is not complete, or over a run where any payment did not open. The screen says the total is withheld and why. Show: an incomplete audit and a partly unopened run each show "Total withheld".
- C54 Every amount a person types goes through one strict parser for the page's language, and the parsed value is shown in that language before any irreversible send. In English and Chinese a comma is only a thousands separator and the first group cannot start with zero. In Spanish, Portuguese and French there is at most one separator, comma or dot, and a "d.ddd" or "d,ddd" shape with a non-zero lead is refused as ambiguous. No text is accepted by two languages' parsers with different values, so switching language never changes what a typed amount means. Show: "1,5" is refused in English and shown as 1,50 in Spanish; "1.500" is refused in Spanish; "0,125" is refused in English.
- C55 Every success message comes from a chain read of the state the call changed, never from a relayer's answer or a transaction hash alone. Show: a sponsor that names an unrelated successful transaction makes the withdraw screen say it cannot confirm, not "Moved".
- C56 Every code, status, step and reason a library can emit has a message in every language the app offers, checked by a test that reads the library's own lists. A refusal can never show as nothing, or as a raw key that hides what happened. Show: removing one status's English message fails the test.
Added with the testnet faucet (11 Oct 2026)
Kalypso hands out free test XLM and test USDC so anyone can try it without hunting for test money. This is the one place the server holds a funded key, a deliberate exception to "no keys on our server" for a wallet that only ever holds testnet funds.
- C57 The faucet key signs only what a faucet payout is: on testnet, from the faucet account, with no memo, time bounds of at most 60 seconds, a capped fee, and exactly one operation, either creating the requested account with the fixed XLM amount or paying it the fixed XLM or USDC amount. The signer accepts only a real transaction, rebuilds it from its encoded bytes, checks the rebuilt one against that shape, and signs only the rebuilt one, so no caller, not even other server code holding the signer, can make it sign anything else or reach the key. The secret is never logged, never repeated in an error and never serialised. Every request is counted against its IP before it is read (60 an hour per IPv4 address or IPv6 /64). Each address may claim each asset once per UTC day, each IPv4 address or IPv6 /48 six times a day, and the whole service 500 times a day; people behind one shared connection share those six. Each limit answers with its own message, so nobody is told they were paid when they were not. Checks on the destination (account exists, can hold USDC) run before the faucet's lock, so they never make others wait, and a payout that fails because of the destination stays counted. A claim is confirmed only from a chain read of its transaction (C55), and the browser then reads the wallet's balance itself before it says the money was added; when the outcome is unknown, the claim stays counted and the person is told the money may still arrive, with its transaction link. One claim runs at a time; a claim after an unknown outcome may reuse its sequence number, which is safe because the fee bid never changes, so the network keeps at most one of them. Show: a transaction with a second operation, another destination, another amount, a memo or another network is refused by the signer; a second claim the same day is refused; the live claims of 11 Oct (create, no trustline, trustline, pay, refused repeat).
General standards
- Primitives over lists. Each check says what it covers and what it does not.
- Addresses: the Stellar SDK's address parser, not a regex. Covers the checksum and the address type. Does not cover whether it is the right person; the roster check does that.
- SEP-10: the SDK's challenge reader and signer verifier, not hand-checked fields. Covers the protocol rules. Does not cover a compromised anchor that is consistent with itself.
- The sponsor: one structural rule (C20), not a list of function names. The contract-id set is two config values compared by equality, so it covers what we deployed and nothing else. It does not stop valid but spammy calls to our contracts; rate limits do that.
- Curve points: the SDK's canonical decoder, not a list of known-bad points.
- Rendering: React's text rendering, not an HTML sanitizer's blocklist.
- Randomness:
crypto.getRandomValuesonly.
- Normalize before you compare. Each pair goes through one parser.
- CSV address, roster address, event address and the archive's account parameter: the Stellar address decoder.
- CSV amount, confirmation display, proof input, the C14 check and the export: one decimal-to-stroops parser, BigInt throughout. A typed amount reaches it through one front end per page language (C54), and no text means two different amounts in two languages.
- Popup origin against anchor origin:
URL().originon both sides. - Event and parameter contract ids against config: decoded ids.
- Event identity across RPC and the archive: (ledger, tx hash, index), never payload bytes, because the host silently reduces non-canonical values.
- Company auditor id against the treasury's registered id: both read from chain (C9).
- Verification-key hashes: the same hash over the same bytes.
- The sponsor: the exact bytes validated are the bytes forwarded.
- The client IP for rate limits: the platform's trusted header only.
- Validate outputs like inputs. Each output is treated as input to whoever reads it next.
- Payslip events and paid flags are read as proof of pay (C8, C18).
- Labels and metadata reach the page and the export (C25).
- Archive replies feed proof building (C16, C17).
- Decrypted amounts feed totals and exports (C19).
- Proofs built in the browser go to a wallet that cannot show the hidden amount (C14).
- Relayer replies would otherwise feed the status labels (C13).
- Logs carry no secrets and no amounts (C12, C23).
- The public page's privacy claims are outputs a visitor acts on, so they must state what stays visible (non-goals below).
- Fail closed.
- Contract: unknown or closed runs, roster misses, duplicates, oversized batches and unregistered recipients revert the whole call.
- Wallet: a commitment mismatch, a missing PRF, or a wallet on the wrong network refuses.
- Server: unparseable XDR, a failed simulation, an unknown action or bad parameters are refused. Every outbound call has a 10 s timeout. Bodies and page sizes are capped, with rate limits and a daily fee budget. The archive job alarms rather than skipping.
- Decryption: bounded range and bounded iterations.
- Batch size: taken from measurement, within a hard cap.
- Defaults: auth entries expire within minutes, demo accounts hold minimal balances, the archive's database role is read-only.
- Non-goals. This system does not defend these, so nobody should assume it does.
- Only amounts are private. Who pays whom, when, how many workers there are, and who is on a roster are public. Deposit and withdraw amounts are public, so the funding deposit reveals the payroll total, and a worker who withdraws exactly their pay reveals it.
- The company's accountant sees every amount the company pays and the treasury's balance after each payment, by design.
- Auditor key rotation protects only future events. A leaked auditor or worker key opens everything encrypted to it, forever.
- The payroll contract cannot know whether a hidden amount is the right salary. The admin's wallet cannot show it either, so a compromised frontend can misdirect amounts within what the admin signs. C14 detects this after the fact; it does not prevent it.
- Not defended: a compromised wallet extension or operating system; a user who signs our derivation message on a phishing site; the USDC issuer freezing or clawing back the pool; the anchor's KYC and its view of cash-out amounts; flaws in the unaudited OpenZeppelin circuits, verifier backend or token wasm; the relayer refusing service (employers and accountants submit and pay their own fees from their wallets, and a worker withdrawing can pay their own fee from their wallet or their cash-out account, but creating a Face ID wallet and joining a company, with a wallet or a passkey, need the sponsor).
- The derivation message protects the key only as far as the wallet displays it. Any wallet interface that signs a raw 32-byte hash with the same key reproduces the key root without showing the text, so the app uses only wallet interfaces that show the message, and a user who signs opaque blobs elsewhere with that key is not defended.
- The hosting platform's own request logs record the caller's IP next to the archive path, which names the account or company asked about. Our logs never join the two (C36), but the token-history route keeps the shape the token SDK's indexer client expects.
- Browser storage has no atomic compare-and-delete, so two tabs racing can still remove each other's in-flight record, and the list of saved openings is itself read and rewritten, so a race can drop an entry. Recovery from either goes through the list of saved openings (C38) when it holds the right one, and otherwise through rebuilding the treasury from chain history.
- The sponsor's and the faucet's hourly per-IP counters hold raw IPs for up to two hours, never logged, so they can enforce the limits; the daily ones keep only a salted tag. Every per-IP limit trusts the client address the host sets (x-real-ip on Vercel).
- The sponsor's creation limits are per IP (IPv6 per /48 site), so an office behind one address can create at most three Face ID wallets a day, and the whole service sixty; more workers there can join with a wallet, or the next day after the count resets.
- Anyone who learns a passkey's credential id before its wallet exists can create a wallet at that address first through passkey-kit's shared deployer, from code of their own, and switch it to ours later. Kalypso creates the wallet as part of sign-up and shows the address only once the wallet's creation, read from chain, ran our code with this passkey's signer alone (C51), so a squatted address is never handed to an employer; the worker makes a new pay key. What the squatter gains is an address nobody pays.
- A wallet's creation is found through this browser's record or the sponsor's records. If none of them holds the creation's hash and the relayer no longer knows the relay (for example, the relayer timed out before naming it and it landed anyway), the address stays hidden on every device, though its pay can still be read and withdrawn, and the worker can make a new pay key. If the sponsor's database is lost, or for any wallet the sponsor created before it kept these records (none in production), every such wallet with no birth saved in this browser reads as one Kalypso never relayed: addresses hide and workers are told to make a new pay key, while pay they already received can still be read and withdrawn.
- The testnet faucet can be drained by someone with many IP addresses. That costs only free test funds, which we top up, and it never touches any other key or account (C57).
- Every company paid from one wallet shares that wallet's one accountant key, because the token binds a payroll account to one key number for good. The app says so when a second company is set up from the same wallet; giving companies different accountants means using different wallets.
- Testnet only.