|
Some checks failed
p2p-intel / engine (push) Has been cancelled
p2p-intel / notifications (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
p2p-intel / exchange-tab (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
ADR 0035 left one of p2p-intel's four limits open: no OS notification backend, so alerts stopped when the window closed. The seam existed with one implementation that truthfully did nothing. This is the crate that fills it. Posting a notification on Linux is one D-Bus method call. Three ways to make it were measured rather than assumed: notify-rust with libdbus is twelve crates but a C library, which needs pkg-config and breaks the Android and iOS cross-compile this crate family keeps clean; notify-rust with zbus is pure Rust and 169 crates including an async executor; writing the wire format out is about 250 lines and nothing at all. robius-sms deleted polkit and gio for exactly this reason -- its E9 note records they were the sole source of two RUSTSEC advisories and an LGPL question for every consumer -- so pulling a 169-crate tree back into the same family for one method call would reverse that decision for a worse reason. The cost of hand-rolling is that the protocol has to be exactly right, and a mistake makes the daemon disconnect with no diagnostic. That cost was paid in tests: the suite starts a private dbus-daemon per test and talks to it. This matters more than it sounds. The marshaller and the parser were written from the same reading of the specification, so them agreeing with each other proves only that I was consistently wrong or consistently right; only a third party can say which. It found three bugs no unit test would have. The first is the one worth dwelling on. Every error reply parsed as success. The header-field walk assumed all fields were strings, but REPLY_SERIAL is a u32, and reading its four bytes as a string length desynchronised the cursor so ERROR_NAME was never reached. `post` returned Ok against a bus with no notification service running. That is precisely the bug this crate was written to eliminate -- a notifier that reports success and delivers nothing -- reintroduced by accident inside its own parser. I cannot think of a stronger argument for testing against something you did not write. Second, is_available() was true on a bare bus, because NameHasOwner *succeeds* and answers false in its body; checking only for an error reported a working notifier on a machine with no notification daemon. Third, replies were not correlated. The bus sends NameAcquired unprompted right after Hello, so "read the next message" consumed a signal and treated it as the answer. Replies are now matched on REPLY_SERIAL, and a single read carrying several messages is walked rather than truncated. The suite also serialises every test that mutates DBUS_SESSION_BUS_ADDRESS behind a mutex. The variable is process-wide and cargo runs tests in parallel threads; three consecutive parallel runs are now green. --test-threads=1 would have made the failures go away too, and would have hidden a real hazard from whoever reads the file next. On the four platforms, honestly. Linux is implemented and tested. Android is implemented and *compiles* -- cargo check and clippy both pass for aarch64-linux-android -- but has never run on a device, and the module says so in its first paragraph. It handles the two things Android drops silently, missing POST_NOTIFICATIONS on API 33+ and a missing channel on API 26+, because both are the same accepted-and-discarded failure this crate exists to remove. Apple and Windows are deliberately not written. Neither could be compiled here -- no macOS or Windows toolchain and no way to add one -- and objc2 message sends or WinRT calls that no compiler has ever seen are not an implementation. They are plausible-looking text that would sit in the same crate as tested code and be read as equally finished. Both return PermanentlyUnavailable with a reason naming ADR 0036, and their module docs record the call sequence so the next person starts from a design rather than a blank file. The support table says "written" and "verified" in separate columns for the same reason. p2p-intel's dashboard now uses SystemNotifications instead of UnavailableNotifications. The latter stays: on a platform with no backend it is still the truthful answer, and a test needs something that reliably cannot deliver. Alerts are tagged per fiat so a market replaces its own previous notification rather than stacking -- a 30-second poll would otherwise fill the shade, and a full shade is what makes someone turn notifications off for the app entirely, which costs more than the feature is worth. Two new tests pin the invariant that a sink must never report delivery it did not achieve. CI gains a notifications job that installs dbus and sets ROBIUS_NOTIFICATION_REQUIRE_DBUS=1. The bus-backed tests skip when dbus-daemon is absent so the suite stays green on a bare machine, but a silent skip in CI would mean the integration tests quietly stopped running while the build stayed green. I verified the guard fails by hiding dbus-daemon behind a stub that exits 127. 50 tests in the new crate, 225 in p2p-intel, clippy clean on host and Android, and the app still starts under Xvfb. |
||
|---|---|---|
| .. | ||
| crates | ||
| data | ||
| fixtures | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.toml | ||
| config.json | ||
| Makefile | ||
| README.md | ||
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.