> ## Documentation Index
> Fetch the complete documentation index at: https://nelo.udokaam.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Packages

> The six TypeScript packages — what each owns, and why it exists outside the app.

Everything here is pure and testable off-device. That is the point: it is the arithmetic that would otherwise hide inside a phone or a validator, where nobody could check it.

***

## `@nelo/voucher`

The 202-byte wire format, and the two conversions between Android and Solana.

```ts theme={null}
signedMessage(fields)      // → the 105 bytes StrongBox signs
encode(voucher)            // → 202-byte packet
decode(bytes)              // → Voucher, or throws
verify(voucher)            // → boolean, P-256 over the signed message

derToRawSignature(der)     // DER → r‖s, normalised low-S
compressPublicKey(uncompressed)   // 0x04‖X‖Y (65B) → SEC1 compressed (33B)
```

The layout is a **contract with the on-chain program** — bytes `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.

<Warning>
  The only package with a runtime dependency (`@noble/curves`), so it is the one suite that needs `pnpm install` before it runs.
</Warning>

**29 tests.** See [Voucher format](/concepts/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:

```ts theme={null}
localToTokenBaseUnits(localMinor, rate)     // rounds UP   — merchant never short
tokenBaseUnitsToLocalMinor(baseUnits, rate) // rounds DOWN — balance never overstated
```

<Note>
  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.
</Note>

### Payment validation

```ts theme={null}
referenceFromBytes(bytes)       // 32 random bytes → the marker
encodeTransferRequest(request)  // → solana: URL
awaitPayment(rpcUrl, reference, expected, opts)  // → PaymentOutcome
validatePayment(tx, expected)   // the pure part — where the tests are
```

<Warning>
  **Finding a transaction that names your reference is not proof of payment.** Anyone can build a transaction naming any account. Every payment is validated against what was asked — right payee, right mint, enough money, transaction did not fail. Underpayment is refused, and the merchant is told *"that was not enough"* rather than left looking at a spinner.
</Warning>

Validation works off the **token-balance deltas in transaction metadata** rather than by decoding instructions: the metadata carries the account *owner*, so it needs no associated-token-address derivation and is indifferent to whether the wallet used `transfer`, `transferChecked`, batched, or routed through several instructions.

### The oracle guard

```ts theme={null}
quoteToRate(quote, now, minorPerMajor, guards)  // → QuoteResult
```

A quote is not a number — it is a number plus *"how old?"* and *"how sure?"*, and both are **refused rather than displayed**. Defaults: stale past 30s, confidence band wider than 50 bps. Also refuses a quote from the future (that is a clock problem, and just as untrustworthy), a non-positive price, and a negative confidence.

Provider-agnostic: Pyth and Switchboard publish the same shape.

### Balance

```ts theme={null}
fetchTokenBalance(rpcUrl, owner, mint)   // → bigint
sumTokenAccounts(accounts, mint)         // the pure part
```

Reads via `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.

```ts theme={null}
localDayKey(atMs, tzOffsetMinutes)   // → "YYYY-MM-DD"
groupByDay(sales, tzOffset)          // newest day first, newest sale first
closeOfDay(sales, day, tzOffset)     // → DayTotals
dayLabel(day, nowMs, tzOffset)       // "Today" / "Yesterday" / the date
```

<Note>
  **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.
</Note>

The timezone offset is passed in rather than read from the environment, so it is deterministic under test — including non-whole-hour offsets like Chatham Islands (UTC+12:45).

**13 tests.**

***

## `@nelo/attest`

Expo native module wrapping Android StrongBox.

```ts theme={null}
isAvailable()                            // native module present (dev build, Android)
isStrongBoxAvailable()                   // this handset has a secure element
generateAttestedKey(alias, challenge)    // → { publicKey (33B), certChain, strongBoxBacked }
sign(alias, message)                     // → P-256 raw r‖s, low-S
hasKey(alias) / deleteKey(alias)
```

<Warning>
  **It 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 security argument. A handset without StrongBox degrades to **online-only** instead.
</Warning>

StrongBox is API 28+ and absent on much budget hardware — which is the hardware this product targets. Treat `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.

<Warning>
  **Never compiled.** Requires a JDK and Android SDK; it compiles for the first time during an EAS build. See [Testing](/operations/testing).
</Warning>

***

## `@nelo/onboard`

The checkable half of merchant onboarding: what a merchant types, and whether it can be paid.

```ts theme={null}
normalisePhone(input, market)             // → E.164, or a reason
formatPhone(e164)                         // → "+234 803 123 4567"
validateBankAccount(market, inst, acct)   // shape blocks; check digit warns
validateMobileMoney(market, phone)        // reuses phone normalisation
toCanonical(destination)                  // → "bank:NG:058:0123456789"
parseCanonical(string)                    // re-validates on the way back

reduce(state, event, now)                 // → { state, effect? }
explain(error)                            // → { audience, message }
```

### Not a libphonenumber reimplementation

<Note>
  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.
</Note>

### 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:

```
bank:NG:058:0123456789
momo:NG:+2348031234567
```

Colon-delimited because no field can contain a colon, which makes the parse unambiguous rather than usually-right. Tested as a round trip, and `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. So `flow.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.

<Note>
  **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.
</Note>

### 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.

<Warning>
  **The NUBAN check digit is advisory and must stay that way.** It is implemented because it catches transposed digits, and it is non-blocking because it has **not been validated against real account numbers**. Refusing a merchant's actual account on an unverified algorithm is a lost merchant; accepting a typo is a payout the partner bounces with a clear reason.
</Warning>

**60 tests.**

***

## `@nelo/reserve`

The insurance line, modelled. Exposure → reserve requirement → what the SKR premium can be.

```ts theme={null}
perVaultExposure(v)        // floor_limit × merchants_reached − collateral − stake
reserveRequirement(v)      // Poisson tail at a chosen confidence
reserveLineVerdict(v)      // is the plan's 0.20% line enough?
crossoverStakeValue(v)     // where staking stops making things worse
premiumCeiling(v)          // what capital relief can actually fund
unsourcedInputs()          // every guess, and what would settle it
```

Run the report:

```bash theme={null}
pnpm --filter @nelo/reserve report
```

It prints its **unsourced assumptions first**, deliberately — every figure below them inherits that uncertainty.

**16 tests.** See [The reserve model](/economics/reserve-model).
