3 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 8bf62e2644 |
feat(p2p-intel): the exchange tab becomes spread intelligence, with rails, drift capture, and three of four limits closed
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
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.
|
|||
| fefde1ecca |
refactor(p2p-intel): Makepad's HTTP stack, in-app alerts, and micro_serde -- serde and reqwest are gone
Three changes that turn out to be one change: the app now uses the platform's own facilities instead of carrying its own. Networking moves to Cx::http_request, alerting moves into the UI, and deserialisation moves to makepad_micro_serde. The default dependency graph drops from serde + serde_derive + serde_json + toml + reqwest + rustls + tokio + hyper to **twenty-five crates total**, none of which is any of those. Networking follows nigig-mpesa/src/pages/exchange/api.rs: build an HttpRequest, hand it to Cx::http_request keyed by a LiveId, match the reply in handle_network_responses. There is no HTTP client, no TLS stack and no async runtime in this workspace any more. That is not only leanness -- on Android and iOS the platform stack is the only one that works without shipping a second TLS implementation, so the `live` feature that gated reqwest has been deleted rather than made default. Correlating replies is where the real trap was. A scan of five markets puts ten requests in flight and the replies come back in whatever order the network gives them, so the LiveId has to say which market and which side. RequestKey encodes both plus a generation counter and round-trips through a u64 with the high bit set, so a LiveId Makepad derived from a name is never decoded as a scan reply, and a late reply from a previous round is dropped instead of folded into fresh data. Six tests cover the codec, including the one that matters most: the two sides of one market must not share an id, or the second reply overwrites the first and every spread is measured against itself. The transport allowlist is lifted from the nigig-mpesa review, which reached the same conclusion I would have: Makepad exposes no certificate pinning -- its only TLS control is set_ignore_ssl_cert, which weakens verification -- so what is enforceable at this layer is that only HTTPS to p2p.binance.com can be dialled at all. The tests cover the two ways a naive check leaks: the lookalike host p2p.binance.com.evil.example, which passes any starts_with test, and the userinfo smuggle https://p2p.binance.com@evil.example/, which resolves to evil.example while reading as Binance. Telegram is gone, as asked, and the app alerts itself: a banner, an unread badge on the status line, an Alerts tab holding the history, and a chime. Removing it removes a bot token from the threat model entirely -- a token in a config file is a bot anyone who reads the file can drive -- and removes a second network dependency from a tool already gated on one endpoint. One test now records the *absence* of a bug rather than its fix: Twin_traders00 is a real merchant from the live capture, and its underscore previously had to be escaped or Telegram rejected the whole message with a 400 and delivered nothing. Rendering in our own UI deletes that failure mode, and the test asserts the name appears unescaped. The trade-off is stated in the README rather than glossed: an in-app alert only reaches you while the app is running. No OS notification is raised, so a minimised window is a missed alert. The chime is synthesised rather than bundled -- a two-note rising blip generated at the device's sample rate, which is a few dozen lines instead of an audio asset shipped on three platforms, and which can therefore be tested. It is, and the tests found the bugs you would expect from writing audio: a freshly rendered chime starts *finished* so opening the output does not announce itself 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. The micro_serde migration surfaced two behaviours that differ from serde and both bit before they were understood. **micro_serde is strict by default.** deserialize_json errors on the first key it does not model. Binance sends about forty fields per advert and we model eight, so the strict parser cannot read the response at all -- and for a config file it means an older build cannot open a file written by a newer one. Everything uses deserialize_json_lenient, and a test pins that the strict call *would* have failed, because the two differ by one word and the strict one looks more correct. **There is no #[serde(default)].** Optional config entries are modelled as Option<T> on a Raw* struct and resolved into the real Config by hand. A few more lines in exchange for two fewer dependency trees. config.toml became config.json for the same arithmetic: micro_serde has no TOML reader, and toml depends on serde, so a single config file would have dragged all of serde back in through the back door. Also worth recording: DeJsonErr implements Debug but not Display, and Debug is the variant carrying line and column, so every error path formats it with {e:?} deliberately rather than by accident. CI gains a check that fails the build if reqwest, tokio, rustls, hyper, serde, serde_derive, serde_json or toml reappears in the default graph. I verified it fails by adding serde back to p2p-core and watching it match serde_derive, then reverted. A gate that cannot fail is decoration. 136 tests in the default feature set and 36 more with --features ui, including the RequestKey codec, which cannot be measured by the coverage job because it lives behind the feature that needs Makepad. Coverage 96.92% with ten per-file floors, up from eight -- chime.rs at 100% and client.rs at 95.69% are new. The desktop binary was built and linked to prove the app half is real. |
|||
| eaeebd3910 |
feat(p2p-intel): Binance P2P spread intelligence, shaped by what the live book actually contains
Some checks failed
p2p-intel / engine (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
repo hygiene / hygiene (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
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
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
A new nested workspace under crates/apps/p2p-intel: six engine crates, a CLI, and a Makepad dashboard that builds for desktop, Android and iOS. It reads public P2P adverts, measures the spread that is actually fillable, alerts when one is worth acting on, and tracks what the float really cost. It never places an order. Binance publishes no 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. The design came from a live capture rather than a sketch, and the capture contradicted the sketch three times. All three are now pinned by tests against checked-in real payloads. **The best price is routinely the least fillable one.** In the KES book the top sell advert was 134.60 from a merchant with three completed trades, implying a 3.6% spread; the next was 130.30. Another advert showed a 0% completion rate. A best-price scan with no quality floor does not find opportunities, it finds outliers, and outliers on a P2P book are bait or a merchant about to run dry. QualityFilter defaults to 95% completion and 50 orders, and analyse() reports every exclusion with its reason rather than dropping it silently. The honest consequence is recorded in the integration suite: at those defaults **not one sell-side advert in the captured KES book qualified**. There was no fillable arbitrage. A tool that reported the raw best-price number would have sent its user after a trade that does not exist, so the test asserts best_sell is None and net_bps is None rather than asserting a comfortable number. **tradeType is inverted between request and response.** Asking the endpoint for tradeType "BUY" returns adverts whose own adv.tradeType reads "SELL". Both are correct: the request parameter is what you want to do, the response field is what the advertiser is doing. Conflating them inverts every spread and the result still looks plausible, which makes it the most expensive mistake available here. Side keeps the two apart with request_trade_type()/advert_trade_type(), and a test asserts they are never equal. **An empty market answers HTTP 200 with success: true.** NGN returned zero adverts. "No ads" and "no answer" need opposite responses, so is_empty_market() is a named predicate and ScanError separates Malformed (Binance changed the payload; retrying makes it worse) from Network (transient). basis_points_above returns None against a zero base rather than an infinity, so an empty book cannot read as an infinite opportunity at 3am. Money is never a float, following the rule in nigig-pay-domain/src/money.rs. IEEE 754 cannot represent 0.1 and a spread is a difference of two nearly equal numbers, which is exactly where binary floating point loses the digits that matter. Binance sends prices as decimal strings, so Price parses them straight into scaled i128 integers and never passes through f64. i128 rather than i64 because the intermediate in a bps calculation overflows, not the result. Excess precision is refused rather than rounded and a thousands separator is refused rather than dropped: "1,299.92" read as 129992 is a 1000x error that still looks like a price. Tests pin 0.1 + 0.2 == 0.3 and rotate 100 round trips at one price asserting exactly zero P&L. Alerting is mostly restraint. At a 30-second poll one wide spread would fire 120 identical messages an hour, and a channel that cries wolf gets muted, at which point the tool has negative value because the user believes they are covered. AlertGate suppresses repeats inside a cooldown and re-alerts early only when the spread improves materially -- a collapsing spread is not worth waking someone for. Telegram MarkdownV2 escaping is tested against a real merchant name from the capture, Twin_traders00, whose underscore would otherwise make Telegram reject the message with a 400 and deliver nothing. Writing the dashboard view model found a bug in my own comparator: sorting descending by swapping the tuple to (b, a) also silently swaps the meaning of the None arms, which put dead markets at the top of the opportunity list. The test that caught it was written first and named for the behaviour, not the implementation. Networking is behind a non-default `live` feature, so an ordinary cargo test cannot make a request and CI never depends on Binance being reachable. A CI step asserts reqwest is absent from the default dependency graph so this cannot regress quietly. A live scan was run once to confirm the fixtures match reality; it reported a negative spread for KES and an empty NGN book, which is the tool working correctly. Conventions follow the repo rather than the generic layout in the request: .forgejo/workflows/p2p-intel.yml rather than .github, and no Dockerfile, since the stack is pure Rust and nothing else here is containerised. error_set is used instead of anyhow, matching nigig-core. The root Cargo.toml excludes the nested workspace by name, as it already does for makepad_table, so the isolation is intentional rather than dependent on a table inside someone else's manifest. 106 tests, coverage 96.86% with per-file floors enforced by tools/test-p2p-coverage.sh. 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: the Makepad widget, which needs a GPU and a windowing backend this repo has no headless backend for, and the CLI main, which is argument parsing and println. Every rule the widget renders lives in view_model.rs, measured at 97%. That split is deliberate -- spreadsheet-ui/grid.rs once hid 36 pure functions behind a file-level exclusion, and excluding a file you have not emptied of logic is how that happens. The Makepad desktop binary was built and linked in the sandbox to prove the app half is real and not just a compiling stub. |