nigig-org/crates/apps/p2p-intel/README.md
andodeki 8bf62e2644
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (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
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
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
p2p-intel / engine (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
feat(p2p-intel): the exchange tab becomes spread intelligence, with rails, drift capture, and three of four limits closed
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.
2026-09-01 16:07:43 +00:00

276 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <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_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<T>` 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.