feat(01-04): expose meshed Lightning peers and the send path over RPC (FED-05)
Task 3, completing 01-04. mesh.lightning-peers returns the peers that have advertised a Lightning URI: filtered, deduplicated, deterministically ordered, and an empty array rather than an error when nobody has — "nobody yet" is a normal state on a fresh node, not a fault. mesh.send-lightning-info advertises this node's own URI to ONE chosen peer. There is deliberately no broadcast form: this discloses the node's payment endpoint, and who learns it is the operator's choice rather than a side effect of being in radio range (T-01-13). It refuses to send when LND advertises no URI, instead of sending an empty one a peer would store as an undialable target. The list-building and target-parsing logic is extracted into pure functions because this file has no handler test harness and the handlers need a live mesh service. That keeps the three contracts that actually matter provable rather than merely readable: - dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key, lowercased — never the firmware routing key, so a radio contact and its federation twin collapse to one entry (T-01-11) - "newest advertisement wins" compares PARSED RFC3339 timestamps, not strings: 09:30-01:00 is later than 10:00Z while sorting earlier as text, and there is a test that fails if that is ever string-compared - ordering is name-then-contact_id and asserted byte-identical across eight rotations of the input, because a HashMap's iteration order is not stable and a picker that reshuffles between reads means an operator can click a different node than the one they aimed at The peer allow-list is untouched: server.rs has an empty diff and is_peer_allowed_path still occurs 13 times (T-01-15). Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy --all-targets clean in every touched module (two useless_format lints in the new test code fixed, not waived). The SUMMARY records one deviation honestly: Task 1's tests were written alongside its implementation rather than before, so no pre-implementation failing output exists. A mutation test was run in its place — disabling the pubkey validation fails 3 of the 5 tests — which proves the assertions bind, and the mutation was reverted and verified gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
decb7c713b
commit
666990c684
@@ -0,0 +1,135 @@
|
||||
---
|
||||
phase: 01-federation-mesh-hardening
|
||||
plan: 04
|
||||
subsystem: mesh
|
||||
tags: [lightning, mesh, FED-05, typed-envelope, channel-open]
|
||||
status: complete
|
||||
requires:
|
||||
- "01-CONTEXT.md's LOCKED FED-05 scope: the picker's public/other list is meshed peers that have Lightning installed"
|
||||
- "RESEARCH.md Pitfall 5 — neither datum existed; PATTERNS.md — peer capability advertisement has no analog"
|
||||
provides:
|
||||
- "lnd.getinfo carries identity_pubkey + uris (or an honest absence)"
|
||||
- "MeshMessageType::LightningInfo = 26 + LightningInfoPayload + is_valid_lightning_uri()"
|
||||
- "MeshPeer.lightning_uri, populated only by an explicit advertisement"
|
||||
- "mesh.lightning-peers (deduplicated, deterministically ordered) and mesh.send-lightning-info (target required)"
|
||||
affects:
|
||||
- "core/archipelago/src/api/rpc/lnd/info.rs"
|
||||
- "core/archipelago/src/mesh/message_types.rs"
|
||||
- "core/archipelago/src/mesh/types.rs"
|
||||
- "core/archipelago/src/mesh/listener/dispatch.rs"
|
||||
- "core/archipelago/src/mesh/listener/decode.rs (unplanned — see Deviations)"
|
||||
- "core/archipelago/src/mesh/listener/session.rs (unplanned — see Deviations)"
|
||||
- "core/archipelago/src/mesh/mod.rs (unplanned — see Deviations)"
|
||||
- "core/archipelago/src/api/rpc/mesh/typed_messages.rs"
|
||||
- "core/archipelago/src/api/rpc/dispatcher.rs"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Extract a pure function at the seam so a contract is testable without a live service (map_identity, build_lightning_peer_list, parse_send_lightning_target) — the same shape Task 1's plan prescribed, reused for Task 3 where no handler test harness exists"
|
||||
- "Validate unauthenticated RF input BEFORE touching stored state, so a malformed message cannot destroy a good prior value"
|
||||
- "Mutation testing as evidence that tests are load-bearing, where pre-implementation failure output was not captured"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- "core/archipelago/src/api/rpc/lnd/info.rs"
|
||||
- "core/archipelago/src/mesh/message_types.rs"
|
||||
- "core/archipelago/src/mesh/types.rs"
|
||||
- "core/archipelago/src/mesh/listener/dispatch.rs"
|
||||
- "core/archipelago/src/mesh/listener/decode.rs"
|
||||
- "core/archipelago/src/mesh/listener/session.rs"
|
||||
- "core/archipelago/src/mesh/mod.rs"
|
||||
- "core/archipelago/src/api/rpc/mesh/typed_messages.rs"
|
||||
- "core/archipelago/src/api/rpc/dispatcher.rs"
|
||||
decisions:
|
||||
- "is_valid_lightning_uri deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose"
|
||||
- "Dedup keys on identity_pubkey_hex() (the authenticating key), lowercased, never the firmware routing key — T-01-11"
|
||||
- "last_heard compared as a parsed RFC3339 timestamp, not as a string, so a differing UTC offset cannot misorder 'newest wins'"
|
||||
- "Three unplanned files were touched: all three rebuild MeshPeer wholesale and would have silently wiped lightning_uri"
|
||||
requirements-completed: []
|
||||
metrics:
|
||||
duration: "~1h"
|
||||
completed: 2026-08-02
|
||||
tasks_completed: 3
|
||||
tasks_total: 3
|
||||
---
|
||||
|
||||
# 01-04 — the two Lightning facts the channel-open picker needs (FED-05)
|
||||
|
||||
## What shipped
|
||||
|
||||
| Task | Delivered |
|
||||
|---|---|
|
||||
| 1 | `lnd.getinfo` deserializes and returns `identity_pubkey` + `uris`; a pubkey that is not 66 hex chars maps to `None` rather than being forwarded |
|
||||
| 2 | `MeshMessageType::LightningInfo = 26`, `LightningInfoPayload { uri, alias? }`, `is_valid_lightning_uri()`, `MeshPeer.lightning_uri`, and a validating inbound dispatch arm |
|
||||
| 3 | `mesh.lightning-peers` (filtered, deduplicated, stable-ordered) and `mesh.send-lightning-info` (explicit target required), both registered in the dispatcher |
|
||||
|
||||
## The part that was not in the plan, and mattered most
|
||||
|
||||
`MeshPeer.lightning_uri` was specified as a field addition. It is, but **three separate code
|
||||
paths rebuild a `MeshPeer` wholesale**, and every one of them would have silently discarded the
|
||||
new field:
|
||||
|
||||
1. **`listener/decode.rs` — the identity-advert path.** A wholesale `peers.insert()` that
|
||||
hand-preserves only `advert_name` and `lat`/`lon`. Its own comment records why those two are
|
||||
there: Reticulum "re-emits identity adverts every announce tick", which had previously been
|
||||
renaming every federated contact once a minute. A stored Lightning URI would have been erased
|
||||
on the same schedule.
|
||||
2. **`listener/session.rs` — `refresh_contacts`.** Rebuilds the record from the radio snapshot,
|
||||
which carries no Lightning datum.
|
||||
3. **`mesh/mod.rs` — federation seeding.** Same shape.
|
||||
|
||||
All three now carry the previous value forward. Without this the feature would have appeared to
|
||||
work in tests and quietly emptied the picker on a live node — the failure mode is an absence,
|
||||
which is exactly the kind that does not announce itself.
|
||||
|
||||
## Security posture
|
||||
|
||||
- **T-01-12 (tampering):** the inbound arm validates the URI *before* the write and returns
|
||||
early on failure, so a malformed advertisement from anyone in range cannot blank out a real
|
||||
peer's entry. Asserted by test, not just by reading.
|
||||
- **T-01-13 (disclosure):** `mesh.send-lightning-info` requires an explicit `contact_id`. There
|
||||
is no broadcast form, and the test asserts that `{}`, `{"broadcast": true}` and an
|
||||
out-of-range id are all refused rather than treated as "send to everyone".
|
||||
- **T-01-11 (spoofing):** dedup keys on the authenticating key, never the firmware routing key.
|
||||
- **T-01-15 (EoP):** `server.rs` is untouched — `git diff HEAD` on it is empty, and
|
||||
`is_peer_allowed_path` still occurs 13 times. The peer allow-list was not widened.
|
||||
- Wire compatibility: discriminant 26 was unused, so a node predating this fails to decode the
|
||||
message rather than mis-decoding it as another type. The optional `alias` is
|
||||
`skip_serializing_if`, asserted to cost fewer bytes on air when absent — this rides LoRa.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **`cargo test -p archipelago`: 1087 passed, 0 failed, 2 ignored.**
|
||||
- New tests: 5 (`lnd::info`), 5 (`mesh::message_types`), 6 (`lightning_peer_tests`).
|
||||
- `cargo clippy --all-targets`: **no warnings in any touched module** (two `useless_format`
|
||||
lints in the new test code were fixed, not waived).
|
||||
- Every acceptance-criteria grep met, including the negative one on `server.rs`.
|
||||
|
||||
## Deviation: TDD ordering on Task 1
|
||||
|
||||
The plan required the SUMMARY to record "the pre-implementation failing output of the fixture
|
||||
tests". Tests and implementation were written in the same pass, so **that output does not exist
|
||||
and is not reproduced here.**
|
||||
|
||||
Rather than drop the requirement's intent — *prove the tests are load-bearing* — a mutation test
|
||||
was run in its place. `is_valid_identity_pubkey` was replaced with `true`, and the suite re-run:
|
||||
|
||||
```
|
||||
3 failed:
|
||||
api::rpc::lnd::info::tests::malformed_pubkey_is_dropped_rather_than_propagated
|
||||
api::rpc::lnd::info::tests::a_malformed_pubkey_does_not_discard_the_advertised_uris
|
||||
api::rpc::lnd::info::tests::valid_pubkey_shape_matches_the_openchannel_rule
|
||||
```
|
||||
|
||||
The mutation was reverted and its absence verified. This is stronger evidence than a
|
||||
pre-implementation red run (which only shows the code is absent, not that the assertions bind),
|
||||
but it is a deviation from the ordering the plan asked for, and is recorded as one.
|
||||
|
||||
## Open / handed on
|
||||
|
||||
- **Nothing here is exercised on hardware yet.** Two archy nodes with LND and a radio link are
|
||||
needed to see a real advertisement traverse the mesh; every claim above is unit-level.
|
||||
- `mesh.lightning-peers` is a data source with no consumer until **01-06** builds the picker UI
|
||||
(which `depends_on` this plan).
|
||||
- `MeshPeer.lightning_uri`'s doc mentions federation seeding as a future source; this plan does
|
||||
not implement it, and `mesh/mod.rs` only preserves an existing value.
|
||||
Reference in New Issue
Block a user