Inspiration
Most payment apps stop working the moment the network does: in a subway, at a crowded festival, on a rural road, or after a storm takes the cell towers down. Most budgeting apps only tell you about overspending after the money is gone. I wanted to see whether an iPhone could do both jobs with no internet at all: carry a balance, stop a purchase before it breaks a budget, and prove that two phones were physically next to each other when money moved, using the hardware already in my pocket (the Secure Enclave, Face ID and the Ultra-Wideband chip).
That became S.E.C.U.R.E: Spatial Enclave Cryptography for Untethered Reserves & Expenses.
What it does
The wallet
- Every transaction is signed by a P-256 key that is created inside the Secure Enclave and can never leave it. The hardware itself requires Face ID before each signature.
- Balances, budgets and history live in an encrypted SQLCipher database, hash-chained so that any edit, deletion or rollback is detectable.
- Category budgets are checked before signing, with alerts at 80 % and 100 % and an over-budget warning that needs an explicit override.
Phone-to-phone payments
- Two people open Pay or get paid nearby. The phones find each other over Bluetooth and peer-to-peer Wi-Fi, with no internet.
- Each phone measures the other with UWB and shows a live distance in centimetres.
- The payer sends an amount, or the payee sends a payment request with a note ("Coffee"). On a request, the payer picks a category and sees its own budget check before approving.
- Both people approve with Face ID. Each phone signs its own distance reading, and the payment only goes through if both readings say the phones are within a metre.
- The payer keeps a countersigned receipt. If the connection drops halfway, Retry re-sends the same signed payment, and it can never be credited twice.
Simulated add-ons, kept apart from the wallet
- Credit-score access tiers for practice markets.
- Connected bank accounts in one dashboard.
- Practice stocks, ETFs and prediction markets with $10,000 of practice money. Sports betting is built but switched off for now.
- A strategy guide (27 strategies) with calculators.
- Responsible-play controls: age checks, stake limits, cool-offs and self-exclusion.
How I built it
| Layer | Technology |
|---|---|
| Language and UI | Swift 6.4 (strict concurrency), SwiftUI, Observation, Swift Charts |
| Keys and signing | CryptoKit with SecureEnclave.P256.Signing, .biometryAny access control, LocalAuthentication |
| Storage | SQLCipher 4.19.0, Keychain, iOS Data Protection |
| Proximity | NearbyInteraction (one NISession per nearby phone) |
| Phone-to-phone link | MultipeerConnectivity (Bluetooth and peer-to-peer Wi-Fi, encrypted sessions) |
| Tests | Swift Testing: 144 tests on macOS and the iOS Simulator |
The code is about 11,700 lines of Swift in five modules:
SecureCore: pure protocol logic.SecureStore: the encrypted ledger.SecureDevice: the Keychain, Secure Enclave, Face ID and notifications.SecurePeer: the phone-to-phone protocol.SecureConnect: the simulated add-ons.
SecurePeer drives the same wallet steps the single-phone demo uses, so every rule the wallet enforces applies unchanged when money crosses between two phones.
Tamper-evident ledger
Each entry \( n \) links to the one before it with a domain-separated hash. In simplified form:
$$ h_n = \operatorname{SHA\text{-}256}\big(\tau \parallel h_{n-1} \parallel n \parallel \delta_n \parallel \mathrm{id}_n\big) $$
The latest \( (n, h_n) \) is also kept as an anchor in the Keychain. If the database ever opens with \( n_{\text{db}} < n_{\text{anchor}} \), someone restored an old copy to get spent money back, and the wallet locks.
Budgets before signing
For a payment \( r \) in a category with limit \( L \) and spending \( S \) so far in the current window:
$$ \text{verdict} = \begin{cases} \text{OK} & S + r \le 0.8L \ \text{alert} & 0.8L < S + r \le L \ \text{over budget (override or hard cap)} & S + r > L \end{cases} $$
Two phones, one proof
Neither phone can choose the session on its own. Each contributes 16 random bytes:
$$ N = \operatorname{SHA\text{-}256}\big(\tau_{\text{session}} \parallel c_{\text{payer}} \parallel c_{\text{payee}}\big) $$
Each phone reduces its UWB samples to a median distance and a p10–p90 spread, then signs that summary together with \( N \) and both wallet keys. With \( d_A \) and \( d_B \) the two medians and \( d_{\text{far}} = \max(d_A, d_B) \), the proof is accepted only if
$$ d_{\text{far}} \le 100\,\text{cm} \quad\text{and}\quad |d_A - d_B| \le \max\big(25\,\text{cm},\; 0.2\, d_{\text{far}}\big) $$
The messages between the phones:
enum PeerMessage {
case hello(PeerHello) // wallet key, name, UWB discovery token
case request(PaymentRequest) // payee → payer: amount and an optional note
case offer(PaymentOffer) // payer → payee, after the payer's budget check
case reply(OfferReply) // payee accepted (and passed Face ID) or declined
case measurement(OfferID, SignedMeasurement)
case ready(OfferID, Money) // payee: proof and limits OK, for exactly this amount
case transfer(OfferID, SignedTransfer, SpatialProof)
case receipt(OfferID, SignedReceipt)
case failure(OfferID?, PeerFailure)
}
Money-safety rules:
- Every refusal (budget, balance, distance, disagreement, mismatched amount) happens before the payer's debit.
- The payer commits its debit only after the payee says "ready" for the exact amount.
- After the debit, a dropped link leaves an unconfirmed payment, never a lost one. Accepting a transfer is idempotent: the receiver records the transfer's ID with its first entry and returns that same entry for any re-delivery. So with \( T \) the signed transfer,
$$ \operatorname{accept}\big(\operatorname{accept}(\text{ledger}, T), T\big) = \operatorname{accept}(\text{ledger}, T) $$
- A requester skips the "Accept?" prompt only when \( \mathrm{id}{\text{offer}} = \mathrm{id}{\text{request}} \) and \( \text{amount}{\text{offer}} = \text{amount}{\text{request}} \). It still signs with Face ID.
Practice markets and calculators
- Practice stock prices are deterministic geometric Brownian motion, so every device sees the same market and tests can check exact outcomes:
$$ S_{t+1} = S_t \exp!\Big(\big(\mu - \tfrac{1}{2}\sigma^2\big)\Delta t + \sigma\sqrt{\Delta t}\,Z\Big), \qquad \Delta t = \tfrac{1}{252} $$
- A prediction-market contract priced at \( c \) cents has expected value \( 100p - c \) for your probability \( p \).
- The Kelly calculator sizes bets with \( f^* = \frac{bp - q}{b} \).
Challenges I ran into
- The spec asked for something the hardware can't do. The original requirements called for Ed25519 keys in the Secure Enclave, but the Enclave only offers P-256 (plus post-quantum ML-DSA and ML-KEM). I switched to P-256 ECDSA and documented why.
- Offline double-spending can't be fully prevented. If someone restores the database and the Keychain, their own phone can't tell. Instead I made cheating provable: two signed claims for the same position with different links form portable fork evidence.
- Choosing the link between phones. The design started with raw CoreBluetooth. But phone-to-phone UWB only runs with both apps in the foreground, so background wake-up wouldn't help a payment. MultipeerConnectivity gave encrypted, reliable sessions in a fraction of the code, and the link stays untrusted anyway: every message that matters is signed.
- The Simulator fooled me twice.
- Two Simulators on one Mac can't complete a Multipeer session; invitations time out. I wrote a loopback stand-in with the same interface, compiled out of phone builds.
- The Simulator emulates NearbyInteraction and reports 0 m. My code first treated those readings as real UWB, which a release build would have accepted. Now any reading taken in the Simulator is flagged
simulated, and only debug builds accept it.
- Concurrency across two phones. Each link is a Swift actor. Messages must be handled in arrival order, every wait needs a timeout, and a person's decision can race against a withdrawal from the other phone. I fed the link through a single ordered stream and settled each race exactly once with a
Mutex. - Face ID timing. A Secure Enclave signing session lasts 60 seconds. I ordered the protocol so each person approves right before their phone's machine-speed steps, never while waiting on the other person.
- Protecting the core from feature creep. My first version of the credit tiers was wired into the ledger. I reverted every core file and rebuilt the add-ons as a separate module with their own encrypted database. A test now proves that wallet payments ignore the credit score.
- Getting it onto a real iPhone. Code signing failed with
errSecInternalComponentuntil macOS was allowed to use the new certificate from my keychain, a step that only works from Xcode itself.
What I learned
- What the Secure Enclave can and can't do, and how
SecAccessControlmakes Face ID a hardware requirement rather than an app check. - How SQLCipher works with a random 256-bit raw key, and why no slow key derivation is needed when the key isn't a password.
- The difference between preventing an attack and detecting it, and why writing down the limits is part of security work.
- Distributed money needs a clear point of no return: refuse everything you can before committing, and make everything after it safe to repeat.
- Deterministic simulation makes complex systems (markets, bank feeds, two-phone protocols) fully testable. One payment test suite runs a tampered transfer, a lost receipt and a withdrawn request in under half a second.
- Keep the trusted core small and put everything else beside it, not inside it.
What's next
- Binding each UWB reading to the wallet key, so a phone can't range with someone else's device.
- Exchanging double-spend evidence when phones meet.
- An issuer mode, so one phone can mint to others in person.
- A radar view with direction.
Log in or sign up for Devpost to join the conversation.