Skip to content
Udoka-AMPublic

About

Accept payments on the phone you already own. Offline-capable stablecoin acceptance on Solana.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

111 Commits

Folders and files

Repository files navigation

Nelo

Accept payments on the phone you already own. Money lands in your own currency, in your own bank. And it keeps working when the network doesn't.

Built for CLOCK IN — a Solana Mobile hackathon presented by Radiants. Submissions close 8 Oct 2026, 11:59pm PST. Android APK, Solana Mobile Stack + Mobile Wallet Adapter, mobile-first, meaningfully interacts with Solana. No website in a wrapper.

📄 Full build plan: docs/BUILD.md — the hackathon rules and scorecard, market, unit economics, stack, partnerships, provisions, the four-week sequence and the risks.

🛠 Build sequence: docs/DELIVERABLES.md — the four graded deliverables, and the week-by-week steps with acceptance criteria.

📊 Pitch deck: docs/deck/index.html — a single self-contained file. Open it in any browser; no build and no server.

💵 Reserve model: docs/RESERVE.md — what the offline guarantee costs, what the insurance line has to be, and what the SKR premium can honestly be priced at.


Status

Week 4 (submission 8 Oct). The live ledger is docs-site/operations/status.mdx, and AGENTS.md is the hand-over for anyone picking the work up. Both apps now run on handsets, and the offline gate has passed on devnet. The history below is how it got here.

The vault program is real: collateral deposit, offline voucher redemption with the device signature verified on chain by the secp256r1 precompile, a 128-slot replay window, and a timelocked withdrawal. A valid signature over the wrong bytes is refused, and so is a deliberate double-spend.

A proven double-spend also freezes the vault via report_conflict — permissionless, on two conflicting signed vouchers. The freeze blocks the payer's exit but leaves redemption open, so merchants holding good vouchers are still paid.

The voucher wire format is real on both sides. packages/voucher encodes, decodes and verifies the 202-byte packet, and vectors/voucher-v1.json is a frozen set of golden vectors generated by the Rust side and asserted by both — so the phone and the chain cannot drift apart silently.

Two facts that cost time if you meet them late, now pinned by tests: @noble/curves does not default to low-S on P-256, and a high-S signature does not settle on chain. Every signing path must normalise; derToRawSignature() does it.

Collateral is SPL — USDC in production. Deposits, redemptions and withdrawals all move real tokens, checked against the mint the vault was enrolled for, and a merchant taking their first Nelo payment gets a token account created for them rather than a failed sale.

The merchant terminal takes an amount in local currency and shows a Solana Pay code any wallet can pay. All the arithmetic is integer-only and lives in packages/pay, tested off device: conversion rounds up so the merchant is never short, display rounds down so a balance is never overstated.

The merchant connects their own wallet through Mobile Wallet Adapter, so 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.

The terminal watches the chain for the payment and says whether it landed. Finding a transaction is not proof: anyone can name your reference, so every payment is validated against what was asked — right payee, right mint, enough money, and the transaction did not fail. Underpayment is refused.

Settled sales land in a day-book held in SQLite, grouped by the merchant's own calendar day — a sale at 00:30 belongs to that day's sheet, not to UTC's.

The balance is held in dollars and shown in the merchant's currency, 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. The conversion rounds down, so the figure on screen is never larger than what is actually there, and an RPC failure leaves the last known number up rather than replacing it with a confident zero.

The rate is quoted through a guarded oracle layer: a price that is stale or whose confidence band is too wide is refused, not displayed. Two things block a live feed — Pyth publishes no NGN pair, and Hermes needs an API key — so the till currently runs a configured rate and labels it on screen as not live.

The offline limit is bought, not fixed. Staked SKR lifts it through a sublinear, hard-capped curve — min(base × (1 + k·√stake) × reputation, hard_cap) — so trust cannot simply be purchased and no merchant creates unbounded exposure. Past the point where the limit covers their largest realistic basket, more collateral buys nothing, which is correct for collateral and exactly why earning is a separate mechanism.

The stake is valued at redemption, with a visible haircut, so a falling price shrinks the limit rather than leaving a merchant holding a ceiling their collateral no longer supports. Requested stake stops backing the limit the moment it is requested, not when it is collected, and the unstake cooldown is pinned to the settlement horizon — a payer must not be able to unstake out from under a loss still in flight.

