How Nelo keeps offline money honest: what is checked, where, by which code, and what happens when something goes wrong. Every rule below names the file that enforces it and the tests that prove it. Where a protection is not built yet, this page says so.
Scope. Offline payments, vaults and redemptions run on devnet until the vault program has an external audit. Cash-outs run on mainnet and move real money; they are non-custodial, so Nelo never holds the funds they move.
A payer can only spend offline what they have already locked in their own on-chain vault
(programs/nelo_vault/src/instructions/deposit.rs). A voucher is a claim against collateral
that provably exists, never a promise to pay. This disposes of "what if they have no money" and
"what if they don't have enough" before any other check runs.
A 202-byte packet: version, vault, sequence number, amount, remaining_after, the merchant it
pays, expiry, salt, a P-256 signature over the first 105 bytes, and the signing device's public
key. Format and golden vectors: packages/voucher (38 tests) and vectors/voucher-v1.json,
generated by the Rust side and asserted by both, so the phone and the chain cannot drift apart.
- It names its payee. A voucher is not bearer:
redeem_voucherpays only the merchant the voucher names (address = voucher.merchant), so whoever submits it — the merchant, the relayer, anyone — cannot redirect the money. - It expires.
now <= voucher.expires_aton chain; the till refuses an expired one offline.
- Generated inside the phone's StrongBox secure element as a non-exportable P-256 key, with a
server-style challenge baked into its attestation certificate
(
packages/attest/android/src/main/java/com/nelo/attest/NeloAttestModule.kt). - Never a software fallback.
setIsStrongBoxBacked(true)throws on a handset without a secure element; the app then offers online payments only. A software key would keep the app working while destroying the argument. - Signatures are normalised to low-S (
derToRawSignature()), because Android Keystore returns DER that may be high-S and a high-S signature does not settle on chain. Pinned by tests inpackages/voucherand the program's own vectors. - The vault records the device key and a 32-byte hash of its attestation chain at enrolment
(
initialize_vault.rs).
Not yet built: verifying that attestation chain against Google's root and revocation list
before a vault is accepted. Today the program trusts the key the payer enrols. This is on the
roadmap ahead of mainnet offline payments (see ROADMAP.md, milestone 6).
packages/accept (27 tests), run on the merchant's phone before the goods change hands:
| Check | Refusal |
|---|---|
| The voucher decodes and is version 1 | the decode error |
| It names this vault | "this voucher names a different vault" |
| It pays this merchant | "this voucher pays a different merchant" |
| It has not expired | "this voucher has expired" |
| The amount is within the vault's offline limit | "above this vault's offline limit" |
| The signing key is the one enrolled on the vault (cached list) | "signed by a key this vault has not enrolled" |
| The P-256 signature verifies | "the signature does not verify" |
| The sequence was not already presented to this till | "sequence N was already presented to this till" |
| The sequence is inside the replay window and not already redeemed (cached) | "below / beyond the replay window", "already redeemed" |
A vault on the cached freeze list is flagged as a risk to the merchant. What the till cannot know offline, by definition, is whether the same sequence was spent at another till a minute ago. No offline system can, EMV included. That gap is what sections 5–7 close.
redeem_voucher (programs/nelo_vault/src/instructions/redeem_voucher.rs):
- Version and vault match.
- Not expired.
- Within the offline limit: the enrolled floor, lifted by staked SKR along a sublinear curve,
weighted by reputation, under a hard cap — and the stake is revalued at redemption, with a
haircut, not when it was posted (
curve.rs, 17 off-chain tests plus the Rust suite). - Within the vault's balance.
- The signature came from this vault's enrolled device: the transaction must carry a
secp256r1 precompile instruction verifying exactly this key over exactly these 105 bytes;
assert_precompile_verifiedreads it back through the instructions sysvar. A valid signature over different bytes is refused — the negative test that was written first. - The replay window (below).
- Only then does USDC move, to the merchant the voucher names.
Each vault keeps a 128-slot sliding bitmap (consume_sequence in voucher.rs), the same
construction IPsec uses against replay:
- a sequence below the window base is refused (
SequenceTooOld); - one 128 or more ahead is refused (
SequenceTooFarAhead); - one whose bit is already set is refused (
SequenceAlreadyRedeemed) — this is where a double spend dies; - otherwise the bit is set, and the base advances past every contiguous redeemed slot.
Out-of-order redemption works — merchant A holding voucher 5 is not hurt because merchant B with voucher 6 reconnected first — but no sequence ever settles twice.
An honest secure element never signs two different vouchers at the same sequence. Two such vouchers, both verified against the vault's enrolled key, are proof of a tampered device.
report_conflict (report_conflict.rs) is permissionless: anyone holding the proof — the
defrauded merchant, the relayer — freezes the vault. It requires the same sequence, different
signed bytes, and both signatures verified by the precompile, so nobody can freeze a vault by
inventing vouchers. On freeze:
- the vault's status becomes
FROZEN, and any withdrawal in flight is cancelled; - the payer can no longer withdraw, stake or unstake;
- merchants holding good vouchers are still paid —
redeem_voucherdeliberately has no "active" check, and the replay window already ensures one voucher per sequence.
The relayer reports conflicts automatically when two tills submit different vouchers at one
sequence (services/relay/src/conflict.ts).
slash (slash.rs) moves all of a frozen vault's staked SKR into the platform reserve, an
account owned by the risk-config PDA, so no key can move it. It is permissionless; the caller
chooses only when. The vault's stake figures are left as they were, so the offline limit honest
merchants were given still holds when they redeem. The relayer cranks it after a conflict.
Not yet built: paying out of the reserve. Who is compensated, by how much and on whose say-so is a commercial and legal decision, made with counsel before code.
- Withdrawal is timelocked:
request_withdrawstarts a 24-hour delay (WITHDRAW_TIMELOCK_SECONDS), longer than the realistic offline window, during which vouchers still redeem. Without it, a payer could sign vouchers all afternoon and withdraw before any merchant reconnects. - Unstaking has the same floor (
MIN_UNSTAKE_COOLDOWN_SECONDS), and stake that has been requested stops backing the limit at once, so nobody can unstake out from under a loss in flight.
| Situation | What bounds the loss |
|---|---|
| A thief has the phone but the key stays in the secure element | They can sign only up to the vault's balance and per-voucher limit; the payer can request a withdrawal, and the 24-hour timelock lets merchants holding earlier vouchers settle first |
| The key is extracted and used to double spend | Every voucher is attributable to that hardware key; the first two conflicting vouchers freeze the vault and slash its stake. Worst case per vault is roughly limit × merchants reached − locked balance, which the insurance reserve is sized for (RESERVE.md) |
| A merchant's phone is lost | Unsettled vouchers on it are lost with it until settled; settled sales and cash-outs are in the merchant's own wallet, which Nelo never holds |
| The relayer's key leaks | It can pay fees and submit vouchers, but cannot redirect a payment (the program pays the named merchant) or move vault funds. Its spend is capped per day |
Known gaps, stated rather than discovered: a vault cannot yet be moved to a new phone; a voucher that is never redeemed leaves a hole the replay window cannot pass after 127 more; the payer can ask for a per-voucher floor up to the platform cap. All three are on the roadmap.
services/relay/src/policy.ts (relay suite, 68 tests):
- only signature-verified vouchers are submitted — a forgery is refused for free, not on chain;
- a daily SOL budget (
RELAY_DAILY_BUDGET_SOL, default 0.5) and a per-vault cap (RELAY_MAX_PER_VAULT, default 200 a day); - a merchant's first token account is funded once, never again;
- idempotent: the submission is written to its ledger before the transaction is sent, so a retried voucher gets the same answer and is never sent twice.
- Non-custodial. The merchant signs the USDC transfer in their own wallet; Nelo's relayer only pays the fee. paj.cash pays the bank.
- The relayer signs first and refuses any transaction it did not build exactly, so a wallet or
an attacker cannot alter the destination or amount after the fact
(
services/relay/src/cashout.ts,packages/cashout). - The holder's name is shown before anything moves.
- Every step is keyed to the cash-out's id, so a retry continues and nothing is sent twice.
- Webhooks are HMAC-signed and only ever prompt a lookup at paj.cash's API.
No keypair, mnemonic or API key is ever in the repository. The program keypair, the relayer's
fee-payer key, the paj.cash API key and webhook secret live only on the operator's machine (and a
secrets manager once hosted). Every EXPO_PUBLIC_ value is compiled into the apps and treated as
public.
anchor test # 45 LiteSVM tests against the built program, plus vectors
cargo test -p nelo_vault --test devnet -- --ignored --nocapture # the same, on a real devnet validator
pnpm --filter @nelo/accept test # the till's offline checks
pnpm --filter @nelo/relay test # relayer policy, idempotency, conflict reporting
pnpm rehearse # the full offline flow on devnet with the apps' own code