Skip to main content
Both apps are Expo with a development build. Expo Go cannot load them@nelo/attest is a native module, and Expo Go has no way to bundle one.

apps/merchant — the till

The screen a shopkeeper actually lives in. Nothing on it says the word crypto.

What it does

Amount entry

A keypad, in the merchant’s own currency. Held as minor units in a bigint, so no float ever touches a price.

Solana Pay

A QR any wallet can pay. No Nelo app on the customer side.

Day-book

Grouped by the merchant’s own calendar day, with close-of-day totals.

Balance

Held in dollars, shown in local currency — both numbers on screen.

Two ways in, and the till does not care which

Mobile Wallet Adapter is the first thing offered, and is required by the hackathon rules. Nelo never holds a key; it learns only an address to pay. The grant is remembered in SecureStore, so the terminal opens ready to trade, and a declined authorisation is surfaced rather than swallowed — the merchant is standing at a counter. A Privy embedded wallet is the other way, for a merchant who has never held a key. A phone number, an SMS code, and a wallet appears behind it. This is what makes step 2’s done-when true — setup completed without ever seeing a key — and it is additive: it does not replace MWA.
The rules live in @nelo/onboard’s flow machine, under test. This file calls the SDK and reports back, and that split is the only reason any of onboarding is verifiable here at all.
create() is not idempotent, so a wallet is never treated as created because a promise resolved. wallet-ready is raised by observing the SDK’s own state, an existing wallet short-circuits the call, and the flow machine refuses a second create. A duplicate leaves the merchant holding an address the day-book has never seen, and there is no undo.
A build with no Privy app ID still runs. The phone-number route is simply not offered and MWA carries the app alone.EXPO_PUBLIC_PRIVY_APP_ID is a public identifier — it ships in the APK and every request already carries it. A Privy app secret is a different thing and belongs nowhere near this bundle.Wrapping the till in a provider with a placeholder ID would be worse than not wrapping it: the SDK would initialise, reject the ID, and fail mid-onboarding in front of a customer.

What onboarding asks for, in order

1

Market

Nigeria or the Philippines, and it is asked rather than inferred: 0917… is a real prefix in both, so the same digits are a different person depending on the answer. Switching market discards the number already typed.
2

Phone number

Normalised to E.164 before anything is sent, so the code goes to the number Privy will actually recognise rather than to what was typed.
3

The code

Six digits. Checked for shape locally first — a four-digit entry never reaches the API — and a resend inside 30 seconds is refused here rather than spent against Privy’s rate limit.
4

The wallet

No question asked. That question is the thing this step exists to remove.
5

Payout account

Bank or mobile money, validated by @nelo/onboard and stored in the canonical form @nelo/settle takes. The NUBAN check digit warns and never blocks.
The bank list is hard-coded and labelled as provisional: a handful of institution codes, not the published register. A bank code baked into an APK cannot be corrected without a release, so it belongs in a file fetched at runtime — which needs a payout partner to fetch it from.

The balance row

Held in dollars, shown in naira, and the till says both.That is the product decision, not a formatting one: a trader in a devaluing currency who holds overnight is better off in a dollar asset converted at payout, and they should be able to see that is what is happening. Showing only the local figure would be the same trick as showing a made-up exchange rate.
Rules it follows:
  • Conversion rounds down — the figure on screen is never larger than what is held
  • A merchant who has never been paid has no token account at all, and that reads as zero, not an error
  • A malformed RPC entry is skipped rather than fatal
  • An RPC failure leaves the last known figure up: a confident zero is worse than a stale number
  • Refreshed on connect and after each settled sale, never on a timer — background polling spends a prepaid data bundle on a figure nobody is reading
  • Reads “at a fixed rate” instead of “held in dollars” while the oracle is not live

The rate

The rate is currently a configured constant, and the till says so on screen.Two things block a live feed, and both are product decisions rather than code:
  1. Pyth publishes no NGN feed. 39 FX pairs, and the naira is not among them. Of the markets the plan names, Manila (PHP) is covered; Lagos is not.
  2. Hermes requires a key. hermes.pyth.network serves feed metadata publicly but returns 401 for prices.
Until one is settled the till runs a configured rate and labels it. A POS that shows a made-up number as though it were live is worse than one that admits it.
Seven Pyth feed ids are wired and each was verified against the Hermes API — one of them was wrong on first pass, reconstructed from a truncated listing, which is why they are checked rather than transcribed.

apps/payer — the probe

This is not the product yet, and its own header says so. It exists to answer the questions only a real handset can, and the real payer UI is week-3 work.There is no vault funding and no voucher emission to a merchant in it — it exercises the codec and the secure element as checks.
Hosts @nelo/attest. The round trip it runs — keygen in the secure element, sign the 105 bytes, verify with the merchant’s own code — is a check, not a payment flow.

The probe

The screen that answers “does this handset actually work?” — and the one that has to run before anything offline can be trusted:
Capability detection is not optional, and the fallback is not software.Where StrongBox is absent the correct behaviour is online-only. Silently falling back to a software key would keep the demo working while destroying the entire argument, and @nelo/attest enforces that at the module level — it throws rather than retrying without the flag.The probe detects and labels this correctly. The enforcement point a merchant would actually hit — refusing to emit an offline voucher and saying why — does not exist yet, because nothing emits vouchers yet. That arrives with the payer UI in week 3.
Check a handset without building anything:

Building it

The Kotlin has never been compiled. There is no JDK or Android SDK on the development machine, so it compiles for the first time during the EAS build — and neither app has ever been bundled. See Testing for what that costs.