Every number the curve is shaped by — base, k, the hard cap, the haircut, the cooldown — is configuration, in a RiskConfig account under a risk authority held separately from the upgrade authority. They fall out of the reserve model, which does not exist yet; baking in three plausible-looking constants would be inventing its answer. 17 tests cover the curve off chain: sublinearity at every doubling, the cap against an absurd stake, reputation and coefficient, and isqrt brute-forced against its floor property.

The payout leg has a real ledger under it. services/settle is double-entry: every transaction balances per currency, because a payout touches dollars and naira in one event and netting one against the other would balance while being nonsense. Posting is idempotent on an id the outside world already made unique — an on-chain signature, a partner reference — since a webhook firing twice is the normal case, not the edge one. The journal is append-only; a mistake is corrected by posting its reversal.

The merchant's dollar claim is discharged when a payout is instructed, not when the partner confirms, so the same dollars cannot pay out twice while it is in flight — and a failure returns them exactly. reconcileCustody checks the journal against what the chain actually holds, which is the one test that catches a missed sale or a double post.

The disbursement partner is a declared stub, and it is built so it cannot be mistaken for a real one: fidelity: "stub" on every quote and every result, references prefixed STUB-, and assertMovesRealMoney() to refuse it at any boundary touching real funds. Swapping in a licensed partner is a constructor change.

The reserve requirement is modelled — packages/reserve, written up in docs/RESERVE.md. It turns the plan's own exposure formula into a number and checks the two things the plan asserts; both came back short. The 0.20% insurance line did not cover expected loss at these inputs — the line charged is now 29 bps, which takes the net take rate to 0.61% — and reserve relief funds an SKR premium of about 1.001× rather than the illustrative 1.5×, which is still to be reflected in the deck. It also finds the Trust Stake curve raises required reserve below $250 of staked value, which is what should set k.

Eleven of its inputs are guesses, and it says which before it says anything else. It states what would have to be true, not what is.

A merchant can be onboarded without ever seeing a key. A phone number, an SMS code, and Privy creates an embedded Solana wallet behind it — while Mobile Wallet Adapter stays the first thing offered to anyone who already has a wallet. The step order, the input rules, the resend cooldown and the failure mapping live in @nelo/onboard as a state machine with no I/O, so the part that decides what happens is tested even though the SDK calls need a handset. Failures are labelled for the merchant or for whoever configured the app, because "that code is not right" and "SMS login is not enabled for this Privy app" are not the same problem and only one of them is fixable at a counter.

522 tests pass: 451 in TypeScript across twelve packages and two services, 45 LiteSVM integration tests against the built program, 19 Rust unit tests for the curve and the precompile layout, and 7 that generate and check the cross-language golden vectors. All of them, plus four typechecks, cargo fmt, and clippy, run in CI on every push — see .github/workflows/ci.yml. The Anchor job is week 4's graded deliverable, clone → install → anchor test green, executed on a machine that starts with nothing.

