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 errSecInternalComponent until 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 SecAccessControl makes 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

  1. Binding each UWB reading to the wallet key, so a phone can't range with someone else's device.
  2. Exchanging double-spend evidence when phones meet.
  3. An issuer mode, so one phone can mint to others in person.
  4. A radar view with direction.

Built With

  • bluetooth
  • cryptokit
  • elevenlabs
  • face-id
  • ios
  • keychain
  • localauthentication
  • multipeerconnectivity
  • nearbyinteraction
  • network-framework
  • observation
  • secure-enclave
  • sqlcipher
  • sqlite
  • swift
  • swift-charts
  • swift-package-manager
  • swift-testing
  • swiftui
  • ultra-wideband
  • xcode
Share this project:

Updates

Submission history