@nelo/voucher
The 202-byte wire format, and the two conversions between Android and Solana.
0..105 must match VoucherArgs::signed_message() byte for byte. vectors/voucher-v1.json is the frozen proof: generated by Rust (the chain is authoritative), asserted by both sides.
29 tests. See Voucher format.
@nelo/pay
Solana Pay requests, payment validation, currency arithmetic, the oracle guard, and balance reads.
Currency arithmetic
The rounding directions are opposite, and both are deliberate:A fraction of a cent per sale is a reconciliation problem by the end of the week. When charging, the remainder lands in the merchant’s favour; when displaying, it never claims more than is held.
Payment validation
transfer, transferChecked, batched, or routed through several instructions.
The oracle guard
Balance
getTokenAccountsByOwner, so there is no ATA derivation to get wrong. The mint is re-checked on the way back — trusting a filter you did not verify is how someone ends up looking at a balance denominated in something else.
54 tests.
@nelo/ledger
The day-book: sale records, day boundaries, close-of-day totals.
A merchant’s day is their local day. A sale at 23:50 belongs to that day, not to tomorrow because UTC has already rolled over. Get this wrong and the close-of-day total silently disagrees with the cash in the tin.
@nelo/attest
Expo native module wrapping Android StrongBox.
isStrongBoxAvailable() === false as an ordinary case, not an error.
The Kotlin side does one conversion it cannot delegate: BigInteger.toByteArray() is signed and variable-width, so affine coordinates are pinned to exactly 32 bytes before they leave. Everything else is done in TypeScript, in @nelo/voucher, so it is covered by tests that run without a handset.
@nelo/onboard
The checkable half of merchant onboarding: what a merchant types, and whether it can be paid.
Not a libphonenumber reimplementation
Global numbering is a large, frequently-changing dataset. A half-remembered subset of it would reject real merchants while looking authoritative — so the launch markets are explicit table rows, and anything outside them is refused rather than guessed.Adding a market is a row in
MARKETS plus its tests. If the table ever becomes the problem, that is the point to take the dependency.Two things that fell out of building it
The markets overlap.0917… is a real Nigerian prefix and a real Philippine one. The same typed string is a different person depending on market, and no digit inspection can tell you which — which is why market is a required parameter rather than something inferred.
SMS is the whole login mechanism, so a non-mobile line cannot complete onboarding. The refusal says exactly that, and deliberately does not say “landline”: a 10-digit number on a non-mobile prefix is not a landline either, since real landlines in both markets are shorter and fail on length. Claiming more would be a guess dressed as a diagnosis.
The canonical form
DisburseRequest.destination in @nelo/settle is a single string, but a bank payout needs two facts — institution and account. So the string carries both and parses back:
parseCanonical re-validates rather than trusting the string — it may have come off a phone, out of a database, or from a partner callback, and a destination that is wrong is money going somewhere else.
The flow is a state machine, and that is why it is here
The Privy wiring — SMS code, embedded wallet — needs an app ID, a development build and a handset. What it does not need is any of the deciding: which step the merchant is on, whether what they typed is acceptable, whether a tap is allowed yet, what a failure means. Soflow.ts holds all of that as reduce(state, event, now) returning the next state and at most one effect for the caller to perform, and apps/merchant/src/privy.ts is left with: call the SDK, report back.
A result is only accepted while the effect that produces it is in flight, and only in the step that could have asked for it. A late promise, a remount or a double tap then cannot advance the flow twice.
busy alone is not sufficient, and the difference is not hypothetical: an effect that starts the next effect leaves busy set across the handover, so a duplicate logged-in sails through a busy-only guard and creates a second wallet — the one failure in this flow with no undo, because the merchant ends up holding an address the day-book has never seen. Two tests pin it shut.Whose problem is it?
explain() labels every failure merchant or operator, and the screen renders them differently.
“That code is not right” is fixable by the person holding the phone. “SMS login is not enabled for this Privy app” is a dashboard setting — showing it as though they mistyped something has them retype a perfectly good number until they give up. The codes are Privy’s own, read off PrivyApiError.code and PrivyClientError.code; anything unrecognised is passed through rather than replaced, because a reassuring “something went wrong” on a first development build is how a configuration problem stays invisible for an afternoon.
60 tests.