Not yet built: a live price feed (see above), paying out of the reserve (slashing moves a frozen vault's stake into it, but nothing yet moves it back out — that waits on a decision about who is compensated and how), and the payout partner adapter itself.

StrongBox has still never run. The payer probe now works on a real handset — 4 of 5, with Hermes, the codec and P-256 all answered — but that handset has no secure element, which is itself the finding. The two checks it skipped are the ones only hardware can answer, and one of them is whether Android Keystore's own DER survives the low-S normalisation the chain requires.

Neither app has been run. An EAS development build compiles the whole native side, so @nelo/attest's Kotlin is no longer unproven at the compiler — but a development client does not embed the JS bundle, so nothing in either app has rendered. Both typecheck clean, which is not the same thing.

See the build sequence for what is next and how each step is judged done.

Layout

programs/nelo_vault/     Anchor program — vault, replay window, Trust Stake
  src/curve.rs           The floor-limit curve: sublinear, capped, integer-only
apps/merchant/           Expo — the terminal (amount entry, Solana Pay, onboarding)
apps/payer/              Expo — the handset probe (codec, curves, StrongBox)
packages/ledger/         The day-book: sale records, day boundaries, totals
packages/pay/            Solana Pay requests + local-currency arithmetic
packages/onboard/        Phone + payout validation, and the onboarding flow machine
packages/reserve/        The insurance line: exposure, reserve, premium ceiling
packages/voucher/        202-byte wire format: encode, decode, verify
packages/attest/         Expo native module — StrongBox P-256 + attestation
services/relay/          The relayer: submits offline vouchers and pays the fees
services/settle/         Double-entry ledger, payout lifecycle, partner interface
docs/BUILD.md            The build plan
docs/DELIVERABLES.md     The build sequence, step by step
docs/deck/               The pitch deck

Getting started

The toolchain is pinned. These are the versions the repo is built and tested against — Anchor.toml pins the first two, rust-toolchain.toml the third.

Tool Version
Anchor 1.2.0
Solana / Agave 4.2.2
Rust (host) 1.98.1
Node 22+
pnpm 9.15.0
# Solana toolchain
agave-install init 4.2.2

# Anchor toolchain
avm install 1.2.0 && avm use 1.2.0

# JS workspace
pnpm install
cp .env.example .env      # fill in HELIUS_API_KEY

The merchant app's onboarding needs a Privy app ID in EXPO_PUBLIC_PRIVY_APP_ID. It is a public identifier, not a secret — a Privy app secret belongs on a server and must never reach this bundle. Leave it empty and the app still runs: onboarding by phone number is simply not offered, and Mobile Wallet Adapter carries it on its own.

Then:

anchor test

That builds the program and runs the Rust test suite. It should pass from a clean clone.

Devnet

The program is deployed to devnet at 29QdPRQC8C5v6C8gMcBqtw9T4RxYyZ1wqThkEj3XJeQx, upgrade authority BX8kSVjmx9Eihd173hdrRW1Ap61AmixQzqjtc3o5DQfu.

The deployment carries the Trust Stake build: the upgrade extended the program data account from 244,088 to 329,664 bytes, and RiskConfig lives at 9JEJkGp3evd8wFAztEyjPLgTaRucThLyDJ5nkwdJg9ry.

A vault opened by an older build cannot be deserialised by this one. Vault gained four fields. On devnet, open fresh ones. RiskConfig is initialised once per deployment — the gate below does it itself, and tolerates one that already exists.

The week-1 gate has been run there end to end against this build — vault funded, voucher redeemed with the device signature verified by the secp256r1 precompile on a real validator, double-spend refused, signature-over-other-bytes refused:

cargo test -p nelo_vault --test devnet -- --ignored --nocapture

It creates its own 6-decimal mint (devnet USDC exists, but Circle holds its mint authority), so a run is self-contained. It is #[ignore] so it never runs in the default suite. It costs devnet SOL and uses a software P-256 key in place of StrongBox, which isolates "does the chain do what we think" from "does the handset do what we think".

The program keypair is not in this repo, and must not be. target/deploy/nelo_vault-keypair.json is what controls the program address, and target/ is gitignored — so rm -rf target/ destroys it and anchor build silently generates a new one with a different address. The canonical copy lives at ~/.config/solana/nelo/nelo_vault-program-keypair.json. If a build ever produces a program id that does not match declare_id!, restore from there rather than editing the id. Before mainnet, that key belongs on a hardware wallet.

Program tests

Program tests are Rust + LiteSVM, in programs/nelo_vault/tests/. There is no mocha/TypeScript test path — Anchor.toml sets [scripts] test = "cargo test", and no local validator is needed.

Anchor's TypeScript client is @anchor-lang/core (1.x). The pre-1.0 @coral-xyz/anchor package is abandoned at 0.32.1 — do not import it.

Hard rules for this repo

  • The judges clone this repo and run it. Keep pnpm install → anchor test working at all times.
  • Everything a judge needs is in the repo. No pointers to links only the team can open. If a document matters, it lives in docs/.
  • Never commit a keypair, a mnemonic, or a real API key. .env is ignored; keep it that way.
  • Start clean. Hackathon eligibility requires the project to have started within roughly the last three months. Do not import an existing codebase.
  • Publish a stub to the dApp Store in week one so the pipeline is proven — winners must publish within 30 days of winning to claim a prize.

Licence

UNLICENSED for now — decide before submission. Judges can read a private repo you share with them; the public licence choice is a launch decision, not a build one.

About

Accept payments on the phone you already own. Offline-capable stablecoin acceptance on Solana.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages