The exchange tab in nigig-mpesa and nigig-pay was a swap mock-up -- Sell/Buy
cards showing hardcoded ETH and AAVE at $38,409.24, wired to nothing -- above
six rate labels, each built from the *first* cached advert for its exchange:
Binance P2P: USDT BUY @ 129.92 KES min 1000 max 200000 via BANK
That is a price, not an opportunity. It never compared the two sides of the
book, said nothing about whether the counterparty could be dealt with, and
ignored what it costs to move the money. Against the live KES book it would
have shown a 134.60 advert from a merchant with three completed trades.
Both pages now run the p2p-intel analyzer over the adverts the app already
caches. No new endpoint, no new traffic, api.rs untouched -- the data was
always there, nothing was being asked of it. The two files are
byte-identical and CI now fails if they drift apart, which they already had
once: data.rs differs between the apps by 188 lines of tests one copy has
and the other does not.
**Rails, because a spread alone is a lie.** 29 bps on a 10,000 KES trade is
29 KES of gross margin; M-Pesa Send Money costs 55 KES a leg, 110 round
trip. The trade is deeply negative and the spread says nothing about it.
rail.rs prices every route and ranks by what survives. M-Pesa fees come from
the published Safaricom bands in robius_ussd::mpesa_bands -- real tariff
rows, not estimates, and flat rather than percentage, which is exactly why
the same spread is ruinous at 500 KES and fine at 200,000. Both legs are
charged. An amount outside the tariff reports OutOfRange and an
unconfigured bank reports Unknown; neither reports zero, because zero is a
claim and it is the wrong one. Bank tariffs ship unconfigured for the same
reason: every bank differs and there is no table to default to.
**API drift capture.** These endpoints are internal and undocumented. When
one changes the symptom is an empty panel -- indistinguishable from a quiet
market -- and the response that broke it is gone by the time anyone looks.
Every parse failure is now recorded with the payload excerpt that caused it
and copyable as a plain-text report. Deduplicated, because a 30-second poll
against a changed endpoint fails 120 times an hour and 120 identical rows is
a log nobody reads; the excerpt is excluded from the equality check, since
two responses differing only in advert ids are the same drift. The exported
header states that it carries response excerpts only and never a request,
credential or account number -- the type can only be constructed from a
response body, so that is enforced rather than promised. Cutting the excerpt
is done on character boundaries: Binance really returns names like
BennyBoss and a byte slice would panic mid-character.
The repo's other clipboard code (nigig-build's crdt_widget.rs) answers a
Hit::TextCopy, which is the query-driven path the platform uses for Ctrl+C
on a focused widget. Right for a text editor, wrong for a button exporting a
report the user never selected, so this uses cx.copy_to_clipboard and
confirms in the UI -- a copy button with no feedback is one people press
three times.
On the four limits, the honest scoreboard is two closed, one usefully
sidestepped, one open. ADR 0035 records why, because rounding all four up to
"addressed" would have been the easy write-up and the wrong one.
The poll timer and the host clock are closed outright. cx.start_interval
delivers the tick as an ordinary UI-thread event, so there is no runtime and
nothing to join at shutdown, and the interval reads through
effective_poll_seconds so the fifteen-second floor still applies -- a config
file cannot be used to hammer a rate-limited endpoint, which a test pins by
name. Staleness is now shown, because a price from four minutes ago is not a
price. One ambiguity is recorded rather than hidden: last_scan_ms == 0 is the
"never scanned" sentinel, so a scan whose timestamp genuinely is 0 reads as
never. That only happens when the host clock is broken, which is exactly
when the UI should not claim the data is current.
The headless-backend limit is sidestepped, and the distinction matters
enough to write down. Xvfb is a real X11 server that draws into memory, so
the app gets the display it insists on and tools/test-p2p-app-smoke.sh runs
the real binary end to end. That earned its place immediately: it caught
`Row = <View> { ... }`, which is not valid in this fork's script language
and which cargo build is entirely silent about, because script_mod! is
parsed at *runtime* -- a broken widget tree compiles perfectly and then
renders nothing. The gate greps for [E] in the log for that reason; the
process exits 0. I verified it fails by reintroducing the bad syntax. What
it still cannot do is drive widgets: makepad_test's Selector::id(..).click()
needs the harness to own the event loop, so the six #[ignore]d interaction
tests stay ignored.
OS notifications stay open. There is no robius-notification crate beside
robius-sms and robius-ussd, and Makepad exposes none on any target; building
one means NotificationCompat on Android, UNUserNotification on iOS and a
D-Bus call on Linux. What shipped is the seam plus one implementation named
UnavailableNotifications -- named for what it is, because a DefaultNotifier
that silently discarded every alert would read like a working feature at
every call site. is_delivering() returns false and the UI shows the reason,
so the user is told that alerts stop with the window instead of assuming
they are covered. A no-op reporting success is the exact "declared versus
delivered" failure ADR 0017 exists to prevent.
223 tests, coverage 97.03% with fifteen per-file floors -- seven of them new
and all measured, after I noticed an earlier edit had silently failed to
apply and the new files were being counted in the total but gated by
nothing. CI gains the Xvfb smoke job and an exchange-tab job that builds
both host apps and diffs their pages.
13 KiB
p2p-intel
Binance P2P spread intelligence: monitor several fiat markets, measure the spread that is actually fillable, alert when one is worth acting on, and track what the float really cost.
It never places an order. Binance exposes no public P2P trading API, and automating an escrow release is how a merchant loses their float to chargeback fraud. This is the intelligence layer; execution stays manual in the Binance app.
Alerts are delivered by the app itself — banner, unread badge and a chime — not by Telegram. That removes a bot token from the threat model (a token in a config file is a bot anyone who reads the file can drive) and removes a second network dependency. The trade-off is stated plainly: an in-app alert only reaches you while the app is running, which suits a tool you watch during a trading session.
What the live data showed while this was built
The design is shaped by a real capture (2026-09-01, KES/USDT), not by a sketch. Three things it revealed:
- The best price is usually the least fillable one. The top KES sell advert was 134.60 from a merchant with three completed trades, which implies a 3.6% spread. The next was 130.30. A best-price scan with no quality floor does not find opportunities, it finds outliers.
tradeTypeis inverted between request and response. Asking fortradeType: "BUY"returns adverts whose ownadv.tradeTypereads"SELL". Both are correct — the request is what you want, the field is what the advertiser is doing. Conflating them inverts every spread, and the result still looks plausible.Sidekeeps them apart.- An empty market answers HTTP 200 with
success: true. NGN returned zero adverts. "No ads" and "no answer" need different responses, soMarketSnapshot::is_empty_marketis a named predicate.
A live scan run against these defaults reported a negative spread for KES and an empty NGN book — that is the tool working. Most of the time there is no fillable arbitrage, and a tool that manufactures one is worse than no tool.
Layout
p2p-intel/
├── Cargo.toml nested workspace (see the note in the file)
├── Makefile test / lint / coverage / app / android / ios
├── config.json thresholds, markets, quality filters
├── data/ trades.json, inventory.json
├── fixtures/ real captured payloads, used by the tests
├── crates/
│ ├── p2p-core/ Price, Side, Advert, Config, drift log, errors
│ ├── p2p-scanner/ parser + request builder + transport allowlist
│ ├── p2p-analyzer/ spread, quality filter, rails, panel, deal bridge
│ ├── p2p-alerter/ formatting, de-duplication, alert history
│ ├── p2p-tracker/ inventory, ledger, realised P&L
│ ├── p2p-cli/ offline analysis and P&L
│ └── p2p-makepad/ view_model + chime + net (tested), widget (GPU)
└── tests/ integration suite over the real fixtures
Money is never a float
crates/nigig-pay-domain/src/money.rs sets the rule this crate follows:
IEEE 754 cannot represent 0.1, and a spread is a difference of two nearly
equal numbers — exactly where binary floating point loses the digits that
matter. Price parses Binance's decimal strings straight into scaled
i128 integers and never passes through f64. A test pins the classic
0.1 + 0.2 == 0.3 case, and another rotates 100 round trips at one price
and asserts the P&L is exactly zero.
Excess precision is refused rather than rounded, and a thousands separator
is refused rather than silently dropped: "1,299.92" read as 129992 is a
1000× error that still looks like a plausible price.
Running it
make test # 106 tests, no network
make lint # fmt + clippy -D warnings
make coverage # enforces the floors; installs and removes its own toolchain
make app # desktop (needs tools/makepad-native-libs.sh --install)
make android # cargo makepad android run
make ios # cargo makepad ios run
make deps # print the dependency tree CI gates on
make smoke # run the app under Xvfb and fail if it cannot start
cargo run -p p2p-cli -- analyse <snapshot.json> and
... -- pnl data/trades.json KES work offline. Live scanning is a button in
the app, not a CLI flag.
Networking: Makepad's stack, not our own
Requests go through Cx::http_request, the same path
nigig-mpesa/src/pages/exchange/api.rs uses: build an HttpRequest, key it
with a LiveId, match the reply in handle_network_responses. There is no
reqwest, no rustls, no tokio — the platform already owns a networking
stack per target, and on Android and iOS it is the only one that works
without shipping a second TLS implementation.
Correlating replies is the part with a real trap. A scan of five markets
issues ten requests at once and the replies arrive in any order, so the
LiveId encodes market, side and a generation counter. RequestKey
round-trips through a u64 with the high bit set, so a LiveId Makepad
derived from a name is never mistaken for a scan reply, and a late reply
from a previous round is discarded rather than folded into fresh data.
The transport allowlist is lifted from the nigig-mpesa review, which
reached the same conclusion: Makepad exposes no certificate pinning, so what
is enforceable here is that only HTTPS to p2p.binance.com can be dialled
at all. Tests cover the lookalike host (p2p.binance.com.evil.example) and
the userinfo smuggle (https://p2p.binance.com@evil.example/).
Lean by construction: micro_serde, not serde
Deserialisation uses makepad_micro_serde, as map and nigig-build
already do. The default dependency graph is 25 crates, and a CI step
fails the build if serde, serde_derive, serde_json, toml, reqwest,
tokio, rustls or hyper reappears.
Two behaviours differ from serde and both bit during the migration:
- micro_serde is strict by default.
deserialize_jsonerrors on the first key it does not model. The Binance payload carries about forty fields per advert and we model eight, so the strict form cannot parse the response at all. Everything here usesdeserialize_json_lenient, and a test pins that the strict form would have failed. - There is no
#[serde(default)]. Optional config entries are modelled asOption<T>on aRaw*struct and resolved by hand. A few more lines, in exchange for two fewer dependency trees.
config.toml became config.json for the same reason: micro_serde has no
TOML reader, and toml depends on serde, so one config file would have
dragged the whole of serde back in.
Testing and coverage
106 tests. Coverage is enforced by tools/test-p2p-coverage.sh, wired into
.forgejo/workflows/p2p-intel.yml:
| File | Coverage | Floor |
|---|---|---|
p2p-analyzer/src/spread.rs |
100.00% | 96 |
p2p-core/src/config.rs |
99.25% | 95 |
p2p-alerter/src/lib.rs |
98.85% | 95 |
p2p-tracker/src/inventory.rs |
98.33% | 94 |
p2p-tracker/src/trade.rs |
97.98% | 94 |
p2p-makepad/src/view_model.rs |
97.26% | 93 |
p2p-makepad/src/chime.rs |
100.00% | 95 |
p2p-alerter/src/notify.rs |
100.00% | 95 |
p2p-analyzer/src/deal_bridge.rs |
99.38% | 95 |
p2p-analyzer/src/panel.rs |
99.03% | 95 |
p2p-core/src/drift.rs |
97.32% | 95 |
p2p-analyzer/src/rail.rs |
91.97% | 88 |
p2p-scanner/src/client.rs |
95.69% | 90 |
p2p-scanner/src/parser.rs |
92.36% | 91 |
p2p-core/src/types.rs |
92.31% | 90 |
| total | 97.03% | 90 |
Both the total and per-file gates were verified to actually fail by running them with impossible floors. A gate that cannot fail is decoration.
Two files are excluded, and only because they were first emptied of decisions:
p2p-makepad/src/dashboard.rs— the widget. Needs a GPU and a windowing backend; this repo has no headless Makepad backend on Linux, which is whyui.rssuites across the repo are#[ignore]d (REVIEWS/PDF_PARITY_PHASE1_STATUS.md). Every rule it renders lives inview_model.rs(97.87%) andchime.rs(100%).p2p-makepad/src/net.rs—request_sideneeds a liveCx. TheRequestKeycodec it exists to protect is tested; those tests run in themakepad-appCI job, which builds with--features ui.p2p-cli/src/main.rs— argument parsing andprintln!.
That split is deliberate. spreadsheet-ui/grid.rs once hid 36 pure
functions behind a file-level coverage exclusion; excluding a file you have
not emptied of logic is how that happens.
The chime
Synthesised, not bundled: a two-note rising blip generated at the device's sample rate. That is a few dozen lines instead of an audio asset shipped on three platforms, and — being generated — it can be tested for the things that matter. It is, and the tests found the bugs you would expect: a freshly rendered chime starts finished so nothing sounds at startup, both note edges fade so neither clicks, the tail pads with silence rather than replaying whatever the buffer last held, and a nonsense sample rate falls back instead of panicking. Rising rather than falling because a falling interval reads as a dismissal, and this is an invitation to act.
Rails and their charges
A spread alone is a lie. 29 bps on a 10,000 KES trade is 29 KES of gross margin; moving that money over M-Pesa Send Money costs a 55 KES band fee on each leg. The trade is deeply negative and the spread number says nothing about it.
So rails are first-class. rail.rs prices every settlement route and ranks
by what survives:
| M-Pesa fees | the published Safaricom bands, from robius_ussd::mpesa_bands — not estimates, not percentages |
| Bank fees | configured per bank (flat + bps), because no published table exists |
| Charged on | both legs |
| Out of tariff range | OutOfRange, never a fee of zero — pricing a trade M-Pesa cannot carry is worse than admitting it cannot |
| Unconfigured rail | Unknown, never zero. Zero is a claim, and the wrong one |
Because a band fee is flat, the same spread is ruinous at 500 KES and fine at 200,000. The panel therefore names the ticket size it priced against. Rails neither counterparty accepts are still quoted but marked unusable.
API drift capture
Binance's P2P endpoint is internal and undocumented. When it changes, the symptom is an empty panel — the same thing a quiet market looks like — and by the time anyone investigates, the response that broke it is long gone.
Every parse failure is recorded with the payload excerpt that caused it, deduplicated (a 30-second poll against a changed endpoint fails 120 times an hour, and 120 identical rows is a log nobody reads), and copyable to the clipboard as a plain-text report.
The exported header names what the report contains and what it does not: response excerpts only, never a request, credential or account number. A pasted payload fragment with no provenance is alarming, and this type is only ever constructed from a response body, so nothing from the user's side can reach it.
The repo's other clipboard code (nigig-build's crdt_widget.rs) answers a
Hit::TextCopy — the query-driven path the platform uses for Ctrl+C on a
focused widget. That is right for a text editor and wrong for a button
exporting a report the user never selected, so this uses
cx.copy_to_clipboard and confirms in the UI.
Where this UI ships
The same analysis backs three front ends:
p2p-makepad— the standalone dashboard (desktop, Android, iOS).nigig-mpesa— the exchange tab.nigig-pay— the same exchange tab; the two files are byte-identical and CI fails if they drift apart.
The exchange tab previously showed a swap mock-up over six rate labels, each
built from the first cached advert for its exchange. That is a price, not
an opportunity: it never compared the two sides, said nothing about whether
the counterparty could be dealt with, and ignored settlement cost. Against
the live KES book it would have shown a 134.60 advert from a merchant with
three completed trades. It now runs the full analyzer over the adverts the
app already caches — no new endpoint, no new traffic, api.rs untouched.
Known limits
- Scraping an internal endpoint. There is no public P2P API. Binance can
change or block this without notice, and polling hard gets an IP banned —
hence the 15-second floor on
poll_interval_seconds, applied with a reported warning rather than silently. - The alert is a starting point, not a fill. By the time you act the book has moved. The window and counterparty names are in the message so you can find them quickly.
- In-app alerts need the app open. No OS notification is raised, so a minimised window is a missed alert.
- Scans are manual. "Scan now" issues one round; the
poll_interval_secondstimer is not yet wired to a Makepad timer, so the config value is validated but unused by the app. - Snapshot timestamps are zero.
captured_at_msand alert times come from the host, which does not yet supply a wall clock. min_tradable_fiatis parsed but not yet enforced. The overlap check inSpreadReport::tradable_windowcovers the case that matters today.- The Makepad widget is compiled, not automatically tested. It builds and links on desktop; interaction is unverified for the reason above.