A group expense-splitting Mini App for Nimiq Pay. One person (the organizer) creates a split — a total amount, a description, and a list of people who owe them — and shares one link. Each participant opens that link, sees their share, and pays it directly to the organizer's Nimiq wallet with a real on-chain NIM transaction. The organizer watches payments come in live.
Built for the Nimiq Mini Apps Competition, Cycle II.
Nimiq wallet interaction is not decorative — it is the entire mechanism the app is built around:
nimiq.listAccounts()— captures the organizer's real address when they create a split, and (separately, with the participant's own consent) the viewer's address when they open one, both used to identify who's who and to read a wallet balance.nimiq.sendBasicTransactionWithData()— the actual payment. Tapping "Pay now" signs and broadcasts a real NIM transaction through Nimiq Pay's own confirmation dialog. Nothing about the payment is mocked or simulated.- Memo-based reconciliation — every payment carries a plain-text memo,
splitpay:<splitId>:<participantId>, in the transaction's data field. The organizer's status view reads the organizer's incoming transactions straight from the chain (via a public Nimiq RPC server) and matches memo + amount to figure out who has paid — no backend bookkeeping, no trusting the client to self-report. - Balance check — the Pay screen reads the participant's on-chain balance (via
getAccountByAddressover the same public RPC) and warns them upfront if it won't cover their share, without ever blocking the actual payment attempt if that read fails or is slow. requestDeviceIdentifier()— tags local history entries with a stable per-device ID instead of inventing an account system. Requested once, early, on app load (not repeatedly) with a visible reason shown to the user in Nimiq Pay's own consent prompt.
A split's entire definition (description, total, organizer address, participant list) is encoded as
JSON, base64url-packed straight into the shareable link's ?s= query parameter. Opening the link
decodes it client-side — there is nothing to look up, so there is no database for split definitions.
"Who has paid" is not something the client is trusted to report either: it's reconciled live by reading
the organizer's real transaction history from a public Nimiq RPC endpoint
(nimiq.dev/rpc/open-servers) and matching the
splitpay:<splitId>:<participantId> memo + exact share amount on each incoming transaction. The chain
itself is the database. This keeps the whole app static — it can be hosted as plain files with no
server, no secrets, and nothing to break.
The tradeoff: a split's fields are visible to anyone with the link (already true of a normal shared bill), and links get long. Both are acceptable for a same-group expense-splitting tool built in three days.
History works the same way: each device keeps its own local record (in localStorage) of the splits it
created and the splits it paid into, tagged with Nimiq Pay's requestDeviceIdentifier() rather than an
account system. It's a convenience list for "what did I do", not a synced ledger — the actual source of
truth for "who's paid" is always the live chain query, never the local history.
- Vite + React + TypeScript
@nimiq/mini-app-sdkfor all wallet interaction- No backend, no database, no analytics or tracking of any kind
src/
lib/
nimiqProvider.ts # SDK init, RPC URL config, wallet error → user-facing message
split.ts # Split type, share math, link encode/decode
reconcile.ts # reads organizer's tx history, matches memos → paid/unpaid
balance.ts # reads a participant's on-chain balance
format.ts # NIM/Luna conversions & display
participantStore.ts # remembers "which participant am I" per split, per device
deviceId.ts # wraps requestDeviceIdentifier(), one-shot & non-blocking
history.ts # local (per-device) record of created/paid splits
components/
CreateSplit.tsx # organizer: build a split, get the share link
SplitView.tsx # routes a `?s=` link to the organizer or participant view
ParticipantPicker.tsx # "which one are you?" (first visit to a link)
PayShare.tsx # participant: see share + balance, pay, see result
SplitStatus.tsx # live paid/unpaid list (organizer, or a participant after paying) + nudge
HistoryView.tsx # this device's created & paid-into splits, newest first
Avatar.tsx # deterministic colored initials avatar
npm install
npm run devSet the network in .env (defaults to testnet):
VITE_NIMIQ_NETWORK=test # or 'main' once the flow is verified end-to-end
This app only works inside Nimiq Pay — outside it, window.nimiq is never injected and wallet calls
have nothing to talk to. During development:
- Run
npm run devand expose it (e.g. with a tunnel) so Nimiq Pay's mobile client can reach it. - In Nimiq Pay, use Load a Local Mini App with that URL.
- Fund a test wallet from the Nimiq testnet faucet — do not use real mainnet NIM until the flow is confirmed working.
- Create a split as the organizer, open the link as a participant on another device/account, pay, and confirm the organizer's status view picks it up.
Switch VITE_NIMIQ_NETWORK=main only after that full loop has been verified on testnet.
Deliberately v1: even splits only (no weighted/partial shares), NIM only (no USDT/EVM path), no accounts, no history beyond the current split. See the competition brief for the full reasoning — the short version is that a three-day build should do one thing completely rather than five things partially.