diff --git a/.claude/reports/2026-07-13-console-v2-phase2-s5-dashboard.md b/.claude/reports/2026-07-13-console-v2-phase2-s5-dashboard.md new file mode 100644 index 0000000..38dd3c8 --- /dev/null +++ b/.claude/reports/2026-07-13-console-v2-phase2-s5-dashboard.md @@ -0,0 +1,145 @@ +# Отчёт: Console v2 Фаза 2, Этап 5 — Dashboard + read-op шедулер + +> Инженер → Гейт-2. Ветка `feat/console-v2-phase2-s5-dashboard` (коммит `feat(app,ui)…`), +> НЕ запушено. Спека: `.claude/specs/2026-07-13-console-v2-phase2-s5-dashboard.md` +> (Гейт-1 две подписи; №1 home=Dashboard ратифицирован Капитаном). + +## Сделано — по пунктам спеки + +1. **protocol.rs**: `Request::Positions`, клиентский `Position` (9 полей, `extra: + BTreeMap` — рендерим-не-парсим; `protocol` оставлен строкой: неизвестный будущий + протокол рендерится как есть, не валит парс), `PositionsOutcome`, `parse_positions` + (ok/пустой список валиден/wallet_locked/unauthorized+protocol_error → fail-closed/мусор + → Malformed). 6 тестов, вкл. verbatim «∞»/«80%» и `{"op":"positions"}`-энкод. +2. **transport.rs**: прокладка Request/Reply (паттерн Context, механически). +3. **app.rs — шедулер (ядро)**: `View::Dashboard` (+home после auth — ратификация №1); + `Positions`-тристейт (`NotYet | Loaded | Unavailable`); `read_age` (тики; **сброс в + точке диспетча** — дизайн-находка кодинга: сброс в apply_* заставил бы первые positions + ждать ~30 c после auth-context; born-stale дефолт даёт первый показ сразу после первого + list-reply, как требовала спека); `next_read` (чередование Context/Positions); + `context_stale` (wallet_locked-рефреш хранит данные + метит; успех снимает). + **Политика list-first дословно**: read-op только из `on_reply(List)`, никогда из тика; + `flush_pending()` первым (/check-1); тройная чистота слота; вход на Dashboard метит + стейл (`max(STALE_TICKS)` — не сбрасывает более старый возраст). Док-коммент + `dispatch_read_op` несёт честную границу гарантии (формулировка Ревьюера с Гейта-1). +4. **ui.rs**: третья вкладка ` Dashboard [d] ` (первая в ряду — home); `render_dashboard`: + Waiting (янтарь при pending>0) → Balance (per-chain `wei_to_eth`, «no balances + reported» ≠ «balance unavailable», staleness-нота) → Positions (тристейт-тексты; + строка: protocol · balance_formatted symbol name — extra-пары verbatim; **адресов + нет** — №4; overflow → «+N more — terminal too small»). +5. **main.rs**: `d` из Queue/Receive; Dashboard-арм: `a`/Esc/`r`/`q`, остальное мертво + (перечислением, вкл. Enter — карточка за экраном не откроется; модельный view-гвард + on_open с Этапа 3 это дублирует). +6. **Хелперы**: ровно 5 (app.rs `watching`, ui.rs ×3, main.rs `confirming_at`) получили + явный `Msg::View(View::Queue)` — **0 существующих тестов переписано** (прогноз спеки + сошёлся). + +## Не сделано / отложено + +Ничего из скоупа. (Секция активности на дашборде — Этап 7, отсутствует, не заглушена.) + +## Замечено, не трогаю + +1. `poll_round`-хелпер тестов ассертит «тик → всегда List» в каждом прогоне — инвариант + (а) проверяется структурно во всех шедулер-тестах, не одним. +2. Red-тест порядка «flush > read-op» реализован ближайшей исполнимой формой (Queue + + parked get → после List уходит Get): состояние «parked get + активный Dashboard» + сегодня недостижимо — гейт переключения отвергает таб при parked get (Этап 3). Порядок + в коде закреплён + задокументирован; наблюдаемым он станет, только если появится вид, + паркующий интенты. Ревьюеру на подтверждение. + +## Как тестировал — команды + вывод + +``` +cargo test → 180 (lib) + 19 (main) + 1 (tty_gate) зелёные [было 159+17+1; +23 теста] +cargo fmt --check → чисто · clippy --all-targets -D warnings → чисто · deny → ok×4 +``` + +**Red→green — 7 мутаций шедулера, каждая роняла ровно свой тест, все откачены:** +| Мутация | Упавший тест | +|---|---| +| list-first снят (`was_list`→false) | `the_first_list_reply_…_dispatches_positions` | +| view-гейт снят | `a_read_op_is_refused_off_the_dashboard` | +| стейлнес-гейт снят | `read_ops_wait_out_the_staleness_window` | +| вход-на-дашборд не метит стейл | `entering_the_dashboard_marks_the_data_stale` | +| `context_stale` не ставится | `a_failed_balance_refresh_keeps_the_data_and_flags_it` | +| home снова Queue | `the_home_view_after_auth_is_the_dashboard` | +| read-op рождается из тика | `the_first_list_reply_…` (через poll_round-ассерт) | + +(Каденция (в) и чередование (г) — два разных ассерта одного теста +`read_ops_wait_out_the_staleness_window`; мутация стейлнеса доказана, мутацию чередования +покрывает финальный ассерт того же теста.) + +**DoD-смоук** (pty 80×24, стаб proto 2 с Aave-фикстурой из канона §3.8, +`scratchpad/smoke_dashboard.py`): **10/10 PASS** — home=Dashboard после PIN · три вкладки +с клавишами · Waiting-блок · «chain 1 0.01 ETH» · «aave_v3 1000 USD» · «∞» и «80%» +verbatim на экране · `a`→Queue · `d`→обратно · exit 6 · stdout чист. + +## Отклонения от плана и почему + +1. **Семантика `read_age`: сброс в точке диспетча, не в apply_*** (дизайн-уточнение, + в плюс): сброс на auth-context-ответе заставил бы первые positions ждать полное окно + (~30 c) после входа — против «первый показ сразу» из спеки. Born-stale дефолт + + сброс-на-диспетч дают заявленную каденцию (каждый op ~раз в 60 c, офсет 30 c) и + немедленный первый фетч. +2. Мутация (б) спеки («read-op при открытой карточке») не исполнима буквально: карточка + существует только в Queue-виде, а view-гейт диспетча (мутация M2) покрывает то же + условие строже. Задокументировано выше. + +## Вопросы Ревьюеру + +1. «Замечено» п.2 — согласен ли, что недостижимость «parked get + Dashboard» делает + red-тест порядка структурно невозможным, и закрепление порядка кодом+комментарием + достаточно? +2. `render_dashboard` overflow-бюджет считает `budget + if remaining == 1 { 1 } else { 0 }` + (последняя позиция может занять строку маркера) — читабельность vs точность; есть ли + более чистая формулировка на твой вкус? + +--- + +# Дельта после Гейта-2 (раунд фиксов) + +## БЛОКЕР — закрыт + +`render_dashboard`, бюджет позиций: внутри цикла сравнивается **`used`** — строки, +добавленные ИМЕННО циклом — против `budget = height - lines.len()` (шапка вычитается +ровно один раз, в момент расчёта). Резерв строки маркера — только пока это не последняя +позиция (точный фит показывает всё без маркера). `shown`-мертвяк ушёл вместе с формулой +(МИНОР-2 закрыт тем же диффом). Коммент функции теперь описывает и сам баг (двойное +вычитание шапки), чтобы формулу не «упростили» обратно. + +**Граничный тест** `the_positions_budget_sits_exactly_on_its_boundary` (геометрия +100×24 расписана в комменте): 15 позиций = точный фит, все 15 видны, маркера нет; +16 позиций → 14 видны + «+2 more» = бюджет заполнен полностью, ничего влезающего не +спрятано. **Red-proof:** возврат багованной формулы роняет ровно этот тест (болк +резал на 11 из 15). + +## МИНОР-1 — закрыт + +- Docstring теста `a_parked_user_intent_outranks_everything_after_a_list_reply` + переписан честно: он пиннит только «parked get уходит после list-reply на Queue», + порядок flush/dispatch НЕ свидетельствует (view-чек режет раньше). +- **Узкий тест на сам гвард**: `the_dispatcher_itself_refuses_a_parked_intent` — + состояние «parked intent + активный Dashboard» собрано руками (тест-модуль имеет + доступ к приватным полям), `dispatch_read_op` отказывает при pending и работает без + него. Будущий вид, умеющий парковать при живом дашборде, наследует отказ. + +## МИНОР-2 — закрыт (мертвяк `shown` удалён переписыванием формулы). + +## НИТы + +- ✅ Прямой тест «Dashboard-свитч отвергнут при parked get» добавлен + (`a_dashboard_switch_is_refused_while_a_get_is_parked`). +- ✅ Граница overflow-бюджета — покрыта (выше). +- ✅ Ветка «N pending» — `the_waiting_block_counts_pending_items` (2 pending, press a). +- ✅ Асимметрия apply_context/apply_positions — комментарий с причиной (второго + потребителя у позиций нет; недостижимо на shipped-ядре; revisit-условие названо). +- ❌ Дубль `q` в 3 армах map_key — оставлен: вынос до матча по фазе меняет структуру + маршрутизации money-смежных клавиш ради трёх строк; не в этом раунде. + +## Гейты после фиксов + +``` +cargo test → 184 + 19 + 1 зелёные (+4 теста к раунду кода) +fmt --check чисто · clippy --all-targets -D warnings чисто · deny ok×4 +``` diff --git a/.claude/reports/2026-07-13-smoke-dashboard.py b/.claude/reports/2026-07-13-smoke-dashboard.py new file mode 100644 index 0000000..126da50 --- /dev/null +++ b/.claude/reports/2026-07-13-smoke-dashboard.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""DoD smoke — Console v2 Этап 5 Dashboard. + +Real binary in a pty against a proto-2 stub server: + unlock → queue (tab bar) → r → Receive (address + QR) → a → queue → q. +Asserts on the raw stderr stream (UI) and the exit code. +""" + +import fcntl +import json +import os +import pty +import socket +import struct +import subprocess +import sys +import termios +import threading +import time + +ADDR = "0x489Fe09Fbb489Fe09Fbb489Fe09Fbb489F9Fbbbb" +SOCK = "/tmp/claude-1000/-home-temrjan-Dev/064de351-afaf-44e1-a4c0-aa64358663a5/scratchpad/approve.sock" +BIN = "/home/temrjan/Dev/projects/rustok/console/target/debug/rustok-console" + + +def serve(listener: socket.socket) -> None: + conn, _ = listener.accept() + f = conn.makefile("rw", encoding="utf-8") + for line in f: + req = json.loads(line) + op = req.get("op") + if op == "hello": + resp = {"ok": True, "proto": req["proto"], "server": "stub/0.0"} + elif op == "auth": + resp = {"ok": True} + elif op == "context": + resp = {"ok": True, "address": ADDR, + "balances": [{"chain_id": 1, "symbol": "ETH", "balance": "10000000000000000"}], + "allowed_chains": [1]} + elif op == "positions": + resp = {"ok": True, "positions": [{ + "protocol": "aave_v3", "chain_id": 1, + "asset_address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", + "asset_symbol": "USD", "asset_name": "Aave v3 account", + "asset_decimals": 8, "balance": "100000000000", "balance_formatted": "1000", + "extra": {"health_factor": "∞", "ltv": "80%"}}]} + elif op == "list": + resp = {"ok": True, "pending": []} + else: + resp = {"ok": False, "error": "protocol_error"} + f.write(json.dumps(resp) + "\n") + f.flush() + + +def main() -> int: + if os.path.exists(SOCK): + os.unlink(SOCK) + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(SOCK) + listener.listen(1) + threading.Thread(target=serve, args=(listener,), daemon=True).start() + + # stdin + stderr on the pty (interactive UI), stdout piped (decision stream). + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + os.set_blocking(master, False) + proc = subprocess.Popen( + [BIN], + stdin=slave, + stdout=subprocess.PIPE, + stderr=slave, + env={**os.environ, "RUSTOK_APPROVE_SOCK": SOCK, "TERM": "xterm-256color"}, + ) + os.close(slave) + + captured = bytearray() + + def drain(seconds: float) -> None: + end = time.time() + seconds + while time.time() < end: + try: + captured.extend(os.read(master, 65536)) + except BlockingIOError: + time.sleep(0.05) + except OSError: + break + + def press(keys: str, wait: float = 0.7) -> None: + os.write(master, keys.encode()) + drain(wait) + + drain(1.0) # connect + hello → unlock screen + press("1\r", wait=1.0) # PIN → auth ok → context → HOME = Dashboard + drain(3.5) # первый poll-цикл: list → positions + dash_frame = bytes(captured) + captured.clear() + press("a", wait=0.7) # → Queue + queue_frame = bytes(captured) + captured.clear() + press("d", wait=0.7) # → назад на Dashboard + back_frame = bytes(captured) + press("q", wait=0.5) # quit + proc.wait(timeout=5) + os.close(master) + + text_dash = dash_frame.decode("utf-8", "replace") + text_queue = queue_frame.decode("utf-8", "replace") + text_back = back_frame.decode("utf-8", "replace") + + checks = [ + ("home = Dashboard (title after PIN)", "Dashboard" in text_dash), + ("three tabs with keys", "Dashboard [d]" in text_dash and "Queue·0 [a]" in text_dash and "Receive [r]" in text_dash), + ("waiting block", "Waiting for you" in text_dash), + ("balance humanized per chain", "chain 1" in text_dash and "0.01 ETH" in text_dash), + ("position row verbatim", "aave_v3" in text_dash and "1000 USD" in text_dash), + ("extra display strings verbatim", "∞" in text_dash and "80%" in text_dash), + ("'a' switches to the queue", "Queue" in text_queue), + ("'d' returns to the dashboard", "Dashboard" in text_back), + ("exit code = aborted (6)", proc.returncode == 6), + ("stdout stayed clean (no decisions)", proc.stdout.read() == b""), + ] + + + ok = True + for name, passed in checks: + print(f" {'PASS' if passed else 'FAIL'} {name}") + ok &= passed + print(f"\nsmoke: {'PASS' if ok else 'FAIL'} ({sum(p for _, p in checks)}/{len(checks)})") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/workflow-state.json b/.claude/workflow-state.json index 3037bd1..0a0d794 100644 --- a/.claude/workflow-state.json +++ b/.claude/workflow-state.json @@ -1,6 +1,6 @@ { - "state": "coding", - "task": "Console v2 Фаза 2, Этап 5 — Dashboard (баланс·позиции·waiting + read-op шедулер list-first): спека написана (.claude/specs/2026-07-13-console-v2-phase2-s5-dashboard.md), /check пройден (5 находок вшиты: порядок flush_pending>read-op; home=Dashboard трогает 5 хелперов; wallet_locked-рефреш не выкидывает данные но метит staleness; тристейт positions; bounded-рефреш на вход), ГЕЙТ-1 ПРОЙДЕН (Ревьюер «го после правок» — правки вшиты eb6d029; Капитан «ГО» = ратификация №1 home=Dashboard по рекомендации + го). КОДИНГ: ветка feat/console-v2-phase2-s5-dashboard; порядок: protocol.rs (типы+парс) → transport.rs → app.rs (View::Dashboard + Model-поля + шедулер list-first + home) → main.rs (map_key) → ui.rs (вкладка + render_dashboard). 5 решений предрешены: home=Dashboard (ТВОЙ ВЫБОР) · рефреш context чередованием · STALE_TICKS=12 · без адресов активов на дашборде · без секции активности до Этапа 7. Кода нет до «го». ПРЕДЫДУЩЕЕ: Этап 4 (core PR2 positions) ЗАКРЫТ (core#92 a505fe1 + console#12 802347d).", + "state": "shipped", + "task": "Этап 5 Dashboard SHIPPED: «чисто» дельты (Ревьюер перепроверил алгебру фикса и геометрию граничного теста вручную — сходится; старая формула резала на 11-й) + «Добро» Капитана → push + PR console#13 (https://github.com/rustok-org/console/pull/13). Примечание: «пара core+console» из добра не применима — Этап 5 только console (core/канон не тронуты). Ждёт CI + мержа Капитаном.", "fast": false, "history": [ { @@ -224,7 +224,31 @@ "to": "coding", "at": "2026-07-13T22:00:00+05:00", "gate": "Гейт-1 Этапа 5 ПРОЙДЕН, обе подписи: Ревьюер (раунд 1 — 1 существенная: инвариант шедулера переобещал, переписан на «порядок происхождения» с честной границей (зависший read-op стопит поллинг — класс риска context Этапа 2, чаще); механизм context_stale назван явно; tab_line=тест-форс; ратифицированы №2/№3/№4; «Го после правок» — выполнены) + Капитан «ГО» (= ратификация №1 home=Dashboard по рекомендации Инженера — буква плана Фазы 2, обратимо). Кодинг начат." + }, + { + "from": "coding", + "to": "reviewing", + "at": "2026-07-13T23:30:00+05:00", + "gate": "Этап 5 код-готов: View::Dashboard (home после auth — ратиф. №1), Positions-тристейт, read-op шедулер list-first ДОСЛОВНО по спеке (только из on_reply(List), никогда из тика, flush_pending первым, тройная чистота, вход метит стейл, чередование Context/Positions, STALE_TICKS=12), context_stale (wallet_locked-рефреш хранит данные+метит), render_dashboard (Waiting янтарём → Balance wei_to_eth+staleness-нота → Positions verbatim extra без адресов + overflow-маркер), третья вкладка, map_key-армы. Дизайн-уточнение В ПЛЮС: read_age сбрасывается в точке ДИСПЕТЧА (не в apply_*) + born-stale дефолт — иначе первые positions ждали бы ~30 c против «первый показ сразу» спеки. 5 хелперов с явным переходом в Queue — 0 существующих тестов переписано (прогноз спеки сошёлся). Гейты: 180+19+1, fmt/clippy/deny чисто. Red→green: 7 мутаций шедулера — каждая роняла свой тест (list-first/view-гейт/стейлнес/вход-метит/context_stale/home/тик-не-рождает-read-op). Смоук pty 10/10: home=Dashboard после PIN, три вкладки, chain 1 0.01 ETH, aave_v3 1000 USD, «∞» «80%» verbatim, a→Queue, d→назад, exit 6, stdout чист. 2 вопроса Ревьюеру (недостижимость red-теста порядка сегодня; формула overflow-бюджета). ⛔ Флот-5 (sonnet)+/rust-review+/security-review — Ревьюеру; push/PR ждут «чисто»." + }, + { + "from": "reviewing", + "to": "reviewing", + "at": "2026-07-13T23:55:00+05:00", + "gate": "Гейт-2 вердикт (флот-5 без 2 последних персон — бюджет Ревьюера): 1 БЛОКЕР (overflow-бюджет render_dashboard — двойное вычитание строк шапки, маркер режет влезающие данные; алгебра перепроверена Ревьюером лично) + 2 МИНОРа (вводящий в заблуждение тест порядка flush/dispatch — на Queue-виде свап неотличим; dead-код shown) + 5 НИТ. Вопрос №1 Инженера: да, код+коммент достаточно; вопрос №2 снят — формула неверна, переписать. ФИКСЫ НЕ НАЧАТЫ — контекст сессии исчерпан, хэндофф в task-строке. Push ждёт дельты." + }, + { + "from": "reviewing", + "to": "reviewing", + "at": "2026-07-14T00:40:00+05:00", + "gate": "Раунд фиксов Гейта-2 (лимиты обновились — хэндофф не понадобился, продолжили в сессии): БЛОКЕР закрыт — budget=height−lines.len() один раз, в цикле used (строки цикла), резерв маркера кроме последней позиции; коммент описывает сам баг; граничный тест (15=точный фит без маркера / 16→14+маркер «+2 more», бюджет заполнен без потерь) + RED-PROOF: багованная формула роняет ровно его (резала 11 из 15). МИНОР-1: честный docstring + узкий тест гварда диспетчера на приватном state. МИНОР-2: мертвяк ушёл. НИТы: 3 теста добавлены, коммент асимметрии, дубль q отклонён с причиной. Гейты: 184+19+1/fmt/clippy/deny. Дельта в отчёте. Ждём взгляда Ревьюера на дельту." + }, + { + "from": "reviewing", + "to": "shipped", + "at": "2026-07-14T01:10:00+05:00", + "gate": "Дельта-ревью «чисто»: Ревьюер прогнал алгебру новой формулы и геометрию граничного теста вручную (15→used 15 без break; 16→обрыв на 15-й, «+2 more»; старая формула на тех же числах резала на 11-й — дельта доказана), shown-мертвяк подтверждён ушедшим. «Добро» Капитана → push + PR console#13. БЛОКЕРов/МИНОРов не осталось." } ], - "updated": "2026-07-13T22:00:00+05:00" + "updated": "2026-07-14T01:10:00+05:00" } diff --git a/src/app.rs b/src/app.rs index 858843e..27fdff3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,8 +15,8 @@ use zeroize::Zeroizing; use crate::protocol::{ - AuthOutcome, Card, ContextOutcome, GetOutcome, ResolveOutcome, Summary, TerminalState, - WalletContext, + AuthOutcome, Card, ContextOutcome, GetOutcome, PositionsOutcome, ResolveOutcome, Summary, + TerminalState, WalletContext, }; use crate::transport::{self, Reply, TransportError}; use crate::ui; @@ -165,10 +165,13 @@ pub enum Phase { Fatal(TransportError), } -/// The resident console's screens (nav-shell). Both are real views — no +/// The resident console's screens (nav-shell). All are real views — no /// placeholder tabs are registered (Gate-1, Stage 2 ratification). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum View { + /// Balance, DeFi positions, and the "waiting for you" count — the home + /// view after auth (Gate-1 Stage 5, plan Фазы 2: PIN-unlock → Dashboard). + Dashboard, /// The queue and its card — the approve surface, and the only view that /// may open one. Queue, @@ -177,6 +180,33 @@ pub enum View { Receive, } +/// The dashboard's positions block — a tri-state, so "still loading" never +/// reads as "unavailable" (spec /check-4). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Positions { + /// No `positions` reply has landed yet this session. + #[default] + NotYet, + /// The wallet's positions, verbatim from the last reply. + Loaded(Vec), + /// The read degraded (`wallet_locked`) — distinct from an empty list. + Unavailable, +} + +/// Which read-op the scheduler sends next — `context` (balance refresh) and +/// `positions` alternate on the same list-first slot (Gate-1 decision №2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ReadOp { + #[default] + Positions, + Context, +} + +/// Read-data staleness threshold, in `Msg::Tick` units (~2.5 s each): ~30 s. +/// Positions/balances are live on-chain reads — not something to hammer on +/// every poll tick (Gate-1 decision №3). +const STALE_TICKS: u32 = 12; + /// The one transient notice slot of the watch screen. A resident console keeps /// living after a decision — the notice is how the human sees what just /// happened without the process ending. Latest event wins the slot; the @@ -391,6 +421,18 @@ pub struct Model { /// The last terminal decision, waiting for the run loop to drain it into /// the machine decision line ([`Self::take_decision`]). decision: Option, + /// The dashboard's positions block (tri-state; see [`Positions`]). + positions: Positions, + /// Ticks since the last completed read-op — the scheduler's staleness + /// clock (the pure model has no wall clock; Stage-2 lesson). + read_age: u32, + /// Which read-op goes out next (context/positions alternate). + next_read: ReadOp, + /// The last `context` REFRESH answered `wallet_locked`: the old data is + /// kept (the card's From→To keeps working) but the dashboard's balance + /// block is flagged stale (Gate-1: named mechanism, not derived from + /// `read_age`). Cleared by a successful refresh. + context_stale: bool, } /// A parked user intent (no `List` — the poll is suppressed, not queued). @@ -430,6 +472,12 @@ impl Default for Model { quit: false, wallet: None, decision: None, + positions: Positions::default(), + // Born stale: the first list reply after auth dispatches the + // first positions read immediately, not ~30 s later. + read_age: STALE_TICKS, + next_read: ReadOp::default(), + context_stale: false, } } } @@ -461,6 +509,26 @@ impl Model { self.wallet.as_ref().map(|w| w.address.as_str()) } + /// The wallet's full context (address, balances, allowed chains), when + /// the read-op has landed — feeds the dashboard's balance block. + #[must_use] + pub fn wallet_context(&self) -> Option<&WalletContext> { + self.wallet.as_ref() + } + + /// The dashboard's positions block (tri-state). + #[must_use] + pub fn positions(&self) -> &Positions { + &self.positions + } + + /// Whether the last balance refresh failed while old data is still shown + /// (the dashboard says "may be stale" instead of lying by omission). + #[must_use] + pub fn context_stale(&self) -> bool { + self.context_stale + } + /// Drain the last terminal decision (for the machine decision line — one /// JSON line per decision on a non-TTY stdout, AGENTS.md #7). pub fn take_decision(&mut self) -> Option { @@ -539,6 +607,12 @@ impl Model { } = &mut self.phase { *current = view; + if view == View::Dashboard { + // Entering the dashboard marks the read data stale: the next + // list reply refreshes it. Bounded structurally — at most one + // read-op per list reply (spec /check-5). + self.read_age = self.read_age.max(STALE_TICKS); + } } } @@ -558,6 +632,11 @@ impl Model { /// Periodic tick: poll `list` in the watch phase — but only when nothing is in /// flight (the poll is suppressed, never queued, so stale polls do not pile up). fn on_tick(&mut self) -> Option { + if matches!(self.phase, Phase::Watching { .. }) { + // The staleness clock of the read-op scheduler — ticks, not a + // wall clock (the model stays pure). + self.read_age = self.read_age.saturating_add(1); + } if matches!(self.phase, Phase::Watching { .. }) && !self.in_flight { self.in_flight = true; Some(transport::Request::List) @@ -752,6 +831,9 @@ impl Model { fn on_reply(&mut self, reply: Reply) -> Option { self.in_flight = false; + // Only a fresh `list` answer may be followed by a read-op (the + // list-first policy) — remembered before the reply is consumed. + let was_list = matches!(reply, Reply::List(_)); match reply { Reply::Hello { .. } => { self.phase = Phase::Authing { @@ -778,6 +860,7 @@ impl Model { } } Reply::Context(outcome) => self.apply_context(outcome), + Reply::Positions(outcome) => self.apply_positions(outcome), Reply::List(items) => self.apply_list(items), Reply::Get(outcome) => self.apply_get(outcome), Reply::Resolve(outcome) => self.apply_resolve(outcome), @@ -792,7 +875,72 @@ impl Model { self.pending = None; return None; } - self.flush_pending() + // Dispatch order is normative (spec /check-1): the parked USER intent + // first — a read-op is considered only when nothing human is waiting. + if let Some(req) = self.flush_pending() { + return Some(req); + } + if was_list { + return self.dispatch_read_op(); + } + None + } + + /// The read-op scheduler (list-first policy, canon §3.8 + the Reviewer's + /// Gate-1 flag): a read-op (`positions` | `context` refresh, alternating) + /// goes out ONLY right after a fresh `list` reply — never from a tick + /// (the tick belongs to `list`), never ahead of a parked user intent + /// (`flush_pending` ran first), never with a card open or a card request + /// anywhere in flight, and only when the read data has gone stale. + /// + /// Guaranteed and tested: the ORDER OF ORIGINATION — a read-op never cuts + /// ahead of a due `list`. Honest limit (Gate-1): once a read-op is on the + /// wire and the server does not answer (no server-side timeout by canon), + /// list polling stalls with it — the same risk class as the post-auth + /// `context` fetch accepted since Stage 2, occurring more often here. + fn dispatch_read_op(&mut self) -> Option { + if !matches!( + self.phase, + Phase::Watching { + confirm: None, + view: View::Dashboard, + .. + } + ) { + return None; + } + // `pending` is None here (flush_pending ran first and found nothing) + // and `awaiting_card` cannot coexist with a just-answered list on the + // single slot — both checked anyway: the model is the boundary. + if self.awaiting_card || self.pending.is_some() || self.read_age < STALE_TICKS { + return None; + } + self.read_age = 0; // the cadence counts from dispatch + self.in_flight = true; + let req = match self.next_read { + ReadOp::Positions => transport::Request::Positions, + ReadOp::Context => transport::Request::Context, + }; + self.next_read = match self.next_read { + ReadOp::Positions => ReadOp::Context, + ReadOp::Context => ReadOp::Positions, + }; + Some(req) + } + + /// Fold a `positions` answer — feeds the dashboard block only, gates + /// nothing. Deliberately simpler than [`Self::apply_context`]: a + /// `wallet_locked` here REPLACES `Loaded` with `Unavailable` instead of + /// keeping-and-flagging, because unlike the balance block nothing else + /// feeds off old positions (the card's From→To feeds off `wallet`) — + /// and the state is unreachable today anyway (the shipped core never + /// re-locks at runtime, canon §3.7). Revisit if positions grow a second + /// consumer (Gate-2 NIT). + fn apply_positions(&mut self, outcome: PositionsOutcome) { + self.positions = match outcome { + PositionsOutcome::Ok(list) => Positions::Loaded(list), + PositionsOutcome::WalletLocked => Positions::Unavailable, + }; } fn apply_auth(&mut self, outcome: AuthOutcome) { @@ -803,7 +951,9 @@ impl Model { selected: 0, confirm: None, notice: None, - view: View::Queue, + // Home is the Dashboard (Gate-1 Stage 5: PIN-unlock → + // Dashboard, the letter of the Phase-2 plan). + view: View::Dashboard, }; } other => { @@ -837,11 +987,21 @@ impl Model { } } - /// Fold the once-per-session `context` answer. Both failure shapes only - /// degrade the display (To-only card) — approve is never gated on them. + /// Fold a `context` answer — the post-auth fetch or a scheduler refresh. + /// Both failure shapes only degrade the display — approve is never gated + /// on them. A REFRESH that answers `wallet_locked` keeps the old data + /// (the card's From→To keeps working) but flags the dashboard's balance + /// block stale (Gate-1: `context_stale`, a named mechanism). fn apply_context(&mut self, outcome: ContextOutcome) { match outcome { - ContextOutcome::Ok(ctx) => self.wallet = Some(*ctx), + ContextOutcome::Ok(ctx) => { + self.wallet = Some(*ctx); + self.context_stale = false; + } + ContextOutcome::WalletLocked if self.wallet.is_some() => { + // A refresh failed: keep what we have, say it may be stale. + self.context_stale = true; + } ContextOutcome::WalletLocked => { if let Phase::Watching { notice, .. } = &mut self.phase && notice.is_none() @@ -1198,6 +1358,10 @@ mod tests { allowed_chains: vec![1], }, ))))); + // Home is the Dashboard since Stage 5 — these tests exercise the + // queue, so they step onto it explicitly (spec: 5 helpers, 0 + // rewritten tests). + m.update(Msg::View(View::Queue)); // deliver the first list assert!(matches!( m.update(Msg::Tick), @@ -2540,4 +2704,225 @@ mod tests { }; assert_eq!(*selected, items.len() - 1); } + + // ── Stage 5: the Dashboard + the read-op scheduler (list-first) ── + + /// Drive a fresh session to the watch phase WITHOUT leaving the home + /// view — the dashboard scheduler flows live here. + fn on_dashboard() -> Model { + let mut m = Model::new(); + m.update(Msg::Resize { + width: 80, + height: 24, + }); + m.update(Msg::Reply(Reply::Hello { + server: "s".to_owned(), + })); + m.update(Msg::PinDigit('1')); + m.update(Msg::PinSubmit); + assert!(matches!( + m.update(Msg::Reply(Reply::Auth(AuthOutcome::Ok))), + Some(transport::Request::Context) + )); + m.update(Msg::Reply(Reply::Context(ContextOutcome::Ok(Box::new( + WalletContext { + address: WALLET.to_owned(), + balances: vec![], + allowed_chains: vec![1], + }, + ))))); + m + } + + /// One poll round: tick (must yield `List` — the tick belongs to list) + /// and the reply; returns what the model wants to send AFTER the reply. + fn poll_round(m: &mut Model) -> Option { + assert!( + matches!(m.update(Msg::Tick), Some(transport::Request::List)), + "a tick with a free slot always originates a List, never a read-op" + ); + m.update(Msg::Reply(Reply::List(vec![]))) + } + + #[test] + fn the_home_view_after_auth_is_the_dashboard() { + let m = on_dashboard(); + assert_eq!(view_of(&m), View::Dashboard, "PIN-unlock lands on the pult"); + } + + #[test] + fn the_first_list_reply_on_the_dashboard_dispatches_positions() { + // Born stale: the human sees positions right away, not ~30 s later. + let mut m = on_dashboard(); + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Positions)), + "the first list reply hands the slot to the first positions read" + ); + } + + #[test] + fn read_ops_wait_out_the_staleness_window() { + let mut m = on_dashboard(); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::Ok(vec![])))); + // Fresh data: the following rounds are pure list polling… + for _ in 0..(STALE_TICKS - 1) { + assert!( + poll_round(&mut m).is_none(), + "no read-op before the staleness window elapses" + ); + } + // …until the window elapses — then the ALTERNATE op goes out. + assert!( + matches!(poll_round(&mut m), Some(transport::Request::Context)), + "context (balance refresh) alternates with positions" + ); + } + + #[test] + fn a_read_op_is_refused_off_the_dashboard() { + let mut m = on_dashboard(); + m.update(Msg::View(View::Queue)); + assert!( + poll_round(&mut m).is_none(), + "stale data does not fetch while the dashboard is not on top" + ); + } + + #[test] + fn a_parked_user_intent_outranks_everything_after_a_list_reply() { + // Pins ONLY that a parked get goes out right after the list reply on + // the queue view. It does NOT witness the flush-vs-read-op order: + // here dispatch_read_op is already cut by its view check, and the + // state "parked intent + active dashboard" is unreachable through + // the public API (Gate-2 МИНОР — the dispatcher's own guard is + // pinned separately in `the_dispatcher_itself_refuses_a_parked_intent`). + let mut m = on_dashboard(); + m.update(Msg::View(View::Queue)); + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + m.update(Msg::Reply(Reply::List(vec![summary("a")]))); + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + assert!( + m.update(Msg::Open).is_none(), + "the get parks behind the poll" + ); + assert!( + matches!( + m.update(Msg::Reply(Reply::List(vec![summary("a")]))), + Some(transport::Request::Get(_)) + ), + "after the list reply the parked get goes out — nothing else" + ); + } + + #[test] + fn entering_the_dashboard_marks_the_data_stale() { + let mut m = on_dashboard(); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::Ok(vec![])))); + assert!(poll_round(&mut m).is_none(), "fresh right after the reply"); + // Leave and come back: the re-entry marks the read data stale. + m.update(Msg::View(View::Receive)); + m.update(Msg::View(View::Dashboard)); + assert!( + poll_round(&mut m).is_some(), + "re-entering the dashboard refreshes at the next list reply" + ); + } + + #[test] + fn a_positions_reply_feeds_the_tristate() { + let mut m = on_dashboard(); + assert!(matches!(m.positions(), Positions::NotYet)); + assert!(matches!( + poll_round(&mut m), + Some(transport::Request::Positions) + )); + m.update(Msg::Reply(Reply::Positions(PositionsOutcome::WalletLocked))); + assert!( + matches!(m.positions(), Positions::Unavailable), + "wallet_locked degrades, distinct from an empty list" + ); + } + + #[test] + fn a_failed_balance_refresh_keeps_the_data_and_flags_it() { + let mut m = on_dashboard(); + assert!(m.wallet_context().is_some()); + assert!(!m.context_stale()); + // A later refresh answers wallet_locked: the data survives, flagged. + m.update(Msg::Reply(Reply::Context(ContextOutcome::WalletLocked))); + assert!( + m.wallet_context().is_some(), + "old data is kept — the card's From→To keeps working" + ); + assert!(m.context_stale(), "…but the dashboard says it may be stale"); + // A successful refresh clears the flag. + m.update(Msg::Reply(Reply::Context(ContextOutcome::Ok(Box::new( + WalletContext { + address: WALLET.to_owned(), + balances: vec![], + allowed_chains: vec![1], + }, + ))))); + assert!(!m.context_stale()); + } + + #[test] + fn the_dispatcher_itself_refuses_a_parked_intent() { + // "Parked intent + active dashboard" is unreachable through the + // public API today (the switch gate refuses on a parked get) — build + // the state by hand to pin dispatch_read_op's OWN guard, so a future + // view that can park while the dashboard is up inherits the refusal + // (Gate-2 МИНОР: the model is the boundary, not the reachability of + // today's key map). + let mut m = on_dashboard(); + m.pending = Some(PendingIntent::Get("x".to_owned())); + assert!( + m.dispatch_read_op().is_none(), + "a parked human intent starves the read-op, never the reverse" + ); + m.pending = None; + assert!( + m.dispatch_read_op().is_some(), + "…and with the intent gone the read-op flows" + ); + } + + #[test] + fn a_dashboard_switch_is_refused_while_a_get_is_parked() { + // The direct twin of the Receive-era test, for the third view. + let mut m = on_dashboard(); + m.update(Msg::View(View::Queue)); + m.update(Msg::Tick); + m.update(Msg::Reply(Reply::List(vec![summary("a")]))); + assert!(matches!( + m.update(Msg::Tick), + Some(transport::Request::List) + )); + assert!(m.update(Msg::Open).is_none(), "the get parks"); + m.update(Msg::View(View::Dashboard)); + assert_eq!(view_of(&m), View::Queue, "a parked get pins the queue"); + } + + #[test] + fn the_dashboard_switch_obeys_the_same_triple_gate() { + // The switch gate is one code path for every view — pin Dashboard to + // it explicitly: an open card refuses the tab. + let mut m = confirming("a", false); + m.update(Msg::View(View::Dashboard)); + assert_eq!(view_of(&m), View::Queue, "the card pins the queue view"); + } } diff --git a/src/main.rs b/src/main.rs index 0bafa05..e332618 100644 --- a/src/main.rs +++ b/src/main.rs @@ -319,6 +319,7 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { KeyCode::Up | KeyCode::Char('k') => Some(Msg::MoveUp), KeyCode::Down | KeyCode::Char('j') => Some(Msg::MoveDown), KeyCode::Enter => Some(Msg::Open), + KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), KeyCode::Char('r') => Some(Msg::View(View::Receive)), KeyCode::Char('q') => Some(Msg::Quit), _ => None, @@ -332,6 +333,18 @@ fn map_key(key: &KeyEvent, phase: &Phase) -> Option { .. } => match key.code { KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), + KeyCode::Char('d') => Some(Msg::View(View::Dashboard)), + KeyCode::Char('q') => Some(Msg::Quit), + _ => None, + }, + // The Dashboard is display-only: navigation and quit, nothing else. + Phase::Watching { + confirm: None, + view: View::Dashboard, + .. + } => match key.code { + KeyCode::Char('a') | KeyCode::Esc => Some(Msg::View(View::Queue)), + KeyCode::Char('r') => Some(Msg::View(View::Receive)), KeyCode::Char('q') => Some(Msg::Quit), _ => None, }, @@ -428,6 +441,7 @@ mod tests { allowed_chains: vec![1], }, ))))); + m.update(Msg::View(View::Queue)); // Stage-5 home is Dashboard m.update(Msg::Tick); m.update(Msg::Reply(Reply::List(vec![Summary { id: "a".to_owned(), @@ -767,4 +781,65 @@ mod tests { EXIT_FATAL ); } + + fn dashboarding() -> Phase { + Phase::Watching { + items: vec![], + selected: 0, + confirm: None, + notice: None, + view: View::Dashboard, + } + } + + #[test] + fn the_dashboard_view_maps_navigation_and_quit_only() { + assert!(matches!( + map_key(&key(KeyCode::Char('a')), &dashboarding()), + Some(Msg::View(View::Queue)) + )); + assert!(matches!( + map_key(&key(KeyCode::Esc), &dashboarding()), + Some(Msg::View(View::Queue)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('r')), &dashboarding()), + Some(Msg::View(View::Receive)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('q')), &dashboarding()), + Some(Msg::Quit) + )); + // Display-only: Enter must not open a card behind the screen. + for dead in [ + KeyCode::Enter, + KeyCode::Up, + KeyCode::Down, + KeyCode::Char('y'), + KeyCode::Char('n'), + KeyCode::Char('d'), + KeyCode::Char('7'), + ] { + assert!( + map_key(&key(dead), &dashboarding()).is_none(), + "{dead:?} must be dead on the Dashboard" + ); + } + } + + #[test] + fn every_view_reaches_every_other_view() { + assert!(matches!( + map_key(&key(KeyCode::Char('d')), &watching()), + Some(Msg::View(View::Dashboard)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('d')), &receiving()), + Some(Msg::View(View::Dashboard)) + )); + assert!(matches!( + map_key(&key(KeyCode::Char('r')), &dashboarding()), + Some(Msg::View(View::Receive)) + )); + } } diff --git a/src/protocol.rs b/src/protocol.rs index f7921bf..315d4ee 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -64,6 +64,10 @@ pub enum Request<'a> { /// (proto 2+, auth-gated — protocol §3.7). Sent once after `auth`; the /// address feeds the card's From→To block. Context, + /// Ask for the wallet's own DeFi positions (proto 2+, auth-gated — + /// protocol §3.8). Dispatched by the read-op scheduler only right after + /// a `list` reply, never ahead of one (the §3.8 client rule). + Positions, } /// Serialize a request to a single JSON line (no trailing `\n`; the transport adds @@ -259,6 +263,47 @@ pub enum ContextOutcome { WalletLocked, } +/// One DeFi position from `positions` (protocol §3.8), kept **verbatim** — the +/// dashboard renders these strings and never parses them: `extra` values are +/// display strings by canon (`health_factor` may be the literal `"∞"`, `ltv` +/// carries a trailing `%`). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Position { + /// Protocol wire form: `"aave_v3"` | `"erc4626"`. Kept as a string — an + /// unknown future protocol renders as-is instead of failing the parse. + pub protocol: String, + /// Chain the position lives on. + pub chain_id: u64, + /// Asset address (EIP-55) — the Aave Pool contract or the vault's + /// underlying token. Not rendered on the dashboard (Gate-1 decision №4); + /// carried so the client type mirrors §3.8 whole. + pub asset_address: String, + /// Human-readable asset symbol (`"USD"` for the Aave account). + pub asset_symbol: String, + /// Human-readable asset name. + pub asset_name: String, + /// Decimal places `balance` is denominated in. + pub asset_decimals: u8, + /// Raw integer balance, decimal string (no point). + pub balance: String, + /// `balance` at `asset_decimals` places, trailing zeros trimmed. + pub balance_formatted: String, + /// Per-protocol extras — display strings, keys sorted (§3.8). + #[serde(default)] + pub extra: std::collections::BTreeMap, +} + +/// Outcome of `positions` (§3.8). `WalletLocked` degrades the dashboard's +/// positions block only — it never gates anything. +#[derive(Debug, PartialEq, Eq)] +pub enum PositionsOutcome { + /// The wallet's positions — an empty list is a valid answer (best-effort: + /// no positions, or every source skipped on RPC failure; §3.8). + Ok(Vec), + /// The core's own keyring isn't unlocked (§3.11). + WalletLocked, +} + /// The terminal state carried by an `already_resolved` reply (protocol §3.5). /// Includes `Pending` (I4): another connection is executing this id right now. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -498,6 +543,36 @@ pub fn parse_context(line: &str) -> Result { } } +/// Parse a `positions` reply (§3.8). Mirrors [`parse_context`]'s error +/// surface: `wallet_locked` is the one degradable answer; +/// `unauthorized`/`protocol_error` mean the channel is not what we negotiated +/// (we only send `positions` post-auth on a proto-2 session) — the same +/// fail-closed class as an unexpected resolve code. +/// +/// # Errors +/// [`ProtocolError::Malformed`]/[`ProtocolError::Unexpected`] as above. +pub fn parse_positions(line: &str) -> Result { + #[derive(Deserialize)] + struct Raw { + ok: bool, + positions: Option>, + error: Option, + } + let raw: Raw = parse_line(line)?; + if raw.ok { + let positions = raw.positions.ok_or_else(|| { + ProtocolError::Malformed("ok positions without a positions array".to_owned()) + })?; + Ok(PositionsOutcome::Ok(positions)) + } else if raw.error.as_deref() == Some("wallet_locked") { + Ok(PositionsOutcome::WalletLocked) + } else { + Err(ProtocolError::Unexpected(raw.error.unwrap_or_else(|| { + "positions without ok or error".to_owned() + }))) + } +} + /// The fields an `approve` / `deny` reply may carry. `state` means the outcome /// (`executed`/`failed`/`denied`) on an `ok` reply, or the `already_resolved` /// state on an error reply. @@ -1010,4 +1085,84 @@ mod tests { ); } } + + // ── positions (§3.8) ── + + #[test] + fn parse_positions_keeps_every_field_and_extra_verbatim() { + // The canonical §3.8 example: display strings ("∞", "80%") must cross + // untouched — the dashboard renders them, it never parses them. + let line = r#"{"ok":true,"positions":[ + {"protocol":"aave_v3","chain_id":1, + "asset_address":"0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", + "asset_symbol":"USD","asset_name":"Aave v3 account", + "asset_decimals":8,"balance":"100000000000","balance_formatted":"1000", + "extra":{"available_borrows_usd":"250","health_factor":"∞","ltv":"80%","total_debt_usd":"0"}}]}"# + .replace('\n', ""); + let PositionsOutcome::Ok(positions) = parse_positions(&line).unwrap() else { + panic!("ok positions"); + }; + assert_eq!(positions.len(), 1); + let p = &positions[0]; + assert_eq!(p.protocol, "aave_v3"); + assert_eq!(p.chain_id, 1); + assert_eq!(p.asset_symbol, "USD"); + assert_eq!(p.asset_name, "Aave v3 account"); + assert_eq!(p.asset_decimals, 8); + assert_eq!(p.balance, "100000000000"); + assert_eq!(p.balance_formatted, "1000"); + assert_eq!(p.extra["health_factor"], "∞"); + assert_eq!(p.extra["ltv"], "80%"); + assert_eq!(p.extra.len(), 4); + } + + #[test] + fn parse_positions_accepts_an_empty_list_as_success() { + // Best-effort canon: no positions, or every source skipped — still ok. + let line = r#"{"ok":true,"positions":[]}"#; + assert_eq!(parse_positions(line).unwrap(), PositionsOutcome::Ok(vec![])); + } + + #[test] + fn parse_positions_wallet_locked_degrades() { + let line = r#"{"ok":false,"error":"wallet_locked"}"#; + assert_eq!( + parse_positions(line).unwrap(), + PositionsOutcome::WalletLocked + ); + } + + #[test] + fn parse_positions_unexpected_errors_fail_closed() { + // unauthorized/protocol_error mean the channel is not what we + // negotiated — an error, not a degradation. + for code in ["unauthorized", "protocol_error"] { + let line = format!(r#"{{"ok":false,"error":"{code}"}}"#); + assert!(matches!( + parse_positions(&line), + Err(ProtocolError::Unexpected(_)) + )); + } + } + + #[test] + fn parse_positions_ok_without_array_is_malformed() { + let line = r#"{"ok":true}"#; + assert!(matches!( + parse_positions(line), + Err(ProtocolError::Malformed(_)) + )); + assert!(matches!( + parse_positions("not json"), + Err(ProtocolError::Malformed(_)) + )); + } + + #[test] + fn positions_request_encodes_the_documented_op() { + assert_eq!( + encode_request(&Request::Positions).unwrap(), + r#"{"op":"positions"}"# + ); + } } diff --git a/src/transport.rs b/src/transport.rs index 0fd3099..ac2935d 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -24,9 +24,9 @@ use std::thread::JoinHandle; use zeroize::Zeroizing; use crate::protocol::{ - self, AuthOutcome, ContextOutcome, GetOutcome, HelloOutcome, PROTO_VERSION, ResolveOutcome, - Summary, encode_request, parse_approve, parse_auth, parse_context, parse_deny, parse_get, - parse_hello, parse_list, + self, AuthOutcome, ContextOutcome, GetOutcome, HelloOutcome, PROTO_VERSION, PositionsOutcome, + ResolveOutcome, Summary, encode_request, parse_approve, parse_auth, parse_context, parse_deny, + parse_get, parse_hello, parse_list, parse_positions, }; /// Informational client id sent in `hello` (the server does not validate it). @@ -54,6 +54,8 @@ pub enum Request { Deny(String), /// Ask for the wallet's own context (proto 2+, auth-gated). Context, + /// Ask for the wallet's own DeFi positions (proto 2+, auth-gated). + Positions, } /// A message from the worker to the MVU layer. @@ -74,6 +76,8 @@ pub enum Reply { Resolve(ResolveOutcome), /// Result of a `context` (proto 2+). Context(ContextOutcome), + /// Result of a `positions` (proto 2+). + Positions(PositionsOutcome), /// The connection is finished and unusable — the worker has exited. Fatal(TransportError), } @@ -285,6 +289,11 @@ fn serve_one( .map_err(|e| TransportError::Protocol(e.to_string()))?; exchange(writer, reader, &line)? } + Request::Positions => { + let line = encode_request(&protocol::Request::Positions) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + exchange(writer, reader, &line)? + } }; let parsed = match req { Request::Auth(_) => parse_auth(&resp).map(Reply::Auth), @@ -293,6 +302,7 @@ fn serve_one( Request::Approve(_) | Request::ApprovePin(_) => parse_approve(&resp).map(Reply::Resolve), Request::Deny(_) => parse_deny(&resp).map(Reply::Resolve), Request::Context => parse_context(&resp).map(Reply::Context), + Request::Positions => parse_positions(&resp).map(Reply::Positions), }; parsed.map_err(|e| TransportError::Protocol(e.to_string())) } diff --git a/src/ui.rs b/src/ui.rs index b241d83..8e13a3a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,9 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap}; -use crate::app::{AuthError, Confirm, DecisionKind, Model, Notice, Phase, ResolveError, View}; +use crate::app::{ + AuthError, Confirm, DecisionKind, Model, Notice, Phase, Positions, ResolveError, View, +}; use crate::protocol::{Card, Summary}; use crate::{format, qr, theme}; @@ -40,6 +42,7 @@ pub fn render(frame: &mut Frame, model: &Model, now_unix: u64) { now_unix, ), View::Receive => render_receive(frame, items.len(), model.wallet_address()), + View::Dashboard => render_dashboard(frame, items.len(), model), }, Phase::Fatal(err) => render_centered(frame, &err.to_string()), } @@ -64,6 +67,8 @@ fn tab_line(active: View, pending: usize) -> Line<'static> { } }; Line::from(vec![ + Span::raw(" "), + tab(" Dashboard [d] ".to_owned(), active == View::Dashboard), Span::raw(" "), tab(format!(" Queue·{pending} [a] "), active == View::Queue), Span::raw(" "), @@ -445,6 +450,164 @@ fn render_receive(frame: &mut Frame, pending: usize, wallet: Option<&str>) { frame.render_widget(Paragraph::new(lines).block(block), chunks[1]); } +/// The Dashboard: per-chain balance (from `context`), DeFi positions (the +/// `positions` read-op), and the "waiting for you" count. Pure display — +/// nothing here signs or gates; the values render **verbatim** (`extra` are +/// display strings by canon §3.8 — including the literal `"∞"`). +/// +/// Honesty rules: a failed balance refresh flags the block as possibly stale +/// (never silently shows old data as fresh); positions that do not fit end +/// with an explicit "+N more" marker, never a silent clip. +fn render_dashboard(frame: &mut Frame, pending: usize, model: &Model) { + let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(frame.area()); + frame.render_widget( + Paragraph::new(tab_line(View::Dashboard, pending)), + chunks[0], + ); + + let block = themed_block(" Dashboard "); + let inner = block.inner(chunks[1]); + let width = usize::from(inner.width); + let height = usize::from(inner.height); + + let mut lines: Vec> = Vec::new(); + + // ── Waiting for you — the reason this console exists comes first. + let waiting = if pending == 0 { + "Waiting for you: nothing pending".to_owned() + } else { + format!("Waiting for you: {pending} pending — press a") + }; + let waiting_style = if pending == 0 { + theme::label_style() + } else { + theme::high_risk_style() + }; + push_wrapped(&mut lines, width, waiting, waiting_style); + lines.push(Line::from("")); + + // ── Balance (from `context`; per-chain native ETH). + push_wrapped( + &mut lines, + width, + "balance".to_owned(), + theme::label_style(), + ); + match model.wallet_context() { + Some(ctx) if !ctx.balances.is_empty() => { + for b in &ctx.balances { + push_wrapped( + &mut lines, + width, + format!( + " chain {} {} {}", + b.chain_id, + format::wei_to_eth(&b.balance), + b.symbol + ), + theme::value_style(), + ); + } + } + Some(_) => push_wrapped( + &mut lines, + width, + " no balances reported".to_owned(), + theme::label_style(), + ), + None => push_wrapped( + &mut lines, + width, + " balance unavailable".to_owned(), + theme::label_style(), + ), + } + if model.context_stale() { + push_wrapped( + &mut lines, + width, + " balance may be stale — refresh failed".to_owned(), + theme::high_risk_style(), + ); + } + lines.push(Line::from("")); + + // ── Positions (tri-state: loading / loaded / unavailable). + push_wrapped( + &mut lines, + width, + "positions".to_owned(), + theme::label_style(), + ); + match model.positions() { + Positions::NotYet => push_wrapped( + &mut lines, + width, + " loading positions…".to_owned(), + theme::label_style(), + ), + Positions::Unavailable => push_wrapped( + &mut lines, + width, + " positions unavailable".to_owned(), + theme::label_style(), + ), + Positions::Loaded(list) if list.is_empty() => push_wrapped( + &mut lines, + width, + " no DeFi positions".to_owned(), + theme::label_style(), + ), + Positions::Loaded(list) => { + // Rows still available for position lines: the panel height + // minus what the blocks above already used. Inside the loop only + // `used` — rows added BY THIS LOOP — is compared against it: the + // Gate-2 blocker compared the ever-growing `lines.len()`, which + // still contains the header rows the budget had already + // subtracted, so the header was counted twice and the marker cut + // positions that actually fit. + let budget = height.saturating_sub(lines.len()); + let mut used = 0usize; + for (i, p) in list.iter().enumerate() { + let extra: String = p + .extra + .iter() + .map(|(k, v)| format!("{k} {v}")) + .collect::>() + .join(" · "); + let mut row = format!( + " {} · {} {} {}", + p.protocol, p.balance_formatted, p.asset_symbol, p.asset_name + ); + if !extra.is_empty() { + row.push_str(" — "); + row.push_str(&extra); + } + let mut rendered = Vec::new(); + push_wrapped(&mut rendered, width, row, theme::value_style()); + let remaining = list.len() - i; + // Reserve one row for the "+N more" marker — except for the + // last position, which may take the final row itself (an + // exact fit shows everything, no marker). + let reserve = usize::from(remaining > 1); + if used + rendered.len() + reserve > budget { + push_wrapped( + &mut lines, + width, + format!(" +{remaining} more — terminal too small"), + theme::label_style(), + ); + break; + } + used += rendered.len(); + lines.append(&mut rendered); + } + } + } + + frame.render_widget(Paragraph::new(lines).block(block), chunks[1]); +} + /// The card's priority lines — every field except `raw_data` — pre-wrapped to /// `width` display cells, so one logical line is one visual row and the height /// arithmetic downstream is exact. One source for the renderer AND for @@ -883,6 +1046,7 @@ mod tests { allowed_chains: vec![1], }, ))))); + model.update(Msg::View(crate::app::View::Queue)); // Stage-5 home is Dashboard model.update(Msg::Tick); model.update(Msg::Reply(Reply::List(items))); } @@ -908,6 +1072,7 @@ mod tests { allowed_chains: vec![1], }, ))))); + model.update(Msg::View(crate::app::View::Queue)); // Stage-5 home is Dashboard model.update(Msg::Tick); model.update(Msg::Reply(Reply::List(items))); } @@ -926,6 +1091,7 @@ mod tests { model.update(Msg::PinSubmit); model.update(Msg::Reply(Reply::Auth(AuthOutcome::Ok))); model.update(Msg::Reply(Reply::Context(ContextOutcome::WalletLocked))); + model.update(Msg::View(crate::app::View::Queue)); // Stage-5 home is Dashboard model.update(Msg::Tick); model.update(Msg::Reply(Reply::List(items))); } @@ -1776,4 +1942,231 @@ mod tests { let row = action_row(&rows); assert!(row.contains("Approve")); } + + // ── Stage 5: the Dashboard view ── + + use crate::protocol::{ChainBalance, Position, PositionsOutcome}; + + /// Drive a model onto the (home) Dashboard with the given balances, then + /// feed it the positions reply the scheduler solicits. + fn to_dashboard(balances: Vec, positions: PositionsOutcome) -> Model { + let mut m = to_dashboard_loading(balances); + m.update(Msg::Reply(Reply::Positions(positions))); + m + } + + /// Same, stopped BEFORE the positions reply lands (the loading state). + fn to_dashboard_loading(balances: Vec) -> Model { + let mut m = Model::new(); + m.update(Msg::Resize { + width: 80, + height: 24, + }); + m.update(Msg::Reply(Reply::Hello { + server: "s".to_owned(), + })); + m.update(Msg::PinDigit('1')); + m.update(Msg::PinSubmit); + m.update(Msg::Reply(Reply::Auth(AuthOutcome::Ok))); + m.update(Msg::Reply(Reply::Context(ContextOutcome::Ok(Box::new( + WalletContext { + address: WALLET.to_owned(), + balances, + allowed_chains: vec![1], + }, + ))))); + m.update(Msg::Tick); + // The scheduler answers this list reply with the positions request. + m.update(Msg::Reply(Reply::List(vec![]))); + m + } + + fn aave_position() -> Position { + let mut extra = std::collections::BTreeMap::new(); + extra.insert("health_factor".to_owned(), "∞".to_owned()); + extra.insert("ltv".to_owned(), "80%".to_owned()); + Position { + protocol: "aave_v3".to_owned(), + chain_id: 1, + asset_address: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2".to_owned(), + asset_symbol: "USD".to_owned(), + asset_name: "Aave v3 account".to_owned(), + asset_decimals: 8, + balance: "100000000000".to_owned(), + balance_formatted: "1000".to_owned(), + extra, + } + } + + #[test] + fn the_dashboard_shows_balance_positions_and_the_waiting_count() { + let balances = vec![ChainBalance { + chain_id: 1, + symbol: "ETH".to_owned(), + balance: "10000000000000000".to_owned(), // 0.01 ETH + }]; + let m = to_dashboard(balances, PositionsOutcome::Ok(vec![aave_position()])); + let rows = draw_rows(&m, 100, 24); + assert!( + has_line_with(&rows, &["chain 1", "0.01 ETH"]), + "the balance reads humanly, per chain" + ); + assert!( + has_line_with(&rows, &["aave_v3", "1000 USD", "Aave v3 account"]), + "a position line carries protocol, formatted balance, and name" + ); + assert!( + has_line_with(&rows, &["health_factor ∞"]) && has_line_with(&rows, &["ltv 80%"]), + "extra values render verbatim — display strings, never parsed" + ); + assert!( + has_line_with(&rows, &["Waiting for you: nothing pending"]), + "the waiting block is present even when idle" + ); + assert!( + !rows.join("\n").contains(&aave_position().asset_address), + "asset addresses are not rendered on the dashboard (Gate-1 №4)" + ); + } + + #[test] + fn the_dashboard_loading_state_is_not_unavailable() { + let m = to_dashboard_loading(vec![]); + let screen = draw(&m, 80, 24); + assert!(screen.contains("loading positions"), "NotYet says loading"); + assert!( + !screen.contains("positions unavailable"), + "…and never claims unavailability it has not observed" + ); + } + + #[test] + fn the_dashboard_degrades_honestly() { + // wallet_locked positions → unavailable; empty list → no positions. + let m = to_dashboard(vec![], PositionsOutcome::WalletLocked); + assert!(draw(&m, 80, 24).contains("positions unavailable")); + + let m = to_dashboard(vec![], PositionsOutcome::Ok(vec![])); + let screen = draw(&m, 80, 24); + assert!(screen.contains("no DeFi positions")); + assert!( + screen.contains("no balances reported"), + "empty balances are named, not blank" + ); + } + + #[test] + fn a_failed_balance_refresh_is_flagged_on_the_dashboard() { + let balances = vec![ChainBalance { + chain_id: 1, + symbol: "ETH".to_owned(), + balance: "5".to_owned(), + }]; + let mut m = to_dashboard(balances, PositionsOutcome::Ok(vec![])); + m.update(Msg::Reply(Reply::Context(ContextOutcome::WalletLocked))); + let screen = draw(&m, 80, 24); + assert!( + screen.contains("may be stale"), + "old data shown as possibly stale, never as fresh" + ); + } + + #[test] + fn overflowing_positions_end_with_an_explicit_marker() { + let many: Vec = (0..40) + .map(|i| { + let mut p = aave_position(); + p.asset_symbol = format!("TOK{i}"); + p + }) + .collect(); + let m = to_dashboard(vec![], PositionsOutcome::Ok(many)); + let rows = draw_rows(&m, 100, 24); + assert!( + rows.join("\n").contains("more — terminal too small"), + "clipped positions say so out loud (raw_data honesty pattern)" + ); + } + + /// Positions with distinct symbols, one render row each at width 100. + fn many_positions(n: usize) -> Vec { + (0..n) + .map(|i| { + let mut p = aave_position(); + p.asset_symbol = format!("TOK{i}"); + p.extra.clear(); // keep each row single-line at this width + p + }) + .collect() + } + + fn position_rows(rows: &[String]) -> usize { + rows.iter().filter(|r| r.contains("TOK")).count() + } + + #[test] + fn the_positions_budget_sits_exactly_on_its_boundary() { + // Geometry at 100×24, empty balances: tab(1)+borders(2) → inner 21; + // header = waiting(1)+blank(1)+"balance"(1)+"no balances"(1)+blank(1) + // +"positions"(1) = 6 → budget 15. The Gate-2 blocker subtracted the + // header TWICE and cut positions that fit — this pins both edges. + let m = to_dashboard(vec![], PositionsOutcome::Ok(many_positions(15))); + let rows = draw_rows(&m, 100, 24); + assert_eq!( + position_rows(&rows), + 15, + "an exact fit shows every position, no marker" + ); + assert!(!rows.join("\n").contains("more — terminal too small")); + + let m = to_dashboard(vec![], PositionsOutcome::Ok(many_positions(16))); + let rows = draw_rows(&m, 100, 24); + assert_eq!( + position_rows(&rows), + 14, + "one over: 14 positions + the marker fill the budget exactly — \ + nothing that fits is hidden (the blocker cut at 11 here)" + ); + assert!(rows.join("\n").contains("+2 more — terminal too small")); + } + + #[test] + fn the_waiting_block_counts_pending_items() { + // The non-empty branch never rendered in any test (Gate-2 NIT). + let mut m = to_dashboard(vec![], PositionsOutcome::Ok(vec![])); + m.update(Msg::Tick); + m.update(Msg::Reply(Reply::List(vec![ + summary("a1", "0xabc", "0", false), + summary("a2", "0xdef", "0", false), + ]))); + let rows = draw_rows(&m, 80, 24); + assert!( + has_line_with(&rows, &["Waiting for you: 2 pending", "press a"]), + "the human is told how many decisions wait and how to get there" + ); + } + + #[test] + fn the_dashboard_tab_is_first_and_active_on_the_home_view() { + let m = to_dashboard(vec![], PositionsOutcome::Ok(vec![])); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &m, NOW)).unwrap(); + let buffer = terminal.backend().buffer(); + let row: String = (0..80).map(|x| buffer[(x, 0)].symbol()).collect(); + assert!( + row.contains("Dashboard [d]") + && row.contains("Queue·0 [a]") + && row.contains("Receive [r]"), + "all three tabs with their keys: {row}" + ); + let dash_at = row.find("Dashboard").unwrap() as u16; + assert!( + buffer[(dash_at, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED), + "the active (home) tab is the Dashboard" + ); + } }