A user can send a channel-open request to a meshed Lightning peer, carrying this node's own Lightning URI, an optional amount, and an optional message
A received channel-open request appears in the recipient's mesh conversation as a typed message showing the requester's URI and note, using the existing typed-message rendering path
Sending a channel-open request requires an explicit target peer — there is no broadcast form
A channel-open request whose payload URI is malformed is rejected on receipt and never stored as a message
Two channel-open requests sent to the same peer in quick succession produce two distinct messages with distinct sender sequence numbers, and neither is silently dropped (FED-05 concurrency edge, mesh half)
A request is never rendered or reported as an opened or funded channel — it carries no channel state
statement
category
A channel-open request MUST NOT be presented anywhere as an accepted, open, or funded channel — a request that has not been acted on by the recipient must never appear in a channel list, a balance, or a connected-peer count
transparency
statement
category
Receiving a channel-open request MUST NOT cause the node to open a channel, connect to the requester, or move funds on its own — acting on a request is always a separate, explicit human decision
inbound ChannelOpenRequest is stored as a MeshMessage with its typed payload
ChannelOpenRequest
Give a meshed Lightning peer a way to be *asked* for a channel: a typed mesh message carrying the
requester's Lightning URI and an optional note, sent to one chosen peer and rendered in the
recipient's conversation.
Purpose: FED-05's second list. CONTEXT.md locks the semantics — meshed peers with Lightning
installed are nodes you "request to open a channel with", not nodes you open against directly,
because mesh peers are not bilaterally trusted the way federated nodes are. 01-UI-SPEC.md fixes the
UI verb ("Request Channel", reusing PeerRequestModal.vue's message field and busy states).
PATTERNS.md records that the send/receive shape for this is typed_messages.rs's existing
reaction/reply family — a struct-per-message-type serialized into the standard envelope — and that
no capability/request mechanism exists yet to extend.
Output: MeshMessageType::ChannelOpenRequest, its payload, an inbound arm that stores it as a
conversation message, and a mesh.request-channel RPC.
Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation
`MeshMessageType` is a radio wire format read by every fleet node
after the next OTA; the discriminant and payload shape become externally visible, so a later change
needs a coordinated fleet upgrade. Kept additive on an unused discriminant with serde-default
optional payload fields, so older nodes ignore it rather than erroring.
`MeshMessageType::LightningInfo = 26` exists (plan 01-04, Task 2) — confirm the highest current discriminant by reading `core/archipelago/src/mesh/message_types.rs` before choosing this type's number.
core/archipelago/src/mesh/message_types.rs, core/archipelago/src/mesh/listener/dispatch.rs, core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/api/rpc/dispatcher.rs
- `core/archipelago/src/mesh/message_types.rs` — the enum and the four places every variant is
registered (`enum`, `from_u8`, `from_label`, `label`), `InvoicePayload` (from L413) as the
closest payload analog (it also carries a payment-ish string plus an optional amount), and the
`TypedEnvelope` doc comment on `compact_bytes` and LoRa frame size.
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` lines 637-760 — `handle_mesh_send_reply`
and `handle_mesh_send_reaction`: param extraction, target-peer resolution, sequence-number
allocation, `TypedEnvelope::new(...).with_seq(seq)`, and the send call.
- `core/archipelago/src/mesh/listener/dispatch.rs` lines 430-500 — the `Reaction` and `Presence`
inbound arms, and how an inbound typed message is turned into a stored `MeshMessage` with
`message_type` and `typed_payload` set.
- `core/archipelago/src/mesh/types.rs` lines 139-180 — the `MeshMessage` fields the stored
request must populate (`plaintext` is the human-readable fallback shown in list views).
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 390-440 — the one-line registration convention.
- `.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract row
for "Primary CTA — meshed Lightning peer", which fixes what the UI in plan 01-08 will send.
- `MeshMessageType` round-trips the new variant through `from_u8`, `from_label`, and `label`.
- `handle_mesh_request_channel` with no target peer in params returns an error; with an unknown
target peer it returns an error naming the peer.
- `handle_mesh_request_channel` with a target sends exactly one envelope of the new type, whose
payload carries this node's own Lightning URI and the caller's optional amount and message.
- Two consecutive calls to the same target allocate two different sequence numbers.
- An inbound envelope of the new type with a well-formed URI is stored as a `MeshMessage` whose
`message_type` is the new label and whose `typed_payload` carries the request fields.
- An inbound envelope whose payload URI is malformed stores nothing and logs a warning.
Write the tests first and confirm they fail.
Add `ChannelOpenRequest = 27` to `MeshMessageType` (confirm 27 is unused first) with a doc comment
stating that this is a *request*, that it carries no channel state, and that receiving one never
causes the node to act. Register it in `from_u8`, `from_label` ("channel_open_request"), and
`label`.
Add `ChannelOpenRequestPayload` beside the other payload structs: a required `uri: String` (the
requester's own `pubkey@host:port`), plus `#[serde(default)] amount_sats: Option<u64>` and
`#[serde(default)] message: Option<String>`. Bound the optional message length before send so a
long note cannot blow past the LoRa framing budget the `TypedEnvelope` doc comment warns about;
truncate at the send side rather than rejecting, and say so in a comment.
Add `handle_mesh_request_channel` following the `handle_mesh_send_reply` shape: require a target
peer identifier in params and error without one (there is no broadcast form); read this node's
own Lightning URI via the identity path plan 01-04 added to `lnd.getinfo`, and error with a clear
message when it is unavailable rather than sending an empty request; build the payload, wrap it in
a `TypedEnvelope` with a freshly allocated sequence number, and send it to that peer only.
Register it in the dispatcher as `"mesh.request-channel"`.
Add the inbound arm in `dispatch.rs` mirroring the `Reaction` arm: deserialize the payload,
validate the URI shape (66-hex pubkey part, optional `@host[:port]`), and on success store a
`MeshMessage` with the new label as `message_type`, the payload as `typed_payload`, and a
human-readable `plaintext` summary naming the requester and the requested amount when present.
On a malformed URI, log at `warn!` and store nothing.
Do not add any code path that connects to, opens a channel with, or funds the requester on
receipt. The inbound arm's only effect is storing a message.
cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh
- `cd core && cargo test -p archipelago mesh::message_types mesh::listener api::rpc::mesh` exits 0 with the six behaviors above present as named cases.
- `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/message_types.rs` is at least 5.
- `grep -c 'channel_open_request' core/archipelago/src/mesh/message_types.rs` is at least 2.
- `grep -c '"mesh.request-channel"' core/archipelago/src/api/rpc/dispatcher.rs` equals 1.
- `grep -c 'ChannelOpenRequest' core/archipelago/src/mesh/listener/dispatch.rs` is at least 1.
- The inbound arm contains no call to any `openchannel`, `connectpeer`, or send-funds path — verified by reading the arm and recorded in the SUMMARY.
- `cd core && cargo test -p archipelago` exits 0.
- The SUMMARY records the pre-implementation failing test output.
A channel-open request travels from an RPC call to a chosen peer's conversation, with no side effect beyond a stored message.
Task 2: Harden the request path against duplicates, oversize, and misuse
core/archipelago/src/api/rpc/mesh/typed_messages.rs, core/archipelago/src/mesh/listener/dispatch.rs
- `core/archipelago/src/api/rpc/mesh/typed_messages.rs` as left by Task 1, plus the
`handle_mesh_send_content_inline` size-tier logic for how this codebase bounds payload size
before a send.
- `core/archipelago/src/mesh/outbox.rs` — whether an outbound send is queued and retried, so the
duplicate-suppression window is placed where it will actually see both attempts.
- `core/archipelago/src/mesh/types.rs` — `MeshPeer`'s authenticating-key accessor doc comment
(never use the firmware routing key for authentication).
Add a short duplicate-suppression window to `handle_mesh_request_channel`: a second request to the
same target peer within a bounded interval returns a distinct, non-error result reporting that a
request was already sent, rather than emitting a second envelope. The UI in plan 01-08 also
disables its button while a send is in flight, but a backend guard is what actually stops a
double-click or a retried RPC from spamming a peer over a slow radio link. Two requests separated
by more than the window must both go out — the window suppresses accidental duplicates, not
legitimate repeat requests. Add tests for both sides of the window.
Bound the inbound side too: reject an inbound payload whose message field exceeds the same
length bound the send side truncates at, and reject an `amount_sats` outside the range
`handle_lnd_openchannel` accepts (its existing 20,000..=16,777,215 sat bounds) so a request can
never carry an amount the recipient could not act on. Read `channels.rs` for those exact bounds
rather than restating them from memory.
Attribute the stored inbound message to the peer's authenticating identity key, not the firmware
routing key, following the `MeshPeer` accessor's documented rule — a request that claims to be
from a trusted peer must be attributable.
cd core && cargo test -p archipelago mesh api::rpc::mesh
- `cd core && cargo test -p archipelago mesh api::rpc::mesh` exits 0, including a within-window suppression case and an outside-window pass-through case.
- A test asserts an inbound request with an out-of-range `amount_sats` is rejected, using the bounds read from `channels.rs` rather than hardcoded duplicates of them.
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `mesh` or `api::rpc::mesh`.
Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity.
<threat_model>
Trust Boundaries
Boundary
Description
radio peer → typed-envelope decode → stored message
Untrusted RF input becomes a conversation entry naming a payment endpoint
operator RPC → outbound request
An operator action discloses this node's payment endpoint to a chosen mesh peer
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-01-26
Spoofing
a peer sending a request that appears to come from a trusted node, luring a channel open to an attacker's URI
high
mitigate
The stored message is attributed to the peer's verified archipelago identity key, never the firmware routing key (Task 2); the recipient's action on a request is always explicit and human
T-01-27
Elevation of Privilege
a received request causing an automatic channel open or fund movement
high
mitigate
The inbound arm's only effect is storing a message; the acceptance criteria require reading the arm and recording that it contains no open/connect/send-funds call
T-01-28
Denial of Service
request flooding filling a peer's conversation or saturating a LoRa link
high
mitigate
Send-side duplicate-suppression window plus inbound length and amount bounds (Task 2)
T-01-29
Information Disclosure
broadcasting this node's payment endpoint to every contact in range
high
mitigate
A target peer is required; the handler errors without one and there is no broadcast form
T-01-30
Tampering
an oversize payload fragmenting into unreassemblable LoRa chunks
medium
mitigate
The message field is truncated at the send side against the framing budget the TypedEnvelope doc comment describes; inbound oversize is rejected
T-01-SC
Tampering
npm/pip/cargo installs
high
mitigate
No new crates. If one becomes necessary, stop and run the Package Legitimacy Gate before installing
</threat_model>
- `cd core && cargo test -p archipelago` — green.
- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
- The SUMMARY states, from a direct read, that the inbound arm performs no Lightning action.
<success_criteria>
A new typed mesh message carries a channel-open request to one named peer.
Receiving one stores a conversation message and does nothing else.
Duplicates within a short window are suppressed; legitimate repeats are not.
Malformed URIs, oversize notes, and out-of-range amounts are refused.
</success_criteria>
Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.