specification
PAYMENTS.md inkhensu-specs, licensed CC BY 4.0.
ñü.wallet: shielded payments specification
Version 0.1 (11 October 2026). Status: public draft for review, verification and penetration testing. This is the document PROTOCOL.md section 13 calls PAYMENTS.md; where the two differ, this one wins and PROTOCOL.md is updated to match.
ñü.wallet is the wallet in the khensu.ñü app and node app: a non-custodial, post-quantum shielded pool for tips, gifts, plays, subscriptions, tickets, sales, node pay and person-to-person payments. Amounts, senders and recipients are hidden; spends travel through the mixnet; nobody, including Elliott Wave LLC and the committee, holds funds or can freeze a note.
1. scope
This document specifies, completely enough for an independent implementation that interoperates byte for byte:
- the recovery phrase and every key derived from it (section 4);
- the note, its commitment, its nullifier and the era tree (sections 5 and 6);
- the spend transaction, its fixed shape and the statement its STARK proves (sections 7 and 8);
- note encryption and delivery (section 9);
- deposits, withdrawals, era refresh and fees (sections 10 to 12);
- tap to pay through a licensed card partner (section 13);
- errors, versioning and registries (sections 14, 15 and 19);
- the threat model, security and privacy considerations (sections 16 and 17);
- how anyone verifies and attacks it: test vectors, reference code, formal models, fuzzing targets and the penetration-testing programme (section 18).
Out of scope: consensus itself (PROTOCOL.md sections 9 to 11), the token’s issuance, and the card partner’s own systems.
2. conventions
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119 and RFC 8174. Structures use the TLS presentation language (RFC 8446 section 3). Integers are big-endian unless a field element is meant. || is concatenation. H(label, x) is SHA-512 over "khensu wallet v1 " || label || 0x00 || x, truncated where a length is given. Field elements are in the Goldilocks field p = 2^64 − 2^32 + 1, encoded as 8-byte little-endian canonical integers.
3. design rules
- Post-quantum everywhere. No elliptic curves, pairings or RSA. Commitments, nullifiers and Merkle trees are hash-based (Poseidon2); proofs are STARKs (hash-based); note encryption is ML-KEM-1024 (FIPS 203) with AES-256-GCM; signatures, where any are needed, are ML-DSA-87 (FIPS 204).
- Fixed shapes. Every spend has the same size, the same number of inputs and outputs, and travels in the same fixed-size mixnet packets, so a payment of 1¢ and one of $10,000 look identical.
- Non-custodial. Value moves only with a proof made on the owner’s device. The committee checks proofs and nullifiers; it can’t move, freeze or see notes.
- Forgetful. Eras close and their trees and nullifier lists are deleted (section 6.3). History is kept only on the owner’s devices, and only as long as the owner keeps it.
- Boundaries are the only public points. Deposits into and withdrawals out of the pool are visible as amounts on public accounts; nothing inside the pool is.
4. keys
4.1 recovery phrase and seed
The recovery kit’s 24-word phrase uses the BIP-39 English word list and checksum (256 bits of entropy from the device’s CSPRNG). The wallet seed is
seed = Argon2id(password = NFKD(phrase), salt = "khensu wallet seed v1",
t = 3, m = 256 MiB, p = 1, len = 64)
A device with less than 256 MiB free MUST refuse to derive rather than lower m. The phrase is shown once at sign-up and on request with Face ID; it is never stored in plain text.
4.2 derived keys
sk_spend = H("spend", seed)[0..32] // never leaves the Secure Enclave-wrapped store
nk = Poseidon2_hash("nk", sk_spend) // nullifier key, 4 field elements
ak = Poseidon2_hash("ak", sk_spend) // spend authority, 4 field elements
owner = Poseidon2_hash("owner", ak, nk) // owner_pk_hash committed in every note
(ek, dk) = ML-KEM-1024.KeyGen_internal(d = H("kem-d", seed)[0..32],
z = H("kem-z", seed)[0..32]) // FIPS 203 section 6.1
ivk = H("view", seed)[0..32] // incoming viewing secret for history export
sk_spendanddkare stored only wrapped by a Secure Enclave key with.biometryCurrentSet; every spend asks for Face ID or Touch ID.ekandownerform the payment address:ñü1+ bech32m(owner || SHA3-256(ek)) for display, with the fullek(1,568 bytes) fetched from the sender’s MLS group or the key transparency log entry for a handle.- A viewing grant (for an accountant or an auditor the owner chooses) is
(dk, owner)exported under a passphrase; it reveals incoming notes and nothing that allows spending.
4.3 multiple devices
Every primary device derives the same keys from the same seed and keeps its own wrapped copy. Devices learn each other’s spends through the owner’s private MLS device group, so a note is never spent twice by two devices of the same owner; the nullifier check (section 8.3) is the backstop.
5. notes
5.1 assets and units
asset_id |
Asset | Base unit |
|---|---|---|
| 1 | USDC (bridged 1:1, section 10.3) | 0.0001 USDC (one hundredth of a cent) |
| 2 | khensu token | 10^-8 token |
Amounts are uint64 base units.
5.2 denominations
Values MUST be in the 1-2-5 series: {1, 2, 5} × 10^k for k = 0…11, so 36 values from 0.0001 to 50,000,000 USDC. Paying an arbitrary amount uses up to four input notes and up to four output notes (section 7). The wallet keeps a working set of notes chosen so that common amounts (1¢ plays, 42¢ minimum payouts, $4.99, $20) need one transaction.
5.3 note
struct {
uint32 asset_id;
uint64 value; // a denomination (5.2)
Felt owner[4]; // owner_pk_hash of the recipient (4.2)
Felt rho[4]; // fresh randomness, H("rho", tx_hash || output_index) in a spend
uint32 era; // the era the note belongs to
opaque memo[64]; // optional, encrypted only, zero-padded
} Note;
5.4 commitment
cm = Poseidon2_hash("note", asset_id, value, owner, rho, era) // 4 field elements, 32 bytes
Poseidon2_hash(label, ...) is the Poseidon2 sponge over Goldilocks with width 12, rate 8, the round constants and MDS matrix of the Plonky3 poseidon2-goldilocks parameter set, the label absorbed first as field elements of its ASCII bytes in 7-byte chunks, and the output the first 4 elements of the state.
5.5 nullifier
nf = Poseidon2_hash("nf", nk, rho, era) // 4 field elements
A nullifier can be computed only with nk, is unique per note, and reveals nothing about the note, its value or its owner.
6. eras
6.1 era tree
Each era has an append-only Merkle tree of depth 32 over note commitments, nodes Poseidon2_hash("node", left, right), empty leaves Poseidon2_hash("empty"). The committee signs an interim root with every agreement round inside an epoch (every few seconds) and commits the last one in the epoch header (PROTOCOL.md section 12.2), signed by at least 2/3 of the committee with ML-DSA-87.
6.2 era length and grace
| Parameter | Value |
|---|---|
| Era length | 30 days (720 network epochs of one hour, PROTOCOL.md section 2) |
| Grace period | 14 days after the era ends |
| Spendable eras | the current era, and the previous one during its grace period |
6.3 closing an era
At the end of the grace period the committee deletes the era’s tree and nullifier set and keeps only a 32-byte digest of the final root for disputes. Notes not refreshed by then are lost; the wallet refreshes in the background while unlocked (section 11), warns at 7, 3 and 1 days, and the node app refreshes for its operator wallet without interaction.
7. spend transaction
Every spend, refresh and withdrawal has the same shape: exactly 4 inputs and 4 outputs. Unused inputs are dummy notes (value 0, random owner, flagged in the witness only); unused outputs are value-0 notes to a random owner. Dummies are indistinguishable on the wire.
struct {
uint16 version = 1;
uint32 era_in[4]; // per input: current or previous era
Felt root_in[4][4]; // era root each input is proved against
Felt nf[4][4]; // nullifiers
Felt cm_out[4][4]; // output commitments, all in the current era
opaque enc_out[4][1680]; // note ciphertexts (9.1)
uint64 fee; // base units of asset 1, public
uint64 public_out; // withdrawal amount, 0 for a private spend
opaque public_dest[32]; // withdrawal account, zero for a private spend
uint32 expiry_epoch; // rejected after this epoch
opaque proof<1..2^20-1>; // STARK (8)
} Spend;
tx_hash = H("tx", Spend without proof)[0..32]. A Spend is padded to 128 KiB and sent as 2 bulk-lane mix packets of 64 KiB payload with final kind 5, committee ingress (PROTOCOL.md sections 8.3 and 8.4).
8. the proof
8.1 statement
Public inputs: every field of Spend except proof, and tx_hash. Private witness: the four input notes, their Merkle paths, sk_spend, the four output notes and their encryption randomness.
The proof shows, for each input i:
cm_i = Poseidon2_hash("note", ...)of the witnessed note, and its Merkle path leads toroot_in[i];owner_i = Poseidon2_hash("owner", ak, nk)withak,nkderived from the witnessedsk_spend;nf[i] = Poseidon2_hash("nf", nk, rho_i, era_in[i]);era_in[i]equals the note’s era;- or input
iis a dummy with value 0;
and for the transaction:
- every output commitment
cm_out[j]is a well-formed note with a value from the 1-2-5 series or zero, and era equal to the current era; - for each asset, the sum of inputs equals the sum of outputs plus
fee(asset 1) pluspublic_out; rhoof each output equalsH("rho", tx_hash || j)reduced to field elements, so outputs can’t be replayed into another transaction;enc_out[j]hashes to the value bound inside the witness (encryption correctness is not proved; a sender who encrypts garbage only harms its own recipient, see 16).
The proof binds tx_hash, so it is also the spend authorisation; no separate signature is used.
8.2 proof system
- AIR over Goldilocks, proved with a FRI-based STARK (Plonky3
uni-stark), blowup 8, 100 queries, 16 bits of proof-of-work grinding, Poseidon2 Merkle commitments: at least 128 bits of conjectured security against classical attackers and about 64 bits against Grover-accelerated attackers on the hash, with SHA-512-based Fiat–Shamir. - Proving on iPhone 15 Pro or later in under 2 seconds (design target), on Mac in under 1 second; the Neural Engine is not used.
- Proof size about 70–90 KiB (design target).
The full constraint list, column layout and transition constraints are published as payments/air.md with the reference verifier (18.2); a change to any constraint is a new version.
8.3 acceptance
A committee member accepts a Spend if and only if: version is supported; expiry_epoch has not passed; every root_in is a published root of the stated era and that era is spendable; no nf is already in that era’s nullifier set or repeated within the transaction (dummies included, since their nullifiers are random); fee is at least the epoch’s minimum fee; the proof verifies. Accepted spends are ordered in the committee’s next agreement round; the nullifiers join the set and the output commitments are appended to the current era tree. The round’s signed spend receipt (tx_hash, round, interim root, at least 2/3 committee ML-DSA-87 signatures) comes back through the sender’s SURB within seconds and is final: the epoch header can only commit rounds already signed. Outputs are spendable from the next round.
9. note encryption and delivery
9.1 ciphertext
(ct_kem, ss) = ML-KEM-1024.Encaps(ek_recipient) // 1,568 bytes
key = H("note-key", ss || cm_out[j])[0..32]
enc_out[j] = ct_kem || AES-256-GCM(key, nonce = 0^12, Note || pad) // 1,568 + 96 + 16 = 1,680 bytes
A fresh encapsulation per output makes the zero nonce safe. Dummy outputs carry random bytes of the same length.
9.2 delivery
- In a chat: the sender also sends the note in an MLS
paymentcontent message (PROTOCOL.mdsection 5.1), so the recipient sees it at once, inside the group’s encryption, with the sender’s signature hidden in the ciphertext. - On chain:
enc_outis stored with the commitment for the life of the era, so a recipient restoring from the phrase finds its notes by trial decryption of the current and previous eras’ ciphertexts (section 17 covers the cost).
10. deposits and withdrawals
10.1 deposit
A public account (token or bridged USDC) calls deposit(asset_id, notes[1..4]) with output commitments and ciphertexts built by the depositor’s wallet, and a STARK showing the commitments sum to the public amount. The amount and the public account are visible; the owner of the new notes is not.
10.2 withdrawal
A Spend with public_out > 0 pays public_out to public_dest. The amount and destination are public; which notes paid it is not.
10.3 USDC
USDC enters through a bridge contract that locks USDC on Ethereum, Base or Solana and mints asset 1 one to one, and burns asset 1 to release USDC. Bridge custody is a committee-threshold ML-DSA-87 account mirrored by the source chain’s own multisig while those chains lack post-quantum signatures; this is the one place classical cryptography touches the system and it guards only bridged balances at the boundary, never the pool’s privacy.
10.4 screening
Sanctions screening applies only at the boundary, by the bridge and the card partner, on public addresses and amounts, as their licences require. Nothing inside the pool can be screened, frozen or reversed.
11. era refresh
A refresh is an ordinary Spend from previous-era notes to current-era notes, owner unchanged, fee paid as usual. The wallet spreads refreshes over random times within the grace period and batches up to four notes per spend, so refreshes don’t reveal how many notes one person holds.
12. fees
| Action | Network fee |
|---|---|
| Send (tip, gift, play, subscription, ticket, sale, person to person) | under 1¢, paid by the sender |
| Receive | none |
| Withdraw to a public account or card | under 1¢ |
| Era refresh | under 1¢ per spend of up to 4 notes |
The minimum fee is set each epoch by consensus from committee load, with a protocol ceiling of 1¢ (100 base units of asset 1); fees go to that epoch’s node pay pool. khensu.ñü takes no commission on any of these. Third-party fees outside the network (a bank’s foreign-exchange fee, a source chain’s gas when bridging) are shown before confirming.
13. tap to pay
A licensed issuing partner provisions a debit card into Apple Wallet. At authorisation, the partner sends an authorisation request to the user’s wallet through the push gateway (a wake only; the request itself is fetched over the mixnet); the wallet asks for Face ID, makes a withdrawal Spend of the amount plus the partner’s quoted fee to the partner’s settlement account, and the partner approves on the committee’s spend receipt (8.3), within the card network’s authorisation window. The partner sees the merchant, the amount and its own card account, as any card issuer does; it never sees the user’s other notes, contacts or history. Offline or slow authorisations are covered by a small prefunded card balance the user chooses.
14. errors
| Code | Name | Meaning |
|---|---|---|
| 0x0001 | bad_version |
unsupported version |
| 0x0002 | expired |
expiry_epoch passed |
| 0x0003 | unknown_root |
a root_in isn’t a published root of a spendable era |
| 0x0004 | era_closed |
an input era is past its grace period |
| 0x0005 | double_spend |
a nullifier is already in the set |
| 0x0006 | fee_too_low |
fee under the epoch minimum |
| 0x0007 | bad_proof |
the STARK doesn’t verify |
| 0x0008 | bad_shape |
wrong field sizes or padding |
| 0x0009 | bridge_limit |
deposit or withdrawal over the bridge’s per-epoch limit |
Errors are returned to the sender through its SURB and never published.
15. versioning
Spend.version covers the transaction layout, the AIR and every hash domain label. A new version is introduced at an era boundary; the committee accepts the previous version until the end of that era’s grace period. Keys and addresses are versioned by the ñü1 prefix.
16. threat model
| Adversary | Can | Gets |
|---|---|---|
| A node operator, or up to 20% of the network | Sees, delays and drops packets | Nothing about who pays whom, or how much |
| The committee, or a coalition below its threshold | Orders and accepts spends | Nullifiers, commitments and boundary amounts; can’t spend, freeze or link notes |
| A global passive observer | Watches every link | Fixed-size packets in cover traffic; boundary amounts only |
| A future quantum computer | Breaks discrete logs and factoring | Nothing: no classical cryptography protects notes or proofs |
| A thief with an unlocked phone | Holds the device | Can’t spend without Face ID or Touch ID; panic wipe erases the wrapped keys first |
| A coerced user | Is forced to unlock | Duress passcode opens a hidden profile with its own wallet |
| A malicious sender | Sends a garbage ciphertext | Its own payment is lost to the recipient; nobody else is affected |
| The card partner | Sees card authorisations | Merchant and amount of card payments only |
Stated limits: boundary deposits and withdrawals are public; timing correlation between a deposit and a later withdrawal of a similar amount by the same person is possible if the pool is small; anonymity grows with the number of people paying in the same eras.
17. security and privacy considerations
- Soundness rests on the AIR being complete; a missing constraint could allow inflation. The AIR is published, independently reviewed and mutation-tested (18.4), and the committee’s total supply check (inputs minus outputs equals fees plus withdrawals, per block, on public values) bounds what any bug could create within one block.
- Trial decryption cost: a full restore decapsulates every ciphertext of two eras (design load 50 million outputs, about 30 minutes on an iPhone 15 Pro in the background). Ordinary use never scans: notes arrive in chats.
- Key separation: nothing derived for the wallet is reused for MLS, identity or the KT log.
- Memory hygiene:
sk_spend,dkand witnesses are zeroised after each proof; proving runs in a separate process with no network access. - No history on servers: the wallet’s history lives only on the owner’s devices and follows chat timers where set.
18. verification and penetration testing
Everything needed to check, reimplement and attack ñü.wallet is public, the same way ñü.media’s codec is.
18.1 test vectors
Published as JSON in khensu-core/vectors/wallet/, each with inputs, every intermediate value and the expected output:
| File | Covers |
|---|---|
phrase-seed.json |
phrase to seed (Argon2id), 20 cases including NFKD edge cases |
keys.json |
seed to sk_spend, nk, ak, owner, ML-KEM ek/dk (checked against FIPS 203 KATs), payment address |
poseidon2.json |
the Poseidon2 permutation and labelled hash, 100 cases |
notes.json |
note encoding, commitment, nullifier, every denomination |
tree.json |
era tree roots and paths at sizes 0, 1, 2, 3, 1,000 and 2^20 |
encryption.json |
note ciphertexts with fixed KEM randomness, and tampered ciphertexts that must fail |
spends-valid.json |
50 full Spends with proofs that MUST verify: payments, refreshes, withdrawals, all-dummy padding |
spends-invalid.json |
200 Spends that MUST be rejected, one per error code and per constraint in the AIR |
18.2 reference code
khensu-core/crates/khensu-wallet(Apache-2.0, open source): key derivation, note format, encryption, the AIR, the prover and a standalone verifier, with a CLI (khensu-wallet verify spend.bin,khensu-wallet vectors --check).- The committee’s acceptance logic is specified in section 8.3 and exercised end to end on
khensu-devnet; the node and chain code stay sealed until released (PROTOCOL.md12.7), so verification never depends on reading them.
18.3 formal models
- Tamarin: note delivery in MLS, spend authorisation and replay, era refresh and the duress profile.
- The AIR is mirrored in a second, independent implementation (Lean 4 over the same constraint list) and both are checked against
spends-valid.jsonandspends-invalid.json.
18.4 fuzzing and mutation
- cargo-fuzz targets:
Spendparsing, note decryption, verifier on random and mutated proofs, Merkle path verification. - Constraint mutation: every constraint in the AIR is removed in turn, and a witness generator searches for a proof that inflates value, forges a nullifier or spends someone else’s note; each mutation must be caught by at least one vector.
- Differential: the Rust and Lean verifiers must agree on every fuzzed input.
18.5 devnet and public builds
khensu-devnet runs the pool, committee, bridge mocks and card-partner mock on one machine. Public TestFlight and macOS builds point at a public test network with valueless test USDC, so anyone can make, intercept and replay real spends.
18.6 penetration-testing programme
- In scope: the khensu.ñü app’s wallet (iPhone, iPad, Mac), the node app’s operator wallet,
khensu-wallet, the public test network, the bridge contracts on testnets, and the card-partner integration in its sandbox. - Wanted: inflation or theft, spending without the owner’s key or biometric, linking a spend to its notes or its owner, learning an amount, double spends, denial of service against the committee with valid-looking spends, key extraction from a locked or unlocked device, and any deviation from this document.
- Out of scope: social engineering of staff, physical attacks on other people’s devices, volumetric DDoS.
- Safe harbour: good-faith research under these rules is authorised; report to security@elliottwave.studio with steps to reproduce. We acknowledge within three working days, and pay bounties on the published scale, highest for soundness and privacy breaks.
- Audits: two independent audits before mainnet (cryptography and AIR; app and bridge), published in full with our responses.
19. registries
| Registry | Values |
|---|---|
asset_id (uint32) |
1 USDC, 2 khensu token; 3–65,535 reserved |
| Hash domain labels | spend, nk, ak, owner, kem-d, kem-z, view, note, nf, node, empty, rho, tx, note-key |
Error codes (uint16) |
section 14 |
| MLS content type | payment (PROTOCOL.md section 5.1) |
| Mix final kind | 5, committee ingress (PROTOCOL.md section 8.4) |
| Spend receipt label | Label("spend receipt") (PROTOCOL.md section 1) |
20. open issues
PLAN.mdplaces the STARK prover and verifier inkhensu-chain; the prover has to run on the phone, so it lives inkhensu-core(khensu-wallet) and the chain links the same verifier.- Proving time and proof size are design targets until the AIR is benchmarked on iPhone 15 Pro.
- Bridge custody on chains without post-quantum signatures (10.3) is the weakest link; it moves to native post-quantum accounts as source chains add them.
- The card partner and its licensing are chosen region by region after legal review.
changelog
- 0.1 (11 Oct 2026): first public draft. Denominations move from 1, 10, 100, 1,000 units to the 1-2-5 series, which needs fewer notes per payment;
PROTOCOL.mdsection 13 updated to match.