# 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: 1. **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. 2. **`tradeType` is inverted between request and response.** Asking for `tradeType: "BUY"` returns adverts whose own `adv.tradeType` reads `"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. `Side` keeps them apart. 3. **An empty market answers HTTP 200 with `success: true`.** NGN returned zero adverts. "No ads" and "no answer" need different responses, so `MarketSnapshot::is_empty_market` is 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 ```bash 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 ` 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_json` errors 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 uses `deserialize_json_lenient`, and a test pins that the strict form *would* have failed. - **There is no `#[serde(default)]`.** Optional config entries are modelled as `Option` on a `Raw*` 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 why `ui.rs` suites across the repo are `#[ignore]`d (`REVIEWS/PDF_PARITY_PHASE1_STATUS.md`). Every rule it renders lives in `view_model.rs` (97.87%) and `chime.rs` (100%). - `p2p-makepad/src/net.rs` — `request_side` needs a live `Cx`. The `RequestKey` codec it exists to protect *is* tested; those tests run in the `makepad-app` CI job, which builds with `--features ui`. - `p2p-cli/src/main.rs` — argument parsing and `println!`. 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_seconds` timer 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_ms` and alert times come from the host, which does not yet supply a wall clock. - **`min_tradable_fiat` is parsed but not yet enforced.** The overlap check in `SpreadReport::tradable_window` covers 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.