The reframe
Most offline payment designs start from “how do we let someone spend money we can’t verify they have?” and end up either trusting a server or accepting credit risk. Nelo does neither, because it changes the question:Offline mode is a prepaid balance, not a promise to pay.Before going offline the payer locks funds into an on-chain vault. There is no offline transaction without collateral that provably exists first.
The chain of custody
The key was generated in hardware
The key was generated in hardware
generateAttestedKey() creates a P-256 key inside StrongBox with an attestation challenge. The resulting certificate chain is verified at enrolment, and its hash is stored on the vault as attestation_id.The module never falls back to a software key. setIsStrongBoxBacked(true) throws on a handset with no secure element, and that exception is deliberately not retried without the flag. A silent fallback would keep the demo working while destroying the entire argument.The signature covers exactly the right bytes
The signature covers exactly the right bytes
The vault does not verify the signature itself — the precompile does, and the program introspects that the precompile instruction verified this public key over these bytes.Get the offsets wrong and it appears to work while verifying nothing — which is the worst failure mode available here. So the first test written was the one that must fail: a valid signature over different bytes has to be rejected.
The signature is in the form the chain accepts
The signature is in the form the chain accepts
Two conversions sit between the phone and the chain, and both are in TypeScript in
@nelo/voucher so they are covered by tests that run without a handset:- DER → raw r‖s, low-S. Android returns DER with an S that may be in the upper half of the curve order. That signature verifies on the phone and is rejected on chain.
- Uncompressed → compressed key. Android returns
0x04 ‖ X ‖ Y(65 bytes); the vault stores 33.
@noble/curves does not default to low-S on P-256, and a high-S signature does not settle on chain.The voucher is bound to one payer and one payee
The voucher is bound to one payer and one payee
The voucher carries
vault (which payer) and merchant (which payee). A voucher is not bearer — presenting someone else’s does nothing. The program checks both.The withdrawal timelock
Without it the attack is trivial: go offline, sign vouchers at every stall on the street, get home, withdraw the collateral before any merchant reconnects.request_withdraw opens a delay longer than the realistic offline window — 24 hours to start — during which vouchers still redeem normally. The timelock blocks the payer’s exit, not the payees.
The freeze, and who it protects
A proven double-spend freezes the vault permanently viareport_conflict — permissionless, on two conflicting signed vouchers at the same sequence.
The freeze is deliberately asymmetric:
Redemption stays open on purpose. Merchants holding good vouchers must still be able to claim against locked collateral — freezing them out would punish the victims of the fraud rather than its author. The replay window already caps the damage, since only one voucher per sequence can ever settle.