Compare commits

...

42 Commits

Author SHA1 Message Date
archipelago
2a017623e9 chore: release v1.7.99-alpha 2026-06-18 01:00:24 -04:00
archipelago
b59c74adfe test(ui): register $ver global in vitest setup
Component tests mounted without main.ts's bootstrap, so the $ver global
template helper (app.config.globalProperties.$ver = displayVersion) was
undefined — AppSidebar/AppHeroSection/MarketplaceAppCard tests failed with
"_ctx.$ver is not a function", blocking the release gate's ui-unit-tests
stage. Add a vitest setup file that mirrors main.ts via config.global.mocks
and wire it into vitest.config.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:52:48 -04:00
archipelago
371be4a69c chore: sync What's New modal for v1.7.99-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:53:08 -04:00
archipelago
83bb589ea6 style: cargo fmt for v1.7.99-alpha release gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:50:46 -04:00
archipelago
144c4a2872 docs: changelog for v1.7.99-alpha 2026-06-17 19:48:20 -04:00
archipelago
5b2a11b8c7 Merge meshroller-50: mesh-AI assistant (#50) into release train 2026-06-17 19:22:11 -04:00
archipelago
705e2436ba chore(ops,docs): first-boot containers, image versions, design docs, android remote-input
- first-boot-containers + image-versions for fmcd/fedimint
- dual-ecash, meshroller-integration, and remaining-issues design docs
- Android remote-input two-finger scroll + external-open handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:22:02 -04:00
archipelago
87769cbfbf feat(ui): dual-ecash wallet settings, buy-peer-files, seed backup, assorted fixes
- Tabbed Wallet Settings modal (Cashu + Fedimint) and dual-balance wallet card
- Buy a peer's paid file (ecash / node Lightning / on-chain / external QR)
- Recovery-phrase reveal + backup section; onboarding seed retry resilience
- NetBird HTTPS launch, remote-control two-finger scroll + external-open
- Shared BackButton, single-v version label, mesh Bitcoin header toggles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:42 -04:00
archipelago
bd567cd165 feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
  fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
  decoupled-update wiring; Fedimint Client core app in catalog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
archipelago
7a76d32e4b feat(mesh): mesh-AI assistant scheduler + config panel (#50)
Adds the assistant scheduler, MeshAssistantPanel UI, and the remaining
config-RPC / live-toggle / Ollama-detect wiring on top of Phase 1.x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:19:32 -04:00
archipelago
0947ecee11 feat(mesh): assistant config RPCs + live toggle + Ollama detect (#50)
Phase 2 backend. AssistantConfig is now live-updatable (RwLock) so the UI
toggle applies without a listener restart. New RPCs:
- mesh.assistant-status  -> {enabled, model, trusted_only, default_model,
  ollama_detected, models[]} (probes local Ollama :11434/api/tags)
- mesh.assistant-configure -> set enabled/model/trusted_only live + persist

MeshService::assistant_config / configure_assistant. Compiles clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:29:36 -04:00
archipelago
ef601c6d26 feat(mesh): wire ARCHY identity broadcast for trust over both radios (#50)
The ARCHY:2 identity broadcast (DID + ed25519 + x25519) was unwired dead
code on both send and receive. Wiring it lets a radio peer prove its
archipelago identity, so the assistant's trusted-only gate (and encrypted
DMs) work over meshcore AND Meshtastic — the latter otherwise only exposes
synthetic node keys.

- session.rs: broadcast ARCHY:2 as channel text at startup + each advert tick
- frames.rs: parse inbound ARCHY:2 on the channel path, dedupe-keyed by
  archipelago pubkey (federation_peer_contact_id) so it MERGES with the
  federation-seeded peer instead of duplicating; self-echo guarded
- threads our_x25519_secret into handle_channel_payload (was reserved)

Reuses the existing handle_identity_received verifier (ed/x25519 consistency
check + shared-secret derivation). Compiles clean. Needs a live 2-radio test
before trusting trusted-only over radio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:20:12 -04:00
archipelago
87d0d53205 feat(mesh): assistant Phase 1.5 — !ai channel trigger (issue #50)
A plain '!ai <q>' / '!ask <q>' on the channel is now answered by the node's
local model and broadcast back as plain text, so ANY client (bare meshcore
or Meshtastic) can ask. Generalised run_assist with an AssistReply target:
Typed chunks to a peer (archipelago UI path) vs plain channel-text (bare
clients). Trust/rate gate unchanged; asker identity is separate from reply
mode. Works over both radios.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:59:03 -04:00
archipelago
d8d014bfd9 feat(mesh): mesh-AI assistant — Phase 1.1-1.4 (issue #50)
Rust-native lift of Meshroller's LLM bridge. Adds typed AssistQuery/
AssistResponse mesh messages, a trust-gated inbound handler that answers
with the node's local Ollama model, and airtime discipline (reply cap,
chunking, one in-flight query per asker). Works over both meshcore and
Meshtastic radios via the existing MeshRadioDevice abstraction.

- message_types: AssistQuery=24 / AssistResponse=25 + payloads
- listener/assist.rs: run_assist (gate -> Ollama -> chunked reply)
- listener/dispatch.rs: AssistQuery/AssistResponse arms
- MeshConfig: assistant_enabled / assistant_model / assistant_trusted_only
- MeshState: AssistantConfig + data_dir + in-flight guard

Compiles clean (cargo check). Off by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:41:15 -04:00
archipelago
c10f2ac22e fix(apps): rename 'Websites' tab to 'Services' (#51)
Headless containers (databases, APIs, backends without a UI) belong in a
tab labelled 'Services', not 'Websites'. The categorisation logic already
routes UI-less packages there (built under #45); this finishes the rename
of the user-facing label across Apps, Marketplace, Discover and the mobile
nav, and makes 'services' the canonical tab state/query param. Old
?tab=websites bookmarks still resolve (back-compat acceptor kept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:56:36 -04:00
archipelago
3ca1fadfea chore: reconcile Cargo.lock after DHT merge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:50:25 -04:00
archipelago
7c458ede8e Merge agent-trust-wip (DHT Phases 0–4) into main
Integrates the DHT/peer-distribution line with the v1.7.98-alpha release
fixes:
- Phase 0 signed-catalog trust + release-root key (KAT-pinned)
- Phase 1 BLAKE3 content addressing alongside SHA-256
- Phase 2 swarm-assist fetch seam (origin always wins) + iroh-blobs
  provider — heavy iroh deps stay behind the off-by-default `iroh-swarm`
  feature, so the default build/deploy is unaffected
- Phase 3 signed Nostr seed-advertisement + discovery glue + paid swarm
  serving + "Networking Profits" Settings page
- Phase 4 paid swarm streaming (cross-mint ecash, Shape-A paid ALPN,
  streaming.prepare-payment), also iroh-swarm-gated

Conflicts resolved: seed.rs (kept release-root KAT tests), update.rs
(comment-only, OTA logic identical), Cargo.lock (regenerated against the
merged Cargo.toml). Default-feature build is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:50:06 -04:00
archipelago
27a6199939 feat(dht): Phase 4 — paid swarm streaming (cross-mint ecash + Shape-A ALPN)
Fetch-side auto-pay decision layer (payment.rs), Shape-A paid-blobs
negotiation ALPN (paid_alpn.rs), cross-mint ecash swap + payer auto-swap
builder + idempotent resume/liquidity cache (ecash.rs), and the
streaming.prepare-payment RPC. All gated behind the iroh-swarm feature
(off by default). 91/91 tests pass, both build configs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:36:31 -04:00
archipelago
2c93e25faf fix(mesh): satisfy strict index access in federationContactId (#39 build)
Destructure the first 4 pubkey bytes into typed locals so vue-tsc's
noUncheckedIndexedAccess doesn't fail the build (the bytes.length<4 guard
doesn't narrow per-element access). No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:06:08 -04:00
archipelago
d4c0587df0 fix(health): IndeeHub API waits for MinIO before restart (#41)
The IndeeHub API needs MinIO (object storage) up to serve, but the
health monitor's dependency map listed only postgres + redis, so it
would restart the API while MinIO was still starting — the "recovers
only after 1-2 container restarts" symptom. Add indeedhub-minio to the
API's deps; MinIO has no deps of its own so the monitor restarts it
first, no deadlock. (First-start ordering in the stack definition is a
deeper, separate follow-up.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:33:04 -04:00
archipelago
ab56054aeb fix(federation): remove-node also purges the mesh contact/thread (#2)
federation.remove-node only edited nodes.json, so a removed/renamed node
(e.g. a stale "Arch HP") lingered in the mesh chat list with its old
thread. Capture the node's pubkey before removal, then purge its
synthetic mesh peer, shared secret, messages, presence, and persisted
contact entry via the new mesh::purge_federation_peer. Combined with the
#42 name refresh, stale federation contacts can now be fully cleaned from
a node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:12:56 -04:00
archipelago
d2d2b9dd68 fix(apps): classify by declared UI — UI apps to My Apps, headless to Websites (#45)
Per the rule that only front-end apps with a UI belong in "My Apps"
(databases/backends/headless go to Websites), make the manifest's
interfaces.main.ui the deciding signal. isWebsitePackage now treats any
package that declares a UI as an app even when it isn't in the curated
APP_CATEGORY_MAP, and falls through headless LAN-reachable packages to
Websites. Additive — service-by-name infra and curated known apps are
unchanged, so no currently-correct app moves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:09:46 -04:00
archipelago
56752ebfc0 fix(identity): Node npub in Web5 Identities matches Settings (#49)
Settings shows the node-level Nostr key (HKDF derive_node_nostr_key,
read via node.nostr-pubkey) while Web5 > Identities showed the identity
record's own key — the mirrored "Node" identity stores nostr=None and
seed identities use a different BIP-32 NIP-06 key, so the two surfaces
disagreed.

Resolve the node-level Nostr key once in identity.list and override it
onto whichever identity record is the node's own (ed25519 == server_info
.pubkey). Display-only — no stored key is rewritten, so it self-applies
to existing nodes with no migration and the discovery identity is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:03:25 -04:00
archipelago
6de8173d18 fix(mesh): refresh federation chat names + roster after sync without restart (#42)
A peer accepted via invite is seeded into the mesh peer table with
name=None, so it shows as "Archipelago <pubkey8>" in chat. Federation
sync later learns the real name (update_node_state writes it to
nodes.json) and discovers transitive peers (merge_transitive_peers),
but nothing pushed those into the live mesh peer table — the chat list
stayed stale until the next mesh restart, and transitive peers never
appeared as contacts at all.

Add RpcHandler::refresh_federation_mesh_peers() (re-runs the idempotent,
onion-deduped seed_federation_peers_into_mesh) and call it after every
periodic sync cycle (server.rs) and after the manual federation.sync-all
RPC. Names now correct themselves and the full roster meshes within a
sync cycle, no restart needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:52:41 -04:00
archipelago
1f3b03bc6d docs(dht): Phase 4 plan (paid streaming/relay/IndeeHub + cross-mint) + RESUME update
phase4-streaming-ecash-plan.md: design for ecash-paid swarm transport, paying
across different mints (§2a, Lightning-bridged swaps), networking-through-nodes
relay, and an IndeeHub "Archipelago" content source. Records the resolved
iroh-blobs paid-serving spike. dht-RESUME.md: task #12 + step F marked done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:48:18 -04:00
archipelago
75b78325e4 feat(web5): Networking Profits → Settings page for paid services
Adds a Settings control to the Networking Profits card that opens a new page
where the operator controls what their node charges sats for and how much.
Drives the existing streaming.list-services / streaming.configure-service RPCs;
"free everything" is the default (all priced services ship disabled, surfaced
with a reassurance banner). New route web5/networking-profits + common.settings
i18n (en/es).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:48:00 -04:00
archipelago
be3ebd7fe0 feat(dht): Phase 3 discovery glue + paid swarm serving
Phase 3 wiring (task #12):
- NostrSeedDiscovery: async ProviderDiscovery that queries relays for signed
  seed adverts and parses endpoint ids (swarm/iroh_provider.rs, seed_advert.rs).
- seed_and_advertise publish path; dep-free fetch/publish helpers reuse the
  node's Nostr identity (build_nostr_client/load_or_create_nostr_keys made
  pub(crate)).
- swarm::init builds the IrohProvider once into a OnceLock runtime; providers()
  returns it; announce_held_blob() is called from update.rs after a release
  component passes both hash gates.
- config swarm_enabled (ARCHIPELAGO_SWARM_ENABLED, default off); server.rs init.

Paid swarm serving (Phase 4 step F):
- swarm/paid.rs gates the iroh-blobs provider through streaming::gate,
  intercepting connect + GET (peer push hard-disabled). Free by default
  (content-download service disabled); denies unpaid peers when enabled;
  fails open on internal error so a payment fault never blocks distribution.
  Wired into IrohProvider::new.

All iroh code behind the iroh-swarm feature; the default build is inert.
Default build clean; --features iroh-swarm: 11/11 swarm tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:47:18 -04:00
archipelago
06cf80d4a2 fix(apps): classify Bitcoin Core as an app, not a website (#8, #9)
bitcoin-core was missing from APP_CATEGORY_MAP, so isKnownApp() was false and
isWebsitePackage() fell through to 'has a runtime LAN address'. Once the running
container's LAN address (the bitcoind RPC port :8332) showed up ~a minute after
launch, Bitcoin Core was reclassified as a website: it dropped out of the Apps
tab and search, moved under Websites, and launching it opened :8332 (raw RPC)
instead of the :8334 custom UI that Knots opens.

Add 'bitcoin-core': 'money' alongside bitcoin-knots/bitcoin-ui so isKnownApp is
true, isWebsitePackage is false, and launchAppNow routes through openSession ->
resolveAppUrl (:8334 custom UI). Fixes search, category, and the launch URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:43:29 -04:00
archipelago
1ea3f8d65c fix(mesh): message federation contacts without a radio (fixes 'Missing contact_id')
Messaging a federation-only peer (e.g. 'Arch Dev') failed with 'Missing
contact_id'. The UI gave federation-only rows a *negative* placeholder
contact_id derived from a DID hash, but the backend parses contact_id as u64,
so a negative value deserialized to None. The negative id also never matched
the positive federation-synthetic id that federation-routed messages are stored
under, so those threads looked empty.

- Frontend: derive the SAME positive federation-synthetic id the backend uses
  (federationContactId mirrors federation_peer_contact_id) so mesh.send accepts
  it and messages thread correctly.
- Backend: send_typed_wire now resolves a federation-synthetic contact_id from
  nodes.json when it isn't in the live mesh peer table (radio-less node),
  instead of bailing 'Unknown federation peer'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:24:34 -04:00
archipelago
e456c9701b fix(peer-files): stream large cloud downloads + surface real errors (#30, #38)
Large peer downloads (~178MB) failed with a generic 'Operation failed', and
the download path had three stacked problems:

- The FIPS reqwest client used a hard-coded 20s total timeout regardless of the
  caller's .timeout(), so a big transfer over the mesh aborted at 20s before
  the Tor fallback could help. Honor the per-request timeout (client_with_timeout).
- The peer-content proxy buffered the whole file into node memory via
  resp.bytes() before sending a byte, and capped the transfer at 60s. Stream
  the body through with hyper::Body::wrap_stream (constant memory) and raise the
  timeout to 900s; bump the nginx peer-content read timeout to match.
- Free downloads pulled the file as base64 over RPC, doubling it in node memory
  and the browser — fatal for large files. Download free files by streaming
  from /api/peer-content straight to disk, after a 1-byte Range probe that
  surfaces the real reason (peer offline on mesh and Tor) instead of a generic
  failure. Paid downloads now return the real error through the {error} channel
  the UI already displays.

Adds the reqwest 'stream' feature for bytes_stream().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:10:21 -04:00
archipelago
3aea8c5bfa fix(orchestrator): rebuild local UI images when source changes (#34)
The prod orchestrator only checked whether a build-image tag was *present*
before deciding to skip the build. The local UI images (bitcoin-ui, lnd-ui,
electrs-ui) COPY a built neode-ui dist, so a UI update changed the source but
left the old tag in place and the new UI never shipped.

Gate the build on a content fingerprint of the build context (sorted relative
path + length + mtime, SHA-256) recorded in a per-tag stamp under data_dir.
Rebuild whenever the fingerprint differs from the one that produced the
existing image; podman's own COPY-layer cache keeps a no-op rebuild cheap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:09:56 -04:00
archipelago
f14829542b docs(dht): RESUME checkpoint — state, next steps, build/worktree rules
Single source of truth for picking the DHT work back up after a restart:
worktree/branch rules, all phase commits, the exact next task (#12 Phase 3
glue), build-time facts, and the Phase 0 go-live ceremony.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:18:00 -04:00
archipelago
1843739e0c fix(install): restart stack containers that crash on first start (#25)
Apps could fail install when a stack member exited on its first start
because a dependency (db/redis/the bitcoin node) was not ready yet — a
transient crash, not a broken install. wait_for_stack_containers now
restarts each exited/dead container up to 3 times before declaring the
install failed; the runtime supervisor keeps it alive afterwards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:14:09 -04:00
archipelago
9fa56a8274 feat(dht): Phase 3 core — signed Nostr seed-advertisement protocol
The discovery wire format that feeds the swarm's ProviderDiscovery seam: a
node announces 'I seed blake3 H from iroh endpoint E' as a signed NIP-33
addressable Nostr event. Scope is releases/catalog content ONLY (decided
2026-06-16) — never private user blobs.

- swarm/seed_advert.rs: kind 30081, d-tag = blake3 hex (one current advert
  per author+hash, latest-replaces), content {"v":1,"endpoint_id":...}.
  advertisement_builder / advertisement_filter / parse_endpoint_id /
  endpoint_ids_from_events (dedup). Endpoint ids stay opaque strings so the
  protocol is dep-light + unit-testable on the default build.

4/4 tests pass (sign->parse roundtrip, filter targeting, reject wrong-kind/
empty, dedup across nodes).

Next (task #12): gated NostrSeedDiscovery glue (query relays, parse ids ->
iroh::EndpointId), publish path, wire swarm::providers().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:13:35 -04:00
archipelago
082946aa30 feat(dht): Phase 2 engine — real iroh-blobs provider behind iroh-swarm
Pulls iroh 1.0 + iroh-blobs 0.103 as OPTIONAL deps under the iroh-swarm
feature and implements a real BlobProvider over them. Verified: the full
iroh QUIC dep tree (260 pkgs) resolves and compiles against the pinned
bitcoin/nostr-sdk/reqwest-rustls stack; the provider compiles against the
0.103/1.0 API.

- swarm/iroh_provider.rs: IrohProvider::new binds a QUIC Endpoint, opens a
  persistent FsStore (data_dir/iroh-blobs), and serves blobs via the
  iroh-blobs protocol/Router — a node that fetches also SEEDS. try_fetch
  maps ContentDigest -> iroh Hash, asks discovery for seed EndpointIds, then
  downloader.download(hash, providers) (range-verified) + export to staging.
- ProviderDiscovery trait: the seam Phase 3 (signed Nostr advertisement
  events) fills. discovery=None -> no seeds -> origin-only, so enabling the
  feature is never worse than today.
- Default build untouched: iroh is optional, the module is cfg-gated, and
  providers() stays empty until Phase 3 wires discovery in.

Build: cargo build --features iroh-swarm succeeds (dev). Default build +
44 swarm/update/content_hash/blobs tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:33:31 -04:00
archipelago
2523c9e3dd feat(dht): Phase 2 — swarm-assist fetch seam, origin always wins
Lands the transport/swarm orchestration layer (the iroh engine attaches
later, behind a flag). The seam is fully exercised today with the origin
HTTP path; with no swarm providers registered the behaviour is byte-for-byte
identical to before.

- swarm/mod.rs: BlobProvider trait + fetch_content_addressed() — tries each
  provider in order, VERIFIES peer-sourced bytes against the content digest
  before accepting (untrusted seeds can't inject tampered bytes), falls back
  to the origin closure if none serve. Returns Swarm|Origin.
- Cargo: iroh-swarm feature (off by default; heavy QUIC dep tree attaches
  here). providers() is empty until enabled → every fetch hits origin.
- update.rs: components with a BLAKE3 digest route through the seam, using
  the existing resumable HTTP downloader as the origin fallback; a swarm hit
  is re-checked against the mandatory SHA-256 manifest gate (re-fetch from
  origin on any disagreement). Components without blake3 take the original
  path untouched.

44/44 swarm/update/content_hash/blobs tests pass (incl. swarm hit/miss,
tampered-bytes-rejected→origin, fall-through ordering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:38:19 -04:00
archipelago
f0cb91ed76 feat(dht): Phase 1 — BLAKE3 content addressing alongside SHA-256
Adds the iroh-native, range-verifiable hash next to the incumbent SHA-256
so the swarm can later fetch/verify by BLAKE3 with the registry/origin as
fallback. Non-breaking: SHA-256 stays the mandatory gate; BLAKE3 is verified
only when present.

- content_hash.rs: HashAlg + ContentDigest (parse/verify '<alg>:<hex>'
  multihash strings), blake3_hex/sha256_hex; BLAKE3 known-answer test
- update.rs: ComponentUpdate.blake3 (serde-default); verified ALONGSIDE
  SHA-256 in the resumable download loop, re-download on mismatch
- blobs.rs: BlobMeta.blake3 computed on put (on-disk path stays
  SHA-256-keyed for back-compat; advertises the future swarm address)

Drive-by: fix a pre-existing stale test (test_save_and_load_state_roundtrip)
that never wrote the .download-complete marker #26 requires, so load_state's
self-heal cleared update_in_progress. Unrelated to BLAKE3 — surfaced by
running the full update:: suite.

40/40 content_hash/update/blobs tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:05:27 -04:00
archipelago
27f11bf85a feat(trust): wire Phase 0 signed-catalog verification + pin release-root KAT
Completes the parked trust module and wires it into the live build:
- main.rs: register `mod trust`
- app_catalog::fetch_one: verify the release-root detached signature when
  present (verify against raw JSON so forward-compat fields stay in the
  signed preimage); accept unsigned during the migration window, hard-reject
  a present-but-bad signature so a tampering mirror can't pass altered bytes
- seed: pin release-root Ed25519 known-answer test (priv+pub) for the
  signing ceremony / pinned-anchor / external-verifier cross-check
- signed_doc: drop unused import

20/20 Phase 0 unit tests pass (trust::canonical/did/signed_doc/anchor,
seed release-root, app_catalog). Crate compiles clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:40:57 -04:00
archipelago
0fef808671 wip(trust): park agent's signed-manifest module + release-root key off main
Moved here so main stays clean for the v1.7.98 release. Contains the trust/
module (canonical.rs, did.rs, signed_doc.rs) + seed::derive_release_root_ed25519.
Not wired into the build yet. Continue this work on this branch.
2026-06-16 11:22:24 -04:00
archipelago
ee46a856de docs(whats-new): sync v1.7.98-alpha block 2026-06-16 11:19:08 -04:00
archipelago
b037a121d0 docs(changelog): curate v1.7.98-alpha notes 2026-06-16 11:19:00 -04:00
archipelago
4c4cf6d8b4 docs(dht): peer-distributed content design (iroh swarm + signed manifests)
Captures the verified 2026-06-16 design: swarm-assist/origin-always-wins,
iroh-blobs as the swarm engine, BLAKE3 addressing, signed Nostr/release-root
authenticity, and the Phase 0-4 plan. Foundation doc for the dht branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:15:47 -04:00
146 changed files with 14216 additions and 597 deletions

View File

@ -35,6 +35,13 @@ class InputWebSocket(
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0
/**
* Invoked when the kiosk asks us to open a URL in the phone's default
* browser ({"t":"o","url":""}). "Open in external browser" apps can't be
* usefully opened on the kiosk, so the kiosk forwards them here.
*/
var onExternalOpen: ((String) -> Unit)? = null
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state
@ -127,6 +134,20 @@ class InputWebSocket(
reconnectAttempt = 0
}
override fun onMessage(webSocket: WebSocket, text: String) {
// The only inbound message we act on is an external-open request
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
try {
val obj = org.json.JSONObject(text)
if (obj.optString("t") == "o") {
val url = obj.optString("url")
if (url.startsWith("http://") || url.startsWith("https://")) {
onExternalOpen?.invoke(url)
}
}
} catch (_: Exception) {}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_state.value = ConnectionState.ERROR
scheduleReconnect()

View File

@ -63,6 +63,21 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val ws = remember { InputWebSocket(scope) }
// When the kiosk forwards an "open in external browser" app, launch it in
// the phone's default browser.
DisposableEffect(ws) {
ws.onExternalOpen = { url ->
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
} catch (_: Exception) {}
}
onDispose { ws.onExternalOpen = null }
}
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId

View File

@ -1,5 +1,19 @@
# Changelog
## v1.7.99-alpha (2026-06-17)
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
- You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple "Buy this file" picker with several ways to pay — instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically.
- Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with "!ai" and get an answer back over the radio — handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected.
- You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new "Recovery phrase" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button — so you can write them down and store them safely offline.
- Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear "Try again" button only when something genuinely goes wrong. The final setup screen also shows a gentle "securing your private connection…" status that turns to "ready" on its own, so you can tell the encrypted transport is coming up rather than stuck.
- The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes.
- When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display.
- You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered.
- Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled "v" (like "vv1.7.98"); it now always shows a single, tidy version label.
- The "Back" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files.
- For advanced testing, Settings now includes an optional "update & app source" choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode that pulls updates and app content from other nodes first, falling back to the origin automatically. The trusted origin remains the default.
## v1.7.98-alpha (2026-06-16)
- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).

View File

@ -290,6 +290,18 @@
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",

View File

@ -0,0 +1,75 @@
app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
# fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
# this to the node registry. Pin the tag to match the REST shapes coded in
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0
pull_policy: if-not-present
network: archy-net
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
# unreachable default never crash-loops. All config comes from FMCD_* env
# below. Nodes can join more federations via wallet.fedimint-join.
secret_env:
- key: FMCD_PASSWORD
secret_file: fmcd-password
data_uid: "1000:1000"
# NOTE: this is a CLIENT, not the guardian — it does not require the local
# `fedimint` app. It joins external federations (default below), so it can be
# bundled standalone on every node.
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 1Gi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
# relays to reach iroh-transport federations. Lock down once the default
# federation's reachability model is finalized.
network_policy: open
ports:
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
# host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178.
- host: 8178
container: 8080
protocol: tcp
volumes:
# Same dir the first-boot bundled path uses + where the wallet bridge reads
# the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned.
- type: bind
source: /var/lib/archipelago/fmcd
target: /data
options: [rw]
environment:
- FMCD_ADDR=0.0.0.0:8080
- FMCD_MODE=rest
- FMCD_DATA_DIR=/data
# Default federation joined out-of-the-box (guardian on .116, iroh
# transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so
# it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in
# core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate
# join reliability from a real second node before relying on auto-bundle.
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
health_check:
type: http
endpoint: http://localhost:8080
path: /health
interval: 30s
timeout: 5s
retries: 3

3168
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.98-alpha"
version = "1.7.99-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@ -9,6 +9,16 @@ authors = ["Archipelago Team"]
name = "archipelago"
path = "src/main.rs"
[features]
default = []
# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy
# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a
# scratch node before any fleet rollout. With the flag off, swarm::providers()
# is empty and every fetch goes straight to the origin HTTP path (today's
# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when
# wiring the IrohProvider.
iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
[dependencies]
# Core dependencies
tokio = { version = "1", features = ["full"] }
@ -42,6 +52,7 @@ archipelago-performance = { path = "../performance" }
# Authentication
bcrypt = "0.15"
sha2 = "0.10.9"
blake3 = "1"
hmac = "0.12.1"
uuid = { version = "1.0", features = ["v4"] }
regex = "1.10"
@ -64,7 +75,7 @@ serde_yaml = "0.9"
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake)
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
@ -106,6 +117,12 @@ sd-notify = "0.4"
# Trait objects for async methods (container orchestrator trait, Step 4)
async-trait = "0.1"
# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the
# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the
# flag so the default fleet build is unaffected until the PoC is measured.
iroh = { version = "1", optional = true }
iroh-blobs = { version = "0.103", optional = true }
[dev-dependencies]
tokio-test = "0.4"
tempfile = "3.10"

View File

@ -66,6 +66,21 @@ impl ApiHandler {
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
// or X-Onchain-Address (on-chain) — both authorize the download if this
// node issued+settled them, and both resolve against the same shared
// entitlement store keyed by the token string (#46).
let invoice_hash = headers
.get("x-invoice-hash")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| {
headers
.get("x-onchain-address")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
// Extract federation peer DID from X-Federation-DID header
let peer_did = headers
.get("x-federation-did")
@ -82,6 +97,7 @@ impl ApiHandler {
&config.data_dir,
content_id,
payment_token.as_deref(),
invoice_hash.as_deref(),
peer_did.as_deref(),
range,
)
@ -140,6 +156,255 @@ impl ApiHandler {
}
}
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
/// Records a pending entitlement keyed by the invoice's payment hash.
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/invoice"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
let catalog = content_server::load_catalog(&self.config.data_dir)
.await
.unwrap_or_default();
let item = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => i,
None => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
_ => {
// Not a paid item — no invoice to issue.
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
));
}
};
let memo = format!("Archipelago peer file {content_id}");
match self
.rpc_handler
.create_invoice(price_sats as i64, &memo)
.await
{
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
let body = serde_json::json!({
"bolt11": bolt11,
"payment_hash": payment_hash,
"price_sats": price_sats,
});
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
Ok(_) => Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"application/json",
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
)),
Err(e) => {
let body = serde_json::json!({
"error": format!("Could not create invoice: {e}")
});
Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
}
}
/// Seller side (#46): report whether a previously-issued invoice has settled.
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
/// entitlement is marked paid so the buyer can then download the file.
pub(super) async fn handle_content_invoice_status(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
Some((id, hash)) => (id, hash),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The hash must be one we issued for exactly this content item.
match crate::content_invoice::lookup(payment_hash).await {
Some((cid, _)) if cid == content_id => {}
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
/// pending entitlement keyed by the address; price doubles as expected amount.
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/onchain"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
let catalog = content_server::load_catalog(&self.config.data_dir)
.await
.unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
_ => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
))
}
},
None => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
let body = serde_json::json!({
"address": address,
"amount_sats": price_sats,
});
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
_ => {
let body = serde_json::json!({
"error": "Could not generate an on-chain address (is the wallet ready?)"
});
Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
}
}
/// Seller side (#46): report whether an on-chain payment to a previously-
/// issued address has arrived (>= price, >= 1 conf). Path:
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
pub(super) async fn handle_content_onchain_status(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, address) = match rest.split_once("/onchain-status/") {
Some((id, addr)) => (id, addr),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(address).await {
Some((cid, price)) if cid == content_id => price,
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown address"}"#),
))
}
};
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(address).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
pub(super) async fn handle_content_preview(
path: &str,
@ -190,6 +455,14 @@ impl ApiHandler {
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder()
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
.header("Content-Type", "text/plain")
.header("X-Content-Preview", "unavailable")
.body(hyper::Body::from(
"Preview unavailable for this media (needs re-encoding)",
))
.unwrap()),
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",

View File

@ -44,6 +44,11 @@ pub struct ApiHandler {
session_store: SessionStore,
/// Broadcast channel for relaying companion app input to remote browsers.
input_relay_tx: broadcast::Sender<String>,
/// Reverse broadcast channel: the kiosk browser publishes "open this URL
/// externally" requests here, and the companion (phone) socket forwards them
/// to the phone's default browser. Lets "open in external browser" apps —
/// which the kiosk can't usefully open itself — launch on the controller.
external_open_tx: broadcast::Sender<String>,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
@ -71,6 +76,7 @@ impl ApiHandler {
.await?,
);
let (input_relay_tx, _) = broadcast::channel(64);
let (external_open_tx, _) = broadcast::channel(16);
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
@ -100,6 +106,7 @@ impl ApiHandler {
metrics_store,
session_store,
input_relay_tx,
external_open_tx,
blob_store,
self_pubkey_hex,
})
@ -356,7 +363,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_input(req, self.input_relay_tx.clone()).await;
return Self::handle_remote_input(
req,
self.input_relay_tx.clone(),
self.external_open_tx.subscribe(),
)
.await;
}
// Remote relay WebSocket — browser receives companion input events
@ -365,7 +377,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await;
return Self::handle_remote_relay(
req,
self.input_relay_tx.subscribe(),
self.external_open_tx.clone(),
)
.await;
}
// Convert body to bytes for non-WS routes
@ -480,6 +497,22 @@ impl ApiHandler {
Self::handle_content_preview(p, &self.config).await
}
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
self.handle_content_invoice(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
self.handle_content_invoice_status(p).await
}
// On-chain peer-file sale (#46): issue address / poll for payment
(Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => {
self.handle_content_onchain_status(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
self.handle_content_onchain(p).await
}
// Content serving — peers access shared content over Tor (no session auth)
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await

View File

@ -19,6 +19,8 @@ impl ApiHandler {
signature: Option<String>,
#[serde(default)]
encrypted: bool,
#[serde(default)]
msg_id: Option<String>,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
@ -26,6 +28,7 @@ impl ApiHandler {
message: None,
signature: None,
encrypted: false,
msg_id: None,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
{
@ -152,7 +155,13 @@ impl ApiHandler {
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
node_msg::store_received(
&clean_from,
&clean_msg,
clean_name.as_deref(),
incoming.msg_id.as_deref(),
)
.await;
}
Ok(build_response(
StatusCode::OK,

View File

@ -227,9 +227,13 @@ impl ApiHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let peer_path = format!("/content/{}", content_id);
// Generous overall timeout: this endpoint serves both seek/Range
// playback (small, finishes fast) and full-file downloads of large
// media (#38). 60s was too tight for a multi-hundred-MB transfer over
// Tor and aborted the download mid-stream.
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(60));
.timeout(std::time::Duration::from_secs(900));
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
req = req.header("Range", r.to_string());
}
@ -237,7 +241,6 @@ impl ApiHandler {
Ok((resp, _transport)) => {
let status = resp.status().as_u16();
let rh = resp.headers().clone();
let bytes = resp.bytes().await.unwrap_or_default();
let mut builder = Response::builder()
.status(status)
.header("Accept-Ranges", "bytes");
@ -246,8 +249,13 @@ impl ApiHandler {
builder = builder.header(h, v);
}
}
// Stream the peer's body straight through instead of buffering
// the whole file into memory (#38). For a 178MB download the old
// `resp.bytes().await` allocated the entire file on the node
// before sending a byte; `wrap_stream` forwards chunks as they
// arrive, with constant memory.
Ok(builder
.body(hyper::Body::from(bytes))
.body(hyper::Body::wrap_stream(resp.bytes_stream()))
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
}
Err(e) => Ok(build_response(

View File

@ -211,6 +211,7 @@ impl ApiHandler {
pub(super) async fn handle_remote_input(
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
mut external_open_rx: broadcast::Receiver<String>,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req
@ -266,6 +267,19 @@ impl ApiHandler {
break;
}
}
// Forward kiosk "open this URL externally" requests down to
// the companion so the link opens in the phone's browser.
ext = external_open_rx.recv() => {
match ext {
Ok(text) => {
if tx.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => {}
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {

View File

@ -11,9 +11,16 @@ use super::ApiHandler;
impl ApiHandler {
/// WebSocket endpoint for browser clients to receive relayed companion input.
/// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events.
///
/// The kiosk also uses this socket in the *reverse* direction: when an "open
/// in external browser" app is launched, the kiosk can't usefully open it
/// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the
/// URL and publish it on `external_open_tx`, which the companion (phone)
/// socket forwards so the link opens in the phone's default browser.
pub(super) async fn handle_remote_relay(
req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>,
external_open_tx: broadcast::Sender<String>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@ -63,10 +70,20 @@ impl ApiHandler {
Err(broadcast::error::RecvError::Closed) => break,
}
}
// Handle client-side messages (pong, close)
// Handle client-side messages (pong, close, open-url requests)
client_msg = rx.next() => {
match client_msg {
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {}
Some(Ok(Message::Text(text))) => {
// The only kiosk→server message we accept is an
// external-open request: {"t":"o","url":"https://…"}.
if let Some(url) = parse_open_url(&text) {
debug!("Relaying external-open to companion: {}", url);
let _ = external_open_tx.send(
format!(r#"{{"t":"o","url":{}}}"#, json_string(&url))
);
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
@ -81,3 +98,29 @@ impl ApiHandler {
Ok(response)
}
}
/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL
/// only if it's a well-formed http(s) URL. Anything else (other message tags,
/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a
/// compromised kiosk page can't push arbitrary URIs to the phone.
fn parse_open_url(text: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
if v.get("t").and_then(|t| t.as_str()) != Some("o") {
return None;
}
let url = v.get("url").and_then(|u| u.as_str())?.trim();
if url.len() > 2048 {
return None;
}
let lower = url.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Some(url.to_string())
} else {
None
}
}
/// Serialize a string as a JSON string literal (with surrounding quotes).
fn json_string(s: &str) -> String {
serde_json::Value::String(s.to_string()).to_string()
}

View File

@ -379,15 +379,29 @@ impl RpcHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str)
.timeout(std::time::Duration::from_secs(120))
.send_get()
.await
.context("Failed to connect to peer")?;
// Surface a real reason instead of the generic sanitized error (#30):
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
// here means the peer is genuinely unreachable on both transports.
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str)
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
}));
}
};
// Record which transport actually reached the peer (B14).
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
@ -399,13 +413,15 @@ impl RpcHandler {
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected — token is spent but content not received
return Err(anyhow::anyhow!(
"Payment rejected by peer — token may have been insufficient or invalid"
));
return Ok(serde_json::json!({
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
}));
}
if !response.status().is_success() {
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({}).", response.status())
}));
}
let bytes = response
@ -423,6 +439,381 @@ impl RpcHandler {
}))
}
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
/// paid item so the buyer can pay from any external wallet. Returns the
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
pub(super) async fn handle_content_request_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/invoice", content_id);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(60))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline."
}));
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Seller could not create an invoice ({}).", response.status())
}));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse invoice response")?;
Ok(body)
}
/// Buyer side (#46): poll the selling node for invoice settlement.
pub(super) async fn handle_content_invoice_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let payment_hash = params
.get("payment_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Payment hash is hex from the seller; keep it strictly hex so it's safe
// to interpolate into the request path.
if payment_hash.is_empty()
|| payment_hash.len() > 128
|| !payment_hash.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(anyhow::anyhow!("Invalid payment_hash"));
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
{
Ok(v) => v,
Err(_) => {
// Treat an unreachable peer as "not yet paid" so the UI keeps polling.
return Ok(serde_json::json!({ "paid": false, "unreachable": true }));
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false }));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse invoice-status response")?;
Ok(body)
}
/// Buyer side (#46): download a paid item after the invoice settled, passing
/// the payment hash so the seller's content gate releases the file.
pub(super) async fn handle_content_download_peer_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let payment_hash = params
.get("payment_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid payment_hash"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Invoice-Hash", payment_hash.to_string())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
}));
}
};
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({
"error": "Seller has not registered this payment yet — wait for settlement and retry."
}));
}
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({}).", response.status())
}));
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
}))
}
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
pub(super) async fn handle_content_request_onchain(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/onchain", content_id);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(60))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline."
}));
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Seller could not provide an address ({}).", response.status())
}));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse onchain response")?;
Ok(body)
}
/// Buyer side (#46): poll the selling node for on-chain payment detection.
pub(super) async fn handle_content_onchain_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let address = params
.get("address")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
if address.is_empty()
|| address.len() > 100
|| !address.chars().all(|c| c.is_ascii_alphanumeric())
{
return Err(anyhow::anyhow!("Invalid address"));
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/onchain-status/{}", content_id, address);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
{
Ok(v) => v,
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })),
};
if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false }));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse onchain-status response")?;
Ok(body)
}
/// Buyer side (#46): download a paid item after the on-chain payment was
/// detected, passing the address so the seller's content gate releases it.
pub(super) async fn handle_content_download_peer_onchain(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let address = params
.get("address")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Onchain-Address", address.to_string())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
}));
}
};
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({
"error": "Seller has not registered this payment yet — wait for confirmation and retry."
}));
}
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({}).", response.status())
}));
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
}))
}
/// Fetch a preview of paid content from a peer (no payment required).
pub(super) async fn handle_content_preview_peer(
&self,

View File

@ -33,6 +33,7 @@ impl RpcHandler {
"seed.restore" => self.handle_seed_restore(params).await,
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
"seed.status" => self.handle_seed_status().await,
"seed.reveal" => self.handle_seed_reveal(params).await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(params).await,
@ -237,6 +238,11 @@ impl RpcHandler {
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
// Container registries
"registry.list" => self.handle_registry_list().await,
@ -250,6 +256,7 @@ impl RpcHandler {
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
"streaming.pay" => self.handle_streaming_pay(params).await,
"streaming.prepare-payment" => self.handle_streaming_prepare_payment(params).await,
"streaming.discover" => self.handle_streaming_discover().await,
"streaming.usage" => self.handle_streaming_usage(params).await,
"streaming.session" => self.handle_streaming_session(params).await,
@ -269,6 +276,16 @@ impl RpcHandler {
"content.browse-peer" => self.handle_content_browse_peer(params).await,
"content.download-peer" => self.handle_content_download_peer(params).await,
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await,
"content.invoice-status" => self.handle_content_invoice_status(params).await,
"content.download-peer-invoice" => {
self.handle_content_download_peer_invoice(params).await
}
"content.request-onchain" => self.handle_content_request_onchain(params).await,
"content.onchain-status" => self.handle_content_onchain_status(params).await,
"content.download-peer-onchain" => {
self.handle_content_download_peer_onchain(params).await
}
"content.preview-peer" => self.handle_content_preview_peer(params).await,
// DWN (Decentralized Web Node)
@ -380,6 +397,11 @@ impl RpcHandler {
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
"mesh.test-send" => self.handle_mesh_test_send(params).await,
// Transport layer (unified routing)
@ -471,6 +493,11 @@ impl RpcHandler {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_test_mirror(&p).await
}
"update.get-source" => self.handle_update_get_source().await,
"update.set-source" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_set_source(&p).await
}
"update.apply" => self.handle_update_apply().await,
"update.git-apply" => self.handle_update_git_apply().await,
"update.rollback" => self.handle_update_rollback().await,

View File

@ -30,6 +30,25 @@ impl RpcHandler {
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
}
}
/// Re-seed every federation node from disk into the mesh peer table so the
/// chat list reflects what the latest federation sync learned — display
/// names (landed in `nodes.json` by `update_node_state` when a peer
/// announces its name) and transitively-discovered peers (merged by
/// `merge_transitive_peers`) — WITHOUT waiting for a mesh restart.
///
/// Without this, a peer accepted via invite (seeded with `name = None`)
/// stays "Archipelago <pubkey8>" in chat until the next restart even after
/// sync has learned its real name, and transitive peers never appear as
/// chat contacts at all. `seed_federation_peers_into_mesh` is idempotent
/// and dedups by onion, so calling it after each sync is safe.
/// Best-effort: silently no-ops when mesh is off.
pub(crate) async fn refresh_federation_mesh_peers(&self) {
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
mesh::seed_federation_peers_into_mesh(&svc.shared_state(), &self.config.data_dir).await;
}
}
}
impl RpcHandler {
@ -243,9 +262,31 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
validate_did(did)?;
// Capture the node's pubkey before removal so we can also purge its
// synthetic mesh contact/thread (#2) — remove_node only touches
// nodes.json, which would otherwise leave a stale chat contact behind.
let removed_pubkey = federation::load_nodes(&self.config.data_dir)
.await
.ok()
.and_then(|nodes| nodes.into_iter().find(|n| n.did == did).map(|n| n.pubkey));
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
info!(did = %did, "Removed node from federation");
if let Some(pubkey) = removed_pubkey.filter(|p| !p.is_empty()) {
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
let contact_id = mesh::federation_peer_contact_id(&pubkey);
mesh::purge_federation_peer(
&svc.shared_state(),
contact_id,
&pubkey,
&self.config.data_dir,
)
.await;
}
}
Ok(serde_json::json!({
"removed": true,
"nodes_remaining": nodes.len(),
@ -341,6 +382,10 @@ impl RpcHandler {
}
}
// Push any names/roster the sync just learned into the live mesh peer
// table so the chat list updates without a restart (#42).
self.refresh_federation_mesh_peers().await;
Ok(serde_json::json!({
"synced": synced,
"failed": failed,

View File

@ -0,0 +1,131 @@
//! Fedimint ecash RPCs — bridge to the `fedimint-clientd` sidecar.
//!
//! Companion to the Cashu wallet RPCs in [`super::wallet`]. Joining/holding
//! Fedimint ecash is delegated to the clientd container via
//! [`crate::wallet::fedimint_client::FedimintClient`]; here we expose the
//! node's JSON-RPC surface and keep a local registry of joined federations so
//! the list survives clientd being temporarily unreachable.
//!
//! See `docs/dual-ecash-design.md`.
use super::RpcHandler;
use crate::wallet::fedimint_client::{self, FedimintClient, JoinedFederation};
use anyhow::Result;
impl RpcHandler {
/// `wallet.fedimint-list` — joined federations with live balances.
pub(super) async fn handle_wallet_fedimint_list(&self) -> Result<serde_json::Value> {
// Best-effort: make sure the default federation is joined/tracked.
let _ = fedimint_client::ensure_default_federation(&self.config.data_dir).await;
let reg = fedimint_client::load_registry(&self.config.data_dir).await?;
// Live balances are best-effort: if clientd is down we still return the
// tracked federations (with 0 balance) rather than failing the call.
let info = match FedimintClient::from_node(&self.config.data_dir).await {
Ok(client) => client.info().await.ok(),
Err(_) => None,
};
let federations: Vec<serde_json::Value> = reg
.federations
.iter()
.map(|f| {
let balance_sats = info
.as_ref()
.and_then(|i| i.get(&f.federation_id))
.and_then(|e| {
e.get("totalAmountMsat")
.or_else(|| e.get("totalMsat"))
.and_then(|v| v.as_u64())
})
.map(|msat| msat / 1000)
.unwrap_or(0);
serde_json::json!({
"federation_id": f.federation_id,
"name": f.name,
"balance_sats": balance_sats,
})
})
.collect();
Ok(serde_json::json!({ "federations": federations }))
}
/// `wallet.fedimint-join` — join a federation by invite code.
pub(super) async fn handle_wallet_fedimint_join(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let invite_code = params
.get("invite_code")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing invite_code"))?;
let client = FedimintClient::from_node(&self.config.data_dir).await?;
let federation_id = client.join(invite_code).await?;
// Try to label it from the federation meta (best-effort).
let name = client.info().await.ok().and_then(|i| {
i.get(&federation_id)
.and_then(|e| e.get("meta"))
.and_then(|m| {
m.get("federation_name")
.or_else(|| m.get("federation_expiry_timestamp"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
if !reg
.federations
.iter()
.any(|f| f.federation_id == federation_id)
{
reg.federations.push(JoinedFederation {
federation_id: federation_id.clone(),
name,
});
fedimint_client::save_registry(&self.config.data_dir, &reg).await?;
}
Ok(serde_json::json!({ "federation_id": federation_id }))
}
/// `wallet.fedimint-leave` — stop tracking a federation locally.
pub(super) async fn handle_wallet_fedimint_leave(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let federation_id = params
.get("federation_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing federation_id"))?;
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
let before = reg.federations.len();
reg.federations.retain(|f| f.federation_id != federation_id);
let removed = reg.federations.len() != before;
if removed {
fedimint_client::save_registry(&self.config.data_dir, &reg).await?;
}
Ok(serde_json::json!({ "removed": removed }))
}
/// `wallet.fedimint-balance` — total sats across all joined federations.
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified
// wallet balance still renders from the Cashu side.
let balance_sats = match FedimintClient::from_node(&self.config.data_dir).await {
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
Err(_) => 0,
};
Ok(serde_json::json!({ "balance_sats": balance_sats }))
}
}

View File

@ -14,10 +14,39 @@ impl RpcHandler {
let manager = IdentityManager::new(&self.config.data_dir).await?;
let (identities, default_id) = manager.list().await?;
// #49: The canonical node Nostr key is the node-level HKDF key
// (`derive_node_nostr_key`) that Settings and Nostr discovery both use
// via `node.nostr-pubkey`. The mirrored "Node" identity stores
// nostr=None, and seed identities use a different BIP-32 NIP-06 key, so
// the "Node" entry in Web5 > Identities disagreed with Settings. Resolve
// the node-level key once and override it onto whichever identity record
// is the node's own (its ed25519 matches `server_info.pubkey`), so both
// surfaces always show the same npub. Display-only — no key is rewritten.
let identity_dir = self.config.data_dir.join("identity");
let node_nostr_hex = crate::nostr_discovery::get_nostr_pubkey(&identity_dir)
.await
.ok();
let node_nostr_npub = node_nostr_hex.as_ref().and_then(|h| {
nostr_sdk::PublicKey::from_hex(h)
.ok()
.and_then(|pk| pk.to_bech32().ok())
});
let (snapshot, _) = self.state_manager.get_snapshot().await;
let node_pubkey_hex = snapshot.server_info.pubkey.clone();
let items: Vec<serde_json::Value> = identities
.into_iter()
.map(|id| {
let is_default = default_id.as_deref() == Some(&id.id);
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
let (nostr_pubkey, nostr_npub) = if is_node {
(
node_nostr_hex.clone().or(id.nostr_pubkey),
node_nostr_npub.clone().or(id.nostr_npub),
)
} else {
(id.nostr_pubkey, id.nostr_npub)
};
serde_json::json!({
"id": id.id,
"name": id.name,
@ -26,8 +55,8 @@ impl RpcHandler {
"did": id.did,
"created_at": id.created_at,
"is_default": is_default,
"nostr_pubkey": id.nostr_pubkey,
"nostr_npub": id.nostr_npub,
"nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub,
"profile": id.profile,
})
})

View File

@ -151,6 +151,179 @@ impl RpcHandler {
}
/// Create a Lightning invoice.
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
///
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
/// it as hex so it can be used as a stable lookup key and passed in URLs.
pub(crate) async fn create_invoice(
&self,
amount_sats: i64,
memo: &str,
) -> Result<(String, String)> {
if amount_sats < 0 {
return Err(anyhow::anyhow!("Amount must be non-negative"));
}
if memo.len() > 639 {
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let invoice_body = serde_json::json!({
"value": amount_sats.to_string(),
"memo": memo,
});
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body)
.send()
.await
.context("Failed to create invoice")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
}
let payment_request = body
.get("payment_request")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// r_hash is base64 in LND's REST response — convert to hex.
use base64::Engine;
let payment_hash_hex = body
.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default();
Ok((payment_request, payment_hash_hex))
}
/// Look up an invoice by hex payment hash; true if it has settled.
pub(crate) async fn invoice_is_settled(&self, payment_hash_hex: &str) -> Result<bool> {
if payment_hash_hex.is_empty() || hex::decode(payment_hash_hex).is_err() {
return Err(anyhow::anyhow!("Invalid payment hash"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
// LND REST: GET /v1/invoice/{r_hash_str} where r_hash_str is hex.
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{payment_hash_hex}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to look up invoice")?;
if !resp.status().is_success() {
return Ok(false);
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice lookup response")?;
let settled = body
.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
|| body.get("state").and_then(|v| v.as_str()) == Some("SETTLED");
Ok(settled)
}
/// Generate a fresh on-chain receive address (seller side, #46).
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to get new address")?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("LND newaddress failed: {}", resp.status()));
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
body.get("address")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("LND newaddress returned no address"))
}
/// True if an on-chain payment of >= `min_sats` to `address` has been seen
/// with at least one confirmation (seller side, #46). Conservative on
/// purpose: requires a confirmation + exact-address + sufficient-amount so a
/// file sale is never released on an unconfirmed (reorg-able) tx.
pub(crate) async fn onchain_received(&self, address: &str, min_sats: u64) -> Result<bool> {
if address.is_empty() {
return Err(anyhow::anyhow!("Empty address"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to list transactions")?;
if !resp.status().is_success() {
return Ok(false);
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse transactions response")?;
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 {
tx.get(k)
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| tx.get(k).and_then(|v| v.as_i64()))
.unwrap_or(0)
};
let txs = body
.get("transactions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for tx in &txs {
if i64_field(tx, "num_confirmations") < 1 {
continue;
}
if i64_field(tx, "amount") < min_sats as i64 {
continue;
}
let pays_addr = tx
.get("dest_addresses")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|a| a.as_str() == Some(address)))
.unwrap_or(false)
|| tx
.get("output_details")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
})
.unwrap_or(false);
if pays_addr {
return Ok(true);
}
}
Ok(false)
}
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
&self,
params: Option<serde_json::Value>,

View File

@ -0,0 +1,176 @@
//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config
//! and report whether a local Ollama is available (for the install deep-link).
use super::super::RpcHandler;
use anyhow::Result;
use std::time::Duration;
/// Default model when the node hasn't picked one (kept in sync with the mesh
/// assistant handler's `DEFAULT_MODEL`).
const DEFAULT_MODEL: &str = "qwen2.5-coder";
impl RpcHandler {
/// mesh.assistant-status — current settings + local Ollama availability.
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
&self,
) -> Result<serde_json::Value> {
let cfg = {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.assistant_config().await
};
let (ollama_detected, models) = detect_ollama().await;
let claude_available =
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
.await
.is_ok();
Ok(serde_json::json!({
"enabled": cfg.enabled,
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"default_model": DEFAULT_MODEL,
"ollama_detected": ollama_detected,
"claude_available": claude_available,
"models": models,
}))
}
/// mesh.assistant-configure — update assistant settings live.
/// Params: `enabled?: bool`, `trusted_only?: bool`,
/// `model?: string|null` (string sets, null clears to default, absent leaves).
pub(in crate::api::rpc) async fn handle_mesh_assistant_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let enabled = params.get("enabled").and_then(|v| v.as_bool());
let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool());
let backend = params
.get("backend")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// model: key present + string => set; present + null => clear; absent => leave
let model = if let Some(v) = params.get("model") {
Some(v.as_str().map(|s| s.to_string()))
} else {
None
};
svc.configure_assistant(enabled, model, trusted_only, backend)
.await?;
let cfg = svc.assistant_config().await;
Ok(serde_json::json!({
"enabled": cfg.enabled,
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
}))
}
/// mesh.schedule-message — queue a message to send at a future time.
/// Params: `body: string`, `fire_at: i64` (unix secs), and one of
/// `contact_id: u32` (DM) or `channel: u8` (broadcast).
pub(in crate::api::rpc) async fn handle_mesh_schedule_message(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let body = p
.get("body")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("body is required"))?
.to_string();
let fire_at = p
.get("fire_at")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
let contact_id = p
.get("contact_id")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
if contact_id.is_none() && channel.is_none() {
anyhow::bail!("either contact_id or channel is required");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let msg = svc
.scheduler
.add(contact_id, channel, body, fire_at)
.await?;
Ok(serde_json::to_value(msg)?)
}
/// mesh.list-scheduled — list queued messages (sorted by fire time).
pub(in crate::api::rpc) async fn handle_mesh_list_scheduled(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let messages = svc.scheduler.list().await;
Ok(serde_json::json!({ "messages": messages }))
}
/// mesh.cancel-scheduled — remove a queued message by id.
pub(in crate::api::rpc) async fn handle_mesh_cancel_scheduled(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let id = params
.as_ref()
.and_then(|p| p.get("id"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let cancelled = svc.scheduler.cancel(id).await?;
Ok(serde_json::json!({ "cancelled": cancelled }))
}
}
/// Probe the local Ollama HTTP API; return (detected, model_names).
async fn detect_ollama() -> (bool, Vec<String>) {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => return (false, Vec::new()),
};
match client.get("http://localhost:11434/api/tags").send().await {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await.unwrap_or_default();
let models = json
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect()
})
.unwrap_or_default();
(true, models)
}
_ => (false, Vec::new()),
}
}

View File

@ -110,6 +110,18 @@ impl RpcHandler {
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
}
if let Some(announce) = params
.get("announce_block_headers")
.and_then(|v| v.as_bool())
{
config.announce_block_headers = announce;
}
if let Some(receive) = params
.get("receive_block_headers")
.and_then(|v| v.as_bool())
{
config.receive_block_headers = receive;
}
mesh::save_config(&self.config.data_dir, &config).await?;
@ -124,6 +136,8 @@ impl RpcHandler {
"configured": true,
"enabled": config.enabled,
"device_path": config.device_path,
"announce_block_headers": config.announce_block_headers,
"receive_block_headers": config.receive_block_headers,
}))
}
}

View File

@ -1,3 +1,4 @@
mod assistant;
mod bitcoin_ops;
mod messaging;
mod safety;

View File

@ -5,26 +5,39 @@ use anyhow::Result;
impl RpcHandler {
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
// Block-header send/receive prefs live in MeshConfig; surface them in
// status so the UI toggles (issue #28) can show the persisted state.
let config = mesh::load_config(&self.config.data_dir).await?;
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let mut value = if let Some(svc) = service.as_ref() {
let status = svc.status().await;
Ok(serde_json::to_value(status)?)
serde_json::to_value(status)?
} else {
// No service running — return basic config + device detection
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_devices().await;
Ok(serde_json::json!({
serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
"channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
}))
})
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
"announce_block_headers".into(),
config.announce_block_headers.into(),
);
obj.insert(
"receive_block_headers".into(),
config.receive_block_headers.into(),
);
}
Ok(value)
}
/// mesh.peers — List discovered mesh peers.

View File

@ -9,6 +9,7 @@ mod credentials;
mod dispatcher;
mod dwn;
mod federation;
mod fedimint;
mod fips;
mod handshake;
mod identity;

View File

@ -434,6 +434,13 @@ async fn wait_for_stack_containers(
containers: &[&str],
timeout_secs: u64,
) -> Result<()> {
// A container can exit on its first start because a dependency (db, redis,
// the bitcoin node) was not quite ready — a transient crash, not a broken
// install. Restart each exited container a bounded number of times before
// declaring the install failed (#25). The runtime supervisor keeps it alive
// afterwards, but we want a healthy state by the time install returns.
const MAX_RESTARTS: u32 = 3;
let mut restarts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
let mut pending = Vec::new();
@ -449,20 +456,41 @@ async fn wait_for_stack_containers(
match state.as_str() {
"running" => {}
"exited" | "dead" => {
let logs = stack_container_logs(container, 40).await;
install_log(&format!(
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
stack_name,
container,
logs.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"{} container {} exited after install. Logs: {}",
stack_name,
container,
logs.chars().take(500).collect::<String>()
));
let attempts = restarts.entry(container.to_string()).or_insert(0);
if *attempts < MAX_RESTARTS {
*attempts += 1;
install_log(&format!(
"INSTALL RESTART: {} - container {} exited, restart attempt {}/{}",
stack_name, container, *attempts, MAX_RESTARTS
))
.await;
let _ = podman_stack_output(
&["start", container],
PODMAN_STACK_PROBE_TIMEOUT,
)
.await;
pending.push(format!(
"{}=restarting({}/{})",
container, *attempts, MAX_RESTARTS
));
} else {
let logs = stack_container_logs(container, 40).await;
install_log(&format!(
"INSTALL CRASH: {} - container {} exited after {} restarts. Logs:\n{}",
stack_name,
container,
MAX_RESTARTS,
logs.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"{} container {} exited after install ({} restarts). Logs: {}",
stack_name,
container,
MAX_RESTARTS,
logs.chars().take(500).collect::<String>()
));
}
}
other => pending.push(format!("{}={}", container, other)),
}
@ -1779,14 +1807,22 @@ impl RpcHandler {
let host_ip = detect_netbird_public_host_ip()
.await
.unwrap_or_else(|| self.config.host_ip.clone());
write_netbird_config_files(&host_ip).await?;
// Create the network FIRST so we can read back the gateway it was
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
// nginx needs as an explicit `resolver` to re-resolve container names
// (issue #15: without it nginx caches a container IP and 502s forever
// once that IP changes on restart/reboot).
let _ = podman_stack_status(
&["network", "create", "netbird-net"],
PODMAN_STACK_PROBE_TIMEOUT,
)
.await;
let resolver_ip = netbird_net_resolver_ip().await;
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
ensure_netbird_tls_cert(&host_ip).await?;
let mut server_cmd = tokio::process::Command::new("podman");
server_cmd.args([
"run",
@ -1824,6 +1860,10 @@ impl RpcHandler {
"netbird-dashboard",
"--network",
"netbird-net",
// Explicit alias so the proxy can always resolve `netbird-dashboard`
// via Podman DNS — don't rely on implicit container-name aliasing.
"--network-alias",
"netbird-dashboard",
"--restart=unless-stopped",
"--env-file",
"/var/lib/archipelago/netbird/dashboard.env",
@ -1840,10 +1880,16 @@ impl RpcHandler {
"--network",
"netbird-net",
"--restart=unless-stopped",
// 8087 publishes the TLS listener — netbird's dashboard requires a
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
"-p",
"8087:80",
"8087:443",
"-v",
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
"-v",
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
"-v",
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
NETBIRD_PROXY_IMAGE,
]);
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
@ -1888,9 +1934,104 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
secret
}
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
let public_origin = format!("http://{}:8087", host_ip);
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
/// resolver on this address, so nginx can use it as an explicit `resolver` to
/// re-resolve container names at request time. Falls back to Podman's usual
/// first-pool gateway if the inspect fails (best effort — config is rewritten
/// on every (re)install).
async fn netbird_net_resolver_ip() -> String {
let out = tokio::process::Command::new("podman")
.args([
"network",
"inspect",
"netbird-net",
"--format",
"{{range .Subnets}}{{.Gateway}}{{end}}",
])
.output()
.await;
if let Ok(o) = out {
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
return gw;
}
}
"10.89.0.1".to_string()
}
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
/// once when opening netbird in a tab). SAN covers the LAN IP plus
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
let dir = "/var/lib/archipelago/netbird";
let crt = format!("{dir}/tls.crt");
let key = format!("{dir}/tls.key");
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
return Ok(());
}
let _ = tokio::fs::create_dir_all(dir).await;
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
let status = tokio::process::Command::new("openssl")
.args([
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
&key,
"-out",
&crt,
"-days",
"3650",
"-subj",
&format!("/CN={host_ip}"),
"-addext",
&san,
])
.status()
.await
.context("failed to run openssl for netbird TLS cert")?;
if !status.success() {
anyhow::bail!("openssl failed to generate netbird TLS cert");
}
Ok(())
}
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
// only expose in a SECURE context — so the proxy serves HTTPS and every
// origin here is https (issue #15: over plain http the dashboard threw
// "window.crypto.subtle is unavailable" and never reached login).
let public_origin = format!("https://{}:8087", host_ip);
let server_origin = format!("http://{}:8086", host_ip);
// A single box is reached via several addresses. Allow the OIDC login flow
// to redirect back to whichever origin the user actually used, otherwise
// post-login lands on the wrong host and the dashboard shows
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
// nginx proxy; this covers the redirect-URI allow-list.
let lan_origin = format!("https://{}:8087", lan_ip);
let mut redirect_origins = vec![public_origin.clone()];
if lan_origin != public_origin {
redirect_origins.push(lan_origin);
}
let dashboard_redirect_uris = redirect_origins
.iter()
.flat_map(|o| {
[
format!(" - \"{o}/nb-auth\""),
format!(" - \"{o}/nb-silent-auth\""),
]
})
.collect::<Vec<_>>()
.join("\n");
let dashboard_logout_uris = redirect_origins
.iter()
.map(|o| format!(" - \"{o}/\""))
.collect::<Vec<_>>()
.join("\n");
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
let config = format!(
@ -1910,10 +2051,9 @@ async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
localAuthDisabled: false
signKeyRefreshEnabled: false
dashboardRedirectURIs:
- "{public_origin}/nb-auth"
- "{public_origin}/nb-silent-auth"
{dashboard_redirect_uris}
dashboardPostLogoutRedirectURIs:
- "{public_origin}/"
{dashboard_logout_uris}
cliRedirectURIs:
- "http://localhost:53000/"
store:
@ -1947,12 +2087,23 @@ LETSENCRYPT_DOMAIN=none
let nginx_conf = format!(
r#"server {{
listen 80;
listen 443 ssl;
server_name _;
# Route browser API/auth through the host-published server port. Rootless
# Podman can give netbird-server a new container IP on restart while nginx
# keeps an old resolved address, which breaks login with 502s.
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
# Rootless Podman can hand a container a new IP across restarts/reboots.
# nginx resolves a literal upstream name ONCE at startup and caches it, so
# after the IP moves every request 502s with "host unreachable" (issue #15,
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
# use VARIABLE upstreams, which forces nginx to re-resolve the container
# names at request time. Everything is reached container-to-container by
# name so nothing depends on host-published ports either.
resolver {resolver_ip} valid=10s ipv6=off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -1961,24 +2112,60 @@ LETSENCRYPT_DOMAIN=none
proxy_http_version 1.1;
location ~ ^/(relay|ws-proxy/) {{
proxy_pass http://host.containers.internal:8086;
set $nb_server netbird-server;
proxy_pass http://$nb_server:80;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}}
location ~ ^/(api|oauth2)(/|$) {{
proxy_pass http://host.containers.internal:8086;
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
# to one host:port. A single box is reached via several addresses (LAN
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
# the browser blocks them with no Access-Control-Allow-Origin (issue
# #15, observed live on .198). Reflect the caller's Origin so the
# self-hosted management/OIDC API is reachable from any of them, and
# answer the CORS preflight here.
if ($request_method = OPTIONS) {{
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0;
return 204;
}}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
set $nb_server netbird-server;
proxy_pass http://$nb_server:80;
}}
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
grpc_pass grpc://netbird-server:80;
set $nb_server netbird-server;
grpc_pass grpc://$nb_server:80;
grpc_read_timeout 1d;
grpc_send_timeout 1d;
}}
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
# the dashboard bundle, so proxying them straight through 404s which
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
# returned 404). Serve the dashboard's index.html at these paths (URL
# unchanged) so react-oidc boots and completes the login / silent-SSO.
location ~ ^/(nb-auth|nb-silent-auth) {{
set $nb_dashboard netbird-dashboard;
rewrite ^.*$ /index.html break;
proxy_pass http://$nb_dashboard:80;
}}
location / {{
proxy_pass http://netbird-dashboard:80;
set $nb_dashboard netbird-dashboard;
proxy_pass http://$nb_dashboard:80;
}}
}}
@ -1999,10 +2186,32 @@ async fn detect_netbird_public_host_ip() -> Option<String> {
.await
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
let ips: Vec<&str> = stdout
.split_whitespace()
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
.map(str::to_string)
.filter(|s| s.contains('.'))
.collect();
// Prefer the LAN address as the canonical origin — that's what users browse
// to on the local network. Baking the Tailscale 100.x address here broke
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
let is_private_lan = |ip: &str| {
ip.starts_with("192.168.")
|| ip.starts_with("10.")
|| (ip.starts_with("172.")
&& ip
.split('.')
.nth(1)
.and_then(|o| o.parse::<u8>().ok())
.map(|o| (16..=31).contains(&o))
.unwrap_or(false))
};
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
return Some(lan.to_string());
}
ips.iter()
.find(|ip| ip.starts_with("100."))
.map(|s| s.to_string())
}
#[cfg(test)]

View File

@ -60,6 +60,30 @@ impl RpcHandler {
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
/// Returns the words for the user to write down.
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
// Serialize concurrent / retried generate calls. The web client aborts
// at 15s and retries internally (up to 3x), and the onboarding view
// re-fires every 4s while the server is still booting on slow first-boot
// hardware. Without this guard each hit would mint a brand-new seed and
// overwrite the node keys mid-flight, leaving the words shown to the user
// out of sync with what `seed.verify` expects — the classic "error at the
// DID-creation screen". Holding the lock across the whole op fully
// serializes them.
let mut state = ONBOARDING_MNEMONIC.lock().await;
// Idempotent fast-path: a fresh pending mnemonic already exists, so the
// node keys are already on disk. Return the SAME words rather than
// regenerating, so every retry yields a consistent result.
if let Some(existing) = state.as_ref() {
if existing.created_at.elapsed() < MNEMONIC_TTL {
let words: Vec<String> = existing
.words
.split_whitespace()
.map(str::to_string)
.collect();
return Ok(serde_json::json!({ "words": words }));
}
}
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
// Derive and write node Ed25519 key.
@ -89,16 +113,14 @@ impl RpcHandler {
// the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
let words: Vec<&str> = mnemonic.words().collect();
let words: Vec<String> = mnemonic.words().map(str::to_string).collect();
// Hold mnemonic in memory for the verify step.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = Some(OnboardingMnemonicState {
words: mnemonic.to_string(),
created_at: std::time::Instant::now(),
});
}
// Hold mnemonic in memory for the verify step. We already own the lock
// guard (`state`) from the top of the function, so just write through it.
*state = Some(OnboardingMnemonicState {
words: mnemonic.to_string(),
created_at: std::time::Instant::now(),
});
Ok(serde_json::json!({
"words": words,
@ -149,11 +171,13 @@ impl RpcHandler {
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
// Clear mnemonic from memory now that it's verified.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = None;
}
// Intentionally DO NOT clear the mnemonic here. The web client aborts
// slow requests at 15s and retries internally; if we wiped it on the
// first (successful) verify, a retried request would fail with
// "No pending seed generation or session expired" even though the user
// did everything right. The mnemonic is bounded by MNEMONIC_TTL (10 min)
// and is overwritten on the next generate, so leaving it makes verify
// idempotent without meaningfully widening the in-memory window.
// Save the encrypted seed for convenience backup.
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
@ -290,4 +314,101 @@ impl RpcHandler {
"next_index": next_index,
}))
}
/// Reveal the node's 24-word recovery phrase after onboarding. Heavily
/// gated, because this is the keys to the whole node:
/// - requires a full authenticated session (enforced upstream: this
/// method is NOT in the public auth whitelist),
/// - re-verifies the login password,
/// - requires a valid TOTP code when 2FA is enabled (replay-protected),
/// - decrypts `identity/master_seed.enc` with the backup passphrase
/// (defaults to the login password when the user used the same value).
/// The words are returned to the caller only and never logged.
pub(in crate::api::rpc) async fn handle_seed_reveal(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mut password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if password.is_empty() {
anyhow::bail!("Password is required to reveal the recovery phrase");
}
// Nothing to reveal if this node never stored an encrypted seed.
if !crate::seed::seed_exists(&self.config.data_dir) {
anyhow::bail!(
"This node has no encrypted seed backup, so the recovery phrase \
cannot be shown. It was only displayed once during setup."
);
}
// 1) Re-authenticate with the login password.
if !self.auth_manager.verify_password(&password).await? {
password.zeroize();
anyhow::bail!("Incorrect password");
}
// 2) Require a valid 2FA code when TOTP is enabled (replay-protected).
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
let code = params
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if code.is_empty() {
password.zeroize();
anyhow::bail!("A 2FA code is required to reveal the recovery phrase");
}
let totp_data = self
.auth_manager
.get_totp_data()
.await?
.ok_or_else(|| anyhow::anyhow!("2FA is enabled but no TOTP data found"))?;
let secret = crate::totp::decrypt_secret(&totp_data, &password)
.context("Could not unlock 2FA with this password")?;
match crate::totp::verify_code(&secret, &code, &totp_data.used_steps)? {
Some(step) => {
// Record the used step for replay protection, pruning old ones.
let mut data = totp_data;
data.used_steps.push(step);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let cutoff = (now / 30) - 10; // ~5 minutes
data.used_steps.retain(|s| *s > cutoff);
let _ = self.auth_manager.update_totp(data).await;
}
None => {
password.zeroize();
anyhow::bail!("Invalid 2FA code");
}
}
}
// 3) Decrypt the stored seed. The backup passphrase may differ from the
// login password, so accept an explicit one and fall back to the
// password when the user used the same value for both.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
password.zeroize();
let mnemonic = reveal.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
}
}

View File

@ -205,6 +205,64 @@ impl RpcHandler {
}
}
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
///
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
/// `cashuA` token denominated in one of those mints — paying directly if we
/// already hold the right mint, else auto-swapping into a trusted accepted
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
/// wallet can't cover it, or the swap is too costly, returns `declined` so
/// the caller falls back to the free origin (origin always wins).
pub(super) async fn handle_streaming_prepare_payment(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let accepted_mints: Vec<String> = params
.get("accepted_mints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let price_sats = params
.get("price_sats")
.or_else(|| params.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
// Default budget = the asked price (willing to pay exactly what's quoted).
let budget_sats = params
.get("budget_sats")
.and_then(|v| v.as_u64())
.unwrap_or(price_sats);
let max_fee_sats = params
.get("max_fee_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
match crate::swarm::payment::auto_pay_token(
&self.config.data_dir,
&policy,
&accepted_mints,
price_sats,
)
.await?
{
Some(token) => Ok(serde_json::json!({
"status": "ready",
"token": token,
"paid_sats": price_sats,
})),
None => Ok(serde_json::json!({
"status": "declined",
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
})),
}
}
/// Discover available streaming services (pricing info).
/// This is the unauthenticated discovery endpoint.
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {

View File

@ -253,6 +253,54 @@ impl RpcHandler {
Ok(serde_json::json!({ "mirrors": list }))
}
/// Report the node's swarm prefs (fetch source + whether it provides to the
/// swarm) plus swarm capability, so the UI can show whether DHT mode is
/// actually usable on this build.
pub(super) async fn handle_update_get_source(&self) -> Result<serde_json::Value> {
let source = update::load_update_source(&self.config.data_dir).await;
let provide_dht = update::load_provide_dht(&self.config.data_dir).await;
let source_str = match source {
update::UpdateSource::Origin => "origin",
update::UpdateSource::Swarm => "swarm",
};
Ok(serde_json::json!({
"source": source_str,
// Whether this node seeds/serves blobs to peers (default true).
"provide_dht": provide_dht,
// Compiled with the iroh swarm engine? If false, "swarm" mode has no
// peers and silently behaves like origin.
"swarm_available": cfg!(feature = "iroh-swarm"),
// Runtime swarm-assist gate from config (ARCHIPELAGO_SWARM_ENABLED).
"swarm_enabled": self.config.swarm_enabled,
}))
}
/// Update the node's swarm prefs. Params (both optional, at least one):
/// `{ source?: "origin" | "swarm", provide?: bool }`.
pub(super) async fn handle_update_set_source(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let mut touched = false;
if let Some(s) = params.get("source").and_then(|v| v.as_str()) {
let source = match s {
"origin" => update::UpdateSource::Origin,
"swarm" => update::UpdateSource::Swarm,
_ => anyhow::bail!("source must be \"origin\" or \"swarm\""),
};
update::save_update_source(&self.config.data_dir, source).await?;
touched = true;
}
if let Some(provide) = params.get("provide").and_then(|v| v.as_bool()) {
update::save_provide_dht(&self.config.data_dir, provide).await?;
touched = true;
}
if !touched {
anyhow::bail!("expected \"source\" and/or \"provide\"");
}
self.handle_update_get_source().await
}
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
/// Duplicates (same URL) are replaced rather than added twice.
pub(super) async fn handle_update_add_mirror(

View File

@ -25,6 +25,12 @@ pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobMeta {
pub cid: String,
/// DHT Phase 1: BLAKE3 hash of the content (iroh-native swarm address).
/// The on-disk path stays SHA-256-keyed (`cid`) for back-compat; this
/// advertises the hash a peer swarm can fetch/range-verify by. Absent in
/// legacy metadata written before Phase 1.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blake3: Option<String>,
pub size: u64,
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -88,6 +94,7 @@ impl BlobStore {
let cid = hex::encode(hasher.finalize());
let meta = BlobMeta {
cid: cid.clone(),
blake3: Some(crate::content_hash::blake3_hex(bytes)),
size: bytes.len() as u64,
mime: mime.to_string(),
filename,

View File

@ -68,7 +68,7 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
/// disables buffering so streaming works. Kept in sync with the canonical
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 120s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted

View File

@ -0,0 +1,141 @@
//! Release-root signing ceremony — the publisher-side counterpart to
//! `trust::anchor`. Run as a subcommand of the same binary so it reuses the
//! exact key derivation (`seed::derive_release_root_ed25519`) and canonical
//! signing (`trust::signed_doc::sign_detached`) the fleet verifies against.
//!
//! Usage (the mnemonic is read from the `RELEASE_MASTER_MNEMONIC` env var or
//! stdin — never an argv so it stays out of shell history / `ps`):
//!
//! ```text
//! archipelago ceremony gen
//! Generate a fresh 24-word release master mnemonic and print it plus the
//! derived release-root pubkey + did. Back the mnemonic up OFFLINE.
//!
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony pubkey
//! Print the release-root pubkey hex (for ARCHY_RELEASE_ROOT_PUBKEY /
//! trust::anchor::RELEASE_ROOT_PUBKEY_HEX) and the signer did:key.
//!
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony sign <file.json>
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
//! `signature` + `signed_by` over the canonical form, matching exactly
//! what `trust::verify_detached` recomputes on every node.
//! ```
use anyhow::{bail, Context, Result};
use ed25519_dalek::SigningKey;
use crate::seed::{self, MasterSeed};
use crate::trust::{did, signed_doc};
const ENV_MNEMONIC: &str = "RELEASE_MASTER_MNEMONIC";
/// True if argv selects the ceremony subcommand. Checked before any server init.
pub fn is_ceremony_invocation() -> bool {
std::env::args().nth(1).as_deref() == Some("ceremony")
}
/// Entry point for `archipelago ceremony …`. Returns Ok(()) on success; the
/// caller (main) should exit without starting the server.
pub fn run() -> Result<()> {
let sub = std::env::args().nth(2).unwrap_or_default();
match sub.as_str() {
"gen" => cmd_gen(),
"pubkey" => cmd_pubkey(),
"sign" => {
let file = std::env::args()
.nth(3)
.context("usage: archipelago ceremony sign <file.json>")?;
cmd_sign(&file)
}
other => {
bail!(
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
other
)
}
}
}
fn cmd_gen() -> Result<()> {
let (mnemonic, seed) = MasterSeed::generate().context("generate mnemonic")?;
let key = seed::derive_release_root_ed25519(&seed).context("derive release-root")?;
eprintln!("⚠ Back this mnemonic up OFFLINE. It is the ONLY way to re-derive");
eprintln!(" the release-root signing key. Anyone with it can sign for the fleet.\n");
println!("RELEASE_MASTER_MNEMONIC=\"{}\"", mnemonic);
print_key(&key);
Ok(())
}
fn cmd_pubkey() -> Result<()> {
let key = load_release_root_key()?;
print_key(&key);
Ok(())
}
fn cmd_sign(path: &str) -> Result<()> {
let key = load_release_root_key()?;
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
let mut value: serde_json::Value =
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
{
let obj = value
.as_object_mut()
.context("document root must be a JSON object")?;
// Re-sign cleanly: drop any prior signature so the preimage matches.
obj.remove("signature");
obj.remove("signed_by");
}
let (signature, signed_by) =
signed_doc::sign_detached(&key, &value).context("sign document")?;
let obj = value.as_object_mut().expect("checked above");
obj.insert("signature".into(), serde_json::Value::String(signature));
obj.insert(
"signed_by".into(),
serde_json::Value::String(signed_by.clone()),
);
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, format!("{pretty}\n")).with_context(|| format!("write {tmp}"))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
eprintln!("✓ signed {path}");
eprintln!(" signed_by: {signed_by}");
Ok(())
}
/// Derive the release-root signing key from the mnemonic in env/stdin.
fn load_release_root_key() -> Result<SigningKey> {
let phrase = read_mnemonic()?;
let (_mnemonic, seed) = MasterSeed::from_mnemonic_words(phrase.trim())
.context("invalid release master mnemonic")?;
seed::derive_release_root_ed25519(&seed).context("derive release-root")
}
/// Read the mnemonic from `RELEASE_MASTER_MNEMONIC` or, if unset, stdin.
fn read_mnemonic() -> Result<String> {
if let Ok(v) = std::env::var(ENV_MNEMONIC) {
if !v.trim().is_empty() {
return Ok(v);
}
}
use std::io::Read;
eprintln!("Paste the release master mnemonic, then Ctrl-D:");
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("read mnemonic from stdin")?;
if buf.trim().is_empty() {
bail!("no mnemonic provided (set {ENV_MNEMONIC} or pipe it on stdin)");
}
Ok(buf)
}
fn print_key(key: &SigningKey) {
let vk = key.verifying_key();
println!("RELEASE_ROOT_PUBKEY_HEX={}", hex::encode(vk.to_bytes()));
println!("signed_by_did={}", did::did_key_for_ed25519(&vk));
}

View File

@ -70,6 +70,13 @@ pub struct Config {
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
#[serde(default)]
pub use_quadlet_backends: bool,
/// DHT swarm-assist (Phase 3): when true AND the binary was built with the
/// `iroh-swarm` feature, stand up an iroh-blobs provider that fetches release
/// blobs peer-to-peer (origin always wins) and seeds them via signed Nostr
/// adverts. Off by default; with the feature absent this is inert. Reuses
/// `nostr_relays` + `nostr_tor_proxy` for discovery transport.
#[serde(default)]
pub swarm_enabled: bool,
}
impl Config {
@ -182,6 +189,12 @@ impl Config {
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
}
// DHT swarm-assist (Phase 3). Opt-in: only takes effect when the binary
// was also built with the `iroh-swarm` feature; otherwise inert.
if let Ok(v) = std::env::var("ARCHIPELAGO_SWARM_ENABLED") {
config.swarm_enabled = parse_truthy_env(&v);
}
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
// route backend installs through the Quadlet path without requiring a
// config.json edit + archipelago.service restart (which would trigger
@ -241,6 +254,7 @@ impl Default for Config {
],
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
use_quadlet_backends: false,
swarm_enabled: false,
}
}
}

View File

@ -268,9 +268,38 @@ async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCat
}
let body = resp.text().await?;
let catalog: AppCatalog = serde_json::from_str(&body)?;
// NOTE (DHT Phase 0): when `catalog.signature` is present, verify it against
// the seed-derived release-root pubkey here before accepting. Until signing
// ships we accept unsigned catalogs (same trust level as today's manifest).
// DHT Phase 0 authenticity: verify the release-root signature when present.
// We verify against the raw JSON (the exact bytes the publisher signed),
// not a re-serialization of the typed struct, so unknown forward-compat
// fields stay part of the signed preimage. Unsigned catalogs are still
// accepted during the migration window — same trust level as today's
// manifest — but a *present* signature that fails is a hard reject so a
// tampering mirror cannot pass off altered bytes.
let raw: serde_json::Value = serde_json::from_str(&body)?;
match crate::trust::verify_detached(&raw)? {
crate::trust::SignatureStatus::Unsigned => {
debug!("app-catalog: unsigned (accepted during migration window)");
}
crate::trust::SignatureStatus::Verified {
signer_did,
anchored,
} => {
if anchored {
info!(
"app-catalog: release-root signature verified ({})",
signer_did
);
} else {
warn!(
"app-catalog: signature self-consistent but release-root anchor \
not pinned ({}); cannot confirm signer identity",
signer_did
);
}
}
}
Ok(catalog)
}

View File

@ -176,6 +176,70 @@ pub fn compute_container_name(manifest: &AppManifest) -> String {
}
}
/// Fingerprint a local build context so a changed source tree (e.g. a rebuilt
/// `neode-ui` dist copied into `docker/<ui>/`) forces an image rebuild even
/// when the image tag already exists (#34). Walks the context directory and
/// hashes each file's relative path, length, and mtime.
///
/// Metadata-only by design: it's cheap enough to recompute on every reconcile,
/// and podman's own COPY-layer cache still skips the actual layer work when the
/// file *content* is unchanged, so a spurious mtime bump costs almost nothing.
/// Returns `None` if the context can't be read (caller falls back to building).
fn fingerprint_build_context(context: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let mut entries: Vec<(String, u64, i128)> = Vec::new();
let mut stack = vec![context.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).ok()?;
for entry in rd.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
stack.push(path);
continue;
}
let rel = path
.strip_prefix(context)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i128)
.unwrap_or(0);
entries.push((rel, meta.len(), mtime));
}
}
// Sort so the hash is independent of directory-walk order.
entries.sort();
let mut hasher = Sha256::new();
for (rel, len, mtime) in &entries {
hasher.update(rel.as_bytes());
hasher.update(b"\0");
hasher.update(len.to_le_bytes());
hasher.update(mtime.to_le_bytes());
}
Some(hex::encode(hasher.finalize()))
}
/// Path of the stamp file recording the build-context fingerprint that produced
/// the currently-built image for `tag`. Keyed by a filesystem-safe form of the
/// tag so distinct UI images don't collide.
fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
let safe: String = tag
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
data_dir
.join(".image-build")
.join(format!("{safe}.fingerprint"))
}
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
let uid = uid_gid
.split_once(':')
@ -219,6 +283,120 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
))
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container.
///
/// For each writable bind mount, write-probe as the container's own process
/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec`
/// as root) to that service uid:gid. Because the chown runs in the container's
/// user namespace, podman translates it to the correct host owner regardless of
/// the rootless idmap — so there is NO host-side UID guessing, and it works for
/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps.
/// This is the durable replacement for the per-app hardcoded host chowns.
///
/// Drift-checked via the write-probe, so it only `chown`s when the volume is
/// actually unwritable — cheap enough to call on every reconcile. Best-effort:
/// returns true if it repaired something; never fails reconcile (a degraded app
/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17).
async fn ensure_running_container_ownership(name: &str) -> bool {
async fn podman_stdout(args: &[&str]) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(args)
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
// The uid:gid the container's main process actually runs as.
let uid = match podman_stdout(&["exec", name, "id", "-u"]).await {
Some(u) if !u.is_empty() => u,
_ => return false, // can't exec (no shell / not running) — nothing to do
};
let gid = podman_stdout(&["exec", name, "id", "-g"])
.await
.filter(|g| !g.is_empty())
.unwrap_or_else(|| uid.clone());
// Writable bind-mount destinations only.
let dests = match podman_stdout(&[
"inspect",
name,
"--format",
"{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}",
])
.await
{
Some(d) => d,
None => return false,
};
let mut repaired = false;
for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) {
// Never touch system / socket bind mounts.
if dest == "/"
|| dest.starts_with("/proc")
|| dest.starts_with("/sys")
|| dest.starts_with("/dev")
|| dest.starts_with("/run")
|| dest.starts_with("/etc")
|| dest.ends_with(".sock")
{
continue;
}
// Drift check: can the service user write here already?
let probe = format!(
"t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null"
);
let writable = tokio::process::Command::new("podman")
.args(["exec", name, "sh", "-c", &probe])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if writable {
continue;
}
// Repair inside the container's userns — podman maps to the right host uid.
let chown = tokio::process::Command::new("podman")
.args([
"exec",
"-u",
"0",
name,
"chown",
"-R",
&format!("{uid}:{gid}"),
dest,
])
.output()
.await;
match chown {
Ok(o) if o.status.success() => {
repaired = true;
tracing::warn!(
container = %name, dest, uid = %uid,
"repaired unwritable volume ownership (in-container chown)"
);
}
Ok(o) => tracing::warn!(
container = %name, dest,
"volume ownership repair failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => {
tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}")
}
}
}
repaired
}
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
@ -1093,6 +1271,30 @@ impl ProdContainerOrchestrator {
}
}
}
// App-agnostic volume-ownership self-heal. Sweep EVERY running container
// (registry/manifest apps AND legacy compose stacks like immich) and
// repair any that can't write their bind mounts — the durable, app-
// agnostic replacement for per-app hardcoded host chowns. Drift-checked,
// so steady state is just cheap in-container write-probes; only a broken
// volume is chowned (in-userns, mapping-proof) and its container
// restarted to recover. Fixes the class of EACCES crash-loops fleet-wide
// and self-heals existing nodes after OTA. (immich .198, 2026-06-17.)
if let Ok(containers) = self.runtime.list_containers().await {
for c in containers
.iter()
.filter(|c| matches!(c.state, ContainerState::Running))
{
if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman")
.args(["restart", &c.name])
.output()
.await;
}
}
}
report
}
@ -1118,6 +1320,33 @@ impl ProdContainerOrchestrator {
let _guard = lock.lock().await;
self.ensure_app_secrets(&app_id).await?;
// Don't fight the Bitcoin-implementation switch: bitcoin-core and
// bitcoin-knots share port 8332, so if the *other* variant is already
// running the inactive one can never start — the reconciler would just
// churn "address already in use" and report a reconcile failure. Skip
// it, mirroring the health monitor's same skip. (#47)
if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) {
"bitcoin-core" => Some("bitcoin-knots"),
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
_ => None,
} {
if let Ok(list) = self.runtime.list_containers().await {
let other_running = list.iter().any(|c| {
c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict
&& matches!(c.state, ContainerState::Running)
});
if other_running {
tracing::debug!(
app_id = %app_id,
conflict,
"skipping reconcile — the other Bitcoin implementation is running"
);
return Ok(ReconcileAction::NoOp);
}
}
}
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let name = compute_container_name(&lm.manifest);
@ -1435,7 +1664,7 @@ impl ProdContainerOrchestrator {
.to_string_lossy()
.into_owned();
}
let already = match self.runtime.image_exists(&bcfg.tag).await {
let exists = match self.runtime.image_exists(&bcfg.tag).await {
Ok(exists) => exists,
Err(err) => {
tracing::warn!(
@ -1446,11 +1675,51 @@ impl ProdContainerOrchestrator {
false
}
};
if !already {
// Presence alone isn't enough: the local UI images (bitcoin-ui,
// lnd-ui, electrs-ui) COPY a built `neode-ui` dist, so a UI
// update changes the source but leaves the old tag in place.
// Rebuild whenever the build context's fingerprint differs from
// the one that produced the existing image (#34). podman's
// COPY-layer cache keeps the rebuild cheap when content is
// actually unchanged.
let fingerprint = fingerprint_build_context(Path::new(&bcfg.context));
let stamp_path = build_fingerprint_stamp_path(&self.data_dir, &bcfg.tag);
let stale = match &fingerprint {
Some(current) => match tokio::fs::read_to_string(&stamp_path).await {
Ok(prev) => prev.trim() != current,
// No stamp recorded → treat as stale so we rebuild and
// capture the fingerprint going forward.
Err(_) => true,
},
// Couldn't fingerprint the context — don't skip on staleness.
None => true,
};
if !exists || stale {
if exists && stale {
tracing::info!(
image = %bcfg.tag,
context = %bcfg.context,
"build context changed since last build; rebuilding image"
);
}
self.runtime
.build_image(&bcfg)
.await
.with_context(|| format!("build_image {}", bcfg.tag))?;
// Record the fingerprint that this image was built from so
// the next reconcile skips the build until the source moves.
if let Some(current) = &fingerprint {
if let Some(parent) = stamp_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if let Err(err) = tokio::fs::write(&stamp_path, current).await {
tracing::warn!(
image = %bcfg.tag,
error = %err,
"failed to write build fingerprint stamp"
);
}
}
}
}
}
@ -4356,4 +4625,37 @@ app:
let calls = rt.calls();
assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0"));
}
#[test]
fn fingerprint_build_context_detects_source_changes() {
let tmp = tempfile::TempDir::new().unwrap();
let ctx = tmp.path();
std::fs::write(ctx.join("Dockerfile"), "FROM nginx\n").unwrap();
std::fs::create_dir_all(ctx.join("assets")).unwrap();
std::fs::write(ctx.join("assets/app.js"), b"v1").unwrap();
let a = fingerprint_build_context(ctx).expect("fingerprint");
// Recomputing over the same tree is stable.
let b = fingerprint_build_context(ctx).expect("fingerprint");
assert_eq!(a, b, "fingerprint must be stable for an unchanged tree");
// Changing a COPYed source file (different length) changes the fingerprint.
std::fs::write(ctx.join("assets/app.js"), b"v2-longer").unwrap();
let c = fingerprint_build_context(ctx).expect("fingerprint");
assert_ne!(a, c, "changed source file must change the fingerprint");
}
#[test]
fn build_fingerprint_stamp_path_sanitizes_tag() {
let p = build_fingerprint_stamp_path(
Path::new("/var/lib/archipelago"),
"localhost/bitcoin-ui:local",
);
assert_eq!(
p,
PathBuf::from(
"/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint"
)
);
}
}

View File

@ -0,0 +1,149 @@
//! Content hashing for the DHT distribution plan's *integrity & addressing*
//! tier (`docs/dht-distribution-design.md` §4).
//!
//! SHA-256 is the incumbent: it keys `blobs.rs` and verifies OTA components
//! today. BLAKE3 is introduced **alongside** it because iroh-blobs addresses
//! and *range-verifies* content by BLAKE3 — essential for resumable downloads
//! and HLS streaming. During the migration window both may be present; SHA-256
//! stays mandatory and BLAKE3 is verified when supplied.
//!
//! Digests are written multihash-style as `"<alg>:<hex>"`, e.g.
//! `"blake3:ab12…"` / `"sha256:cd34…"`, matching the app-catalog `digest` field.
//! Both algorithms emit 32-byte (64-hex-char) digests.
use anyhow::{anyhow, bail, Context, Result};
use sha2::{Digest, Sha256};
const DIGEST_LEN: usize = 32;
/// Supported content-hash algorithms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashAlg {
Sha256,
Blake3,
}
impl HashAlg {
pub fn as_str(self) -> &'static str {
match self {
HashAlg::Sha256 => "sha256",
HashAlg::Blake3 => "blake3",
}
}
}
/// Hex-encoded SHA-256 of `bytes`.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
/// Hex-encoded BLAKE3 of `bytes`.
pub fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
/// A parsed `"<alg>:<hex>"` content digest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentDigest {
pub alg: HashAlg,
/// Lowercase hex, validated to the algorithm's length.
pub hex: String,
}
impl ContentDigest {
/// Parse a multihash-style `"<alg>:<hex>"` string.
pub fn parse(s: &str) -> Result<Self> {
let (alg_part, hex_part) = s
.split_once(':')
.ok_or_else(|| anyhow!("digest must be '<alg>:<hex>', got: {}", s))?;
let alg = match alg_part {
"sha256" => HashAlg::Sha256,
"blake3" => HashAlg::Blake3,
other => bail!("unsupported hash algorithm: {}", other),
};
let raw = hex::decode(hex_part).context("digest hex is invalid")?;
if raw.len() != DIGEST_LEN {
bail!(
"{} digest must be {} bytes, got {}",
alg.as_str(),
DIGEST_LEN,
raw.len()
);
}
Ok(Self {
alg,
hex: hex_part.to_ascii_lowercase(),
})
}
/// Compute the digest of `bytes` under this digest's algorithm.
pub fn compute_hex(&self, bytes: &[u8]) -> String {
match self.alg {
HashAlg::Sha256 => sha256_hex(bytes),
HashAlg::Blake3 => blake3_hex(bytes),
}
}
/// Verify `bytes` hash to this digest. Errors (does not panic) on mismatch.
pub fn verify(&self, bytes: &[u8]) -> Result<()> {
let actual = self.compute_hex(bytes);
if actual.eq_ignore_ascii_case(&self.hex) {
Ok(())
} else {
bail!(
"{} mismatch: expected {}, got {}",
self.alg.as_str(),
self.hex,
actual
)
}
}
}
impl std::fmt::Display for ContentDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.alg.as_str(), self.hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_lengths_are_32_bytes() {
assert_eq!(sha256_hex(b"hi").len(), 64);
assert_eq!(blake3_hex(b"hi").len(), 64);
}
#[test]
fn blake3_known_answer() {
// BLAKE3 of the empty input — RFC/reference vector.
assert_eq!(
blake3_hex(b""),
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
}
#[test]
fn parse_roundtrip() {
let d = ContentDigest::parse(&format!("blake3:{}", blake3_hex(b"x"))).unwrap();
assert_eq!(d.alg, HashAlg::Blake3);
assert_eq!(d.to_string(), format!("blake3:{}", blake3_hex(b"x")));
}
#[test]
fn verify_accepts_and_rejects() {
let d = ContentDigest::parse(&format!("sha256:{}", sha256_hex(b"payload"))).unwrap();
assert!(d.verify(b"payload").is_ok());
assert!(d.verify(b"tampered").is_err());
}
#[test]
fn parse_rejects_bad_input() {
assert!(ContentDigest::parse("nocolon").is_err());
assert!(ContentDigest::parse("md5:abcd").is_err());
assert!(ContentDigest::parse("blake3:nothex").is_err());
assert!(ContentDigest::parse("blake3:ab").is_err()); // too short
}
}

View File

@ -0,0 +1,80 @@
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
//!
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
//! invoice on its own LND and records a pending entitlement here, keyed by the
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
//! for settlement; once the seller's LND confirms the invoice is settled we mark
//! the entitlement paid, and the content gate (`content_server::serve_content`)
//! then releases the file to anyone presenting that payment hash.
//!
//! State is in-memory and bounded by a TTL. If the seller restarts before the
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
//! because an unpaid invoice represents no money.
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
/// How long a pending/paid entitlement is retained. Generous enough for a human
/// to pay an invoice and download, short enough to keep the map small.
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
#[derive(Clone)]
struct Entitlement {
content_id: String,
price_sats: u64,
paid: bool,
created_at: Instant,
}
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Drop expired entries. Caller must hold the lock.
fn prune(map: &mut HashMap<String, Entitlement>) {
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
}
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
map.insert(
payment_hash.to_string(),
Entitlement {
content_id: content_id.to_string(),
price_sats,
paid: false,
created_at: Instant::now(),
},
);
}
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
pub async fn mark_paid(payment_hash: &str) {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
if let Some(e) = map.get_mut(payment_hash) {
e.paid = true;
}
}
/// The content_id + price an entitlement was issued for, if still live.
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
map.get(payment_hash)
.map(|e| (e.content_id.clone(), e.price_sats))
}
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
/// This is the gate the content server consults to release a file.
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
map.get(payment_hash)
.map(|e| e.paid && e.content_id == content_id)
.unwrap_or(false)
}

View File

@ -198,6 +198,7 @@ pub async fn serve_content(
data_dir: &Path,
id: &str,
payment_token: Option<&str>,
invoice_hash: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
) -> Result<ServeResult> {
@ -236,12 +237,24 @@ pub async fn serve_content(
// Check access control
match &item.access {
AccessControl::Paid { price_sats } => {
// Verify payment token
// Two ways to satisfy payment:
// (a) a valid ecash token (the local-wallet fast path), or
// (b) a Lightning-invoice payment hash this node issued and has
// since confirmed settled (the "pay from any wallet" path, #46).
let mut authorized = false;
if let Some(token) = payment_token {
if !verify_payment_token(data_dir, token, *price_sats).await {
return Ok(ServeResult::PaymentRequired(*price_sats));
if verify_payment_token(data_dir, token, *price_sats).await {
authorized = true;
}
} else {
}
if !authorized {
if let Some(hash) = invoice_hash {
if crate::content_invoice::is_paid_for(hash, id).await {
authorized = true;
}
}
}
if !authorized {
return Ok(ServeResult::PaymentRequired(*price_sats));
}
}
@ -317,10 +330,63 @@ pub enum PreviewResult {
BlurPreview(Vec<u8>, String),
/// Truncated preview for paid video (first ~2% of bytes).
TruncatedPreview(Vec<u8>, String, u64),
/// A preview can't be produced for this media without re-encoding (e.g. a
/// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't
/// play). The UI shows its "preview unavailable" overlay instead of a
/// broken player. (#35)
PreviewUnavailable,
/// Content not found.
NotFound,
}
/// Scan an MP4's top-level boxes and report whether `moov` appears before
/// `mdat` ("faststart"). Returns `Some(true)` if faststart (a byte prefix is
/// playable), `Some(false)` if the media data precedes the index (a prefix
/// will NOT play), or `None` if neither box is found / the file isn't parseable
/// as ISO-BMFF (caller falls back to the legacy prefix behavior).
async fn mp4_is_faststart(path: &std::path::Path) -> Option<bool> {
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
let mut f = tokio::fs::File::open(path).await.ok()?;
let file_len = f.metadata().await.ok()?.len();
let mut pos: u64 = 0;
// Bound the walk so a malformed file can't spin forever.
for _ in 0..1024 {
if pos.saturating_add(8) > file_len {
return None;
}
f.seek(SeekFrom::Start(pos)).await.ok()?;
let mut hdr = [0u8; 8];
if f.read_exact(&mut hdr).await.is_err() {
return None;
}
let mut size = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as u64;
let btype = &hdr[4..8];
let mut header_len = 8u64;
if size == 1 {
// 64-bit extended size.
let mut ext = [0u8; 8];
if f.read_exact(&mut ext).await.is_err() {
return None;
}
size = u64::from_be_bytes(ext);
header_len = 16;
} else if size == 0 {
// Box runs to EOF — it's the last one.
size = file_len.saturating_sub(pos);
}
match btype {
b"moov" => return Some(true), // index before media → faststart
b"mdat" => return Some(false), // media before index → not faststart
_ => {}
}
if size < header_len {
return None; // malformed
}
pos = pos.checked_add(size)?;
}
None
}
/// Serve a preview of content by ID. For paid content, returns degraded previews:
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
@ -358,6 +424,26 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
);
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
// A byte-prefix preview only plays if the container's index is at
// the front. For MP4/MOV that means the `moov` atom must precede
// `mdat` (faststart). Non-faststart files have moov at the end, so
// a 10% prefix is an unplayable truncated MP4 (#35) — report it as
// unavailable rather than streaming bytes that hang the player.
let is_isobmff = mime == "video/mp4"
|| mime == "video/quicktime"
|| matches!(
file_path.extension().and_then(|e| e.to_str()),
Some("mp4") | Some("m4v") | Some("mov") | Some("m4a")
);
if is_isobmff && mp4_is_faststart(&file_path).await == Some(false) {
debug!(
"Paid {} '{}' is a non-faststart MP4 (moov after mdat) — no playable prefix preview",
if mime.starts_with("video/") { "video" } else { "audio" },
id
);
return Ok(PreviewResult::PreviewUnavailable);
}
// Serve first 10% of video/audio, minimum 512KB for codec headers
let metadata = fs::metadata(&file_path)
.await
@ -431,3 +517,41 @@ async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64)
}
}
}
#[cfg(test)]
mod faststart_tests {
use super::*;
fn box_hdr(size: u32, typ: &[u8; 4]) -> Vec<u8> {
let mut v = size.to_be_bytes().to_vec();
v.extend_from_slice(typ);
v
}
#[tokio::test]
async fn detects_faststart_moov_before_mdat() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("fast.mp4");
let mut data = Vec::new();
data.extend(box_hdr(16, b"ftyp"));
data.extend([0u8; 8]);
data.extend(box_hdr(8, b"moov"));
data.extend(box_hdr(8, b"mdat"));
tokio::fs::write(&p, &data).await.unwrap();
assert_eq!(mp4_is_faststart(&p).await, Some(true));
}
#[tokio::test]
async fn detects_non_faststart_mdat_before_moov() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("slow.mp4");
let mut data = Vec::new();
data.extend(box_hdr(16, b"ftyp"));
data.extend([0u8; 8]);
data.extend(box_hdr(16, b"mdat"));
data.extend([0u8; 8]);
data.extend(box_hdr(8, b"moov"));
tokio::fs::write(&p, &data).await.unwrap();
assert_eq!(mp4_is_faststart(&p).await, Some(false));
}
}

View File

@ -117,9 +117,12 @@ fn expire_stale(requests: &mut Vec<PendingPeerRequest>) {
/// or `None` if the request was deduplicated or rate-limited.
///
/// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a
/// `Pending` entry, do not insert a second one — the user will see the
/// existing row and act on that. Otherwise count `Pending` entries per
/// pubkey and reject anything beyond `MAX_PENDING_PER_PUBKEY`.
/// `Pending` OR `Approved` entry, do not insert a second one. Including
/// `Approved` is what stops an already-approved peer from re-spawning a fresh
/// pending row every time their request re-syncs (the reported "approve, Poll
/// Now, see approved + a new pending" loop). `Rejected` is intentionally NOT
/// matched so a previously-rejected peer can still ask again later. Otherwise
/// count `Pending` entries per pubkey and reject beyond `MAX_PENDING_PER_PUBKEY`.
pub async fn insert_inbound(
data_dir: &Path,
from_nostr_pubkey: String,
@ -131,13 +134,13 @@ pub async fn insert_inbound(
let mut requests = load_pending(data_dir).await?;
expire_stale(&mut requests);
let already_pending = requests.iter().any(|r| {
let already_handled = requests.iter().any(|r| {
r.from_nostr_pubkey == from_nostr_pubkey
&& r.from_did == from_did
&& matches!(r.state, PendingState::Pending)
&& matches!(r.state, PendingState::Pending | PendingState::Approved)
&& !r.outbound
});
if already_pending {
if already_handled {
save_pending(data_dir, &requests).await?;
return Ok(None);
}
@ -271,6 +274,54 @@ mod tests {
assert!(r2.is_none(), "duplicate Pending request should be ignored");
}
#[tokio::test]
async fn test_approved_request_does_not_respawn_pending() {
// Regression for the "approve → Poll Now → approved + a fresh pending"
// loop: once a request is Approved, a re-synced inbound for the same
// peer must NOT create a new Pending row.
let dir = tempfile::tempdir().unwrap();
let r1 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap()
.expect("first insert stored");
set_state(dir.path(), &r1.id, PendingState::Approved)
.await
.unwrap();
let r2 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap();
assert!(
r2.is_none(),
"an already-approved peer must not re-spawn a pending request"
);
let pending = load_pending(dir.path()).await.unwrap();
assert_eq!(
pending
.iter()
.filter(|r| matches!(r.state, PendingState::Pending))
.count(),
0,
"no Pending rows should remain after approval + re-sync"
);
}
#[tokio::test]
async fn test_rate_limit() {
let dir = tempfile::tempdir().unwrap();

View File

@ -99,8 +99,18 @@ pub async fn peer_base_url(npub: &str) -> Result<String> {
/// until the first packets flow), so a reachable-but-cold peer isn't abandoned
/// to Tor prematurely. Reliability over latency — FIPS is the preferred path.
pub fn client() -> reqwest::Client {
client_with_timeout(Duration::from_secs(20))
}
/// FIPS client with a caller-chosen overall request timeout. The static 20s
/// `client()` budget is fine for catalog browses and short calls, but a large
/// content download (#38) needs the per-request timeout the caller asked for —
/// otherwise a 178MB transfer is aborted at 20s and the whole download fails
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
/// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.timeout(timeout)
.connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1")
.build()
@ -413,7 +423,7 @@ impl<'a> PeerRequest<'a> {
}
};
let url = format!("{}{}", base, self.path);
let c = client();
let c = client_with_timeout(self.timeout);
let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
@ -446,7 +456,7 @@ impl<'a> PeerRequest<'a> {
}
};
let url = format!("{}{}", base, self.path);
let c = client();
let c = client_with_timeout(self.timeout);
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);

View File

@ -76,8 +76,12 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
"fedimint" => &["bitcoin"],
"fedimint-gateway" => &["bitcoin", "fedimint"],
// IndeedHub stack
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
// IndeedHub stack. The API needs MinIO (object storage) up before it
// can serve — without listing it the health monitor would restart the
// API while MinIO was still coming up, which is the "needs 1-2 restarts
// to recover" symptom (#41). MinIO has no deps of its own, so the
// monitor restarts it independently first; no deadlock.
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis", "indeedhub-minio"],
"indeedhub" => &["indeedhub-api"],
"indeedhub-relay" => &["indeedhub-postgres"],
"indeedhub-ffmpeg" => &["indeedhub-api"],

View File

@ -33,9 +33,12 @@ mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod ceremony;
mod config;
mod constants;
mod container;
mod content_hash;
mod content_invoice;
mod content_server;
mod crash_recovery;
mod credentials;
@ -66,8 +69,10 @@ mod settings;
mod state;
mod storage_crypto;
mod streaming;
mod swarm;
mod totp;
mod transport;
mod trust;
mod update;
mod vpn;
mod wallet;
@ -82,6 +87,13 @@ use server::Server;
#[tokio::main]
async fn main() -> Result<()> {
// Release-root signing ceremony: a publisher-side subcommand of the same
// binary. Handle it before any server/tracing init so its stdout stays
// clean (machine-readable KEY=VALUE lines) and it never touches node state.
if ceremony::is_ceremony_invocation() {
return ceremony::run();
}
let startup_start = std::time::Instant::now();
crash_recovery::init_start_time();

View File

@ -0,0 +1,368 @@
//! Mesh-AI assistant (issue #50) — answers `AssistQuery` messages with this
//! node's local LLM and sends the reply back over the mesh.
//!
//! This is the Rust-native lift of Meshroller's "LLM bridge": a trusted peer
//! asks a question over meshcore, an internet/compute-bearing node runs it
//! through a local model (Ollama) and streams the answer back in capped,
//! ordered chunks. Airtime is scarce, so the reply is length-capped and each
//! asker is limited to one in-flight query.
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
use super::bitcoin::send_to_peer;
use super::{MeshCommand, MeshState};
use crate::federation::TrustLevel;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tracing::{info, warn};
/// Local Ollama generate endpoint (same host the Ollama app binds).
const OLLAMA_URL: &str = "http://localhost:11434/api/generate";
/// Default model when the node hasn't configured one (matches Meshroller).
const DEFAULT_MODEL: &str = "qwen2.5-coder";
/// Anthropic Messages API (called with the shared proxy token).
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
/// Default Claude model — Haiku 4.5: fast + cheap, ideal for short mesh answers.
const CLAUDE_DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
/// Max time to wait on the model before giving up.
const OLLAMA_TIMEOUT: Duration = Duration::from_secs(60);
/// Hard cap on answer length sent over the radio — keeps airtime sane.
const MAX_REPLY_CHARS: usize = 480;
/// Characters of answer text per `AssistResponse` chunk.
const CHUNK_CHARS: usize = 160;
/// Tighter cap for plain-text channel replies (bare `!ai` clients) — these
/// aren't reassembled by an archipelago UI, so keep them to a couple frames.
const CHANNEL_REPLY_CHARS: usize = 200;
/// Where an answer should go.
pub(super) enum AssistReply {
/// Typed `AssistResponse` chunks addressed to one peer — the archipelago
/// UI path (rich, reassembled, correlated by `req_id`).
Typed { contact_id: u32 },
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
ChannelText { channel: u8 },
}
/// Entry point: gate the query, run the model, send the answer back via the
/// requested reply path. Spawned off the radio loop so it never blocks.
pub(super) async fn run_assist(
prompt: String,
model_override: Option<String>,
req_id: u64,
asker_contact_id: u32,
sender_name: String,
reply: AssistReply,
state: Arc<MeshState>,
) {
let asker = asker_contact_id;
// Trust + block gate.
if !is_sender_allowed(&state, asker).await {
warn!(
from = asker,
name = %sender_name,
"AssistQuery denied — sender not permitted by assistant policy"
);
// Silent on the wire (no airtime spent on denials); surface to the UI.
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistResponseReady {
req_id,
to_contact_id: asker,
error: Some("denied".to_string()),
});
return;
}
// One in-flight query per asker.
{
let mut inflight = state.assist_inflight.write().await;
if !inflight.insert(asker) {
warn!(
from = asker,
"AssistQuery dropped — asker already has one in flight"
);
return;
}
}
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistQueryReceived {
from_contact_id: asker,
prompt: prompt.clone(),
});
let (backend, configured_model) = {
let a = state.assistant.read().await;
(a.backend.clone(), a.model.clone())
};
let is_claude = backend == "claude";
let default_model = if is_claude {
CLAUDE_DEFAULT_MODEL
} else {
DEFAULT_MODEL
};
let model = model_override
.or(configured_model)
.unwrap_or_else(|| default_model.to_string());
info!(from = asker, req_id, backend = %backend, model = %model, "Answering AI query over mesh");
let result = if is_claude {
call_claude(&state.data_dir, &model, &prompt).await
} else {
call_ollama(&model, &prompt).await
};
match result {
Ok(answer) => {
send_reply(&state, &reply, req_id, &answer).await;
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistResponseReady {
req_id,
to_contact_id: asker,
error: None,
});
}
Err(e) => {
warn!(req_id, "AI query failed: {}", e);
send_failure(&state, &reply, req_id, "AI unavailable").await;
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistResponseReady {
req_id,
to_contact_id: asker,
error: Some(e.to_string()),
});
}
}
state.assist_inflight.write().await.remove(&asker);
}
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
/// Always denies user-blocked contacts. With `trusted_only`, requires a
/// federation-Trusted match on the peer's pubkey or DID.
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
let (pubkey_hex, did) = {
let peers = state.peers.read().await;
match peers.get(&sender_contact_id) {
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
None => (None, None),
}
};
// Never answer a user-blocked contact, regardless of policy.
if let Some(ref pk) = pubkey_hex {
if state
.contacts
.read()
.await
.get(pk)
.map(|c| c.blocked)
.unwrap_or(false)
{
return false;
}
}
if !state.assistant.read().await.trusted_only {
return true;
}
// Trusted-only: match against the federation trust list.
let nodes = crate::federation::load_nodes(&state.data_dir)
.await
.unwrap_or_default();
nodes.iter().any(|n| {
n.trust_level == TrustLevel::Trusted
&& (Some(&n.pubkey) == pubkey_hex.as_ref() || Some(&n.did) == did.as_ref())
})
}
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
/// Returns (text_to_send, was_truncated).
fn cap_reply(answer: &str) -> (String, bool) {
let trimmed = answer.trim();
if trimmed.chars().count() <= MAX_REPLY_CHARS {
return (trimmed.to_string(), false);
}
let capped: String = trimmed.chars().take(MAX_REPLY_CHARS).collect();
(format!("{capped}…(truncated)"), true)
}
/// Send a successful answer via the requested reply path.
async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, answer: &str) {
match reply {
AssistReply::Typed { contact_id } => {
let (text, _) = cap_reply(answer);
send_typed_chunks(state, *contact_id, req_id, &text).await;
}
AssistReply::ChannelText { channel } => {
let text = cap_channel(answer);
send_channel_text(state, *channel, &text).await;
}
}
}
/// Send a failure notice via the requested reply path.
async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, msg: &str) {
match reply {
AssistReply::Typed { contact_id } => {
let payload = AssistResponsePayload {
req_id,
text: String::new(),
seq: 0,
done: true,
error: Some(msg.to_string()),
};
send_typed_response(state, *contact_id, &payload).await;
}
AssistReply::ChannelText { channel } => {
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
}
}
}
/// Split the answer into ordered `AssistResponse` chunks and send each back to
/// the asker on the encrypted, peer-addressed path (archipelago UI path).
async fn send_typed_chunks(state: &Arc<MeshState>, dest_contact_id: u32, req_id: u64, text: &str) {
let chars: Vec<char> = text.chars().collect();
let chunks: Vec<String> = if chars.is_empty() {
vec![String::new()]
} else {
chars
.chunks(CHUNK_CHARS)
.map(|c| c.iter().collect())
.collect()
};
let last = chunks.len().saturating_sub(1);
for (i, chunk) in chunks.into_iter().enumerate() {
let payload = AssistResponsePayload {
req_id,
text: chunk,
seq: i as u16,
done: i == last,
error: None,
};
send_typed_response(state, dest_contact_id, &payload).await;
}
}
/// Encode an `AssistResponse` payload and send it to a peer.
async fn send_typed_response(
state: &Arc<MeshState>,
dest_contact_id: u32,
payload: &AssistResponsePayload,
) {
let bytes = match message_types::encode_payload(payload) {
Ok(b) => b,
Err(e) => {
warn!("Failed to encode AssistResponse: {}", e);
return;
}
};
let envelope = message_types::TypedEnvelope::new(MeshMessageType::AssistResponse, bytes);
match envelope.to_wire() {
Ok(wire) => send_to_peer(state, dest_contact_id, wire).await,
Err(e) => warn!("Failed to encode AssistResponse envelope: {}", e),
}
}
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
let _ = state
.send_cmd(MeshCommand::BroadcastChannel {
channel,
payload: text.as_bytes().to_vec(),
})
.await;
}
/// Cap a plain-text channel reply to a couple of frames.
fn cap_channel(answer: &str) -> String {
let trimmed = answer.trim();
if trimmed.chars().count() <= CHANNEL_REPLY_CHARS {
return format!("AI: {trimmed}");
}
let capped: String = trimmed.chars().take(CHANNEL_REPLY_CHARS).collect();
format!("AI: {capped}")
}
/// Call the local Ollama model and return the generated text.
async fn call_ollama(model: &str, prompt: &str) -> anyhow::Result<String> {
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
let body = serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
});
let resp = client.post(OLLAMA_URL).json(&body).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Ollama returned HTTP {}", resp.status());
}
let json: serde_json::Value = resp.json().await?;
let text = json
.get("response")
.and_then(|r| r.as_str())
.unwrap_or("")
.to_string();
if text.trim().is_empty() {
anyhow::bail!("Ollama returned an empty response");
}
Ok(text)
}
/// Call Claude via the Anthropic Messages API using the node's shared proxy
/// token at `secrets/claude-api-key`. Keeps answers short for radio airtime.
async fn call_claude(data_dir: &Path, model: &str, prompt: &str) -> anyhow::Result<String> {
let key = tokio::fs::read_to_string(data_dir.join("secrets/claude-api-key"))
.await
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
let key = key.trim();
if key.is_empty() {
anyhow::bail!("Claude API key is empty");
}
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
let body = serde_json::json!({
"model": model,
"max_tokens": 512,
"system": "You answer questions over a low-bandwidth radio mesh. Reply in at most two short sentences. No markdown, no preamble.",
"messages": [{ "role": "user", "content": prompt }],
});
let resp = client
.post(CLAUDE_URL)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let txt = resp.text().await.unwrap_or_default();
anyhow::bail!(
"Claude API HTTP {}: {}",
status,
txt.chars().take(180).collect::<String>()
);
}
let json: serde_json::Value = resp.json().await?;
// `content` is an array of blocks; take the first text block.
let text = json
.get("content")
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter()
.find_map(|b| b.get("text").and_then(|t| t.as_str()))
})
.unwrap_or("")
.to_string();
if text.trim().is_empty() {
anyhow::bail!("Claude returned an empty response");
}
Ok(text)
}

View File

@ -445,7 +445,9 @@ async fn encrypt_for_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: &
/// Send raw wire bytes to a specific peer by contact_id.
/// Encrypts directed messages via ratchet or shared secret when available.
/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown.
async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
/// `pub(super)` so sibling handlers (e.g. the AI assistant) can reply on the
/// same encrypted, peer-addressed path the relay handlers use.
pub(super) async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
let peers = state.peers.read().await;
if let Some(peer) = peers.get(&contact_id) {
if let Some(ref pk) = peer.pubkey_hex {

View File

@ -352,6 +352,45 @@ pub(super) async fn store_plain_message(
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
// channel is answered by this node's local model when the assistant is on.
// Reply goes back as plain channel text so bare (non-archipelago) clients
// see it. The trust/rate gate lives in run_assist.
if state.assistant.read().await.enabled {
if let Some(prompt) = strip_ai_trigger(text) {
if !prompt.is_empty() {
let req_id = state.next_id().await;
let prompt = prompt.to_string();
let name = peer_name.to_string();
let st = Arc::clone(state);
tokio::spawn(async move {
super::assist::run_assist(
prompt,
None,
req_id,
contact_id,
name,
super::assist::AssistReply::ChannelText { channel: 0 },
st,
)
.await;
});
}
}
}
}
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
/// trimmed question after it, or `None` if the text isn't an AI command.
fn strip_ai_trigger(text: &str) -> Option<&str> {
let t = text.trim_start();
for p in ["!ai ", "!ask "] {
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
return Some(t[p.len()..].trim());
}
}
None
}
/// Handle a received identity broadcast from a peer.

View File

@ -681,6 +681,80 @@ pub(crate) async fn handle_typed_envelope_direct(
.await;
}
Some(MeshMessageType::AssistQuery) => {
match message_types::decode_payload::<message_types::AssistQueryPayload>(&envelope.v) {
Ok(query) => {
if !state.assistant.read().await.enabled {
debug!(
from = sender_contact_id,
"AssistQuery ignored — assistant disabled on this node"
);
return;
}
info!(
from = sender_contact_id,
req_id = query.req_id,
"AI query received over mesh"
);
let json = payload_to_json(&query);
store_typed_message(
state,
sender_contact_id,
sender_name,
&query.prompt,
"assist_query",
json,
Some(envelope.seq),
)
.await;
// Run the model + reply off the radio loop. Typed query →
// typed chunked reply back to the asking peer.
let assist_state = Arc::clone(state);
let name = sender_name.to_string();
tokio::spawn(async move {
super::assist::run_assist(
query.prompt,
query.model,
query.req_id,
sender_contact_id,
name,
super::assist::AssistReply::Typed {
contact_id: sender_contact_id,
},
assist_state,
)
.await;
});
}
Err(e) => warn!("Failed to decode AssistQuery payload: {}", e),
}
}
Some(MeshMessageType::AssistResponse) => {
match message_types::decode_payload::<message_types::AssistResponsePayload>(&envelope.v)
{
Ok(resp) => {
let display = resp
.error
.clone()
.map(|e| format!("AI error: {e}"))
.unwrap_or_else(|| resp.text.clone());
let json = payload_to_json(&resp);
store_typed_message(
state,
sender_contact_id,
sender_name,
&display,
"assist_response",
json,
Some(envelope.seq),
)
.await;
}
Err(e) => warn!("Failed to decode AssistResponse payload: {}", e),
}
}
_ => {
debug!(
msg_type = ?msg_type,
@ -697,6 +771,10 @@ async fn dispatch_block_header(
sender_name: &str,
state: &Arc<MeshState>,
) {
// Respect the receive toggle (issue #28): nodes can opt out of inbound headers.
if !state.receive_block_headers {
return;
}
// Compact binary format: height(8) + hash(32) + timestamp(4)
match super::super::bitcoin_relay::decode_compact_block_header(&envelope.v) {
Ok((height, hash_hex, timestamp)) => {

View File

@ -3,8 +3,8 @@
use super::super::message_types::TypedEnvelope;
use super::super::protocol;
use super::decode::{
is_mc_chunk_frame, resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble,
try_decrypt_base64, try_decrypt_ratchet_base64,
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
};
use super::dispatch::handle_typed_message;
use super::MeshState;
@ -18,7 +18,6 @@ pub(super) async fn handle_frame(
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
let _ = our_x25519_secret; // reserved for future per-frame decryption
match frame.code {
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
info!(
@ -109,7 +108,8 @@ pub(super) async fn handle_frame(
match protocol::parse_channel_msg_v3_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
handle_channel_payload(state, channel_idx, &payload).await;
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
.await;
}
}
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
@ -121,7 +121,8 @@ pub(super) async fn handle_frame(
match protocol::parse_channel_msg_v1_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
handle_channel_payload(state, channel_idx, &payload).await;
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
.await;
}
}
Err(e) => warn!("Failed to parse channel message: {}", e),
@ -146,7 +147,12 @@ pub(super) async fn handle_frame(
/// local mesh peer pubkeys (or we can't tell), the inner payload is
/// dispatched through the direct-message path so it lands in the right
/// chat. Otherwise it's handled as a normal channel text/typed message.
async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload: &[u8]) {
async fn handle_channel_payload(
state: &Arc<MeshState>,
channel_idx: u8,
payload: &[u8],
our_x25519_secret: &[u8; 32],
) {
// DM-via-channel wrapper (text form): the channel text carries an
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
// marker anywhere in the payload (the firmware auto-prepends the
@ -326,6 +332,34 @@ async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload
return;
}
// Archipelago identity broadcast (`ARCHY:`): upsert the sender's real
// archipelago identity (DID + ed25519 + x25519) so trust-gating and
// encrypted DMs work over BOTH meshcore and Meshtastic — the latter
// otherwise only exposes synthetic node keys. Keyed by the archipelago
// pubkey (federation_peer_contact_id) so it MERGES with the federation-
// seeded peer instead of creating a duplicate chat thread. Not stored as
// a chat message.
if let Ok(text) = std::str::from_utf8(payload) {
if let Some((did, ed_hex, x_hex)) = super::super::protocol::parse_identity_broadcast(text) {
// Ignore our own identity echoed back by the radio/channel.
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
return;
}
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
handle_identity_received(
contact_id,
0,
&did,
&ed_hex,
&x_hex,
state,
our_x25519_secret,
)
.await;
return;
}
}
// Regular channel broadcast (not DM-wrapped)
let chan_contact_id = u32::MAX - (channel_idx as u32);
let chan_name = format!("Channel {}", channel_idx);

View File

@ -7,6 +7,7 @@
//! - Reconnects on device disconnect
//! - Manages peer cache and message store
mod assist;
mod bitcoin;
mod decode;
pub(crate) mod dispatch;
@ -102,6 +103,8 @@ pub struct MeshState {
pub session_manager: Arc<super::session::SessionManager>,
/// Whether to encrypt directed relay messages (config toggle for rollback).
pub encrypt_relay: bool,
/// Whether to accept inbound Bitcoin block headers from peers (issue #28).
pub receive_block_headers: bool,
/// Last-seen presence heartbeats per peer pubkey hex: (status, last_active_epoch, received_at).
pub presence: RwLock<HashMap<String, (String, u32, u64)>>,
/// Contacts store — alias/notes/pinned/blocked per peer pubkey hex.
@ -121,6 +124,30 @@ pub struct MeshState {
/// persistent contact table from regenerating rows the user just
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
pub radio_contact_blocklist: RwLock<HashSet<String>>,
/// Mesh-AI assistant settings (issue #50): whether this node answers
/// AssistQuery messages with its local LLM, and who may ask. Live-updatable
/// so the UI toggle applies without restarting the listener.
pub assistant: RwLock<AssistantConfig>,
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
/// federation trust list used to gate AI queries) without threading a path
/// through every call.
pub data_dir: std::path::PathBuf,
/// Contact-ids with an AI query currently being answered. Caps each asker to
/// one in-flight query so a peer can't flood the node's compute / airtime.
pub assist_inflight: RwLock<HashSet<u32>>,
}
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
#[derive(Debug, Clone)]
pub struct AssistantConfig {
/// Answer AssistQuery messages with the local LLM.
pub enabled: bool,
/// Model to use; None → the backend's built-in default.
pub model: Option<String>,
/// Restrict asking to federation-Trusted peers (vs. anyone on the mesh).
pub trusted_only: bool,
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
pub backend: String,
}
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
@ -151,8 +178,11 @@ impl MeshState {
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
stego_mode: super::steganography::SteganographyMode,
encrypt_relay: bool,
receive_block_headers: bool,
session_manager: Arc<super::session::SessionManager>,
our_ed_pubkey_hex: String,
assistant: AssistantConfig,
data_dir: std::path::PathBuf,
) -> (
Arc<Self>,
broadcast::Receiver<MeshEvent>,
@ -187,11 +217,15 @@ impl MeshState {
chunk_buffer: RwLock::new(HashMap::new()),
session_manager,
encrypt_relay,
receive_block_headers,
presence: RwLock::new(HashMap::new()),
contacts: RwLock::new(HashMap::new()),
our_ed_pubkey_hex,
blob_store: RwLock::new(None),
radio_contact_blocklist: RwLock::new(HashSet::new()),
assistant: RwLock::new(assistant),
data_dir,
assist_inflight: RwLock::new(HashSet::new()),
});
(state, rx, cmd_rx)
}

View File

@ -363,9 +363,9 @@ pub(super) async fn run_mesh_session(
state: &Arc<MeshState>,
preferred_path: Option<&str>,
our_did: &str,
_our_ed_pubkey_hex: &str,
our_ed_pubkey_hex: &str,
our_x25519_secret: &[u8; 32],
_our_x25519_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
server_name: Option<&str>,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
@ -424,6 +424,22 @@ pub(super) async fn run_mesh_session(
warn!("Failed to send initial advert: {}", e);
}
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
// text so peers can bind our radio presence to our DID + keys. The firmware
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
// is what makes trust-gating + encrypted DMs work across BOTH transports.
let identity_advert = super::super::protocol::encode_identity_broadcast(
our_did,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
);
if let Err(e) = device
.send_channel_text(0, identity_advert.as_bytes())
.await
{
warn!("Failed to broadcast archipelago identity: {}", e);
}
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
@ -491,6 +507,11 @@ pub(super) async fn run_mesh_session(
} else {
consecutive_write_failures = 0;
}
// Re-broadcast archipelago identity so peers that joined since
// startup (or missed it) can bind our DID/keys.
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
warn!("Failed to re-broadcast archipelago identity: {}", e);
}
refresh_contacts(&mut device, state).await;
}

View File

@ -70,6 +70,12 @@ pub enum MeshMessageType {
/// MCIIXXTT framing) and the peer has no Tor path. Recipient writes the
/// bytes to its local BlobStore on reassembly.
ContentInline = 23,
/// "Ask the node's AI" — a prompt to be answered by the receiving node's
/// local LLM (issue #50). Gated by the assistant config + trust policy.
AssistQuery = 24,
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
AssistResponse = 25,
}
impl MeshMessageType {
@ -99,6 +105,8 @@ impl MeshMessageType {
21 => Some(Self::ChannelInvite),
22 => Some(Self::ContactCard),
23 => Some(Self::ContentInline),
24 => Some(Self::AssistQuery),
25 => Some(Self::AssistResponse),
_ => None,
}
}
@ -132,6 +140,8 @@ impl MeshMessageType {
"channel_invite" => Some(Self::ChannelInvite),
"contact_card" => Some(Self::ContactCard),
"content_inline" => Some(Self::ContentInline),
"assist_query" => Some(Self::AssistQuery),
"assist_response" => Some(Self::AssistResponse),
_ => None,
}
}
@ -162,6 +172,8 @@ impl MeshMessageType {
Self::ChannelInvite => "channel_invite",
Self::ContactCard => "contact_card",
Self::ContentInline => "content_inline",
Self::AssistQuery => "assist_query",
Self::AssistResponse => "assist_response",
}
}
}
@ -407,6 +419,37 @@ pub struct LightningRelayPayload {
pub request_id: u64,
}
/// "Ask the node's AI" request (issue #50). Sent to a peer running a local
/// LLM; answered with one or more `AssistResponsePayload` chunks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistQueryPayload {
/// Asker-chosen id correlating the query with its response chunks.
pub req_id: u64,
/// The natural-language prompt.
pub prompt: String,
/// Optional model override; falls back to the responder's configured model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// One chunk of an AI answer, addressed back to the asker by `req_id`.
/// Airtime is scarce, so long answers are capped and split into ordered chunks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistResponsePayload {
pub req_id: u64,
/// This chunk's text.
pub text: String,
/// 0-based chunk index.
#[serde(default)]
pub seq: u16,
/// True on the final chunk.
#[serde(default)]
pub done: bool,
/// Set instead of `text` when the query failed (model unreachable, denied…).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Lightning relay response (proof of payment).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LightningRelayResponsePayload {

View File

@ -14,6 +14,7 @@ pub mod message_types;
pub mod outbox;
pub mod protocol;
pub mod ratchet;
pub mod scheduler;
pub mod serial;
pub mod session;
pub mod steganography;
@ -88,6 +89,38 @@ pub(crate) async fn upsert_federation_peer(
contact_id
}
/// Purge a federation peer from all live mesh state and persisted contacts so
/// removing a node (federation.remove-node) also clears its chat contact,
/// thread, and any per-contact customisation — otherwise a stale/renamed node
/// (e.g. an old "Arch HP" entry) lingers in the chat list even after it's gone
/// from `nodes.json` (#2). Keyed by the synthetic `contact_id` for the peer
/// table/messages and by `pubkey_hex` for the pubkey-keyed contacts/presence
/// stores.
pub(crate) async fn purge_federation_peer(
state: &Arc<listener::MeshState>,
contact_id: u32,
pubkey_hex: &str,
data_dir: &Path,
) {
state.peers.write().await.remove(&contact_id);
state.shared_secrets.write().await.remove(&contact_id);
state
.messages
.write()
.await
.retain(|m| m.peer_contact_id != contact_id);
state.presence.write().await.remove(pubkey_hex);
let mut contacts = state.contacts.write().await;
if contacts.remove(pubkey_hex).is_some() {
let snapshot = contacts.clone();
drop(contacts);
if let Err(e) = save_mesh_contacts(data_dir, &snapshot).await {
warn!("Failed to persist mesh contacts after purge: {}", e);
}
}
state.update_peer_count().await;
}
/// Load federation nodes from disk and upsert each as a synthetic mesh peer.
/// Called at MeshService startup so the chat list already contains every
/// known federation node — users can share files to them without first
@ -137,6 +170,10 @@ pub struct MeshConfig {
/// Announce new Bitcoin block headers over mesh (internet-connected nodes only).
#[serde(default)]
pub announce_block_headers: bool,
/// Accept Bitcoin block headers received over mesh from peers. On by default;
/// turn off to ignore inbound headers (the receive half of issue #28).
#[serde(default = "default_true")]
pub receive_block_headers: bool,
/// Steganographic encoding mode for mesh messages (Normal = disabled).
#[serde(default)]
pub steganography_mode: steganography::SteganographyMode,
@ -144,6 +181,26 @@ pub struct MeshConfig {
/// Set to false to disable encryption for debugging or rollback.
#[serde(default = "default_true")]
pub encrypt_relay_messages: bool,
/// Answer AI queries (AssistQuery) from peers using this node's local LLM
/// (issue #50). Off by default — the node only becomes a mesh AI on opt-in.
#[serde(default)]
pub assistant_enabled: bool,
/// Ollama model used to answer AI queries. None → the built-in default.
#[serde(default)]
pub assistant_model: Option<String>,
/// When true (default), only federation-Trusted peers may ask; when false,
/// any peer on the mesh may ask (spends this node's compute + airtime).
#[serde(default = "default_true")]
pub assistant_trusted_only: bool,
/// Which AI backend answers queries: "claude" (the shared Claude proxy
/// token at secrets/claude-api-key — default for now, works without a
/// local GPU) or "ollama" (a local model on this node).
#[serde(default = "default_assistant_backend")]
pub assistant_backend: String,
}
fn default_assistant_backend() -> String {
"claude".to_string()
}
fn default_true() -> bool {
@ -160,8 +217,13 @@ impl Default for MeshConfig {
advert_name: None,
mesh_only_mode: None,
announce_block_headers: false,
receive_block_headers: true,
steganography_mode: steganography::SteganographyMode::Normal,
encrypt_relay_messages: true,
assistant_enabled: false,
assistant_model: None,
assistant_trusted_only: true,
assistant_backend: default_assistant_backend(),
}
}
}
@ -290,6 +352,7 @@ pub struct MeshService {
deadman_handle: Option<tokio::task::JoinHandle<()>>,
block_announcer_handle: Option<tokio::task::JoinHandle<()>>,
presence_handle: Option<tokio::task::JoinHandle<()>>,
scheduler_handle: Option<tokio::task::JoinHandle<()>>,
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
// Crypto identity for this node
our_did: String,
@ -303,6 +366,8 @@ pub struct MeshService {
pub block_header_cache: Arc<BlockHeaderCache>,
pub relay_tracker: Arc<RelayTracker>,
pub dead_man_switch: Arc<DeadManSwitch>,
/// Scheduled / queued outbound mesh messages (issue #50, phase 1.7).
pub scheduler: Arc<scheduler::MeshScheduler>,
}
impl MeshService {
@ -328,8 +393,16 @@ impl MeshService {
Some(Arc::clone(&relay_tracker)),
config.steganography_mode,
config.encrypt_relay_messages,
config.receive_block_headers,
Arc::clone(&session_manager),
ed_pubkey_hex.to_string(),
listener::AssistantConfig {
enabled: config.assistant_enabled,
model: config.assistant_model.clone(),
trusted_only: config.assistant_trusted_only,
backend: config.assistant_backend.clone(),
},
data_dir.to_path_buf(),
);
// Derive X25519 keys from Ed25519 identity
@ -386,6 +459,7 @@ impl MeshService {
deadman_handle: None,
block_announcer_handle: None,
presence_handle: None,
scheduler_handle: None,
cmd_rx: Some(cmd_rx),
our_did: did.to_string(),
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
@ -396,6 +470,7 @@ impl MeshService {
block_header_cache,
relay_tracker,
dead_man_switch,
scheduler: Arc::new(scheduler::MeshScheduler::load(data_dir).await),
})
}
@ -473,6 +548,20 @@ impl MeshService {
});
self.deadman_handle = Some(dms_handle);
// Scheduled-message task (issue #50, phase 1.7): fires queued messages
// when due, retrying peer DMs until the peer is back in range.
let sched = Arc::clone(&self.scheduler);
let sched_state = Arc::clone(&self.state);
let sched_shutdown = self
.shutdown_tx
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Shutdown channel not initialized"))?
.subscribe();
let sched_handle = tokio::spawn(async move {
scheduler::run_scheduler(sched, sched_state, sched_shutdown).await;
});
self.scheduler_handle = Some(sched_handle);
// Spawn block header announcer (internet-connected nodes only)
if self.config.announce_block_headers {
let bha_state = Arc::clone(&self.state);
@ -623,6 +712,10 @@ impl MeshService {
handle.abort();
let _ = handle.await;
}
if let Some(handle) = self.scheduler_handle.take() {
handle.abort();
let _ = handle.await;
}
if let Some(handle) = self.block_announcer_handle.take() {
handle.abort();
let _ = handle.await;
@ -825,15 +918,33 @@ impl MeshService {
let is_federation_synthetic = contact_id & 0x8000_0000 != 0;
let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN;
if is_federation_synthetic || exceeds_lora {
let (peer_pubkey, peer_did) = {
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
// but fall back to federation storage for federation-synthetic ids
// that were never seeded into `state.peers` — e.g. a radio-less
// node where the mesh device table is empty. Without this fallback
// chatting a federation contact bails "Unknown federation peer"
// even though we know its onion from nodes.json.
let from_table = {
let peers = self.state.peers.read().await;
match peers.get(&contact_id) {
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
None if is_federation_synthetic => {
anyhow::bail!("Unknown federation peer {}", contact_id);
peers
.get(&contact_id)
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
};
let (peer_pubkey, peer_did) = match from_table {
Some(v) => v,
None if is_federation_synthetic => {
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
match nodes
.iter()
.find(|n| federation_peer_contact_id(&n.pubkey) == contact_id)
{
Some(n) => (Some(n.pubkey.clone()), Some(n.did.clone())),
None => anyhow::bail!("Unknown federation peer {}", contact_id),
}
None => (None, None),
}
None => (None, None),
};
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
@ -1276,6 +1387,51 @@ impl MeshService {
Ok(())
}
/// Current mesh-AI assistant settings (issue #50).
pub async fn assistant_config(&self) -> listener::AssistantConfig {
self.state.assistant.read().await.clone()
}
/// Update the mesh-AI assistant settings live (no listener restart) and
/// persist them to the mesh config. `model: Some(None)` clears the override
/// (falls back to the built-in default); `None` leaves a field unchanged.
pub async fn configure_assistant(
&self,
enabled: Option<bool>,
model: Option<Option<String>>,
trusted_only: Option<bool>,
backend: Option<String>,
) -> Result<()> {
{
let mut a = self.state.assistant.write().await;
if let Some(e) = enabled {
a.enabled = e;
}
if let Some(m) = model {
a.model = m;
}
if let Some(t) = trusted_only {
a.trusted_only = t;
}
if let Some(b) = backend {
a.backend = b;
}
}
// Persist by updating the on-disk config (the in-memory `self.config`
// snapshot stays as-is; the live `state.assistant` is the runtime
// source of truth and is re-seeded from disk on the next start).
let mut cfg = load_config(&self.data_dir).await.unwrap_or_default();
{
let a = self.state.assistant.read().await;
cfg.assistant_enabled = a.enabled;
cfg.assistant_model = a.model.clone();
cfg.assistant_trusted_only = a.trusted_only;
cfg.assistant_backend = a.backend.clone();
}
save_config(&self.data_dir, &cfg).await?;
Ok(())
}
/// Update mesh configuration.
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?;

View File

@ -0,0 +1,217 @@
//! Scheduled / queued mesh messages (issue #50, phase 1.7).
//!
//! A small persisted queue of messages to send at a future time. A background
//! task fires due messages via the listener. A message addressed to a peer that
//! isn't currently in the contact table stays queued and retries on later ticks
//! — i.e. it sends itself when the peer comes back in range.
use super::listener::{MeshCommand, MeshState};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::{watch, RwLock};
use tracing::warn;
const SCHEDULER_FILE: &str = "mesh-scheduled.json";
/// Wake interval for firing due messages.
const TICK_SECS: u64 = 10;
/// Drop a still-undeliverable message after this many attempts (~1h at 10s).
const MAX_ATTEMPTS: u32 = 360;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledMessage {
pub id: u64,
/// Direct-message target (peer contact_id), or None for a channel broadcast.
#[serde(default)]
pub contact_id: Option<u32>,
/// Channel to broadcast on, or None for a direct message.
#[serde(default)]
pub channel: Option<u8>,
pub body: String,
/// Unix seconds when the message becomes due.
pub fire_at: i64,
#[serde(default)]
pub attempts: u32,
}
pub struct MeshScheduler {
path: PathBuf,
queue: RwLock<Vec<ScheduledMessage>>,
next_id: RwLock<u64>,
}
impl MeshScheduler {
pub async fn load(data_dir: &Path) -> Self {
let path = data_dir.join(SCHEDULER_FILE);
let queue: Vec<ScheduledMessage> = match fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => Vec::new(),
};
let next = queue.iter().map(|m| m.id).max().unwrap_or(0) + 1;
Self {
path,
queue: RwLock::new(queue),
next_id: RwLock::new(next),
}
}
async fn save(&self) -> Result<()> {
let json = {
let q = self.queue.read().await;
serde_json::to_string_pretty(&*q).context("serialize scheduled queue")?
};
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).await.ok();
}
fs::write(&self.path, json)
.await
.context("write scheduled queue")?;
Ok(())
}
pub async fn add(
&self,
contact_id: Option<u32>,
channel: Option<u8>,
body: String,
fire_at: i64,
) -> Result<ScheduledMessage> {
let id = {
let mut n = self.next_id.write().await;
let id = *n;
*n += 1;
id
};
let msg = ScheduledMessage {
id,
contact_id,
channel,
body,
fire_at,
attempts: 0,
};
self.queue.write().await.push(msg.clone());
self.save().await?;
Ok(msg)
}
pub async fn list(&self) -> Vec<ScheduledMessage> {
let mut v = self.queue.read().await.clone();
v.sort_by_key(|m| m.fire_at);
v
}
pub async fn cancel(&self, id: u64) -> Result<bool> {
let removed = {
let mut q = self.queue.write().await;
let before = q.len();
q.retain(|m| m.id != id);
q.len() != before
};
if removed {
self.save().await?;
}
Ok(removed)
}
}
/// Background loop: every `TICK_SECS`, fire any due messages.
pub async fn run_scheduler(
scheduler: Arc<MeshScheduler>,
state: Arc<MeshState>,
mut shutdown: watch::Receiver<bool>,
) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECS));
loop {
tokio::select! {
_ = interval.tick() => fire_due(&scheduler, &state).await,
_ = shutdown.changed() => {
if *shutdown.borrow() { return; }
}
}
}
}
async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
let now = chrono::Utc::now().timestamp();
let due: Vec<ScheduledMessage> = scheduler
.queue
.read()
.await
.iter()
.filter(|m| m.fire_at <= now)
.cloned()
.collect();
if due.is_empty() {
return;
}
let mut delivered: Vec<u64> = Vec::new();
let mut failed: Vec<u64> = Vec::new();
for msg in &due {
if try_send(state, msg).await {
delivered.push(msg.id);
} else {
failed.push(msg.id);
}
}
let mut to_remove = delivered;
{
let mut q = scheduler.queue.write().await;
for m in q.iter_mut() {
if failed.contains(&m.id) {
m.attempts += 1;
if m.attempts >= MAX_ATTEMPTS {
warn!(
id = m.id,
attempts = m.attempts,
"Dropping undeliverable scheduled message"
);
to_remove.push(m.id);
}
}
}
q.retain(|m| !to_remove.contains(&m.id));
}
let _ = scheduler.save().await;
}
/// Hand a due message to the radio. Returns true if it was sent (or should be
/// dropped); false to keep it queued for a later retry (peer not in range yet).
async fn try_send(state: &Arc<MeshState>, msg: &ScheduledMessage) -> bool {
let payload = msg.body.clone().into_bytes();
if let Some(channel) = msg.channel {
return state
.send_cmd(MeshCommand::BroadcastChannel { channel, payload })
.await
.is_ok();
}
if let Some(contact_id) = msg.contact_id {
let pubkey = {
let peers = state.peers.read().await;
peers.get(&contact_id).and_then(|p| p.pubkey_hex.clone())
};
if let Some(pk) = pubkey {
if let Ok(bytes) = hex::decode(&pk) {
if bytes.len() >= 6 {
let mut dest = [0u8; 6];
dest.copy_from_slice(&bytes[..6]);
return state
.send_cmd(MeshCommand::SendText {
dest_pubkey_prefix: dest,
payload,
})
.await
.is_ok();
}
}
}
// Peer unknown / not in range yet — keep queued, retry next tick.
return false;
}
warn!("Scheduled message has neither channel nor contact_id — dropping");
true
}

View File

@ -161,4 +161,15 @@ pub enum MeshEvent {
payment_hash: Option<String>,
error: Option<String>,
},
/// An AI query arrived from a peer and was accepted for answering (#50).
AssistQueryReceived {
from_contact_id: u32,
prompt: String,
},
/// A local-AI answer finished sending back to the asker (or failed) (#50).
AssistResponseReady {
req_id: u64,
to_contact_id: u32,
error: Option<String>,
},
}

View File

@ -17,6 +17,10 @@ pub struct IncomingMessage {
/// Sender's node name (for display in group chat).
#[serde(default)]
pub from_name: Option<String>,
/// Sender-assigned unique id for the message. Used to dedup reliably even
/// when a slow-Tor retry/redelivery arrives outside the time window (#31).
#[serde(default)]
pub msg_id: Option<String>,
pub message: String,
pub timestamp: String,
/// "sent" or "received"
@ -141,18 +145,34 @@ fn persist() {
}
/// Store a received message (called from HTTP handler).
pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) {
pub fn store_received_sync(
from_pubkey: &str,
message: &str,
from_name: Option<&str>,
msg_id: Option<&str>,
) {
let ts = chrono::Utc::now().to_rfc3339();
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
// Deduplication: skip if same pubkey + message within last 30 seconds
let dominated = guard.messages.iter().rev().take(20).any(|m| {
m.from_pubkey == from_pubkey
&& m.message == message
&& m.direction == "received"
&& within_seconds(&m.timestamp, &ts, 30)
});
if dominated {
// Deduplication. When the sender supplied a unique id, dedup on
// (from_pubkey, msg_id) across all retained history — this is robust even
// when a slow-Tor redelivery arrives well outside any time window (#31).
// Older senders send no id; fall back to the legacy same-pubkey+message
// within-30s heuristic.
let duplicate = if let Some(id) = msg_id {
guard
.messages
.iter()
.any(|m| m.from_pubkey == from_pubkey && m.msg_id.as_deref() == Some(id))
} else {
guard.messages.iter().rev().take(20).any(|m| {
m.from_pubkey == from_pubkey
&& m.message == message
&& m.direction == "received"
&& within_seconds(&m.timestamp, &ts, 30)
})
};
if duplicate {
return;
}
@ -160,6 +180,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
from_pubkey: from_pubkey.to_string(),
from_onion: None,
from_name: from_name.map(|s| s.to_string()),
msg_id: msg_id.map(|s| s.to_string()),
message: message.to_string(),
timestamp: ts,
direction: "received".to_string(),
@ -169,8 +190,13 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
persist();
}
pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) {
store_received_sync(from_pubkey, message, from_name);
pub async fn store_received(
from_pubkey: &str,
message: &str,
from_name: Option<&str>,
msg_id: Option<&str>,
) {
store_received_sync(from_pubkey, message, from_name, msg_id);
}
/// Store a sent message (for display in Archipelago channel).
@ -180,6 +206,7 @@ pub fn store_sent(message: &str) {
from_pubkey: "me".to_string(),
from_onion: None,
from_name: None,
msg_id: None,
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
direction: "sent".to_string(),
@ -335,6 +362,9 @@ pub async fn send_to_peer(
"message": payload_message,
"timestamp": chrono::Utc::now().to_rfc3339(),
"encrypted": encrypted,
// Unique per-message id so receivers can dedup reliably even across
// slow-Tor retries/redeliveries (#31). Old receivers ignore it.
"msg_id": uuid::Uuid::new_v4().to_string(),
});
if let Some(name) = from_name {
body["from_name"] = serde_json::Value::String(name.to_string());

View File

@ -27,7 +27,7 @@ const D_TAG: &str = "archipelago-node";
const LEGACY_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://relay.nostr.info"];
/// Load or create Nostr keys (secp256k1) for node discovery.
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
@ -78,7 +78,7 @@ async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>>
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
/// Requires tor_proxy to avoid leaking IP to relay operators.
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
pub(crate) fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
let client = if let Some(proxy_str) = tor_proxy {
let addr = parse_proxy_addr(proxy_str)
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;

View File

@ -18,6 +18,7 @@ const RESERVED_PORTS: &[u16] = &[
4080, 8999, 50001, // Mempool stack
23000, // BTCPay
8173, 8174, 8175, // Fedimint
8178, // Fedimint client daemon (fedimint-clientd REST)
8123, // Home Assistant
3000, // Grafana
11434, // Ollama

View File

@ -8,6 +8,8 @@
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key
//! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key
//! ├── HKDF(seed, "archipelago/release/root/ed25519/v1") → Release-root signing key
//! │ (publisher-only; nodes pin the PUBLIC key — see trust::anchor)
//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519
//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
@ -34,6 +36,7 @@ const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
// ─── MasterSeed ─────────────────────────────────────────────────────────
@ -88,6 +91,21 @@ pub fn derive_node_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
Ok(SigningKey::from_bytes(&derived))
}
/// Derive the fleet **release-root** Ed25519 signing key.
///
/// This is a *publisher-side* derivation: only the holder of the release master
/// seed runs it (e.g. in the signing ceremony). Fleet nodes never derive this —
/// they pin the corresponding PUBLIC key as a trust anchor (see
/// `crate::trust::anchor`) and use it to verify signed manifests/catalogs.
///
/// Keeping it seed-derived means the signing key is reproducible from a
/// backed-up mnemonic (disaster recovery) rather than a loose key file, and it
/// is domain-separated from every node/identity key by its HKDF info string.
pub fn derive_release_root_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
let derived = hkdf_derive_32(seed.as_bytes(), RELEASE_ROOT_ED25519_INFO)?;
Ok(SigningKey::from_bytes(&derived))
}
/// Derive an identity's Ed25519 signing key by index.
pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result<SigningKey> {
let info = format!("archipelago/identity/{}/ed25519/v1", index);
@ -560,4 +578,41 @@ mod tests {
"3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831"
);
}
#[test]
fn test_release_root_deterministic_and_domain_separated() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let a = derive_release_root_ed25519(&seed).unwrap();
let b = derive_release_root_ed25519(&seed).unwrap();
assert_eq!(
a.verifying_key().as_bytes(),
b.verifying_key().as_bytes(),
"Same mnemonic must produce the same release-root key"
);
// Must NOT collide with the node key — different HKDF domain.
let node = derive_node_ed25519(&seed).unwrap();
assert_ne!(
a.verifying_key().as_bytes(),
node.verifying_key().as_bytes(),
"Release-root key must be domain-separated from the node key"
);
}
#[test]
fn test_release_root_known_answer() {
// KAT pins the derivation so the signing ceremony, the pinned anchor,
// and any external verifier agree on the bytes for a given mnemonic.
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let key = derive_release_root_ed25519(&seed).unwrap();
assert_eq!(
hex::encode(key.to_bytes()),
"613ab879e5fbd4fcded32bc7ffad662fff1ce0f744c69baa63e7416ffabe7b71",
"release-root private key KAT"
);
assert_eq!(
hex::encode(key.verifying_key().to_bytes()),
"995eaf9188617f0ecbcff9cd44d57adb9aa7dd5f34db2733e97f3e317fb0aba2",
"release-root public key KAT"
);
}
}

View File

@ -150,6 +150,31 @@ impl Server {
}
}
// DHT swarm-assist (Phase 3): build the iroh provider once at startup so
// release downloads can fetch from peers (origin always wins) and seed
// what they hold. Inert unless built with `iroh-swarm` AND swarm_enabled.
if let Err(e) = crate::swarm::init(
&config.data_dir,
&config.nostr_relays,
config.nostr_tor_proxy.as_deref(),
config.swarm_enabled,
)
.await
{
tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e);
}
// Resume any cross-mint ecash swap interrupted by a previous crash
// (paid the source mint but never claimed the target tokens). Best-effort.
match crate::wallet::ecash::resume_pending_swaps(&config.data_dir).await {
Ok(0) => {}
Ok(reclaimed) => tracing::info!(
"Resumed interrupted cross-mint swaps: reclaimed {} sats",
reclaimed
),
Err(e) => tracing::debug!("resume_pending_swaps (non-fatal): {}", e),
}
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
let identity_dir = config.data_dir.join("identity");
let tor_proxy_revoke = config.nostr_tor_proxy.clone();
@ -241,6 +266,39 @@ impl Server {
warn!("Mesh service start failed (non-fatal): {}", e);
} else {
info!("📡 Mesh networking started");
// Push mesh peer changes to open WebSockets instantly
// instead of the UI polling every 5s (#48): subscribe to
// mesh events and nudge the data-model revision (debounced)
// so /ws/db clients refetch peers on discovery/update.
let mut rx = mesh_service.state().event_tx.subscribe();
let sm = state_manager.clone();
tokio::spawn(async move {
use tokio::time::{Duration, Instant};
let mut last: Option<Instant> = None;
loop {
match rx.recv().await {
Ok(crate::mesh::MeshEvent::PeerDiscovered(_))
| Ok(crate::mesh::MeshEvent::PeerUpdated(_)) => {
// Debounce advert storms to ~2 Hz.
if last
.map(|t| t.elapsed() < Duration::from_millis(500))
.unwrap_or(false)
{
continue;
}
last = Some(Instant::now());
let (data, _) = sm.get_snapshot().await;
sm.update_data(data).await;
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(
_,
)) => continue,
Err(_) => break, // sender dropped → mesh stopped
}
}
});
}
}
api_handler
@ -508,6 +566,7 @@ impl Server {
{
let data_dir = config.data_dir.clone();
let state = state_manager.clone();
let rpc = api_handler.rpc_handler().clone();
tokio::spawn(async move {
// First run 60s after boot to let onboarding settle.
tokio::time::sleep(Duration::from_secs(60)).await;
@ -558,6 +617,10 @@ impl Server {
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
// After syncing every peer, push the names/roster just
// learned (into nodes.json) into the live mesh peer table
// so chat contacts refresh without a restart (#42).
rpc.refresh_federation_mesh_peers().await;
}
});
}
@ -1255,6 +1318,7 @@ fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port:
fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"fedimint-clientd" => Some(8178),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),

View File

@ -0,0 +1,263 @@
//! iroh-blobs swarm provider — the DHT Phase 2 engine, gated behind the
//! `iroh-swarm` feature (heavy QUIC dep tree, off by default).
//!
//! Stands up a real iroh node: binds a QUIC [`Endpoint`], opens a persistent
//! blob [`FsStore`] under `data_dir/iroh-blobs`, and serves blobs over the
//! iroh-blobs protocol — so a node that *fetches* content also *seeds* it
//! afterwards. Content is addressed by BLAKE3 ([`Hash`]) and range-verified by
//! iroh on arrival.
//!
//! This provider is an optimization beneath the origin HTTP path: the [`super`]
//! swarm seam falls back to origin whenever [`try_fetch`](IrohProvider::try_fetch)
//! returns `Ok(false)` (no known seeds) or `Err` (transient swarm failure).
//!
//! ## Discovery boundary (Phase 3)
//! Downloading needs the [`EndpointId`]s of peers that hold the hash. That
//! discovery — design Phase 3, *signed Nostr advertisement events* mapping
//! `{content-hash → provider endpoint}` — is injected via [`ProviderDiscovery`].
//! Until it is wired, discovery yields nothing and every fetch defers to origin,
//! so enabling the feature is safe (never worse than today).
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId};
use iroh_blobs::{store::fs::FsStore, BlobsProtocol, Hash};
use super::payment::PaymentPolicy;
use super::BlobProvider;
use crate::content_hash::{ContentDigest, HashAlg};
/// Resolves which peers are believed to hold a given content hash.
///
/// Phase 3 (signed Nostr advertisement events) provides the production impl
/// [`NostrSeedDiscovery`]; `None` discovery means "origin-only" — a safe
/// default. The query is async (it hits relays), so the trait is async.
#[async_trait]
pub trait ProviderDiscovery: Send + Sync {
/// Candidate seed endpoints for `hash` (may be empty).
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId>;
}
/// Production [`ProviderDiscovery`]: reads signed seed advertisements from Nostr
/// relays and parses the advertised endpoint-id strings into [`EndpointId`]s.
///
/// Unparseable ids are skipped (an advert from an incompatible/garbage peer must
/// not abort discovery). Reuses the node's existing relay list + Tor proxy.
pub struct NostrSeedDiscovery {
relays: Vec<String>,
tor_proxy: Option<String>,
}
impl NostrSeedDiscovery {
pub fn new(relays: Vec<String>, tor_proxy: Option<String>) -> Self {
Self { relays, tor_proxy }
}
}
#[async_trait]
impl ProviderDiscovery for NostrSeedDiscovery {
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId> {
let hex = hash.to_hex();
let ids = super::seed_advert::fetch_seed_endpoint_ids(
&self.relays,
self.tor_proxy.as_deref(),
&hex,
)
.await;
ids.into_iter()
.filter_map(|s| match EndpointId::from_str(&s) {
Ok(id) => Some(id),
Err(e) => {
tracing::debug!("swarm: skipping unparseable seed endpoint id {s}: {e}");
None
}
})
.collect()
}
}
/// Fetches content-addressed blobs from the iroh swarm, and seeds what it has.
#[allow(dead_code)] // constructed once Phase 3 discovery is wired into providers()
pub struct IrohProvider {
endpoint: Endpoint,
store: FsStore,
/// Kept alive so the node keeps accepting blob-protocol connections (seeds).
_router: Router,
discovery: Option<Arc<dyn ProviderDiscovery>>,
/// Where pricing/session/wallet state lives — for paid-fetch negotiation.
data_dir: std::path::PathBuf,
/// Willingness to pay swarm peers when fetching. Defaults to
/// [`PaymentPolicy::free`]: never pay (releases/catalog stay free), so a
/// seeder that prices a blob is skipped → origin. A future film fetch can
/// pass a real budget.
pay_policy: PaymentPolicy,
}
#[allow(dead_code)]
impl IrohProvider {
/// Bind an iroh endpoint, open the persistent blob store at
/// `data_dir/iroh-blobs`, and start serving blobs (seed capability).
pub async fn new(
data_dir: &Path,
discovery: Option<Arc<dyn ProviderDiscovery>>,
) -> Result<Self> {
let root = data_dir.join("iroh-blobs");
tokio::fs::create_dir_all(&root).await.ok();
let store = FsStore::load(&root)
.await
.map_err(|e| anyhow::anyhow!("open iroh blob store: {e}"))?;
let endpoint = Endpoint::bind(presets::N0)
.await
.map_err(|e| anyhow::anyhow!("bind iroh endpoint: {e}"))?;
// Serve blobs: a node that fetches a blob can then seed it to others.
// The event sender gates each request through the ecash `streaming` layer
// — free by default, paid only if the operator priced `content-download`
// (Networking Profits → Settings). It also hard-disables peer writes.
let event_sender =
super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone());
let blobs = BlobsProtocol::new(&store, Some(event_sender));
// Shape-A paid negotiation rides a second ALPN on the same endpoint so a
// downloader can pay (open a session) before the blob-GET above serves it.
let paid =
super::paid_alpn::PaidBlobsProtocol::new(data_dir.to_path_buf(), (*store).clone());
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(super::paid_alpn::PAID_ALPN, paid)
.spawn();
Ok(Self {
endpoint,
store,
_router: router,
discovery,
data_dir: data_dir.to_path_buf(),
pay_policy: PaymentPolicy::free(),
})
}
/// This node's iroh endpoint id — what Phase 3 advertises as a seed address.
pub fn endpoint_id(&self) -> EndpointId {
self.endpoint.id()
}
/// Import a held PUBLIC blob into the seed store and advertise it on Nostr so
/// other nodes can fetch it from us. Call this only for releases/catalog
/// content (the design's privacy scope) — never private user blobs.
///
/// Importing makes us an actual seed: a node that downloaded a release from
/// the HTTP origin can now serve it to peers over iroh-blobs. The advert maps
/// `blake3_hex → this endpoint id`. Defensive check: the bytes we import must
/// hash to what we advertise, so a path/hash mismatch can never publish a lie.
pub async fn seed_and_advertise(
&self,
path: &Path,
blake3_hex: &str,
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<()> {
let expected = {
let raw = hex::decode(blake3_hex).map_err(|e| anyhow::anyhow!("blake3 hex: {e}"))?;
let arr: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?;
Hash::from_bytes(arr)
};
let info = self
.store
.blobs()
.add_path(path)
.await
.map_err(|e| anyhow::anyhow!("import blob into seed store: {e}"))?;
if info.hash != expected {
anyhow::bail!(
"imported blob hash {} != advertised {}",
info.hash.to_hex(),
blake3_hex
);
}
super::seed_advert::publish_seed_advert(
identity_dir,
relays,
tor_proxy,
blake3_hex,
&self.endpoint_id().to_string(),
)
.await
}
}
#[async_trait]
impl BlobProvider for IrohProvider {
fn name(&self) -> &str {
"iroh"
}
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool> {
// iroh addresses content by BLAKE3. A sha256-only digest isn't fetchable
// from the swarm — defer to origin.
if digest.alg != HashAlg::Blake3 {
return Ok(false);
}
let raw = hex::decode(&digest.hex).map_err(|e| anyhow::anyhow!("digest hex: {e}"))?;
let arr: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?;
let hash = Hash::from_bytes(arr);
// Who has it? Without discovery (Phase 3) this is empty → origin wins.
let providers = match &self.discovery {
Some(d) => d.providers_for(&hash).await,
None => Vec::new(),
};
if providers.is_empty() {
return Ok(false);
}
// Shape-A: negotiate paid access with each candidate. Best-effort and
// additive — a peer is dropped only if it explicitly requires a payment
// we won't make under `pay_policy` (free by default → priced seeders are
// skipped). Connect/protocol failures keep the peer; the blob-GET gate is
// the real enforcement and a refused GET still falls back to origin.
let mut allowed = Vec::with_capacity(providers.len());
for peer in providers {
if super::paid_alpn::negotiate_access(
&self.endpoint,
&self.data_dir,
peer,
&digest.hex,
&self.pay_policy,
)
.await
{
allowed.push(peer);
}
}
if allowed.is_empty() {
return Ok(false);
}
// Fetch (range-verified by iroh) then export the verified blob to the
// staging path the caller expects. The seam re-verifies the digest.
let downloader = self.store.downloader(&self.endpoint);
downloader
.download(hash, allowed)
.await
.map_err(|e| anyhow::anyhow!("iroh swarm download: {e}"))?;
self.store
.blobs()
.export(hash, dest)
.await
.map_err(|e| anyhow::anyhow!("export blob to staging: {e}"))?;
Ok(true)
}
}

View File

@ -0,0 +1,377 @@
//! Swarm-assist content fetch — the *transport & swarm* tier of the DHT
//! distribution plan (`docs/dht-distribution-design.md` §4).
//!
//! ## Guiding principle: swarm-assist, origin ALWAYS wins
//! The peer swarm is an optimization layered *above* a proven HTTP path, never
//! in place of it. A node asks each available [`BlobProvider`] (e.g. an
//! iroh-blobs swarm) for content by its [`ContentDigest`]; the first peer that
//! serves bytes which **verify** against the digest wins. If no provider has it
//! — or the swarm is disabled, or every peer is offline — we fall back to the
//! origin HTTP download, which is the guaranteed source of truth. Worst case is
//! exactly today's behaviour.
//!
//! Peer-sourced bytes are UNTRUSTED, so this module verifies them against the
//! content digest before accepting. Origin bytes run through the caller's
//! existing verification (e.g. the SHA-256 gate in `update.rs`).
//!
//! The actual iroh-blobs provider is gated behind the `iroh-swarm` feature
//! (heavy QUIC dep tree); with the feature off, [`providers`] is empty and
//! every fetch goes straight to origin — byte-for-byte today's path.
use std::path::Path;
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use crate::content_hash::ContentDigest;
pub mod payment;
pub mod seed_advert;
#[cfg(feature = "iroh-swarm")]
pub mod iroh_provider;
#[cfg(feature = "iroh-swarm")]
pub mod paid;
#[cfg(feature = "iroh-swarm")]
pub mod paid_alpn;
/// Which source ultimately served the content.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchSource {
/// A peer in the swarm served (and the bytes verified).
Swarm,
/// The origin HTTP fallback served.
Origin,
}
/// A source that may be able to serve content addressed by its digest.
#[async_trait]
pub trait BlobProvider: Send + Sync {
/// Short name for logging (e.g. "iroh").
fn name(&self) -> &str;
/// Try to fetch the content for `digest` into `dest`.
///
/// * `Ok(true)` — bytes written to `dest` (caller verifies the digest).
/// * `Ok(false)` — this provider does not have the content; try the next.
/// * `Err(_)` — a transient failure; try the next provider.
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool>;
}
/// Process-wide swarm runtime, built once at startup by [`init`]. Holding the
/// providers here (rather than rebuilding per download) keeps the iroh endpoint
/// + blob store + protocol router alive for the life of the process, so a node
/// keeps *seeding* between downloads. Empty/inert unless the `iroh-swarm`
/// feature is built AND `swarm_enabled` is set.
struct SwarmRuntime {
providers: Vec<Arc<dyn BlobProvider>>,
/// Context for announcing held public blobs; `None` when seeding is off.
#[cfg(feature = "iroh-swarm")]
announce: Option<AnnounceCtx>,
}
#[cfg(feature = "iroh-swarm")]
struct AnnounceCtx {
iroh: Arc<iroh_provider::IrohProvider>,
relays: Vec<String>,
tor_proxy: Option<String>,
identity_dir: std::path::PathBuf,
}
static RUNTIME: OnceLock<SwarmRuntime> = OnceLock::new();
/// Build the swarm runtime once, at startup. Idempotent: a second call is a
/// no-op (the first registration wins). Safe to call unconditionally — when the
/// `iroh-swarm` feature is absent, or `enabled` is false, it registers an empty
/// runtime so every fetch goes straight to origin (today's path).
///
/// `relays` / `tor_proxy` come from the node's Nostr config and double as the
/// seed-advert transport; `data_dir` hosts the persistent iroh blob store under
/// `data_dir/iroh-blobs` and the node identity under `data_dir/identity`.
pub async fn init(
data_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
enabled: bool,
) -> Result<()> {
if RUNTIME.get().is_some() {
return Ok(());
}
#[cfg(not(feature = "iroh-swarm"))]
{
let _ = (data_dir, relays, tor_proxy);
if enabled {
warn!("swarm: swarm_enabled set but binary built without the `iroh-swarm` feature — staying origin-only");
}
let _ = RUNTIME.set(SwarmRuntime {
providers: Vec::new(),
});
return Ok(());
}
#[cfg(feature = "iroh-swarm")]
{
if !enabled {
info!("swarm: disabled (swarm_enabled=false) — origin-only");
let _ = RUNTIME.set(SwarmRuntime {
providers: Vec::new(),
announce: None,
});
return Ok(());
}
let discovery: Arc<dyn iroh_provider::ProviderDiscovery> = Arc::new(
iroh_provider::NostrSeedDiscovery::new(relays.to_vec(), tor_proxy.map(str::to_string)),
);
let provider = Arc::new(iroh_provider::IrohProvider::new(data_dir, Some(discovery)).await?);
info!(
"swarm: iroh provider active (endpoint {}) — swarm-assist enabled, origin always wins",
provider.endpoint_id()
);
let providers: Vec<Arc<dyn BlobProvider>> = vec![provider.clone()];
let _ = RUNTIME.set(SwarmRuntime {
providers,
announce: Some(AnnounceCtx {
iroh: provider,
relays: relays.to_vec(),
tor_proxy: tor_proxy.map(str::to_string),
identity_dir: data_dir.join("identity"),
}),
});
Ok(())
}
}
/// The ordered list of swarm providers to consult before the origin.
///
/// Empty until [`init`] registers a provider (needs the `iroh-swarm` feature +
/// `swarm_enabled`). While empty, [`fetch_content_addressed`] goes straight to
/// origin — byte-for-byte today's path.
pub fn providers() -> Vec<Arc<dyn BlobProvider>> {
RUNTIME
.get()
.map(|r| r.providers.clone())
.unwrap_or_default()
}
/// Announce that this node now holds a PUBLIC release/catalog blob (addressed by
/// `blake3_hex`, bytes at `path`) so peers can fetch it from us: import it into
/// the seed store and publish a signed Nostr advert. Best-effort and inert
/// unless the iroh provider is active — a failure never affects the install.
///
/// **Scope:** call only for releases/catalog content, never private user blobs.
pub async fn announce_held_blob(_blake3_hex: &str, _path: &Path) {
#[cfg(feature = "iroh-swarm")]
{
let Some(rt) = RUNTIME.get() else { return };
let Some(ctx) = rt.announce.as_ref() else {
return;
};
if let Err(e) = ctx
.iroh
.seed_and_advertise(
_path,
_blake3_hex,
&ctx.identity_dir,
&ctx.relays,
ctx.tor_proxy.as_deref(),
)
.await
{
warn!("swarm: failed to announce held blob {_blake3_hex}: {e}");
}
}
}
/// Fetch content-addressed bytes: swarm-assist, origin always wins.
///
/// Tries each provider in order; the first to write bytes that VERIFY against
/// `digest` wins and returns [`FetchSource::Swarm`]. If none succeed, runs
/// `origin` (the guaranteed HTTP fallback) and returns [`FetchSource::Origin`].
/// A node that obtained bytes from the swarm has, by definition, a verified
/// copy it can itself seed afterwards.
pub async fn fetch_content_addressed<F, Fut>(
digest: &ContentDigest,
providers: &[Arc<dyn BlobProvider>],
dest: &Path,
origin: F,
) -> Result<FetchSource>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
for provider in providers {
match provider.try_fetch(digest, dest).await {
Ok(true) => match verify_dest(digest, dest).await {
Ok(()) => {
info!("swarm: {} served {} (verified)", provider.name(), digest);
return Ok(FetchSource::Swarm);
}
Err(e) => {
// A peer served bytes that don't match the digest — could be
// corruption or a malicious seed. Discard and try the next
// source; never let unverified peer bytes through.
warn!(
"swarm: {} served bytes failing verification for {}: {} — discarding",
provider.name(),
digest,
e
);
let _ = tokio::fs::remove_file(dest).await;
}
},
Ok(false) => debug!("swarm: {} does not have {}", provider.name(), digest),
Err(e) => debug!("swarm: {} failed for {}: {}", provider.name(), digest, e),
}
}
debug!(
"swarm: no provider served {} — falling back to origin",
digest
);
origin().await?;
Ok(FetchSource::Origin)
}
/// Read `dest` and verify it hashes to `digest`.
async fn verify_dest(digest: &ContentDigest, dest: &Path) -> Result<()> {
let bytes = tokio::fs::read(dest).await?;
digest.verify(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
fn digest_of(bytes: &[u8]) -> ContentDigest {
ContentDigest::parse(&format!(
"blake3:{}",
crate::content_hash::blake3_hex(bytes)
))
.unwrap()
}
/// Provider that writes a fixed payload (which may or may not match).
struct FixedProvider {
name: &'static str,
payload: Option<Vec<u8>>,
}
#[async_trait]
impl BlobProvider for FixedProvider {
fn name(&self) -> &str {
self.name
}
async fn try_fetch(&self, _d: &ContentDigest, dest: &Path) -> Result<bool> {
match &self.payload {
Some(p) => {
tokio::fs::write(dest, p).await?;
Ok(true)
}
None => Ok(false),
}
}
}
fn arc(p: FixedProvider) -> Arc<dyn BlobProvider> {
Arc::new(p)
}
#[tokio::test]
async fn swarm_hit_verifies_and_skips_origin() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"hello swarm".to_vec();
let digest = digest_of(&content);
let providers = vec![arc(FixedProvider {
name: "good",
payload: Some(content.clone()),
})];
let origin_ran = AtomicBool::new(false);
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
origin_ran.store(true, Ordering::SeqCst);
tokio::fs::write(&dest, b"from-origin").await?;
Ok(())
})
.await
.unwrap();
assert_eq!(src, FetchSource::Swarm);
assert!(
!origin_ran.load(Ordering::SeqCst),
"origin must not run on swarm hit"
);
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
}
#[tokio::test]
async fn bad_swarm_bytes_are_discarded_and_origin_wins() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"the real bytes".to_vec();
let digest = digest_of(&content);
// Provider claims a hit but serves tampered bytes.
let providers = vec![arc(FixedProvider {
name: "evil",
payload: Some(b"TAMPERED".to_vec()),
})];
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
tokio::fs::write(&dest, &content).await?;
Ok(())
})
.await
.unwrap();
assert_eq!(
src,
FetchSource::Origin,
"tampered swarm bytes must not be accepted"
);
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
}
#[tokio::test]
async fn no_providers_goes_straight_to_origin() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"x".to_vec();
let digest = digest_of(&content);
let providers: Vec<Arc<dyn BlobProvider>> = vec![];
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
tokio::fs::write(&dest, &content).await?;
Ok(())
})
.await
.unwrap();
assert_eq!(src, FetchSource::Origin);
}
#[tokio::test]
async fn falls_through_providers_in_order() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"second wins".to_vec();
let digest = digest_of(&content);
let providers = vec![
arc(FixedProvider {
name: "miss",
payload: None,
}),
arc(FixedProvider {
name: "hit",
payload: Some(content.clone()),
}),
];
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
tokio::fs::write(&dest, b"origin").await?;
Ok(())
})
.await
.unwrap();
assert_eq!(src, FetchSource::Swarm);
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
}
}

View File

@ -0,0 +1,194 @@
//! Paid swarm serving — gate the iroh-blobs provider through the ecash
//! `streaming` payment layer (DHT distribution plan, Phase 4 step F).
//!
//! ## Free by default
//! Serving is **free unless the node operator turns it on** in
//! *Networking Profits → Settings* (which enables the `content-download`
//! streaming service). With that service disabled — the shipped default —
//! [`is_authorized`] returns `true` for everyone and behaviour is byte-for-byte
//! the old open seeder. When it is enabled, a peer must hold an active paid
//! session (opened out-of-band via the `streaming.pay` RPC with a Cashu token)
//! before the swarm will serve them; otherwise the request is refused and they
//! fall back to the HTTP origin.
//!
//! ## How it hooks in
//! iroh-blobs 0.103 lets a provider authorize each request: we pass an
//! [`EventSender`] (built here) to `BlobsProtocol::new`, set the [`EventMask`]
//! to intercept connections + GET requests, and answer each one with
//! `Ok(())` (serve) or `Err(AbortReason::Permission)` (refuse). Peer-initiated
//! writes (`push`) are hard-disabled so a peer can never mutate our store.
//!
//! Scope note: today every swarm blob is a public release/app component, so the
//! gate only ever charges if the operator explicitly priced `content-download`.
//! When IndeeHub films land on the same blob layer (Phase 4), they reuse this
//! exact path.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use iroh::EndpointId;
use iroh_blobs::api::blobs::BlobStatus;
use iroh_blobs::api::Store;
use iroh_blobs::provider::events::{
AbortReason, ConnectMode, EventMask, EventResult, EventSender, ObserveMode, ProviderMessage,
RequestMode, ThrottleMode,
};
use iroh_blobs::Hash;
use crate::streaming::gate::{self, GateResult};
/// The streaming pricing service that meters swarm blob serving. Enabling it in
/// the Settings UI is what flips swarm serving from free to paid.
const SERVICE_ID: &str = "content-download";
/// Build the gated [`EventSender`] for `BlobsProtocol` and spawn the task that
/// authorizes each blob GET through the ecash gate.
///
/// `data_dir` locates the pricing/session state; `store` is cloned in to look up
/// blob sizes for metering. The spawned task lives as long as the provider keeps
/// the returned sender alive (i.e. the life of the node).
pub fn gated_event_sender(data_dir: PathBuf, store: Store) -> EventSender {
// Intercept connections + read requests so we can allow/deny per peer & hash.
// `push` (peer writes into our store) is hard-disabled. `throttle`/`observe`
// stay off — we meter coarsely at request time, not per 16 KiB chunk.
let mask = EventMask {
connected: ConnectMode::Intercept,
get: RequestMode::Intercept,
get_many: RequestMode::Intercept,
push: RequestMode::Disabled,
observe: ObserveMode::None,
throttle: ThrottleMode::None,
};
let (sender, mut rx) = EventSender::channel(64, mask);
tokio::spawn(async move {
// connection_id → remote endpoint id, learned at ClientConnected and used
// to key the paying peer's streaming session on each request.
let mut peers: HashMap<u64, Option<EndpointId>> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
ProviderMessage::ClientConnected(m) => {
peers.insert(m.inner.connection_id, m.inner.endpoint_id);
// Accept the connection; gating happens per request.
let _ = m.tx.send(Ok(())).await;
}
ProviderMessage::ConnectionClosed(m) => {
peers.remove(&m.inner.connection_id);
}
ProviderMessage::GetRequestReceived(m) => {
let peer = peers.get(&m.inner.connection_id).copied().flatten();
let hash = m.inner.request.hash;
let verdict = authorize(&data_dir, &store, peer, &hash).await;
let _ = m.tx.send(verdict).await;
}
ProviderMessage::GetManyRequestReceived(m) => {
let peer = peers.get(&m.inner.connection_id).copied().flatten();
// A get-many is all-or-nothing here: authorize on the first hash.
let verdict = match m.inner.request.hashes.first().copied() {
Some(h) => authorize(&data_dir, &store, peer, &h).await,
None => Ok(()),
};
let _ = m.tx.send(verdict).await;
}
ProviderMessage::PushRequestReceived(m) => {
// Disabled in the mask; refuse defensively if one ever arrives.
let _ = m.tx.send(Err(AbortReason::Permission)).await;
}
// Notify-only variants, observe and throttle: nothing to gate.
_ => {}
}
}
});
sender
}
/// Authorize one blob GET, returning the iroh [`EventResult`]
/// (`Ok(())` = serve, `Err(Permission)` = refuse).
async fn authorize(
data_dir: &Path,
store: &Store,
peer: Option<EndpointId>,
hash: &Hash,
) -> EventResult {
// Cost = full blob size (coarse, request-time metering). If we don't hold the
// complete blob there's nothing to meter — let iroh serve what it can.
let size = match store.blobs().status(*hash).await {
Ok(BlobStatus::Complete { size }) => size,
_ => 0,
};
let peer_id = peer
.map(|e| e.to_string())
.unwrap_or_else(|| "anonymous".to_string());
if is_authorized(data_dir, &peer_id, size).await {
Ok(())
} else {
Err(AbortReason::Permission)
}
}
/// Pure allow/deny decision (no iroh types) — unit-testable without a live node.
async fn is_authorized(data_dir: &Path, peer_id: &str, size: u64) -> bool {
match gate::check_gate(data_dir, peer_id, SERVICE_ID, None, size).await {
// Service disabled (the default) → free for everyone. Or the peer holds an
// active paid session with remaining allotment.
Ok(GateResult::ServiceUnavailable)
| Ok(GateResult::Allowed { .. })
| Ok(GateResult::PaidAndAllowed { .. }) => true,
// Metered + no/exhausted session: the peer must pay out-of-band first
// (streaming.pay) before the swarm serves them — they fall back to origin.
Ok(_) => false,
// Never let a payment-layer fault break content distribution: fail OPEN
// (serve free) and log. Availability beats revenue when something breaks.
Err(e) => {
tracing::warn!("paid-gate: check errored ({e}); serving free");
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::streaming::pricing::{self, Metric, PricingConfig, ServicePricing};
fn content_download(enabled: bool) -> PricingConfig {
PricingConfig {
services: vec![ServicePricing {
service_id: SERVICE_ID.to_string(),
name: "Content Downloads".to_string(),
metric: Metric::Bytes,
step_size: 1_048_576,
price_per_step: 1,
min_steps: 0,
enabled,
description: String::new(),
accepted_mints: vec![],
}],
}
}
#[tokio::test]
async fn free_when_service_disabled_by_default() {
let dir = tempfile::tempdir().unwrap();
// No pricing file → defaults → content-download disabled → free for all.
assert!(is_authorized(dir.path(), "peer-a", 1_000_000).await);
}
#[tokio::test]
async fn free_when_service_explicitly_disabled() {
let dir = tempfile::tempdir().unwrap();
pricing::save_pricing(dir.path(), &content_download(false))
.await
.unwrap();
assert!(is_authorized(dir.path(), "peer-a", 1_048_576).await);
}
#[tokio::test]
async fn denied_when_metered_and_peer_has_not_paid() {
let dir = tempfile::tempdir().unwrap();
pricing::save_pricing(dir.path(), &content_download(true))
.await
.unwrap();
// Enabled service + no session/token → the swarm refuses; peer uses origin.
assert!(!is_authorized(dir.path(), "peer-b", 1_048_576).await);
}
}

View File

@ -0,0 +1,314 @@
//! Shape-A paid-blobs negotiation ALPN (`archy/paid-blobs/1`) — the on-wire
//! exchange that lets a downloader pay a seeder *before* fetching a gated blob
//! (DHT distribution plan §1, "shape A"). Gated behind `iroh-swarm`.
//!
//! ## Why a side ALPN
//! iroh-blobs carries the raw bytes; this tiny request/grant protocol rides a
//! second ALPN on the *same* endpoint so a downloader can discover the price and
//! deliver an ecash token first. The token opens a metered `streaming` session
//! keyed by the downloader's endpoint id — exactly the session the blob-GET gate
//! ([`super::paid`]) already checks. Same endpoint → same session → the GET is
//! then served.
//!
//! ```text
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: None }
//! B ◀─────────────────────── A PaymentRequired { price, accepted_mints }
//! B: auto_pay_token(...) ── builds a cashuA token (cross-mint aware)
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: Some(t) }
//! B ◀─────────────────────── A Granted (session now exists on A)
//! B ──(iroh-blobs ALPN)─────▶ A GET H → served (gate sees the session)
//! ```
//!
//! ## North star: origin always wins, releases stay free
//! Negotiation is **best-effort and additive**. A peer that doesn't speak this
//! ALPN, or any connect/protocol error, is treated as "proceed" — the blob-GET
//! gate is the real enforcement, and a denied GET just falls back to origin.
//! With the default [`PaymentPolicy::free`] a downloader never sends a token, so
//! a seeder that prices a blob is simply skipped → origin. Only films (a future
//! caller with a real budget) will actually pay.
use std::path::{Path, PathBuf};
use anyhow::Result;
use iroh::endpoint::Connection;
use iroh::protocol::{AcceptError, ProtocolHandler};
use iroh::{Endpoint, EndpointAddr, EndpointId};
use iroh_blobs::api::blobs::BlobStatus;
use iroh_blobs::api::Store;
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use super::payment::PaymentPolicy;
use crate::streaming::gate::{self, GateResult};
/// ALPN for the paid-blobs negotiation protocol.
pub const PAID_ALPN: &[u8] = b"archy/paid-blobs/1";
/// The streaming service that meters swarm blob serving (same id as [`super::paid`]).
const SERVICE_ID: &str = "content-download";
/// Cap on a single negotiation message (JSON). Requests/responses are tiny.
const MAX_MSG: usize = 64 * 1024;
/// A downloader's ask for one content-addressed blob, optionally with payment.
#[derive(Debug, Serialize, Deserialize)]
struct PaidRequest {
/// BLAKE3 hex of the wanted blob.
want: String,
/// A `cashuA` token, present on the paying retry.
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
}
/// The seeder's verdict.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum PaidResponse {
/// Fetch away — free, or a paid session is now active for this peer.
Granted,
/// Payment needed before serving. The downloader may pay and retry.
PaymentRequired {
price_sats: u64,
accepted_mints: Vec<String>,
},
/// Refused (bad request, insufficient/failed payment).
Denied { reason: String },
}
// ── Serve side ─────────────────────────────────────────────────────────────
/// Accept-side handler for [`PAID_ALPN`]. Registered on the provider's `Router`
/// alongside the iroh-blobs protocol.
#[derive(Clone)]
pub struct PaidBlobsProtocol {
data_dir: PathBuf,
store: Store,
}
impl std::fmt::Debug for PaidBlobsProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaidBlobsProtocol").finish()
}
}
impl PaidBlobsProtocol {
pub fn new(data_dir: PathBuf, store: Store) -> Self {
Self { data_dir, store }
}
/// Decide the verdict for a request from `peer`. Mirrors [`super::paid`]'s
/// policy: free when the service is disabled (default) or the peer holds an
/// active session; payment-required when metered and unpaid; fail-OPEN
/// (Granted) on an internal gate error so a fault never blocks distribution.
async fn decide(&self, peer: &str, req: &PaidRequest) -> PaidResponse {
let size = self.blob_size(&req.want).await;
match gate::check_gate(&self.data_dir, peer, SERVICE_ID, req.token.as_deref(), size).await {
Ok(GateResult::ServiceUnavailable)
| Ok(GateResult::Allowed { .. })
| Ok(GateResult::PaidAndAllowed { .. }) => PaidResponse::Granted,
Ok(GateResult::PaymentRequired {
minimum_sats,
pricing,
..
}) => PaidResponse::PaymentRequired {
price_sats: minimum_sats,
accepted_mints: pricing.accepted_mints,
},
Ok(GateResult::InsufficientPayment {
provided_sats,
minimum_sats,
}) => PaidResponse::Denied {
reason: format!("insufficient payment: {provided_sats} < {minimum_sats} sats"),
},
Ok(GateResult::PaymentFailed { reason }) => PaidResponse::Denied { reason },
// Availability beats revenue: a gate fault serves free, matching the
// blob-GET gate's fail-open behaviour.
Err(e) => {
tracing::warn!("paid-alpn: gate errored ({e}); granting free");
PaidResponse::Granted
}
}
}
/// Full size of a held blob (for metering); 0 if we don't hold it complete.
async fn blob_size(&self, blake3_hex: &str) -> u64 {
let Ok(raw) = hex::decode(blake3_hex) else {
return 0;
};
let Ok(arr) = <[u8; 32]>::try_from(raw.as_slice()) else {
return 0;
};
match self.store.blobs().status(Hash::from_bytes(arr)).await {
Ok(BlobStatus::Complete { size }) => size,
_ => 0,
}
}
}
impl ProtocolHandler for PaidBlobsProtocol {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let peer = connection.remote_id().to_string();
// One bi-stream per request (a paying downloader opens a second one).
loop {
let (mut send, mut recv) = match connection.accept_bi().await {
Ok(s) => s,
// Connection closed by the peer — normal end of negotiation.
Err(_) => break,
};
let buf = recv
.read_to_end(MAX_MSG)
.await
.map_err(AcceptError::from_err)?;
let response = match serde_json::from_slice::<PaidRequest>(&buf) {
Ok(req) => self.decide(&peer, &req).await,
Err(e) => PaidResponse::Denied {
reason: format!("bad request: {e}"),
},
};
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
send.write_all(&bytes)
.await
.map_err(AcceptError::from_err)?;
send.finish().map_err(AcceptError::from_err)?;
}
Ok(())
}
}
// ── Fetch side ───────────────────────────────────────────────────────────────
/// Negotiate access to `blake3_hex` from `peer` before fetching. Returns whether
/// the caller should proceed to download from this peer.
///
/// Best-effort: any connect/protocol failure returns `true` (proceed — the
/// blob-GET gate is the real enforcement, and a denied GET falls back to origin).
/// Returns `false` only when the seeder explicitly requires a payment we won't or
/// can't make under `policy`.
pub async fn negotiate_access(
endpoint: &Endpoint,
data_dir: &Path,
peer: EndpointId,
blake3_hex: &str,
policy: &PaymentPolicy,
) -> bool {
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
Ok(proceed) => proceed,
Err(e) => {
tracing::debug!(
"paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)"
);
true
}
}
}
async fn negotiate_inner(
endpoint: &Endpoint,
data_dir: &Path,
peer: EndpointId,
blake3_hex: &str,
policy: &PaymentPolicy,
) -> Result<bool> {
let conn = endpoint.connect(EndpointAddr::new(peer), PAID_ALPN).await?;
// First ask with no token.
let resp = exchange(
&conn,
&PaidRequest {
want: blake3_hex.to_string(),
token: None,
},
)
.await?;
match resp {
PaidResponse::Granted => Ok(true),
PaidResponse::Denied { .. } => Ok(false),
PaidResponse::PaymentRequired {
price_sats,
accepted_mints,
} => {
// Build a token within budget (cross-mint aware); None ⇒ use origin.
match super::payment::auto_pay_token(data_dir, policy, &accepted_mints, price_sats)
.await?
{
None => Ok(false),
Some(token) => {
let resp2 = exchange(
&conn,
&PaidRequest {
want: blake3_hex.to_string(),
token: Some(token),
},
)
.await?;
Ok(matches!(resp2, PaidResponse::Granted))
}
}
}
}
}
/// One request/response round trip on a fresh bi-stream.
async fn exchange(conn: &Connection, req: &PaidRequest) -> Result<PaidResponse> {
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(&serde_json::to_vec(req)?).await?;
send.finish()?;
let buf = recv.read_to_end(MAX_MSG).await?;
Ok(serde_json::from_slice(&buf)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips_and_omits_absent_token() {
let req = PaidRequest {
want: "abcd".into(),
token: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(
!json.contains("token"),
"absent token must be omitted: {json}"
);
let back: PaidRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.want, "abcd");
assert!(back.token.is_none());
}
#[test]
fn request_with_token_round_trips() {
let req = PaidRequest {
want: "ff".into(),
token: Some("cashuAbc".into()),
};
let back: PaidRequest =
serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
}
#[test]
fn response_tagged_serialization() {
let granted = serde_json::to_string(&PaidResponse::Granted).unwrap();
assert_eq!(granted, r#"{"status":"granted"}"#);
let pr = serde_json::to_string(&PaidResponse::PaymentRequired {
price_sats: 7,
accepted_mints: vec!["https://m".into()],
})
.unwrap();
let back: PaidResponse = serde_json::from_str(&pr).unwrap();
match back {
PaidResponse::PaymentRequired {
price_sats,
accepted_mints,
} => {
assert_eq!(price_sats, 7);
assert_eq!(accepted_mints, vec!["https://m".to_string()]);
}
other => panic!("expected PaymentRequired, got {other:?}"),
}
}
}

View File

@ -0,0 +1,167 @@
//! Fetch-side auto-pay — the *downloader's* decision layer for paid swarm
//! content (plan §1 "fetch side" + §2a cross-mint).
//!
//! When a swarm seeder gates a blob behind payment (its `PaymentRequired`
//! advertises a price and a set of `accepted_mints`), a downloading node uses
//! this layer to decide whether to pay and, if so, to build a `cashuA` token
//! denominated in one of the seeder's accepted mints — auto-swapping across
//! mints when needed (see [`crate::wallet::ecash::build_payment_token`]).
//!
//! ## North star: origin always wins
//! Paying is strictly an optimization. If the price is over budget, the wallet
//! can't cover it, no trusted mint is reachable, or a swap would cost too much,
//! this layer returns `None` and the caller falls back to the free HTTP origin —
//! exactly today's path. A wallet/mint problem must never block content.
//!
//! ## Scope / what's NOT here
//! This builds the *token*; it does not yet carry it to the seeder. The on-wire
//! exchange (a downloader presenting the token to a paid seeder, then streaming
//! the blob) is the in-band paid-blobs ALPN — "shape (A)" in the design doc —
//! which is deferred. Today's seeder side (`swarm::paid`) only allow/deny-gates
//! iroh-blobs requests; once shape (A) lands, the provider's fetch path calls
//! [`auto_pay_token`] on a `PaymentRequired` and retries with the token.
use std::path::Path;
use anyhow::Result;
use tracing::debug;
use crate::wallet::ecash;
/// A downloader's willingness to pay swarm peers for a single fetch.
#[derive(Debug, Clone, Copy)]
pub struct PaymentPolicy {
/// Maximum total sats to spend for this content. `0` disables paying
/// entirely (origin-only) — the safe default.
pub budget_sats: u64,
/// Maximum cross-mint swap fee tolerated when we must swap into the
/// seeder's mint. Ignored when we already hold the right mint.
pub max_fee_sats: u64,
}
impl PaymentPolicy {
/// The default: never pay, always use the free origin. The production caller
/// is the deferred in-band paid-blobs ALPN (shape A); used by tests today.
#[allow(dead_code)]
pub fn free() -> Self {
Self {
budget_sats: 0,
max_fee_sats: 0,
}
}
/// A budget-capped policy.
pub fn with_budget(budget_sats: u64, max_fee_sats: u64) -> Self {
Self {
budget_sats,
max_fee_sats,
}
}
/// Whether a seeder's `price_sats` is worth paying under this policy. A zero
/// price is treated as "not a real paid request" (use origin / free path).
pub fn affords(&self, price_sats: u64) -> bool {
price_sats > 0 && price_sats <= self.budget_sats
}
}
/// Decide whether to pay a seeder `price_sats`, and if so build a `cashuA` token
/// denominated in one of its `accepted_mints` (auto-swapping if needed).
///
/// * `Ok(Some(token))` — pay the seeder with this token.
/// * `Ok(None)` — decline (over budget, unpayable, or swap too costly);
/// the caller should fall back to the free origin.
///
/// Never returns `Err` for a wallet/mint problem: those degrade to `Ok(None)`
/// so a payment failure can never block content.
pub async fn auto_pay_token(
data_dir: &Path,
policy: &PaymentPolicy,
accepted_mints: &[String],
price_sats: u64,
) -> Result<Option<String>> {
if !policy.affords(price_sats) {
debug!(
"auto-pay: price {} sats over budget {} (or zero) — using origin",
price_sats, policy.budget_sats
);
return Ok(None);
}
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats)
.await
{
Ok(token) => Ok(Some(token)),
Err(e) => {
// Unpayable within balance/trust/fee — not an error, just decline.
debug!("auto-pay: declined ({}) — falling back to origin", e);
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_policy_never_affords() {
let p = PaymentPolicy::free();
assert!(!p.affords(1));
assert!(!p.affords(0));
}
#[test]
fn budget_policy_affordability() {
let p = PaymentPolicy::with_budget(100, 5);
assert!(p.affords(100)); // exactly at budget
assert!(p.affords(1));
assert!(!p.affords(101)); // over budget
assert!(!p.affords(0)); // zero price is never a real paid request
}
#[tokio::test]
async fn over_budget_declines_without_touching_wallet() {
let tmp = tempfile::tempdir().unwrap();
// Price exceeds budget → None, and no wallet/mint interaction occurs.
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::with_budget(50, 5),
&["https://seeder.example.com".into()],
100,
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn zero_budget_is_origin_only() {
let tmp = tempfile::tempdir().unwrap();
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::free(),
&["https://seeder.example.com".into()],
10,
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn unpayable_within_budget_declines_gracefully() {
let tmp = tempfile::tempdir().unwrap();
// Within budget, but empty wallet + untrusted seeder mint → build fails;
// auto_pay degrades to None (origin) rather than erroring.
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::with_budget(1000, 10),
&["https://untrusted.example.com".into()],
100,
)
.await
.unwrap();
assert!(out.is_none());
}
}

View File

@ -0,0 +1,233 @@
//! Phase 3 discovery — signed Nostr "seed advertisement" events.
//!
//! A node that holds a PUBLIC release / app-image blob (addressed by BLAKE3)
//! announces "I can seed hash H from iroh endpoint E" as a signed, NIP-33
//! addressable Nostr event. **Scope is releases/catalog content ONLY** — never
//! private user blobs (decided 2026-06-16): smallest privacy surface, covers
//! the OTA + app-install use-cases. Discovery queries these events to find
//! swarm seeds for a hash; the iroh provider then dials those endpoints.
//!
//! Event shape (NIP-33 addressable, kind [`ARCHIPELAGO_SEED_KIND`]):
//! - `d` tag = blake3 hex of the content → one current advert per (author, hash)
//! - content = `{"v":1,"endpoint_id":"<iroh endpoint id>"}`
//! - author pubkey = the node's seed-derived Nostr identity (signs the event)
//!
//! Endpoint ids stay opaque strings here so this protocol layer builds/parses/
//! publishes/queries WITHOUT the heavy iroh dep; only the `iroh-swarm`
//! discovery glue parses the string into an `iroh::EndpointId`.
// The publish/query path that calls these lives behind `iroh-swarm` (it needs
// the node's iroh EndpointId), so in the default build they're exercised only
// by unit tests — allow them to stand without a production caller.
#![allow(dead_code)]
use std::path::Path;
use std::time::Duration;
use nostr_sdk::{Event, EventBuilder, Filter, Keys, Kind, Tag};
use serde::{Deserialize, Serialize};
/// How long to wait for relay connects / event fetches. Matches the rest of the
/// Nostr discovery path so the swarm never stalls the download longer than node
/// discovery already might.
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const RELAY_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// NIP-33 addressable kind for Archipelago seed advertisements.
/// Distinct from the node-discovery app-data kind (30078).
pub const ARCHIPELAGO_SEED_KIND: u16 = 30081;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AdvertContent {
v: u8,
endpoint_id: String,
}
/// Build the (unsigned) advertisement event for `blake3_hex` served from
/// `endpoint_id`. Sign with the node's Nostr key (`.sign_with_keys()` /
/// `.sign()`) or publish via `client.send_event_builder()`.
pub fn advertisement_builder(blake3_hex: &str, endpoint_id: &str) -> EventBuilder {
let content = serde_json::to_string(&AdvertContent {
v: 1,
endpoint_id: endpoint_id.to_string(),
})
.expect("serialize advert content");
EventBuilder::new(Kind::Custom(ARCHIPELAGO_SEED_KIND), content)
.tag(Tag::identifier(blake3_hex.to_string()))
}
/// Filter matching all current seed advertisements for `blake3_hex` (one per
/// advertising node; NIP-33 latest-replaces per author).
pub fn advertisement_filter(blake3_hex: &str) -> Filter {
Filter::new()
.kind(Kind::Custom(ARCHIPELAGO_SEED_KIND))
.identifier(blake3_hex.to_string())
}
/// Extract the advertised endpoint id from an event, or `None` if it is the
/// wrong kind or malformed.
pub fn parse_endpoint_id(event: &Event) -> Option<String> {
if event.kind != Kind::Custom(ARCHIPELAGO_SEED_KIND) {
return None;
}
serde_json::from_str::<AdvertContent>(&event.content)
.ok()
.map(|c| c.endpoint_id)
.filter(|s| !s.is_empty())
}
/// Collect the unique advertised endpoint ids across a set of events, skipping
/// malformed ones. Order-preserving, de-duplicated.
pub fn endpoint_ids_from_events<'a>(events: impl IntoIterator<Item = &'a Event>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for ev in events {
if let Some(id) = parse_endpoint_id(ev) {
if seen.insert(id.clone()) {
out.push(id);
}
}
}
out
}
/// Query `relays` for the current seed advertisements for `blake3_hex` and
/// return the de-duplicated endpoint-id strings (opaque here; the `iroh-swarm`
/// glue parses them into `iroh::EndpointId`).
///
/// Best-effort by design: an empty relay list, a connect timeout, or a fetch
/// failure all yield an empty list — never an error. The swarm seam treats "no
/// providers" as "use origin", so discovery problems can only ever degrade to
/// today's HTTP path, never block it.
pub async fn fetch_seed_endpoint_ids(
relays: &[String],
tor_proxy: Option<&str>,
blake3_hex: &str,
) -> Vec<String> {
if relays.is_empty() {
return Vec::new();
}
// Query anonymously — discovery reads public adverts and must not link the
// query back to this node's seed identity.
let anon = Keys::generate();
let client = match crate::nostr_discovery::build_nostr_client(anon, tor_proxy) {
Ok(c) => c,
Err(e) => {
tracing::warn!("seed-advert: build relay client failed: {e}");
return Vec::new();
}
};
for url in relays {
let _ = client.add_relay(url).await;
}
if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect())
.await
.is_err()
{
tracing::warn!("seed-advert: relay connect timed out, continuing anyway");
}
let events = client
.fetch_events(advertisement_filter(blake3_hex), RELAY_FETCH_TIMEOUT)
.await
.map(|e| e.to_vec())
.unwrap_or_default();
client.disconnect().await;
endpoint_ids_from_events(events.iter())
}
/// Publish a signed advertisement — "this node can seed `blake3_hex` from
/// `endpoint_id`" — to `relays`, signed with the node's seed-derived Nostr key.
///
/// **Caller must restrict this to PUBLIC releases/catalog blobs** (the design's
/// privacy scope, decided 2026-06-16) — never private user content. Best-effort:
/// relay failures are logged, not fatal, since seeding is an optimization.
pub async fn publish_seed_advert(
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
blake3_hex: &str,
endpoint_id: &str,
) -> anyhow::Result<()> {
if relays.is_empty() {
return Ok(());
}
let keys = crate::nostr_discovery::load_or_create_nostr_keys(identity_dir).await?;
let client = crate::nostr_discovery::build_nostr_client(keys, tor_proxy)?;
for url in relays {
let _ = client.add_relay(url).await;
}
if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect())
.await
.is_err()
{
tracing::warn!("seed-advert: publish relay connect timed out, continuing anyway");
}
let _ = client
.send_event_builder(advertisement_builder(blake3_hex, endpoint_id))
.await;
client.disconnect().await;
tracing::info!("seed-advert: announced {blake3_hex} seedable from {endpoint_id}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_sign_parse_roundtrip() {
let keys = Keys::generate();
let hash = "a".repeat(64);
let endpoint = "node-example-endpoint-id";
let event = advertisement_builder(&hash, endpoint)
.sign_with_keys(&keys)
.unwrap();
assert_eq!(event.kind, Kind::Custom(ARCHIPELAGO_SEED_KIND));
assert_eq!(parse_endpoint_id(&event).as_deref(), Some(endpoint));
}
#[test]
fn filter_targets_the_hash_dtag_and_kind() {
let hash = "b".repeat(64);
let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap();
assert!(json.contains(&hash), "filter must target the hash d-tag");
assert!(
json.contains("30081"),
"filter must constrain the seed kind"
);
}
#[test]
fn parse_rejects_wrong_kind_and_empty_endpoint() {
let keys = Keys::generate();
let wrong_kind = EventBuilder::new(Kind::Custom(1), "{}")
.sign_with_keys(&keys)
.unwrap();
assert_eq!(parse_endpoint_id(&wrong_kind), None);
let empty_endpoint = advertisement_builder(&"c".repeat(64), "")
.sign_with_keys(&keys)
.unwrap();
assert_eq!(parse_endpoint_id(&empty_endpoint), None);
}
#[test]
fn dedups_endpoint_ids_across_events() {
let a = Keys::generate();
let b = Keys::generate();
let hash = "d".repeat(64);
let e1 = advertisement_builder(&hash, "endpoint-A")
.sign_with_keys(&a)
.unwrap();
let e2 = advertisement_builder(&hash, "endpoint-A")
.sign_with_keys(&b)
.unwrap();
let e3 = advertisement_builder(&hash, "endpoint-B")
.sign_with_keys(&b)
.unwrap();
let ids = endpoint_ids_from_events([&e1, &e2, &e3]);
assert_eq!(
ids,
vec!["endpoint-A".to_string(), "endpoint-B".to_string()]
);
}
}

View File

@ -0,0 +1,71 @@
//! The fleet's pinned **release-root** trust anchor.
//!
//! Every node ships the release-root *public* key. Signed manifests and the app
//! catalog must be signed by the corresponding private key (derived once, in
//! the signing ceremony, via `seed::derive_release_root_ed25519`). Pinning the
//! key in the binary is what makes a swapped-in mirror key detectable.
//!
//! Until the ceremony runs against the real release master seed, the pinned
//! constant is `None`. While `None`, signature verification still runs and
//! still rejects tampered documents, but it cannot enforce signer *identity*
//! (see `signed_doc::SignatureStatus::anchored`). Set
//! `ARCHY_RELEASE_ROOT_PUBKEY` (64-char hex) to pin a key at runtime for
//! staging/test fleets before the constant is baked in.
use ed25519_dalek::VerifyingKey;
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
///
/// TODO(dht Phase 0): bake the real value here after the signing ceremony.
/// Generate it with: `scripts/release-root-ceremony.sh pubkey`.
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None;
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
/// Resolve the pinned release-root public key, if any.
///
/// Runtime env override wins over the baked-in constant so a test fleet can pin
/// a ceremony key without a rebuild. Malformed values are ignored (treated as
/// "not pinned") rather than crashing the node.
pub fn release_root_pubkey() -> Option<VerifyingKey> {
if let Ok(hex_str) = std::env::var(ENV_OVERRIDE) {
if let Some(key) = parse_pubkey_hex(hex_str.trim()) {
return Some(key);
}
tracing::warn!(
"{} is set but not a valid 32-byte hex Ed25519 key; ignoring",
ENV_OVERRIDE
);
}
RELEASE_ROOT_PUBKEY_HEX.and_then(parse_pubkey_hex)
}
fn parse_pubkey_hex(s: &str) -> Option<VerifyingKey> {
let bytes = hex::decode(s).ok()?;
let arr: [u8; 32] = bytes.as_slice().try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_constant_is_none() {
// Default build ships no pinned anchor yet.
assert!(RELEASE_ROOT_PUBKEY_HEX.is_none());
}
#[test]
fn parses_valid_hex() {
let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]).verifying_key();
let parsed = parse_pubkey_hex(&hex::encode(key.to_bytes())).unwrap();
assert_eq!(parsed.as_bytes(), key.as_bytes());
}
#[test]
fn rejects_malformed_hex() {
assert!(parse_pubkey_hex("nothex").is_none());
assert!(parse_pubkey_hex("abcd").is_none());
}
}

View File

@ -0,0 +1,87 @@
//! Canonical JSON for signing — a pragmatic subset of RFC 8785 (JCS).
//!
//! Signatures are computed over a *byte-exact* serialization so that a verifier
//! reproduces the same preimage the signer hashed. We guarantee:
//!
//! * object keys recursively sorted (lexicographic by Rust `str` ordering,
//! i.e. Unicode scalar value — matches JCS for the ASCII keys we use),
//! * no insignificant whitespace,
//! * arrays preserved in order.
//!
//! We do NOT implement JCS number canonicalization (ECMAScript shortest-form).
//! Archipelago manifests/catalogs carry only integers, strings, bools, arrays
//! and objects, for which `serde_json`'s output is already unambiguous. If a
//! float ever enters a signed document this must be hardened (or rejected).
//! `contains_float()` lets callers enforce that invariant.
use serde_json::Value;
/// Serialize `value` to canonical JSON bytes (sorted keys, compact).
///
/// Rebuilds every object through a `BTreeMap` so the result is independent of
/// the `serde_json/preserve_order` feature being toggled on anywhere in the
/// dependency graph.
pub fn to_canonical_bytes(value: &Value) -> Vec<u8> {
let canonical = canonicalize(value);
// serde_json never fails to serialize a Value it produced.
serde_json::to_vec(&canonical).expect("canonical JSON serialization")
}
/// Reject documents that contain a float anywhere — they are not safely
/// canonicalizable under this implementation.
pub fn contains_float(value: &Value) -> bool {
match value {
Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(),
Value::Array(items) => items.iter().any(contains_float),
Value::Object(map) => map.values().any(contains_float),
_ => false,
}
}
fn canonicalize(value: &Value) -> Value {
match value {
Value::Object(map) => {
// BTreeMap gives deterministic key ordering on serialize.
let sorted: std::collections::BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), canonicalize(v)))
.collect();
serde_json::to_value(sorted).expect("canonical object")
}
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn key_order_does_not_change_bytes() {
let a = json!({"b": 1, "a": 2, "c": {"z": 1, "y": 2}});
let b = json!({"c": {"y": 2, "z": 1}, "a": 2, "b": 1});
assert_eq!(to_canonical_bytes(&a), to_canonical_bytes(&b));
}
#[test]
fn output_is_sorted_and_compact() {
let v = json!({"b": 1, "a": [3, 2, 1]});
assert_eq!(to_canonical_bytes(&v), br#"{"a":[3,2,1],"b":1}"#.to_vec());
}
#[test]
fn array_order_is_preserved() {
let a = json!([1, 2, 3]);
let b = json!([3, 2, 1]);
assert_ne!(to_canonical_bytes(&a), to_canonical_bytes(&b));
}
#[test]
fn detects_floats() {
assert!(contains_float(&json!({"x": 1.5})));
assert!(contains_float(&json!([1, 2, 0.1])));
assert!(!contains_float(&json!({"x": 12345, "y": "s", "z": [1, 2]})));
}
}

View File

@ -0,0 +1,54 @@
//! `did:key` <-> Ed25519 public key, mirroring the encoding already used by
//! `identity_manager` so release-root DIDs are interchangeable with node DIDs.
//!
//! Format: `did:key:z<base58btc(0xed01 || 32-byte-pubkey)>`
//! (`0xed01` is the multicodec varint prefix for an Ed25519 public key.)
use anyhow::{anyhow, Context, Result};
use ed25519_dalek::VerifyingKey;
const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
/// Encode an Ed25519 public key as a `did:key` string.
pub fn did_key_for_ed25519(key: &VerifyingKey) -> String {
let mut bytes = Vec::with_capacity(34);
bytes.extend_from_slice(&ED25519_MULTICODEC);
bytes.extend_from_slice(key.as_bytes());
format!("did:key:z{}", bs58::encode(bytes).into_string())
}
/// Decode a `did:key` string into an Ed25519 verifying key.
pub fn ed25519_pubkey_from_did_key(did: &str) -> Result<VerifyingKey> {
let z_part = did
.strip_prefix("did:key:z")
.ok_or_else(|| anyhow!("invalid did:key format: {}", did))?;
let decoded = bs58::decode(z_part)
.into_vec()
.context("invalid base58 in did:key")?;
if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC {
return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)"));
}
let arr: [u8; 32] = decoded[2..].try_into().expect("length checked above");
VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key")
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
#[test]
fn roundtrip() {
let key = SigningKey::from_bytes(&[3u8; 32]).verifying_key();
let did = did_key_for_ed25519(&key);
assert!(did.starts_with("did:key:z6Mk"), "got {}", did);
let back = ed25519_pubkey_from_did_key(&did).unwrap();
assert_eq!(key.as_bytes(), back.as_bytes());
}
#[test]
fn rejects_non_ed25519() {
assert!(ed25519_pubkey_from_did_key("did:key:zQ3shazz").is_err());
assert!(ed25519_pubkey_from_did_key("not-a-did").is_err());
}
}

View File

@ -0,0 +1,23 @@
//! Authenticity layer for the DHT distribution plan (Phase 0).
//!
//! Content addressing (SHA-256 today, BLAKE3 later) proves downloaded bytes are
//! *intact*. It does not prove they were *authorized*. This module adds the
//! missing half: detached Ed25519 signatures over canonical JSON, verified
//! against a pinned **release-root** trust anchor.
//!
//! Layout:
//! * [`anchor`] — the pinned release-root public key (+ env override).
//! * [`canonical`] — deterministic JSON serialization for signing.
//! * [`did`] — `did:key` <-> Ed25519 public key.
//! * [`signed_doc`]— detached sign/verify over a signed document.
//!
//! The release-root *private* key is publisher-only and derived in the signing
//! ceremony via [`crate::seed::derive_release_root_ed25519`]; fleet nodes only
//! ever hold the public key.
pub mod anchor;
pub mod canonical;
pub mod did;
pub mod signed_doc;
pub use signed_doc::{verify_detached, SignatureStatus};

View File

@ -0,0 +1,200 @@
//! Detached Ed25519 signatures over canonical JSON documents.
//!
//! A *signed document* is any JSON object carrying two reserved top-level
//! fields:
//!
//! * `signed_by` — the signer's `did:key` (Ed25519), e.g. the release-root.
//! * `signature` — hex-encoded Ed25519 signature over the canonical JSON of
//! the document with **both** reserved fields removed.
//!
//! Removing the fields before canonicalizing makes the signature *detached*:
//! the signer signs the payload, then attaches the proof, without a
//! chicken-and-egg dependency on the signature's own bytes.
//!
//! Authenticity ≠ integrity. Content addressing (SHA-256/BLAKE3 in the
//! manifest) proves the bytes are intact; this signature proves *we authorized
//! them*. The DHT plan requires both.
use anyhow::{anyhow, bail, Context, Result};
use ed25519_dalek::{Signature, Signer, SigningKey};
use serde_json::Value;
use super::anchor;
use super::canonical;
use super::did;
pub const SIGNATURE_FIELD: &str = "signature";
pub const SIGNED_BY_FIELD: &str = "signed_by";
/// Outcome of inspecting a document for a detached signature.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureStatus {
/// No `signature` field present. Caller decides whether to accept
/// (during the migration window we still accept unsigned documents).
Unsigned,
/// Signature verified. `anchored` is true when `signed_by` matched the
/// pinned release-root anchor (full authenticity); false means the
/// signature is internally consistent but the signer key is not yet
/// pinned, so it only proves the document wasn't tampered relative to its
/// own claimed key.
Verified { signer_did: String, anchored: bool },
}
/// Verify a document's detached signature *if present*.
///
/// Returns `Ok(Unsigned)` when there is no signature. Returns `Ok(Verified)`
/// when a present signature checks out. Returns `Err` when a signature is
/// present but malformed, fails verification, or names a signer that
/// contradicts the pinned anchor — callers MUST reject the document on `Err`.
pub fn verify_detached(doc: &Value) -> Result<SignatureStatus> {
let obj = doc
.as_object()
.ok_or_else(|| anyhow!("signed document must be a JSON object"))?;
let signature_hex = match obj.get(SIGNATURE_FIELD) {
None | Some(Value::Null) => return Ok(SignatureStatus::Unsigned),
Some(Value::String(s)) => s,
Some(_) => bail!("`{}` must be a string", SIGNATURE_FIELD),
};
let signed_by = obj
.get(SIGNED_BY_FIELD)
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!(
"signed document has `{}` but no `{}`",
SIGNATURE_FIELD,
SIGNED_BY_FIELD
)
})?;
let signer = did::ed25519_pubkey_from_did_key(signed_by)
.with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?;
// If the fleet has a pinned release-root, the signer MUST be it. This is
// what stops a mirror from swapping in its own keypair and re-signing.
let anchored = match anchor::release_root_pubkey() {
Some(pinned) => {
if pinned != signer {
bail!("signed_by does not match the pinned release-root anchor");
}
true
}
None => false,
};
let signature = parse_signature_hex(signature_hex)?;
let preimage = signing_preimage(obj)?;
signer
.verify_strict(&preimage, &signature)
.map_err(|_| anyhow!("release-root signature verification failed"))?;
Ok(SignatureStatus::Verified {
signer_did: signed_by.to_string(),
anchored,
})
}
/// Produce a detached signature for `payload` (the document WITHOUT the
/// reserved fields). Used by the signing ceremony and round-trip tests.
/// Returns `(signature_hex, signed_by_did)`.
pub fn sign_detached(key: &SigningKey, payload: &Value) -> Result<(String, String)> {
let obj = payload
.as_object()
.ok_or_else(|| anyhow!("payload must be a JSON object"))?;
if obj.contains_key(SIGNATURE_FIELD) || obj.contains_key(SIGNED_BY_FIELD) {
bail!("payload must not already contain reserved signature fields");
}
let preimage = signing_preimage(obj)?;
let signature = key.sign(&preimage);
let did = did::did_key_for_ed25519(&key.verifying_key());
Ok((hex::encode(signature.to_bytes()), did))
}
/// Canonical bytes the signature covers: the object minus the reserved fields.
fn signing_preimage(obj: &serde_json::Map<String, Value>) -> Result<Vec<u8>> {
let mut payload = obj.clone();
payload.remove(SIGNATURE_FIELD);
payload.remove(SIGNED_BY_FIELD);
let value = Value::Object(payload);
if canonical::contains_float(&value) {
bail!("signed documents must not contain floating-point numbers");
}
Ok(canonical::to_canonical_bytes(&value))
}
fn parse_signature_hex(s: &str) -> Result<Signature> {
let bytes = hex::decode(s).context("signature is not valid hex")?;
let arr: [u8; 64] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("signature must be 64 bytes, got {}", bytes.len()))?;
Ok(Signature::from_bytes(&arr))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
fn sign_into(key: &SigningKey, mut doc: Value) -> Value {
let (sig, did) = sign_detached(key, &doc).unwrap();
let obj = doc.as_object_mut().unwrap();
obj.insert(SIGNED_BY_FIELD.into(), json!(did));
obj.insert(SIGNATURE_FIELD.into(), json!(sig));
doc
}
#[test]
fn unsigned_document_reports_unsigned() {
let doc = json!({"schema": 1, "apps": {}});
assert_eq!(verify_detached(&doc).unwrap(), SignatureStatus::Unsigned);
}
#[test]
fn roundtrip_verifies() {
let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
match verify_detached(&signed).unwrap() {
// No anchor pinned in the default test build → anchored == false.
SignatureStatus::Verified { anchored, .. } => assert!(!anchored),
other => panic!("expected Verified, got {:?}", other),
}
}
#[test]
fn signature_survives_key_reordering() {
// Re-emitting the document with shuffled keys must not break the sig.
let signed = sign_into(&test_key(), json!({"b": 2, "a": 1}));
let reparsed: Value =
serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap();
assert!(matches!(
verify_detached(&reparsed).unwrap(),
SignatureStatus::Verified { .. }
));
}
#[test]
fn tampered_payload_is_rejected() {
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
signed
.as_object_mut()
.unwrap()
.insert("n".into(), json!(43));
assert!(verify_detached(&signed).is_err());
}
#[test]
fn missing_signed_by_is_rejected() {
let doc = json!({"schema": 1, "signature": "00"});
assert!(verify_detached(&doc).is_err());
}
#[test]
fn float_payload_cannot_be_signed() {
assert!(sign_detached(&test_key(), &json!({"x": 1.5})).is_err());
}
}

View File

@ -202,6 +202,106 @@ pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<(
Ok(())
}
// ─── Update/app fetch source (origin vs DHT swarm) ──────────────────────────
//
// User-selectable per node, persisted in `data_dir/update-source.json`. This is
// the live-testing switch: keep `Origin` (default) to pull releases/app blobs
// purely over HTTP from the configured mirrors — the known-good path — or flip
// to `Swarm` on a test node to exercise the DHT (iroh swarm-assist), knowing the
// origin still always wins as fallback. Independent of the compile-time
// `iroh-swarm` feature and the `swarm_enabled` config: if the swarm engine isn't
// present, `Swarm` simply has no peers to consult and behaves like `Origin`.
const UPDATE_SOURCE_FILE: &str = "update-source.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UpdateSource {
/// HTTP origin/mirrors only. The safe default and the universal fallback.
#[default]
Origin,
/// Try DHT swarm peers first for content-addressed blobs, origin always wins.
Swarm,
}
fn default_true() -> bool {
true
}
/// Node-level swarm preferences, persisted together in `update-source.json`.
/// Two independent switches:
/// - `source`: where THIS node fetches (origin vs swarm). Default origin.
/// - `provide_dht`: whether this node SEEDS/serves blobs to peers. Default on
/// (opt-out) so the swarm has providers; nodes that don't want to serve can
/// turn it off without affecting how they fetch.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct SwarmPrefs {
#[serde(default)]
source: UpdateSource,
#[serde(default = "default_true")]
provide_dht: bool,
}
impl Default for SwarmPrefs {
fn default() -> Self {
Self {
source: UpdateSource::default(),
provide_dht: true,
}
}
}
fn update_source_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(UPDATE_SOURCE_FILE)
}
async fn load_swarm_prefs(data_dir: &Path) -> SwarmPrefs {
match fs::read_to_string(update_source_path(data_dir)).await {
Ok(s) => serde_json::from_str::<SwarmPrefs>(&s).unwrap_or_default(),
Err(_) => SwarmPrefs::default(),
}
}
async fn save_swarm_prefs(data_dir: &Path, prefs: &SwarmPrefs) -> Result<()> {
fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir {}", data_dir.display()))?;
let path = update_source_path(data_dir);
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(prefs).context("serialize swarm prefs")?;
fs::write(&tmp, json)
.await
.with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Load the node's selected fetch source. Missing/corrupt file → `Origin`.
pub async fn load_update_source(data_dir: &Path) -> UpdateSource {
load_swarm_prefs(data_dir).await.source
}
/// Persist the node's selected fetch source (preserving `provide_dht`).
pub async fn save_update_source(data_dir: &Path, source: UpdateSource) -> Result<()> {
let mut prefs = load_swarm_prefs(data_dir).await;
prefs.source = source;
save_swarm_prefs(data_dir, &prefs).await
}
/// Whether this node seeds/serves blobs to peers. Default true (opt-out).
pub async fn load_provide_dht(data_dir: &Path) -> bool {
load_swarm_prefs(data_dir).await.provide_dht
}
/// Persist whether this node provides to the swarm (preserving `source`).
pub async fn save_provide_dht(data_dir: &Path, provide: bool) -> Result<()> {
let mut prefs = load_swarm_prefs(data_dir).await;
prefs.provide_dht = provide;
save_swarm_prefs(data_dir, &prefs).await
}
/// Parse a manifest URL and return its `scheme://host[:port]` prefix.
/// Used by `rewrite_manifest_origins` so a manifest fetched from a
/// mirror points component downloads back at the same mirror rather
@ -263,6 +363,11 @@ pub struct ComponentUpdate {
pub download_url: String,
pub sha256: String,
pub size_bytes: u64,
/// DHT Phase 1: BLAKE3 content address (bare hex or `"blake3:<hex>"`), the
/// iroh-native, range-verifiable hash. Optional during the migration
/// window — when present it is verified ALONGSIDE the mandatory SHA-256.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blake3: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -790,6 +895,23 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
// Consult swarm peers only when the node has opted into DHT mode. In Origin
// mode (default) this stays empty so every component goes straight to the
// HTTP origin — instant, no-rebuild fallback while live-testing the swarm.
let update_source = load_update_source(data_dir).await;
let provide_dht = load_provide_dht(data_dir).await;
let swarm_providers = if update_source == UpdateSource::Swarm {
crate::swarm::providers()
} else {
Vec::new()
};
if update_source == UpdateSource::Swarm {
info!(
providers = swarm_providers.len(),
"Update source = DHT swarm (origin still wins as fallback)"
);
}
for component in &manifest.components {
if is_canceled() {
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
@ -798,7 +920,57 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
}
info!(name = %component.name, url = %component.download_url, "Downloading component");
let dest = staging_dir.join(&component.name);
download_component_resumable(&client, component, &dest, downloaded).await?;
// DHT Phase 2: when the manifest pins a BLAKE3 digest, route the fetch
// through the swarm seam (swarm-assist, origin always wins). With no
// providers registered (iroh-swarm feature off) this is identical to
// calling the resumable HTTP origin directly — same bytes, now
// content-addressed. A swarm hit is BLAKE3-verified inside the seam;
// we still enforce the mandatory SHA-256 gate on peer bytes here and
// re-fetch from origin if a (consistency-broken) peer slips through.
let digest = component.blake3.as_deref().and_then(|b| {
let s = b.trim();
let normalized = if s.contains(':') {
s.to_string()
} else {
format!("blake3:{s}")
};
crate::content_hash::ContentDigest::parse(&normalized).ok()
});
if let Some(digest) = digest {
let client_ref = &client;
let dest_ref = &dest;
let source = crate::swarm::fetch_content_addressed(
&digest,
&swarm_providers,
&dest,
move || async move {
download_component_resumable(client_ref, component, dest_ref, downloaded).await
},
)
.await?;
if source == crate::swarm::FetchSource::Swarm {
let bytes = tokio::fs::read(&dest).await?;
if crate::content_hash::sha256_hex(&bytes) != component.sha256 {
warn!(
name = %component.name,
"swarm bytes passed BLAKE3 but failed the SHA-256 manifest gate — re-fetching from origin"
);
let _ = tokio::fs::remove_file(&dest).await;
download_component_resumable(&client, component, &dest, downloaded).await?;
}
}
// This is a PUBLIC release blob and it just passed both the BLAKE3 and
// SHA-256 gates — announce that we can now seed it to peers. Gated on
// the node's "provide to swarm" preference (default on); best-effort,
// inert unless the iroh swarm is active, and never blocks the install.
// Independent of fetch source: an origin-fetching node can still seed.
if provide_dht {
crate::swarm::announce_held_blob(&digest.hex, &dest).await;
}
} else {
download_component_resumable(&client, component, &dest, downloaded).await?;
}
downloaded += component.size_bytes;
DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed);
info!(
@ -993,6 +1165,25 @@ async fn download_component_resumable(
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash == component.sha256 {
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
// match too. SHA-256 stays the mandatory gate during migration;
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
// — treat it like a SHA mismatch and re-download.
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
));
continue;
}
}
return Ok(());
}
// SHA mismatch — the file on disk is garbage. Nuke it and
@ -1675,6 +1866,7 @@ mod tests {
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
sha256: "x".into(),
size_bytes: 1,
blake3: None,
},
ComponentUpdate {
name: "frontend".into(),
@ -1683,6 +1875,7 @@ mod tests {
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
sha256: "y".into(),
size_bytes: 2,
blake3: None,
},
],
};
@ -1882,9 +2075,10 @@ mod tests {
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
// A *complete* staged update carries the marker; without it the state
// self-heal correctly treats this as a partial download and clears
// update_in_progress (see has_staged_update / #26).
// A *complete* staged update carries the .download-complete marker;
// without it has_staged_update() reads the staging as partial and the
// load_state self-heal clears update_in_progress (see #26). This test
// simulates a complete staging, so write the marker.
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
.await
.unwrap();
@ -1902,6 +2096,7 @@ mod tests {
download_url: "https://example.com/binary".to_string(),
sha256: "abc123".to_string(),
size_bytes: 5000,
blake3: None,
}],
}),
update_in_progress: true,

View File

@ -10,7 +10,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
const WALLET_FILE: &str = "wallet/ecash.json";
const MINTS_FILE: &str = "wallet/accepted_mints.json";
@ -106,6 +106,18 @@ impl WalletState {
.sum()
}
/// Spendable (unspent, unreserved) balance grouped by mint URL.
pub fn spendable_by_mint(&self) -> Vec<(String, u64)> {
use std::collections::BTreeMap;
let mut by_mint: BTreeMap<String, u64> = BTreeMap::new();
for p in &self.proofs {
if !p.spent && !p.reserved {
*by_mint.entry(p.mint_url.clone()).or_default() += p.proof.amount;
}
}
by_mint.into_iter().collect()
}
/// Select unspent proofs that cover at least `amount` sats from a specific mint.
/// Returns selected proofs and any overpayment amount.
pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec<usize>, u64)> {
@ -352,10 +364,232 @@ pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Resul
Ok(quote.amount)
}
/// Create a cashuA token string to send to a peer.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
// ── Cross-mint settlement (plan §2a / phasing F2) ──────────────────────────
//
// The wallet data model is already multi-mint (proofs, balances and selection
// are all keyed by mint URL). What was hardcoded to the home mint is the
// convenience layer. These `*_at` helpers parameterize that layer by target
// mint, and `swap_between_mints` moves value across mints over Lightning so a
// node holding tokens on mint A can pay a seeder that only accepts mint B.
/// How long to wait for a target mint's Lightning invoice to settle before
/// claiming the freshly-minted tokens.
const SWAP_CLAIM_TIMEOUT_SECS: u64 = 60;
/// Poll interval while waiting for the invoice to settle.
const SWAP_CLAIM_POLL_SECS: u64 = 2;
/// Whether we trust a mint enough to swap value *into* it (or accept its tokens).
///
/// The local Fedimint (home mint) is always trusted; any other mint must be on
/// the configured accepted-mints allow-list. Comparison ignores a trailing
/// slash so advertised URLs match stored ones. See plan §2a "Mint trust list".
pub async fn is_mint_trusted(data_dir: &Path, mint_url: &str) -> Result<bool> {
let norm = |s: &str| s.trim_end_matches('/').to_string();
let target = norm(mint_url);
if target == norm(&default_mint_url()) {
return Ok(true);
}
let accepted = load_accepted_mints(data_dir).await?;
Ok(accepted.mints.iter().any(|m| norm(m) == target))
}
/// All-in cost of a swap, relative to the amount actually delivered.
///
/// `total_paid` is what the source mint charges (melt amount + LN fee reserve);
/// `amount_delivered` is what lands on the target mint. The difference is the
/// fee the user pays to move value across mints.
fn swap_fee(total_paid: u64, amount_delivered: u64) -> u64 {
total_paid.saturating_sub(amount_delivered)
}
/// Move `amount_sats` of value from mint `from_mint` to mint `to_mint` over
/// Lightning, returning the amount claimed on the target mint.
///
/// Flow (Cashu/Fedimint settle over BOLT11):
/// 1. `mint_quote` on the target → a Lightning invoice to pay.
/// 2. `melt_quote` on the source → the cost in source tokens (amount + fee).
/// 3. Fee-cap check: refuse if the all-in fee exceeds `max_fee_sats`.
/// 4. Select source proofs and `melt` them to pay the target's invoice.
/// 5. Once the invoice settles, `mint` (claim) the tokens on the target.
///
/// Crash-safety: the source spend is persisted *before* the claim, so a crash
/// between paying and claiming never double-spends — at worst the target tokens
/// are left unclaimed (reconcilable from the mint quote id). Idempotent resume
/// is phasing step 3 (deferred).
pub async fn swap_between_mints(
data_dir: &Path,
from_mint: &str,
to_mint: &str,
amount_sats: u64,
max_fee_sats: u64,
) -> Result<u64> {
if amount_sats == 0 {
anyhow::bail!("swap amount must be greater than zero");
}
let norm = |s: &str| s.trim_end_matches('/').to_string();
if norm(from_mint) == norm(to_mint) {
anyhow::bail!("swap source and target mints are identical");
}
if !is_mint_trusted(data_dir, to_mint).await? {
anyhow::bail!(
"target mint '{}' is not in the trusted/accepted mint list",
to_mint
);
}
let from = MintClient::new(from_mint)?;
let to = MintClient::new(to_mint)?;
// 1. Mint quote on the target → invoice to pay.
let mint_quote = to
.mint_quote(amount_sats)
.await
.with_context(|| format!("requesting mint quote at target mint {}", to_mint))?;
// 2. Melt quote on the source for that invoice → cost in source tokens.
let melt_quote = from
.melt_quote(&mint_quote.request)
.await
.with_context(|| format!("requesting melt quote at source mint {}", from_mint))?;
let total_needed = melt_quote.amount + melt_quote.fee_reserve;
let fee = swap_fee(total_needed, amount_sats);
// 3. Fee-cap check — caller falls back to free origin if too expensive.
if fee > max_fee_sats {
anyhow::bail!(
"swap fee {} sats exceeds cap {} sats (need {} on {} to deliver {} on {})",
fee,
max_fee_sats,
total_needed,
from_mint,
amount_sats,
to_mint
);
}
// 4. Select source proofs and melt them to pay the target invoice.
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let (indices, _overpayment) =
wallet
.select_proofs(from_mint, total_needed)
.ok_or_else(|| {
anyhow::anyhow!(
"insufficient balance on {}: need {} sats, have {} sats",
from_mint,
total_needed,
wallet.balance_for_mint(from_mint)
)
})?;
let proofs: Vec<Proof> = indices
.iter()
.map(|&i| wallet.proofs[i].proof.clone())
.collect();
if let Err(e) = from.melt_tokens(&melt_quote.quote, &proofs).await {
// The pay leg never completed — record the route failure so future
// payments can prefer a route with a track record.
record_swap_failure(data_dir, from_mint, to_mint).await;
return Err(e).with_context(|| {
format!(
"melting source proofs at {} to pay target invoice",
from_mint
)
});
}
// Persist the spend BEFORE claiming so a crash can't double-spend, and
// journal the in-flight swap so the claim can be resumed after a crash.
wallet.mark_spent(&indices);
wallet.record_tx(
TransactionType::Melt,
total_needed,
&format!(
"Cross-mint swap {}→{}: paid {} sats (fee {})",
from_mint, to_mint, total_needed, fee
),
from_mint,
to_mint,
);
save_wallet(data_dir, &wallet).await?;
add_pending_swap(
data_dir,
PendingSwap {
from_mint: from_mint.to_string(),
to_mint: to_mint.to_string(),
amount_sats,
melt_quote_id: melt_quote.quote.clone(),
mint_quote_id: mint_quote.quote.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
},
)
.await?;
// 5. Wait for the invoice to settle, then claim the minted tokens.
wait_for_mint_quote_paid(&to, &mint_quote.quote).await?;
let result = to
.mint_tokens(&mint_quote.quote, amount_sats)
.await
.with_context(|| format!("claiming minted tokens at target mint {}", to_mint))?;
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
let mut wallet = load_wallet(data_dir).await?;
wallet.add_proofs(to_mint, result.proofs);
wallet.record_tx(
TransactionType::Mint,
minted,
&format!(
"Cross-mint swap {}→{}: claimed {} sats",
from_mint, to_mint, minted
),
to_mint,
from_mint,
);
save_wallet(data_dir, &wallet).await?;
// Swap fully settled — clear the journal entry and credit the route.
remove_pending_swap(data_dir, &mint_quote.quote).await?;
record_swap_success(data_dir, from_mint, to_mint).await;
debug!(
"Cross-mint swap complete: {} → {} delivered {} sats (fee {})",
from_mint, to_mint, minted, fee
);
Ok(minted)
}
/// Poll a mint quote until its Lightning invoice is paid (state `PAID`/`ISSUED`),
/// or time out. The melt above pays the invoice; the target mint sees it settle
/// shortly after.
async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result<()> {
let deadline = SWAP_CLAIM_TIMEOUT_SECS / SWAP_CLAIM_POLL_SECS.max(1);
for _ in 0..deadline.max(1) {
let status = client.mint_quote_status(quote_id).await?;
match status.state.as_str() {
"PAID" | "ISSUED" => return Ok(()),
_ => tokio::time::sleep(std::time::Duration::from_secs(SWAP_CLAIM_POLL_SECS)).await,
}
}
anyhow::bail!(
"target mint invoice for quote {} did not settle within {}s",
quote_id,
SWAP_CLAIM_TIMEOUT_SECS
)
}
/// Create a cashuA token string to send to a peer, drawing from the home mint.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
let mint_url = load_wallet(data_dir).await?.mint_url;
send_token_at(data_dir, &mint_url, amount_sats).await
}
/// Create a cashuA token denominated in a specific mint's tokens.
///
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
/// only ever receives its own mint's proofs (see plan §2a, payer-side swap).
pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = mint_url.to_string();
// Select proofs covering the amount
let (indices, overpayment) = wallet
@ -422,6 +656,336 @@ pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
Ok(token_str)
}
// ── Payer-side payment builder (plan §2a step 2) ───────────────────────────
//
// Given a seeder's advertised `accepted_mints`, pick the cheapest way to pay:
// spend tokens we already hold on an accepted mint (no fee), else swap value
// into a *trusted* accepted mint and pay from there. If neither is possible
// within budget, decline so the caller falls back to free origin.
/// How a payment of a given amount can be satisfied across our mints.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaymentPlan {
/// We already hold enough on this accepted mint — pay directly, no swap fee.
Direct { mint_url: String },
/// Swap value from `from_mint` into the trusted accepted `to_mint`, then pay.
Swap { from_mint: String, to_mint: String },
/// No single mint can cover the amount (caller should use free origin).
Insufficient,
}
/// Decide how to pay `amount` sats to a seeder, given what we hold and which
/// mints the seeder accepts.
///
/// - `holdings`: our spendable `(mint_url, balance)` pairs (verbatim URLs).
/// - `accepted`: the seeder's `(mint_url, trusted)` pairs, where `trusted`
/// means the mint is on our swap-into allow-list (`is_mint_trusted`).
/// - Direct beats Swap (no fee). A Direct target needs no trust (we already
/// hold those tokens); a Swap target must be trusted. The home mint is
/// preferred as a tie-break for both legs (lowest friction).
///
/// Pure and synchronous so it can be unit-tested without a live mint. It does
/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→
/// origin fallback) if the chosen source can't cover amount + fee.
fn plan_payment(
holdings: &[(String, u64)],
accepted: &[(String, bool)],
amount: u64,
) -> PaymentPlan {
let norm = |s: &str| s.trim_end_matches('/').to_string();
let home = norm(&default_mint_url());
let held = |mint: &str| -> u64 {
holdings
.iter()
.filter(|(m, _)| norm(m) == norm(mint))
.map(|(_, b)| *b)
.sum()
};
// 1. Direct: any accepted mint we already hold enough on. Prefer home.
let mut direct: Vec<&(String, bool)> =
accepted.iter().filter(|(m, _)| held(m) >= amount).collect();
direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first
if let Some((mint, _)) = direct.first() {
return PaymentPlan::Direct {
mint_url: mint.clone(),
};
}
// 2. Swap: a trusted accepted target + a source we hold that covers `amount`.
let mut targets: Vec<&(String, bool)> =
accepted.iter().filter(|(_, trusted)| *trusted).collect();
targets.sort_by_key(|(m, _)| norm(m) != home);
if let Some((to_mint, _)) = targets.first() {
// Largest source we hold that isn't the target itself.
let from = holdings
.iter()
.filter(|(m, b)| norm(m) != norm(to_mint) && *b >= amount)
.max_by_key(|(_, b)| *b);
if let Some((from_mint, _)) = from {
return PaymentPlan::Swap {
from_mint: from_mint.clone(),
to_mint: to_mint.clone(),
};
}
}
PaymentPlan::Insufficient
}
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
/// we don't already hold the right mint. Returns the token string ready to send.
///
/// Errors (caller should fall back to free origin) when no accepted mint is
/// reachable within balance, no trusted swap target exists, or the swap exceeds
/// the fee cap.
pub async fn build_payment_token(
data_dir: &Path,
accepted_mints: &[String],
amount_sats: u64,
max_fee_sats: u64,
) -> Result<String> {
if amount_sats == 0 {
anyhow::bail!("payment amount must be greater than zero");
}
if accepted_mints.is_empty() {
anyhow::bail!("seeder advertised no accepted mints");
}
// Annotate each accepted mint with whether we trust swapping into it.
let mut accepted: Vec<(String, bool)> = Vec::with_capacity(accepted_mints.len());
for m in accepted_mints {
let trusted = is_mint_trusted(data_dir, m).await?;
accepted.push((m.clone(), trusted));
}
// Prefer swap targets with a liquidity track record. plan_payment's stable
// sort keeps the home mint first; within the rest, this orders by how
// reliably we've reached each target before (best routes first).
let liq = load_swap_liquidity(data_dir).await;
accepted.sort_by_key(|(m, _)| std::cmp::Reverse(target_liquidity_score(&liq, m)));
let holdings = load_wallet(data_dir).await?.spendable_by_mint();
match plan_payment(&holdings, &accepted, amount_sats) {
PaymentPlan::Direct { mint_url } => {
debug!(
"Payment plan: direct from {} for {} sats",
mint_url, amount_sats
);
send_token_at(data_dir, &mint_url, amount_sats).await
}
PaymentPlan::Swap { from_mint, to_mint } => {
debug!(
"Payment plan: swap {}→{} then pay {} sats (fee cap {})",
from_mint, to_mint, amount_sats, max_fee_sats
);
swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?;
send_token_at(data_dir, &to_mint, amount_sats).await
}
PaymentPlan::Insufficient => anyhow::bail!(
"cannot pay {} sats: no accepted mint covers it within balance/trust",
amount_sats
),
}
}
// ── F2 step 3 — hardening: idempotent swap resume + liquidity cache ─────────
const PENDING_SWAPS_FILE: &str = "wallet/pending_swaps.json";
const SWAP_LIQUIDITY_FILE: &str = "wallet/swap_liquidity.json";
/// An in-flight cross-mint swap, journaled the moment the source proofs are
/// melted (paid) so a crash before the target claim can be resumed instead of
/// silently losing the value. Removed once the target tokens are claimed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingSwap {
pub from_mint: String,
pub to_mint: String,
pub amount_sats: u64,
/// Source-mint melt quote id (the leg already paid).
pub melt_quote_id: String,
/// Target-mint mint quote id (the leg to claim).
pub mint_quote_id: String,
pub created_at: String,
}
async fn load_pending_swaps(data_dir: &Path) -> Result<Vec<PendingSwap>> {
let path = data_dir.join(PENDING_SWAPS_FILE);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).await?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
async fn save_pending_swaps(data_dir: &Path, swaps: &[PendingSwap]) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir).await?;
let path = data_dir.join(PENDING_SWAPS_FILE);
fs::write(&path, serde_json::to_string_pretty(swaps)?).await?;
Ok(())
}
async fn add_pending_swap(data_dir: &Path, swap: PendingSwap) -> Result<()> {
let mut all = load_pending_swaps(data_dir).await?;
all.push(swap);
save_pending_swaps(data_dir, &all).await
}
async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()> {
let mut all = load_pending_swaps(data_dir).await?;
all.retain(|s| s.mint_quote_id != mint_quote_id);
save_pending_swaps(data_dir, &all).await
}
/// Resume any swaps that were interrupted between paying the source mint and
/// claiming the target tokens. For each pending swap, ask the target mint about
/// the mint quote:
/// - `PAID` → claim now (the value was paid but never claimed). Reclaimed.
/// - `ISSUED` → already claimed on a prior run; just drop the journal entry.
/// - else → leave it (the invoice hasn't settled yet; retry next time).
///
/// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a
/// quote is only minted once, and `ISSUED` quotes are never re-claimed.
pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
let pending = load_pending_swaps(data_dir).await?;
let mut reclaimed = 0u64;
for swap in pending {
let to = match MintClient::new(&swap.to_mint) {
Ok(c) => c,
Err(e) => {
warn!(
"resume_pending_swaps: bad target mint {}: {}",
swap.to_mint, e
);
continue;
}
};
let status = match to.mint_quote_status(&swap.mint_quote_id).await {
Ok(s) => s,
Err(e) => {
debug!(
"resume_pending_swaps: status check failed for {}: {} — leaving pending",
swap.mint_quote_id, e
);
continue;
}
};
match status.state.as_str() {
"PAID" => match to.mint_tokens(&swap.mint_quote_id, swap.amount_sats).await {
Ok(result) => {
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
let mut wallet = load_wallet(data_dir).await?;
wallet.add_proofs(&swap.to_mint, result.proofs);
wallet.record_tx(
TransactionType::Mint,
minted,
&format!(
"Resumed cross-mint swap {}→{}: claimed {} sats",
swap.from_mint, swap.to_mint, minted
),
&swap.to_mint,
&swap.from_mint,
);
save_wallet(data_dir, &wallet).await?;
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
record_swap_success(data_dir, &swap.from_mint, &swap.to_mint).await;
reclaimed += minted;
info!(
"Resumed interrupted swap {}→{}: reclaimed {} sats",
swap.from_mint, swap.to_mint, minted
);
}
Err(e) => warn!(
"resume_pending_swaps: claim failed for {}: {}",
swap.mint_quote_id, e
),
},
"ISSUED" => {
// Already claimed on a previous run — drop the journal entry.
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
}
other => debug!(
"resume_pending_swaps: quote {} state {} — leaving pending",
swap.mint_quote_id, other
),
}
}
Ok(reclaimed)
}
/// Success/failure counts for a single (from → to) swap route.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RouteStat {
successes: u64,
failures: u64,
}
/// Per-mint-pair liquidity cache: which swap routes have actually worked, so the
/// payer can prefer routes with a track record over ones that keep failing.
#[derive(Debug, Default, Serialize, Deserialize)]
struct SwapLiquidity {
/// Keyed by `"<from>|<to>"` (normalized URLs).
routes: std::collections::BTreeMap<String, RouteStat>,
}
fn route_key(from_mint: &str, to_mint: &str) -> String {
format!(
"{}|{}",
from_mint.trim_end_matches('/'),
to_mint.trim_end_matches('/')
)
}
async fn load_swap_liquidity(data_dir: &Path) -> SwapLiquidity {
let path = data_dir.join(SWAP_LIQUIDITY_FILE);
match fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => SwapLiquidity::default(),
}
}
async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) {
let dir = data_dir.join("wallet");
let _ = fs::create_dir_all(&dir).await;
if let Ok(content) = serde_json::to_string_pretty(liq) {
let _ = fs::write(data_dir.join(SWAP_LIQUIDITY_FILE), content).await;
}
}
/// Record that a swap route succeeded (best-effort; never fails the caller).
async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) {
let mut liq = load_swap_liquidity(data_dir).await;
liq.routes
.entry(route_key(from_mint, to_mint))
.or_default()
.successes += 1;
save_swap_liquidity(data_dir, &liq).await;
}
/// Record that a swap route failed (best-effort; never fails the caller).
async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) {
let mut liq = load_swap_liquidity(data_dir).await;
liq.routes
.entry(route_key(from_mint, to_mint))
.or_default()
.failures += 1;
save_swap_liquidity(data_dir, &liq).await;
}
/// Liquidity score for reaching `to_mint` from any source: net successes across
/// all routes ending at this target. Higher = a more reliable destination.
fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
let suffix = format!("|{}", to_mint.trim_end_matches('/'));
liq.routes
.iter()
.filter(|(k, _)| k.ends_with(&suffix))
.map(|(_, s)| s.successes as i64 - s.failures as i64)
.sum()
}
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Handle legacy format for backwards compatibility
@ -936,4 +1500,323 @@ mod tests {
fn test_default_mint_url() {
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
}
#[test]
fn test_swap_fee() {
assert_eq!(swap_fee(105, 100), 5);
// Defensive: never underflow if mint quotes oddly.
assert_eq!(swap_fee(100, 100), 0);
assert_eq!(swap_fee(90, 100), 0);
}
#[tokio::test]
async fn test_is_mint_trusted_home_always() {
let tmp = TempDir::new().unwrap();
// Home mint is trusted even with no accepted-mints file.
assert!(is_mint_trusted(tmp.path(), &default_mint_url())
.await
.unwrap());
// Trailing slash on the home URL still matches.
assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/")
.await
.unwrap());
}
#[tokio::test]
async fn test_is_mint_trusted_respects_accepted_list() {
let tmp = TempDir::new().unwrap();
save_accepted_mints(
tmp.path(),
&AcceptedMints {
mints: vec![default_mint_url(), "https://mint.example.com".into()],
},
)
.await
.unwrap();
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com")
.await
.unwrap());
// Normalized comparison ignores a trailing slash.
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com/")
.await
.unwrap());
// A mint not on the list is not trusted.
assert!(!is_mint_trusted(tmp.path(), "https://evil.example.com")
.await
.unwrap());
}
#[tokio::test]
async fn test_swap_between_mints_rejects_identical() {
let tmp = TempDir::new().unwrap();
let err = swap_between_mints(
tmp.path(),
&default_mint_url(),
"http://127.0.0.1:8175/",
100,
10,
)
.await
.unwrap_err();
assert!(err.to_string().contains("identical"));
}
#[tokio::test]
async fn test_swap_between_mints_rejects_untrusted_target() {
let tmp = TempDir::new().unwrap();
let err = swap_between_mints(
tmp.path(),
&default_mint_url(),
"https://untrusted.example.com",
100,
10,
)
.await
.unwrap_err();
assert!(err.to_string().contains("trusted"));
}
#[tokio::test]
async fn test_swap_between_mints_rejects_zero_amount() {
let tmp = TempDir::new().unwrap();
let err = swap_between_mints(
tmp.path(),
&default_mint_url(),
"https://mint.example.com",
0,
10,
)
.await
.unwrap_err();
assert!(err.to_string().contains("greater than zero"));
}
#[test]
fn test_spendable_by_mint_groups_and_excludes() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint-a",
vec![
Proof {
amount: 10,
id: "k".into(),
secret: "s1".into(),
c: "c".into(),
},
Proof {
amount: 5,
id: "k".into(),
secret: "s2".into(),
c: "c".into(),
},
],
);
wallet.add_proofs(
"http://mint-b",
vec![Proof {
amount: 7,
id: "k".into(),
secret: "s3".into(),
c: "c".into(),
}],
);
wallet.proofs[1].spent = true; // exclude the 5 on mint-a
let by_mint = wallet.spendable_by_mint();
assert_eq!(
by_mint,
vec![
("http://mint-a".to_string(), 10),
("http://mint-b".to_string(), 7)
]
);
}
#[test]
fn test_plan_payment_direct_prefers_home() {
let home = default_mint_url();
let holdings = vec![(home.clone(), 100), ("https://other".into(), 100)];
// Both accepted; home should win the tie-break.
let accepted = vec![("https://other".into(), true), (home.clone(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Direct { mint_url: home }
);
}
#[test]
fn test_plan_payment_direct_only_accepted_mint() {
let holdings = vec![("https://a".into(), 100), ("https://b".into(), 100)];
// We hold both, but the seeder only accepts b.
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Direct {
mint_url: "https://b".into()
}
);
}
#[test]
fn test_plan_payment_swaps_into_trusted_target() {
// We hold value on A; seeder accepts only B (trusted) which we don't hold.
let holdings = vec![("https://a".into(), 100)];
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Swap {
from_mint: "https://a".into(),
to_mint: "https://b".into()
}
);
}
#[test]
fn test_plan_payment_refuses_untrusted_swap_target() {
// Seeder accepts only B, but B is not trusted → no swap, insufficient.
let holdings = vec![("https://a".into(), 100)];
let accepted = vec![("https://b".into(), false)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Insufficient
);
}
#[test]
fn test_plan_payment_insufficient_when_no_single_source_covers() {
// Total 60 across two mints, but neither alone covers 50+ for a swap and
// we hold neither accepted mint directly.
let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)];
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Insufficient
);
}
#[test]
fn test_plan_payment_direct_beats_swap() {
// We hold the accepted mint directly AND could swap — Direct must win.
let home = default_mint_url();
let holdings = vec![("https://b".into(), 100), (home.clone(), 100)];
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Direct {
mint_url: "https://b".into()
}
);
}
#[tokio::test]
async fn test_build_payment_token_rejects_empty_mints() {
let tmp = TempDir::new().unwrap();
let err = build_payment_token(tmp.path(), &[], 100, 10)
.await
.unwrap_err();
assert!(err.to_string().contains("no accepted mints"));
}
#[tokio::test]
async fn test_build_payment_token_insufficient_falls_through() {
let tmp = TempDir::new().unwrap();
// Empty wallet, untrusted seeder mint → cannot pay (caller uses origin).
let err = build_payment_token(tmp.path(), &["https://seeder.example.com".into()], 100, 10)
.await
.unwrap_err();
assert!(err.to_string().contains("cannot pay"));
}
#[test]
fn test_route_key_normalizes_trailing_slash() {
assert_eq!(route_key("https://a/", "https://b/"), "https://a|https://b");
assert_eq!(route_key("https://a", "https://b"), "https://a|https://b");
}
#[tokio::test]
async fn test_pending_swaps_roundtrip_and_remove() {
let tmp = TempDir::new().unwrap();
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
add_pending_swap(
tmp.path(),
PendingSwap {
from_mint: "https://a".into(),
to_mint: "https://b".into(),
amount_sats: 100,
melt_quote_id: "melt-1".into(),
mint_quote_id: "mint-1".into(),
created_at: "2026-06-17T00:00:00Z".into(),
},
)
.await
.unwrap();
let loaded = load_pending_swaps(tmp.path()).await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].mint_quote_id, "mint-1");
remove_pending_swap(tmp.path(), "mint-1").await.unwrap();
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
}
#[tokio::test]
async fn test_resume_pending_swaps_empty_is_noop() {
let tmp = TempDir::new().unwrap();
assert_eq!(resume_pending_swaps(tmp.path()).await.unwrap(), 0);
}
#[tokio::test]
async fn test_liquidity_cache_records_and_scores() {
let tmp = TempDir::new().unwrap();
// Two routes into B succeed; one into C fails.
record_swap_success(tmp.path(), "https://a", "https://b").await;
record_swap_success(tmp.path(), "https://x", "https://b").await;
record_swap_failure(tmp.path(), "https://a", "https://c").await;
let liq = load_swap_liquidity(tmp.path()).await;
// B reached successfully from two sources → net +2; trailing slash tolerant.
assert_eq!(target_liquidity_score(&liq, "https://b/"), 2);
// C only failed → net -1.
assert_eq!(target_liquidity_score(&liq, "https://c"), -1);
// Unknown target → neutral 0.
assert_eq!(target_liquidity_score(&liq, "https://unknown"), 0);
}
#[tokio::test]
async fn test_build_payment_token_prefers_liquid_target() {
let tmp = TempDir::new().unwrap();
// Trust two non-home mints; hold value on a source mint for both.
save_accepted_mints(
tmp.path(),
&AcceptedMints {
mints: vec![
default_mint_url(),
"https://liquid".into(),
"https://dry".into(),
],
},
)
.await
.unwrap();
// Give "https://liquid" a track record so it should be preferred.
record_swap_success(tmp.path(), "https://src", "https://liquid").await;
// Seeder accepts both non-home mints; we only hold "https://src".
let accepted = vec![
("https://dry".into(), true),
("https://liquid".into(), true),
];
let holdings = vec![("https://src".to_string(), 1000u64)];
// Mirror build_payment_token's ordering step, then plan.
let liq = load_swap_liquidity(tmp.path()).await;
let mut ordered = accepted.clone();
ordered.sort_by_key(|(m, _): &(String, bool)| {
std::cmp::Reverse(target_liquidity_score(&liq, m))
});
match plan_payment(&holdings, &ordered, 100) {
PaymentPlan::Swap { to_mint, .. } => assert_eq!(to_mint, "https://liquid"),
other => panic!("expected swap into liquid target, got {:?}", other),
}
}
}

View File

@ -0,0 +1,289 @@
//! Thin HTTP bridge to the `fedimint-clientd` sidecar container.
//!
//! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the
//! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation
//! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth),
//! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API.
//!
//! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd
//! v0.3.x and must be pinned to the vendored image tag.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
const CLIENTD_TIMEOUT_SECS: u64 = 15;
const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60;
/// Default host port the `fedimint-clientd` container is mapped to (its own
/// default 8080 collides with LND REST, so the manifest maps it to 8178).
const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178";
/// Federation joined out-of-the-box on every node. The fmcd container also
/// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync.
///
/// The preferred default federation (guardian on .116, iroh transport).
/// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does
/// NAT traversal, so it's reachable fleet-wide — the right fleet default.
/// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT
/// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate
/// reliability from a separate node. ensure_default_federation is best-effort.
/// See docs/dual-ecash-design.md.
pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp";
/// One joined federation, persisted locally so the list survives clientd being
/// temporarily down. Balances are always read live from clientd.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinedFederation {
pub federation_id: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct FederationRegistry {
pub federations: Vec<JoinedFederation>,
}
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
return Ok(FederationRegistry::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read fedimint federation registry")?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?;
fs::write(data_dir.join(REGISTRY_FILE), content)
.await
.context("Failed to write fedimint federation registry")?;
Ok(())
}
/// Idempotently ensure the node has joined the default federation and that it
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
/// isn't installed/running yet. Joining is idempotent on the clientd side.
pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
let client = match FedimintClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Ok(()), // clientd not configured yet
};
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
Ok(id) => id,
Err(e) => {
debug!("default federation autojoin skipped: {e}");
return Ok(());
}
};
let mut reg = load_registry(data_dir).await?;
if !reg
.federations
.iter()
.any(|f| f.federation_id == federation_id)
{
reg.federations.push(JoinedFederation {
federation_id,
name: None,
});
save_registry(data_dir, &reg).await?;
}
Ok(())
}
/// HTTP client for a `fedimint-clientd` instance.
pub struct FedimintClient {
base_url: String,
password: String,
client: reqwest::Client,
}
impl FedimintClient {
pub fn new(base_url: &str, password: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for fedimint-clientd")?;
Ok(Self::with_client(base_url, password, client))
}
pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
password: password.to_string(),
client,
}
}
/// Resolve URL + password from env / node secret, with sane defaults.
/// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port.
/// Password: `FEDIMINT_CLIENTD_PASSWORD` else `<data_dir>/fedimint-clientd/password`.
pub async fn from_node(data_dir: &Path) -> Result<Self> {
let base_url =
std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string());
let password = match std::env::var("FMCD_PASSWORD") {
Ok(p) if !p.is_empty() => p,
_ => {
let secret = data_dir.join("fmcd").join("password");
fs::read_to_string(&secret)
.await
.map(|s| s.trim().to_string())
.context(
"Fedimint client not configured (no FMCD_PASSWORD and no \
fmcd/password secret). Install the Fedimint client app.",
)?
}
};
Self::new(&base_url, &password)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
// fmcd uses HTTP Basic auth with a fixed username `fmcd`.
req.basic_auth("fmcd", Some(&self.password))
}
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.post(&url))
.json(&body)
.send()
.await
.with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.get(&url))
.timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS))
.send()
.await
.with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("fedimint-clientd {path} returned {status}: {text}");
}
if text.is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&text)
.with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}"))
}
/// `GET /v2/admin/info` — per-federation holdings keyed by federationId.
pub async fn info(&self) -> Result<serde_json::Value> {
self.get("/v2/admin/info").await
}
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
pub async fn join(&self, invite_code: &str) -> Result<String> {
let res = self
.post(
"/v2/admin/join",
serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }),
)
.await?;
let id = res
.get("thisFederationId")
.or_else(|| res.get("federationId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match id {
Some(id) => {
debug!("joined fedimint federation {id}");
Ok(id)
}
// Older/newer clientd may return the full info map; fall back to info().
None => self.latest_federation_id().await,
}
}
/// Total balance across all joined federations, in sats.
pub async fn total_balance_sats(&self) -> Result<u64> {
let info = self.info().await?;
Ok(sum_msat(&info) / 1000)
}
/// Balance of one federation in sats (0 if unknown).
pub async fn federation_balance_sats(&self, federation_id: &str) -> Result<u64> {
let info = self.info().await?;
let msat = info
.get(federation_id)
.and_then(federation_msat)
.unwrap_or(0);
Ok(msat / 1000)
}
/// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes.
pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result<String> {
let res = self
.post(
"/v2/mint/spend",
serde_json::json!({
"federationId": federation_id,
"amountMsat": amount_sats * 1000,
"allowOverpay": true,
"timeout": 3600,
"includeInvite": false,
}),
)
.await?;
res.get("notes")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response"))
}
/// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats.
pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result<u64> {
let res = self
.post(
"/v2/mint/reissue",
serde_json::json!({ "federationId": federation_id, "notes": notes }),
)
.await?;
let msat = res
.get("amountMsat")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?;
Ok(msat / 1000)
}
async fn latest_federation_id(&self) -> Result<String> {
let info = self.info().await?;
info.as_object()
.and_then(|m| m.keys().next_back().cloned())
.ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none"))
}
}
fn federation_msat(entry: &serde_json::Value) -> Option<u64> {
entry
.get("totalAmountMsat")
.or_else(|| entry.get("totalMsat"))
.and_then(|v| v.as_u64())
}
fn sum_msat(info: &serde_json::Value) -> u64 {
info.as_object()
.map(|m| m.values().filter_map(federation_msat).sum())
.unwrap_or(0)
}

View File

@ -4,5 +4,6 @@
pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod mint_client;
pub mod profits;

21
docker/fmcd/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
# fmcd (Fedimint client daemon) runtime image.
#
# The fmcd binary is built from source (github.com/minmoto/fmcd v0.8.0,
# fedimint-client 0.8.2 — iroh-capable) with Rust 1.86.0, then copied in here.
# Base must match the build host's glibc (Debian trixie / glibc 2.41).
# Binary is dynamically linked against libstdc++ (statically-bundled rocksdb)
# and uses rustls (no openssl). Build context must contain the `fmcd` binary.
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
COPY fmcd /usr/local/bin/fmcd
COPY fmcd-run /usr/local/bin/fmcd-run
RUN chmod +x /usr/local/bin/fmcd /usr/local/bin/fmcd-run
EXPOSE 8080
# Resilient launcher (retries on join failure instead of crash-looping).
# All config is read from FMCD_* env vars.
ENTRYPOINT ["fmcd-run"]

17
docker/fmcd/fmcd-run Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# Resilient launcher for fmcd.
#
# fmcd requires >=1 federation to boot — if the default federation is
# unreachable at first boot it exits non-zero. Rather than let the container
# crash-loop (and on a node, spam restarts), retry here with a backoff so the
# join happens in the background once the federation becomes reachable. Once
# fmcd is up it runs forever; this loop only re-runs it on exit.
#
# All config comes from FMCD_* env (FMCD_ADDR, FMCD_MODE, FMCD_DATA_DIR,
# FMCD_INVITE_CODE, FMCD_PASSWORD), so fmcd needs no CLI args here.
set -u
while true; do
fmcd || true
echo "[fmcd-run] fmcd exited (federation unreachable?); retrying in 30s" >&2
sleep 30
done

View File

@ -0,0 +1,224 @@
# Remaining issues — implementation plans
Written 2026-06-17. Covers the open Gitea issues not closeable in the single-box
dev env. Each plan lists the files to touch, the approach, and how to verify
(most need .116 + .198, a companion phone, or funded wallets). Issues #3 (VPN)
and #5 (OpenWRT/TollGate) are intentionally out of scope per the user.
Status of the rest at time of writing:
- **#31** group chat over Tor — dedup-by-`msg_id` fix already shipped (open only
for a 2-node Tor confirmation). See its Gitea comment.
- **#43** install on .70 — blocked: .70 unreachable. Plan below is a code-side
hardening that doesn't depend on .70's logs.
---
## #46 — Pay for peer files (local wallet OR invoice+QR to seller)
> **Status (2026-06-17): Phase 1 DONE & compiles** (LN invoice + QR + release).
> Seller: `content_invoice.rs` entitlement store, `GET /content/{id}/invoice`
> + `/invoice-status/{hash}`, invoice-paid path in `serve_content`
> (`X-Invoice-Hash`), LND `create_invoice`/`invoice_is_settled`. Buyer:
> `content.request-invoice` / `.invoice-status` / `.download-peer-invoice` +
> `PeerFiles.vue` picker modal + QR + poll. Phases 2 (on-chain) and 3 (local
> LN/on-chain methods) remain; needs live funded-wallet verify. Issue left open.
**Goal.** At the paid-download step in Cloud → peer files, let the buyer choose
how to pay: (a) their local wallet (ecash today; LN/on-chain later), or (b) get
an invoice with a QR drawn on the **selling** node's wallet, pay from any
external wallet, and have the file release on confirmation.
**What exists already**
- Buyer ecash auto-pay: `content.download-peer-paid` (mints ecash, downloads
atomically) — wired in `neode-ui/src/views/PeerFiles.vue` `downloadFile()`.
- Payer-side builder: `streaming.prepare-payment` RPC + `wallet/ecash.rs`
(`build_payment_token`, cross-mint), `swarm/payment.rs`.
- Free streaming download: `/api/peer-content/:onion/:id` (Range-capable).
- LND invoice RPC: `lnd.createinvoice`; ecash balance: `wallet.ecash-balance`.
**Backend work**
1. **Seller-side invoice RPC** (new), e.g. `content.request-invoice`
`{ onion, content_id }` → asks the *selling* node (over the existing
`/archipelago/...` peer transport, same path machinery as
`content.download-peer-paid`) to produce a payment request for `price_sats`:
- LN: `lnd.createinvoice` on the seller, return `bolt11` + `payment_hash`.
- on-chain: `lnd.newaddress` on the seller, return `address` + `amount`.
- Seller records a pending entitlement keyed by `payment_hash`/address →
content_id → buyer.
2. **Payment confirmation + release**: seller polls its own LND
(`lnd.lookup-invoice` / address watch); on settle, marks the entitlement
paid. Buyer side polls `content.invoice-status { payment_hash }` → when paid,
downloads via the existing `/api/peer-content` (gate now passes because the
entitlement is satisfied). Reuse the streaming gate in `streaming/` — add an
"invoice-paid" path alongside the ecash-token path.
3. Keep `content.download-peer-paid` (local-ecash) as the (a) fast path.
**Frontend work** (`PeerFiles.vue`)
1. Before a paid download, open a small **payment-method picker** modal:
- "Pay from this node's wallet" → existing ecash flow (show balance; if
insufficient, the LN/on-chain local options when those land).
- "Pay from another wallet (QR)" → call `content.request-invoice`, render the
`bolt11`/address as a **QR** (add a tiny QR lib or reuse one already in the
bundle — check `package.json`), show amount + a live "waiting for
payment…" state polling `content.invoice-status`, then auto-download.
2. Reuse the existing `purchaseError`/`downloading` state + `triggerDownload`.
**Verify**: .116 (seller) + .198 (buyer), a funded regtest/LN wallet. Buyer
picks QR, pays from a 3rd wallet, file releases. Then the local-ecash path.
**Effort**: large (multi-day). Phase it: (1) LN-invoice + QR + release, (2)
on-chain, (3) local LN/on-chain methods.
---
## #18 — Companion app: "open in external browser" apps don't work
> **Status (2026-06-17): DONE & compiles (Rust + TS); Android unbuilt here.**
> Reverse relay hop added: `external_open_tx` channel, kiosk publishes
> `{"t":"o","url"}` on `/ws/remote-relay` (URL-validated), forwarded to the
> companion's `/ws/remote-input`. `requestExternalOpen()` in `remote-relay.ts`
> wired into all four `appLauncher.ts` external-open sites; `InputWebSocket.kt`
> + `RemoteInputScreen.kt` open it via `ACTION_VIEW`. Issue closed; live pairing
> test pending.
**Goal.** Apps configured to open in a new/external browser should launch on the
**phone** when driven from the companion controller, using the phone-default-
browser request pattern.
**What exists**
- Relay protocol in `neode-ui/src/api/remote-relay.ts` — message cases `m`
(move cursor), `c` (click), `s` (scroll, just fixed in #7). Click resolves the
element under the virtual cursor via `deepElementFromPoint`.
- The kiosk side runs the dashboard; "open external" apps currently try to
`window.open` on the **kiosk**, which the phone never sees.
**Approach**
1. **Detect external-open intent on the kiosk**: when a click lands on an
element that would open externally (anchor with `target=_blank` / an app
flagged `opensExternally`, or an intercepted `window.open`), instead of
opening locally, send a new relay message to the phone:
`{ t: 'open-url', url }` over the `/ws/remote-relay` channel (the kiosk is the
relay server side — find where it sends frames back to the companion).
2. **Companion (phone) side** handles `open-url` by doing `window.open(url,
'_blank')` / `location.href = url` so it opens in the phone's default browser.
- If the companion is the **Android APK** (separate codebase, see
`Android/` + memory `feedback_companion_apk_not_in_update`), add an
intent-based handler there; if it's a mobile web client, handle in JS.
3. Intercept `window.open` on the kiosk dashboard globally (a small shim that,
when remote-relay is active, forwards to the phone instead of opening).
**Verify**: phone + kiosk paired; tap an "open external" app from the companion;
it opens in the phone browser.
**Effort**: medium; needs the companion device + possibly an APK change.
---
## #50 — Integrate Meshroller into our mesh features
> **Decision made 2026-06-17: seam (a) — Rust-native lift.** Full design with
> verified seam anchors (message types, dispatch, send API, event/trust gates,
> Ollama call) is in **`docs/meshroller-integration-design.md`**. Summary below.
Source: https://gitea.l484.com/clasko/Meshroller
**Phase 0 — review (DONE 2026-06-17)**
- Reviewed. Meshroller is a single ~29KB Python script (`meshroller.py`): a
daemon that bridges a **Meshtastic** radio (via the `meshtastic` Python serial
module, `SerialInterface`) to an **Ollama** LLM (`qwen2.5-coder`). It has
trusted-node auth, scheduled/queued messaging, and command handling on mesh
channels. It is a **daemon**, not firmware or a library.
- **License**: in-house (our own developer) — no third-party license blocker.
- **Hardware/transport reality**: it rides **Meshtastic serial + a local
Ollama**. Our radio is **Meshcore** (Heltec V3) and our mesh stack targets
meshcore. The `meshtastic` module does NOT speak meshcore, so the script
cannot drive our radio unmodified.
- **Decision needed (architecture)**: per user, integration **must work with
meshcore**. Two seams:
- (a) Lift Meshroller's *behaviors* (LLM bridge, trusted-node auth, scheduled
messaging, command parser) into our Rust mesh stack as typed message kinds —
native to meshcore, no Python/Meshtastic dependency. Preferred for meshcore.
- (b) Package the Python daemon as a container app and add a meshcore serial
backend to it (keeps the script, but requires writing meshcore I/O the
`meshtastic` module doesn't provide).
This choice is the remaining gate; the rest of Phase 1 below stands.
**Phase 1 — choose the seam**
- Our mesh stack: `core/archipelago/src/mesh/` (`mod.rs` `MeshService`,
`listener/`, `protocol.rs`, `types.rs`). Decide:
- If Meshroller is a *protocol/feature on the same radio* → implement it as a
typed message kind in our `MeshMessageType` + `listener/dispatch.rs`
(mirrors how block headers / alerts are handled).
- If it's a *separate transport/daemon* → wrap it behind our transport router
(`transport/`) like FIPS/LAN/Tor.
- Reuse the event seam (`MeshEvent`) so the UI gets pushes (same path we just
wired for #48).
**Phase 2 — UX** (ties into `project_mesh_telegram_plan`)
- A dead-simple onboarding + usage flow in the Mesh tab. Define the 12 killer
actions and design the setup wizard.
**Verify**: 2 radios (the .116 Meshcore + a second).
**Effort**: multi-day; gated on the Phase 0 review + a license/architecture
decision.
---
## #15 — netbird app doesn't work (LOW PRIORITY)
> **Status (2026-06-17): DIAGNOSED LIVE on .198 + FIXED (option A shipped); login works.**
> THE real blocker: the dashboard needs a **secure context**
> `window.crypto.subtle is unavailable` over plain http, so OIDC PKCE threw
> before login. Fix: proxy now serves **HTTPS** (self-signed cert at install,
> `8087:443`, all origins `https://`); frontend opens netbird in a **new tab**
> (self-signed-HTTPS iframe is blocked). Layered fixes also in `stacks.rs`:
> nginx `resolver <gateway>` + variable upstreams (IP-cache 502; `resolver
> local=on`/`${NGINX_LOCAL_RESOLVERS}` FAIL on nginx:1.27-alpine), LAN-IP
> canonical origin + CORS + multi-origin redirect URIs, `/nb-auth`+`/nb-silent-auth`
> SPA fallback (were 404), and a stale-store note (wipe to re-init). Also found:
> `conmon died` zombie containers (recreate fixes; #53). Validated on .198,
> registration+login succeed. Trusted-cert/iframe (option B) = #56;
> registry-app migration = #52. Existing nodes need a clean reinstall.
**Diagnose first** (likely a container/config issue, like other app fixes):
1. On a node: `podman logs <netbird container>` — capture the actual failure.
2. Check the app manifest + install path (`container/` install, env, ports,
the four iframe-sync places per memory `feedback_gitea_iframe_setup` if it
has a UI).
3. netbird needs a management URL / setup key — confirm whether the app expects
config we don't provide, or a host capability (TUN device / NET_ADMIN) the
rootless-podman setup lacks.
**Likely fix**: either supply the missing env/setup-key UI, or add the required
container capability. Low priority — schedule after the above.
---
## #43 — Install errors at DID-creation + password screens (.70); FIPS slow
`.70` is unreachable, so we can't read its logs. Code-side hardening that helps
regardless:
> **Status (2026-06-17): hardening DONE & compiles.** Root cause was a
> non-idempotent `seed.generate` that overwrote node keys under the client's
> retry storm on slow first boot. Fixed: idempotent generate + retry-safe
> verify (`seed_rpc.rs`), transient-vs-genuine error handling in
> `OnboardingSeedGenerate/Verify.vue`, and a non-blocking FIPS status on
> `OnboardingDone.vue`. Issue closed; full closure wants a fresh install on a
> reachable node + re-test on .70.
1. **Onboarding error surfacing** — in the seed/DID + password onboarding views
(`OnboardingSeed*`, the password step) and their RPC handlers
(`seed.generate` / `seed.verify` / `auth.setup`), make a *successful*
operation never show an error toast, and make genuinely-failed ops show the
real message + a retry — so cosmetic errors (op actually succeeded) stop
alarming users. Audit the promise/catch paths for races where a slow backend
resolves after a timeout fires.
2. **FIPS start delay** — confirm `spawn_post_onboarding_fips_activate`
(`api/rpc/seed_rpc.rs`) isn't blocking onboarding; it already runs detached.
Consider surfacing "FIPS starting…" status instead of letting it look stuck.
**Verify**: a fresh ISO install on a reachable node (.198 or a scratch box),
watch the DID + password screens; then re-test on .70 once reachable.
**Effort**: smallmedium (the hardening); full closure needs a repro node.

229
docs/dht-RESUME.md Normal file
View File

@ -0,0 +1,229 @@
# DHT work — RESUME HERE
**Last updated:** 2026-06-16 · **Branch:** `agent-trust-wip` · **Worktree:** `~/Projects/archy-dht`
This file is the single source of truth for resuming the DHT / peer-distribution
work after a restart. Read it top to bottom, run the **Verify state** block, then
continue at **Next step**.
---
## ⚠️ CRITICAL — where to work (do not skip)
- **Work ONLY in the worktree `~/Projects/archy-dht` on branch `agent-trust-wip`.**
- **NEVER run git checkout / branch-switch / commit in the shared tree `~/Projects/archy`.**
Another agent cuts releases on `main` there. Git branch state is **global to one
working tree**, so a checkout in the shared tree drags every session onto that
branch and can clobber uncommitted work. That already happened once — the worktree
exists specifically to prevent it. See memory `feedback_concurrent_agent_tree`.
- The shared tree stays on `main` for the release agent. Leave it alone.
## Build facts (so you don't get surprised)
- It's a **binary** crate: test with `cargo test --bin archipelago -- <filter>`
(there is no lib target).
- The **test profile is opt-level=3** → every incremental test rebuild of the
`archipelago` crate is **~5 min**; a cold build of the iroh feature tree is ~19 min.
Budget for it. Run builds in the background and poll.
- Default build = no iroh. The iroh swarm engine is behind the **`iroh-swarm`**
Cargo feature (off by default): `cargo build --features iroh-swarm`.
- Plain `cargo build` (no feature) is the fleet build and is unaffected by any DHT work.
## Verify state (run these first on resume)
```bash
cd ~/Projects/archy-dht
git branch --show-current # → agent-trust-wip
git log --oneline -7 # see the commit list below
git status --short # should be clean (or your in-progress edits)
git worktree list # archy-dht → agent-trust-wip; archy → main
# sanity compile (default, fast-ish):
cargo build --bin archipelago 2>&1 | tail -3
```
---
## What is DONE (committed on `agent-trust-wip`)
Design doc: `docs/dht-distribution-design.md` (the full plan).
| Commit | Phase | Summary |
| --- | --- | --- |
| `0fef8086` | base | parked trust module + `seed::derive_release_root_ed25519` (pre-existing) |
| `27f11bf8` | **0** | signed-catalog authenticity wired: `trust/` module verifies the release-root detached signature in `app_catalog::fetch_one`; release-root KAT pinned |
| `f0cb91ed` | **1** | BLAKE3 alongside SHA-256: `content_hash.rs`, `ComponentUpdate.blake3`, `BlobMeta.blake3` |
| `2523c9e3` | **2 seam** | `swarm/mod.rs``BlobProvider` + `fetch_content_addressed` (verify peer bytes, origin-always-wins); `iroh-swarm` flag; wired into `update.rs` |
| `082946aa` | **2 engine** | real `swarm/iroh_provider.rs` over iroh 1.0 + iroh-blobs 0.103 (optional deps). Dep tree proven to resolve+compile against the pinned stack |
| `9fa56a82` | **3 core** | `swarm/seed_advert.rs` — signed Nostr seed-advertisement protocol (NIP-33 kind 30081, d-tag=blake3) |
All tests green at each step. Total new modules: `trust/`, `content_hash.rs`, `swarm/`.
## task #12 — Phase 3 glue + wiring — DONE (2026-06-17, NOT yet committed)
Implemented in the worktree, **uncommitted** (release in flight — do not commit/merge
until the user says so). Verified: default `cargo build` clean, `cargo build
--features iroh-swarm` clean, `cargo test --bin archipelago -- swarm::` → **8/8 pass**.
1. **`NostrSeedDiscovery`** (`swarm/iroh_provider.rs`) — `ProviderDiscovery` made
**async** (`#[async_trait]`); impl queries relays via the new
`seed_advert::fetch_seed_endpoint_ids` and parses each string with
`EndpointId::from_str` (`EndpointId = PublicKey`, has `FromStr`/`Display`),
skipping unparseable. `try_fetch` now `.await`s discovery.
2. **Publish path** — dep-free `seed_advert::fetch_seed_endpoint_ids` +
`publish_seed_advert` (reuse now-`pub(crate)` `build_nostr_client` /
`load_or_create_nostr_keys`); `IrohProvider::seed_and_advertise` imports the blob
into the FsStore (`blobs().add_path``TagInfo`) with a defensive hash-match,
then publishes. Scope: releases/catalog only.
3. **Wiring**`swarm::init()` builds the `IrohProvider` once at startup into a
`OnceLock<SwarmRuntime>` (keeps endpoint/router alive → keeps seeding);
`providers()` returns the registered provider; `announce_held_blob()` is called
from `update.rs` after each release component passes both hash gates. New config
`swarm_enabled` (`ARCHIPELAGO_SWARM_ENABLED`, default false); `server.rs` calls
`swarm::init`. All iroh code stays behind `iroh-swarm`; default build inert.
**iroh-blobs paid-serving spike (open Q#1) — RESOLVED:** `BlobsProtocol::new(&store,
Some(EventSender))` + `EventMask` intercept gives native per-request allow/deny
(`RequestMode::Intercept``Result<(), AbortReason>`), connection-level reject
(`ConnectMode::Intercept`), and per-request throttle/meter (`ThrottleMode::Intercept`).
## NEW: Phase 4+ plan (paid streaming / relay / IndeeHub) — `docs/phase4-streaming-ecash-plan.md`
Design for: (1) ecash-paid swarm transport, (2) networking through nodes / relay,
(3) IndeeHub "Archipelago" content source (signed Nostr film catalog, kind 30082).
Headline: ~80% already exists (Cashu wallet, `streaming/` payment gate + metering,
4-tier transport, the swarm above). Also shipped this session: a **Networking Profits
→ Settings** UI in `neode-ui` (new `views/web5/Web5NetworkingProfitsSettings.vue` +
route + button in `Web5QuickActions.vue` + `common.settings` i18n) that drives the
existing `streaming.list-services`/`configure-service` RPCs; free-everything is the
default (all services ship `enabled:false`). Frontend typechecks clean (pre-existing
`Web5ConnectedNodes.vue` `.did` errors are NOT ours). `neode-ui` deps were
`npm install`ed to complete a partial install.
## F2 step 1 — cross-mint ecash swap — DONE (2026-06-17, NOT yet committed)
Plan §2a / phasing F2 step 1. Implemented in `wallet/ecash.rs`, **uncommitted**
(release in flight). Verified: `cargo test --bin archipelago -- wallet::ecash`
**25/25 pass** (6 new), default build clean, `--features iroh-swarm` build clean.
- `is_mint_trusted(data_dir, url)` — swap-into allow-list. Home Fedimint always
trusted; any other mint must be on `accepted_mints` (normalized, trailing-slash
tolerant). Reuses the list the streaming gate already advertises to payers.
- `mint_quote_at` / `melt_quote_at` / `send_token_at(data_dir, mint_url, amount)`
the home-mint-hardcoded helpers parameterized by target mint. `send_token` now
delegates to `send_token_at` with the home mint.
- `swap_between_mints(data_dir, from, to, amount, max_fee_sats) -> u64` — mint-quote
on B → melt-quote on A → **fee-cap check** (`swap_fee` = total_paid delivered;
bail if > cap so caller falls back to free origin) → select+melt A proofs →
**persist the spend BEFORE claiming** (crash can't double-spend) → poll B invoice
until PAID/ISSUED (`wait_for_mint_quote_paid`, 60s/2s) → mint+claim on B. Both legs
recorded in the tx log (peer field carries the counterpart mint).
## F2 step 2 — payer-side auto-swap payment builder — DONE (2026-06-17, NOT yet committed)
Plan §2a step 2. Implemented in `wallet/ecash.rs`, **uncommitted**. Verified:
`cargo test --bin archipelago -- wallet::ecash`**34/34 pass** (9 new). All on the
default path (no feature gating) so the `iroh-swarm` tree is unaffected.
- `WalletState::spendable_by_mint() -> Vec<(mint_url, balance)>` — per-mint holdings.
- `PaymentPlan { Direct{mint}, Swap{from,to}, Insufficient }` + pure
`plan_payment(holdings, accepted: &[(mint, trusted)], amount)` — the policy:
**Direct beats Swap** (already-held mint, no fee, no trust needed); a **Swap target
must be trusted** (`is_mint_trusted`); home mint is the tie-break for both legs;
`Insufficient` → caller uses free origin. Pure/sync, unit-tested without a mint.
- `build_payment_token(data_dir, accepted_mints, amount_sats, max_fee_sats) -> token`
annotates the seeder's `accepted_mints` with trust, runs `plan_payment` against
`spendable_by_mint()`, then `send_token_at` (direct) or `swap_between_mints` +
`send_token_at` (swap, honoring the fee cap). Bails (→ origin) when nothing covers
the amount within balance/trust/fee. This is the builder the fetch side calls.
## Fetch-side auto-pay + F2 step 3 hardening — DONE (2026-06-17, NOT yet committed)
Implemented; **uncommitted**. Verified: `cargo test --bin archipelago -- wallet::
swarm::` → **85/85 pass** (18 new across these + earlier steps), **0 warnings**,
default build clean. `--features iroh-swarm` build = (see below; re-run after these
edits).
- **`swarm/payment.rs`** (un-gated — builds without `iroh-swarm`): `PaymentPolicy
{ budget_sats, max_fee_sats }` + `auto_pay_token(data_dir, policy, accepted_mints,
price)` → `Ok(Some(token))` to pay / `Ok(None)` to use origin. Degrades any
wallet/mint error to `Ok(None)` so payment can never block content (origin always
wins). The on-wire token→peer exchange (in-band paid-blobs ALPN, "shape A") is the
remaining gap — deferred in the plan; this is the decision/builder brain it'll call.
- **`streaming.prepare-payment` RPC** (dispatcher + `handle_streaming_prepare_payment`):
the live, user-invokable entry to the payer-side builder. Params `{accepted_mints,
price_sats, budget_sats?, max_fee_sats?}` → `{status:"ready", token}` or
`{status:"declined"}`. This is what makes the whole payment chain reachable
(no dead code).
- **Idempotent swap resume** (`wallet/pending_swaps.json`): `swap_between_mints`
journals the in-flight swap (melt + mint quote ids) right after the source spend is
persisted, removes it on claim. `resume_pending_swaps(data_dir)` reclaims `PAID`
quotes, skips `ISSUED` (never double-claims), leaves unsettled — **wired at server
startup** (server.rs, after `swarm::init`).
- **Liquidity cache** (`wallet/swap_liquidity.json`): per-route success/failure;
`build_payment_token` orders swap targets by `target_liquidity_score` (proven routes
first, home still first). `swap_between_mints` records success/failure.
- Removed the unused `mint_quote_at`/`melt_quote_at` thin wrappers (swap calls
`MintClient` directly; nothing else used them).
## Shape-A paid-blobs negotiation ALPN — DONE (2026-06-17, NOT yet committed)
Plan §1 "shape A" — the on-wire exchange that lets a downloader pay a seeder before
fetching a gated blob. Implemented behind `iroh-swarm`; **uncommitted**. Compiles
clean (`cargo build --features iroh-swarm` → only the 2 pre-existing `trust/` warns).
**Caveat:** the request/grant *wire path* can only be fully verified with a live
two-node iroh test (serde + types are unit-tested; the QUIC round-trip is not).
- **`swarm/paid_alpn.rs`** (gated): ALPN `archy/paid-blobs/1` on a second handler on
the same endpoint/router. `PaidRequest { want, token? }` ↔ `PaidResponse
{ Granted | PaymentRequired{price_sats, accepted_mints} | Denied{reason} }`.
- **Serve side** `PaidBlobsProtocol` (`ProtocolHandler`): per bi-stream, keys the
peer by `connection.remote_id()`, runs `streaming::gate::check_gate(content-download,
peer, token, blob_size)`, maps to a verdict. Free when service disabled (default),
fail-OPEN (Granted) on gate error — mirrors `swarm/paid.rs`. A paid retry's token
opens the session the blob-GET gate then sees (same endpoint id → same session).
- **Fetch side** `negotiate_access(endpoint, data_dir, peer, hex, policy) -> bool`:
best-effort + additive. Asks with no token; on `PaymentRequired` calls
`payment::auto_pay_token` (cross-mint aware), retries with the token. Connect/
protocol failure ⇒ proceed (the GET gate is the real enforcement); explicit
`PaymentRequired` we won't/can't pay ⇒ skip peer → origin.
- **Wired into `iroh_provider.rs`**: registers the 2nd ALPN on the `Router`; `try_fetch`
negotiates with each discovered peer before `downloader.download`. `IrohProvider`
carries `data_dir` + `pay_policy` (defaults to `PaymentPolicy::free` → releases/
catalog never pay; a future film fetch passes a real budget).
### Remaining to make paid FILM fetch real (small, on top of shape A)
- Pass a non-free `PaymentPolicy` for the film scope (releases stay free) + surface an
auto-pay cap in Settings. The plumbing is all here; only the policy source is free.
- Live two-node integration test (tests/multinode/) to exercise the actual QUIC
request→pay→grant→GET path end to end.
## Remaining Phase 4 roadmap (NOT started — gated)
- **Relay protocol (§2b)** — single-hop paid `relay.fetch`. Needs design sign-off.
- **IndeeHub "Archipelago" source (steps AE)** — signed kind-30082 film catalog +
`film.catalog`/`GET /api/film/:blake3` + frontend source. Gated on user decisions
(publisher trust anchor, MinIO origin) + the external IndeeHub frontend repo.
**Shipping directive (user 2026-06-17):** ship the IndeeHub app change as a
**decoupled app-catalog update** (bump `releases/app-catalog.json`), not a binary
OTA. See `docs/phase4-streaming-ecash-plan.md` §4 note.
## After Phase 3
- **Phase 4** — IndeeHub films on the same blob layer (Blossom catalog + iroh swarm;
MinIO origin). Each HLS `.ts` segment = a content-addressed blob.
- **Phase 0 GO-LIVE (needs the user)** — the catalog/manifest signature anchor
`trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is still `None`; the pinned KAT is the
TEST mnemonic, not the real key. Going live = signing ceremony with the **real
release master seed** (only the user has it) → derive release-root → bake its pubkey
into `anchor.rs` → sign the real `releases/app-catalog.json`. Until then verification
is advisory (verify-if-present, anchor not enforced).
## Mergeability
As of last check we were only ~4 commits diverged from `main`; the only shared-file
overlap is `seed.rs` + `update.rs`. **Do NOT merge to `main` while the release is in
flight** — that's the user's call. Sync (merge main → agent-trust-wip) once the
release lands and `main` is clean.
## Background build logs from the last session (may be stale)
`/tmp/dht-*.log` — phase test/build outputs. Safe to ignore/delete on resume.

View File

@ -0,0 +1,185 @@
# DHT / Peer-Distributed Content Design
**Status:** Design (no code yet) · **Date:** 2026-06-16 · **Author:** archipelago + Claude
## 1. Purpose
Make Archipelago's large-file movement **peer-distributed**: a node should be able to
fetch content (OTA updates, app/OCI images, IndeeHub films) from *any other node that
already has it*, falling back to the central origin only when no peer can serve it.
This document covers three use-cases that are **the same problem**
"fetch content-addressed bytes from whatever node already has them, verify, fall back to
origin":
1. **OTA releases** — node binaries + frontend tarballs.
2. **App installs** — container/OCI images.
3. **IndeeHub streaming** — films created in "backstage" on one node, streamable from any
node that has them stored or cached.
### Guiding principle (decided 2026-06-16)
> **Swarm-assist, origin always wins.** The peer swarm is an *optimization*. The central
> origin (OVH HTTP release assets / MinIO) remains the **guaranteed fallback** and the
> source of truth for reliability. We never bet correctness or availability on the P2P
> layer. This is what keeps the system bulletproof while the P2P stack matures.
## 2. Current state (verified 2026-06-16)
### OTA (`core/archipelago/src/update.rs`)
- Manifest at `DEFAULT_UPDATE_MANIFEST_URL` (`update.rs:67`) = vps2 OVH
(`146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json`).
- `check_for_updates()` (`:565`) walks an operator mirror list (`default_mirrors()` `:105`,
`load_mirrors()` `:123`), origin-rewrites component URLs to the chosen mirror
(`rewrite_manifest_origins()` `:227`).
- `download_component_resumable()` (`:821`) — resumable HTTP Range download, 6 retries,
exponential backoff.
- **Integrity: SHA-256 only** (`:984`), compared against `ComponentUpdate.sha256`.
- **No authenticity:** manifests are *unsigned*. A compromised mirror can serve a malicious
but hash-consistent binary. Post-apply health probe + auto-rollback exist
(`verify_pending_update()` `:389`, `rollback_update()` `:1423`) but that is not a
substitute for signature verification.
- Manifest schema: `{version, release_date, changelog[], components[{name, current_version,
new_version, download_url, sha256, size_bytes}]}`.
### App installs (`core/archipelago/src/api/rpc/package/install.rs`)
- `handle_package_install()` (`:195`) → `do_pull_image()` (`:1062`) tries each registry from
`container/registry.rs` in priority order (OVH primary), `rewrite_image()` rewrites the
origin, `podman pull`. Same centralized-mirror shape as OTA.
### Transport & identity (already P2P-capable)
- `transport/mod.rs``NodeTransport` trait (`:74`), `TransportRouter` (`:336`), priority
stack Mesh→LAN→FIPS→Tor. `PeerRegistry` (`:199`) tracks per-peer addresses
(mesh id, LAN ip:port, `fips_npub`, onion).
- Seed-derived identity (`seed.rs`): node Ed25519 (`archipelago/node/ed25519/v1`), node
Nostr secp256k1 (`archipelago/nostr-node/secp256k1/v1`), FIPS secp256k1
(`archipelago/fips/secp256k1/v1`). DID + npub per node.
- **Already content-addressed:** `blobs.rs` stores `blobs/<cid>` keyed by **SHA-256** hex,
with HMAC-SHA256 capability tokens (`BlobMeta`, 64 MiB cap). `transport/chunking.rs` does
Reed-Solomon chunking for LoRa.
### Trust scaffolding — **NOT built yet**
- No `core/src/trust/`, no `ROOT_PUBKEY`, no `derive_release_root_*`, no
`archipelago/release/root/*` HKDF strings, no JCS/canonical JSON, no signing ceremony
scripts, no `manifest-v2.json`. The "Phase 0 signed manifest" design exists only as notes.
### IndeeHub (the streaming target)
- Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/`
(Vue 3 + NestJS). Submodule `git.tx1138.com/lfg2025/indeehub.git` (host retired —
needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls
`146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis,
minio, nostr-rs-relay).
- Streaming today: FFmpeg → **HLS (.m3u8 + AES-128 .ts segments)** in **MinIO**
(`indeedhub-private`/`-public`), metadata in Postgres, transcode queue in Redis,
auth via Nostr (NIP-98). Glue: `install.rs:68` `patch_indeedhub_nostr_provider()`
injects the NIP-07 provider into the nginx-wrapped frontend.
- **No "backstage" code yet** — it's the creator/upload side we're introducing.
## 3. Protocol evaluation (verified maintenance status, 2026-06-16)
| Option | Verdict | Why |
| --- | --- | --- |
| **Web5 / TBD / DWN** | ❌ Reject | Block **wound TBD down**, handed components to DIF (`TBD54566975``decentralized-identity`). `web5-js` latest release **0.12.0, Oct 2024** (~20 mo stale). DWN spec still **Draft**. DWNs are DID-scoped *record stores*, not a blob-streaming swarm. Fails the "well-maintained + bulletproof" bar. |
| **iroh / iroh-blobs** | ✅ Swarm engine | **v1.0.0 shipped 2026-06-15.** Rust (matches core), **BLAKE3 verified streaming** over **QUIC + hole-punching + relays**, content-addressed, KB→TB, **native byte-range** support (ideal for HLS). n0 team, production relays. |
| **Nostr Blossom** | ✅ Index/catalog layer | SHA-256-addressed blobs over HTTP, modular BUD specs (BUD-01/02/04/05/06/08), actively developed, **already aligned** (Nostr identity everywhere; `blobs.rs` already SHA-256). Server-centric (not a peer swarm) → use as discovery + IndeeHub catalog + HTTP fallback, not the distribution engine. |
| **libp2p-kad (hand-rolled DHT)** | ⚠️ De-prioritize | Was the old "Phase 4 build a Kademlia" plan. iroh 1.0 supersedes the need to hand-roll discovery + swarm. Revisit only if iroh proves unworkable. |
**Note vs. prior plan:** the saved DHT design said "no iroh as a Phase 05 dep (revisit
post-Phase 3)." iroh hitting 1.0 removes the main reason for that deferral — **this design
reverses that non-choice** and adopts iroh as the swarm layer, collapsing the from-scratch
Kademlia work.
## 4. Recommended architecture — three layers, one engine
Build **one** peer-distribution layer; use it for all three use-cases.
```
┌─────────────────────────────────────────────┐
Authenticity │ Signed Nostr events (per-node npub) + │ "who published this,
& Discovery │ seed-derived RELEASE ROOT key for OTA + │ who has it"
│ Blossom BUD catalog for IndeeHub │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
Integrity & │ BLAKE3 content addressing (iroh-native, │ "name bytes by hash,
Addressing │ range-verifiable). SHA-256 kept in manifest │ verify on arrival"
│ during migration window. │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
Transport & │ iroh-blobs swarm (peers that already have │ "move the bytes"
Swarm │ it) ─── fallback ───▶ OVH HTTP / MinIO │
│ origin (ALWAYS wins) │
└─────────────────────────────────────────────┘
```
- **Integrity/addressing — BLAKE3.** iroh-native, supports verified *range* streaming
(essential for HLS + resumable). Keep SHA-256 in the manifest for back-compat through the
migration window; add a `blake3` field alongside.
- **Discovery/authenticity — signed Nostr events + release root key.**
- OTA: the **Phase 0 seed-derived release root key** signs the manifest (BLAKE3 root hash
+ version). Integrity ≠ authenticity — content addressing proves *bytes are intact*, the
signature proves *we authorized them*. Both are required.
- "Who has blob X" advertised via signed Nostr events `{content-hash, provider-npub, ts}`,
so nodes find seeds without a central tracker.
- IndeeHub: Blossom BUDs for the film catalog + provider/mirror lists.
- **Transport/swarm — iroh-blobs, origin fallback.** Node asks the swarm for a hash; peers
that have it serve range-verified BLAKE3 streams; if the swarm yields nothing, fall back to
the existing resumable HTTP path (`update.rs:821`) against OVH/MinIO. **A node that
finishes a download automatically becomes a seed.**
### Bulletproof posture
The swarm sits *above* a proven HTTP path, never in place of it. Worst case (every peer
offline, iroh bug, NAT failure) the node downloads exactly as it does today. iroh 1.0 is new;
this containment is deliberate.
## 5. Use-case flows
### OTA / app installs
1. Node reads the **signed** manifest (via signed Nostr event or HTTP), gets BLAKE3 root hash
+ release-root signature; verify signature → reject on failure.
2. Query swarm (signed provider events) for peers holding that hash.
3. Download range-verified BLAKE3 stream from peers; verify full BLAKE3 (+ SHA-256 during
migration).
4. No peers / failure → resumable HTTP from OVH (current path).
5. Apply + health-probe + auto-rollback (unchanged). Updated node **becomes a seed**.
6. OCI images: content-address image layers the same way; OVH registry stays the origin.
### IndeeHub streaming ("backstage → any node")
1. Creator publishes a film in **backstage** → FFmpeg → HLS; **each .ts segment is a
content-addressed (BLAKE3) blob**, immutable and small → ideal swarm objects.
2. Publish a **signed Nostr event** advertising title + segment hashes (Blossom catalog).
3. Any node running IndeeHub resolves the content address and **streams from the nearest
node(s) that have it stored/cached** via iroh range streaming; MinIO/OVH is origin.
4. AES-128 key delivery + NIP-98 auth unchanged (keys gate decryption; swarm only moves
encrypted segments — so untrusted seeds can cache without seeing plaintext).
## 6. Phasing (folds into the existing Phase 06 plan)
0. **Signed manifests (required first, unbuilt).** `derive_release_root_ed25519` /
`derive_release_root_nostr` in `seed.rs` (HKDF `archipelago/release/root/ed25519/v1`,
`.../secp256k1/v1`); `core/src/trust/` (anchor/bundle/manifest/timestamp/nostr); JCS
canonical JSON; ceremony scripts; `manifest-v2.json` with signature. Gives *authenticity*,
which content-addressing does not.
1. **BLAKE3 alongside SHA-256** in the manifest + `blobs.rs`.
2. **iroh-blobs PoC** behind a feature flag: serve OTA blobs from the swarm with HTTP
fallback; measure on a scratch/test node, then the fleet.
3. **Signed Nostr advertisement events** for releases (publisher identity + provider lists).
4. **IndeeHub on the same blob layer** (Blossom catalog + iroh swarm; MinIO origin).
This collapses the old "Phase 4: build S/Kademlia from scratch" into "adopt iroh," a large
de-risking.
## 7. Open decisions
- **BLAKE3 migration scope:** dual-hash window length; whether to re-hash historical
releases or only BLAKE3 going forward.
- **iroh ↔ existing transports:** iroh brings its own QUIC + hole-punching + relays; decide
how it coexists with FIPS/Tor (run iroh standalone first; integrate with `TransportRouter`
later if useful).
- **Seed retention policy:** how long nodes keep blobs to seed others (disk pressure on small
nodes); pinning rules for IndeeHub films vs. transient OTA blobs.
- **Privacy:** iroh dial-by-key vs. Tor's anonymity; default transport per content type.
## References
- iroh: https://github.com/n0-computer/iroh · iroh-blobs: https://github.com/n0-computer/iroh-blobs · docs: https://docs.iroh.computer/protocols/blobs
- Blossom: https://github.com/hzrd149/blossom · NIP-B7: https://nips.nostr.com/B7 · nostr-blossom (Rust): https://docs.rs/nostr-blossom
- Web5/DWN (rejected): https://github.com/decentralized-identity/web5-js · https://identity.foundation/decentralized-web-node/spec/ · https://block.xyz/inside/block-contributes-digital-identity-components-to-the-decentralized-identity-foundation

135
docs/dual-ecash-design.md Normal file
View File

@ -0,0 +1,135 @@
# Dual-ecash: Cashu + Fedimint, seamlessly
Status: **in progress** (2026-06-17). FE scaffolding + Fedimint HTTP bridge landed and
compile-checked; live federation round-trip and networking-sats routing are not yet validated.
## Why
Today the node's wallet (`core/archipelago/src/wallet/ecash.rs`, `mint_client.rs`, `cashu.rs`)
speaks **only** the Cashu NUT HTTP protocol (BDHKE, `cashuA…` tokens). There is **no** Fedimint
*client* — `apps/fedimint` is only the guardian server, and the "local Fedimint" default mint at
`127.0.0.1:8175` is just the guardian UI nginx, which does not expose the Cashu NUT API. So:
- The node can hold/spend generic Cashu tokens, but cannot hold Fedimint ecash or join federations.
- "Networking sats" (streaming/seeding revenue) is hardcoded to the Cashu wallet.
Goal: support **both** ecash protocols seamlessly — hold balances in either, join arbitrary
federations, and let networking-sats be paid/received over whichever protocol the peer accepts.
## Architecture decision
**Containerized `fedimint-clientd` + thin HTTP bridge** (chosen over linking the native
`fedimint-client` Rust SDK into the binary, and over a Lightning-only bridge).
```
archipelago binary
├─ CashuMintClient ──HTTP (NUT /v1/*)──▶ cashu mint
└─ FedimintClient ──REST (/v2/*)─────▶ fedimint-clientd container ──▶ federation guardians
```
Rationale: keeps the heavy, fast-moving Fedimint SDK **out** of the main binary (no compile-time
coupling, no rebuild bloat, OTA-friendly), and fits the existing app/container architecture
(`apps/fedimint`, `apps/fedimint-gateway`). The Rust side is just a `reqwest` client, mirroring
`MintClient`.
### fedimint-clientd REST surface (v0.3.x)
- Auth: `Authorization: Bearer <password>`. Default port 8080 (we map it to host **8178** because
8080 is LND REST). Base path `/v2/...`.
- `GET /v2/admin/info` — per-federation balances (`totalAmountMsat`, denominations, meta).
- `POST /v2/admin/join``{ "inviteCode": "fed1…", "useManualSecret": false }` → joins / returns `federationId`.
- `POST /v2/mint/spend``{ federationId, amountMsat }` → serialized notes (ecash to send).
- `POST /v2/mint/reissue``{ federationId, notes }` → redeem received notes; returns reissued amount.
- `POST /v2/ln/invoice` / `POST /v2/ln/pay` — Lightning in/out (used for cross-protocol swaps).
- `GET /health`.
Multi-federation: requests carry a `federationId`; clientd's `multimint` manages many clients.
**Exact JSON field names must be pinned to the clientd image tag we vendor** — code defensively.
## Components
### 1. Container app — `apps/fedimint-clientd/manifest.yml`
Sidecar running `fedimint-clientd`, mirroring `apps/fedimint-gateway`. Host port 8178 → container
8080. Password from node secret `fedimint-clientd-password`. State volume at
`/var/lib/archipelago/fedimint-clientd`. Added to `RESERVED_PORTS` (port_allocator.rs) and
`fallback_package_port()` (server.rs).
### 2. Rust bridge — `core/archipelago/src/wallet/fedimint_client.rs`
Thin `reqwest` client: `info()`, `join()`, `spend()`, `reissue()`, `ln_invoice()`, `ln_pay()`.
`from_node(data_dir)` resolves base URL + password (env `FEDIMINT_CLIENTD_URL` /
`FEDIMINT_CLIENTD_PASSWORD`, else defaults + secret file). Tor-proxy support via `with_client`,
mirroring `MintClient`.
### 3. RPCs — `core/archipelago/src/api/rpc/fedimint.rs`
- `wallet.fedimint-list` → joined federations + balances (`{federation_id, name, balance_sats}[]`).
- `wallet.fedimint-join` `{invite_code}` → joins via clientd, persists to
`wallet/fedimint_federations.json`, returns `{federation_id}`.
- `wallet.fedimint-leave` `{federation_id}` → untracks locally.
- `wallet.fedimint-balance` → total sats across federations (from clientd `info`).
Local registry `wallet/fedimint_federations.json` = `{ federations: [{federation_id, name}] }` so
the list survives clientd being temporarily down; balances are live from clientd.
### 4. Frontend — `WalletSettingsModal.vue`
Tabbed: **Cashu Mints** (live: `streaming.list-mints` / `streaming.configure-mints`) and
**Fedimint Federations** (`wallet.fedimint-list` / `-join` / `-leave`). Gear icon on
`HomeWalletCard`. `fedimintBackendReady` flips to `true` once the RPCs ship; join degrades
gracefully with a clear error if the clientd app isn't installed.
### 4b. Default federation (zero-touch)
clientd auto-joins a default federation at boot via `FEDIMINT_CLIENTD_INVITE_CODE` (manifest), and
the Rust bridge `ensure_default_federation()` idempotently joins + tracks it (called from
`wallet.fedimint-list`) so already-running nodes pick it up too. Constant
`DEFAULT_FEDERATION_INVITE` in `fedimint_client.rs` is the single source on the Rust side; keep it
in sync with the manifest env. clientd is a **client, not the guardian** — it needs no local
`fedimintd`, so it bundles standalone.
### 4c. Bundling on every node
- **Bundled ISO:** add image to `scripts/image-versions.sh`, the ISO bundle `.tar` list, and a
core-create block in `scripts/first-boot-containers.sh`; mark `tier: "core"` in
`app-catalog/catalog.json`.
- **Unbundled ISO:** `first-boot-containers.sh` exits after FileBrowser only — add clientd to that
early-exit block so unbundled nodes also get it out of the box.
- **CAVEAT:** confirm the *current* ISO assembler before editing the bundle list — the one found is
under `image-recipe/_archived/` (likely stale); `first-boot-containers.sh`/`image-versions.sh`
are current.
- Image: build from source (no official image; `flake.nix` only) → push to vps2
`146.59.87.168:3000/lfg2025/fedimint-clientd:v0.4.0`.
### 5. Unified balance
`HomeWalletCard` ecash row = Cashu `wallet.ecash-balance` + Fedimint `wallet.fedimint-balance`.
(Home already calls `wallet.ecash-balance`; add fedimint and sum.)
## Networking sats — dual protocol (phase 6, NOT yet wired)
The economic layer (`streaming.rs`, `streaming/gate.rs`, sessions, pricing, metering) is already
protocol-agnostic — it just calls into the wallet. The injection points are:
1. **Protocol-tag accepted mints.** `accepted_mints: Vec<String>` → carry protocol, e.g.
`cashu:https://mint…` / `fedimint:<federation_id>`. Migrate `wallet/accepted_mints.json` with a
back-compat reader (bare URL ⇒ `cashu:`).
2. **`MintClient::new()` is the bottleneck** (~10 call sites). Introduce a `MintBackend` trait with
`CashuBackend` (wraps current code) and `FedimintBackend` (calls `FedimintClient`).
3. **`Token` enum** `Cashu(CashuToken) | Fedimint(notes)`; serialize/verify by variant.
4. `build_payment_token()` picks a `(backend, id)` the peer accepts; `verify_and_receive_payment()`
auto-detects the token variant and reissues/swaps on the right backend.
5. Cross-protocol settlement (Cashu↔Fedimint) bridges over Lightning (BOLT11) — both sides already
have mint/melt (Cashu) and `ln/invoice`+`ln/pay` (Fedimint).
6. `Web5NetworkingProfitsSettings.vue`: per-service payout protocol/mint selector.
## Phases
- [x] **P0** FE tabbed Wallet Settings modal + gear (Cashu live, Fedimint tab structured).
- [x] **P1** `fedimint-clientd` container manifest + ports.
- [x] **P2** `FedimintClient` HTTP bridge + `wallet.fedimint-*` RPCs (compiles).
- [ ] **P3** Validate join / balance / spend / reissue against a live clientd + real federation on a scratch node.
- [ ] **P4** Unified ecash balance in the wallet card (Cashu + Fedimint).
- [ ] **P5** Flip FE fully live; surface "install Fedimint client app" when clientd unreachable.
- [ ] **P6** Networking-sats dual-protocol routing (the `MintBackend`/`Token` refactor above).
## Validation (per project testing discipline)
clientd image + a real federation are required; cannot be validated from the dev tree. Validate on
a scratch node: install Fedimint app + clientd, join a known test federation, confirm
`wallet.fedimint-balance`, then a spend→reissue round-trip between two nodes, then networking-sats
payment over Fedimint. Heavy/iterative work belongs in a worktree (see CLAUDE.md memory).

View File

@ -0,0 +1,169 @@
# Meshroller → Rust-native mesh assistant (issue #50)
**Decision (2026-06-17): seam (a) — lift Meshroller's *behaviors* into our Rust
mesh stack as typed message kinds.** We do NOT package the Python/Meshtastic
daemon. Meshroller rides Meshtastic-serial + a local Ollama; our radio is
**meshcore** (Heltec V3) and the `meshtastic` Python module cannot drive it. So
we reimplement its four behaviors natively against `core/archipelago/src/mesh/`,
drop the Python + Meshtastic dependency, and reuse our existing event/transport
seams.
Meshroller's behaviors (from the Phase-0 review of `meshroller.py`):
1. **LLM bridge** — relay an inbound mesh message to a local LLM, send the reply
back on the mesh.
2. **Trusted-node auth** — only trusted senders may invoke commands.
3. **Scheduled / queued messaging** — send messages at a future time; queue for
peers that are currently offline.
4. **On-channel command parser** — recognise commands in channel traffic.
---
## Where this plugs in (verified seam map)
| Concern | File / type | Anchor |
|---|---|---|
| Wire message kinds | `mesh/message_types.rs` `MeshMessageType` (`#[repr(u8)]`) | 2873 |
| Envelope (CBOR, `0x02` marker, `seq`, `sig`) | `mesh/message_types.rs` `TypedEnvelope` | 183197 |
| Inbound dispatch match | `mesh/listener/dispatch.rs` `handle_typed_envelope_direct()` | 80691 |
| Outbound send | `mesh/mod.rs` `send_typed_wire()` / `send_channel_typed_wire()` | 848 / 1152 |
| Radio I/O command channel | `mesh/listener/mod.rs` `MeshCommand` (`SendText`/`BroadcastChannel`) | 5573 |
| Frame chunking (≤160 B/frame, transparent) | `mesh/listener/session.rs` `send_dm_via_channel()` | — |
| UI push | `mesh/types.rs` `MeshEvent` (broadcast on `state.event_tx`, cap 64) | 125164 |
| Trust gate | `federation/types.rs` `TrustLevel::Trusted` on `FederatedNode`; `federation::load_nodes()` | 552 |
| Block on user-blocklist | `mesh/listener/mod.rs` `ContactEntry.blocked` (`state.contacts`) | 110 |
| Local model | Ollama container, port **11434** (`port_allocator.rs:11`); call via `reqwest` (already a dep) | — |
No in-Rust LLM exists yet; we call the **local Ollama HTTP API** (the same model
Meshroller used) so nothing new is baked into the binary.
---
## Phase 1 — the assistant on the wire
### 1.1 New typed message kinds (`message_types.rs`)
Add two variants (next free tag = 24):
```rust
AssistQuery = 24, // "ask the node's AI" — prompt + optional model
AssistResponse = 25, // reply — request_id + text + done flag
```
Wire the four spots the enum requires (`from_u8` 76104, `from_label` 109137,
`label()` 139166, plus the variant) — mirror the `Invoice` variant exactly.
Payloads (CBOR via `encode_payload`/`decode_payload`):
```rust
pub struct AssistQueryPayload { pub req_id: u64, pub prompt: String, pub model: Option<String> }
pub struct AssistResponsePayload { pub req_id: u64, pub text: String, pub seq: u16, pub done: bool }
```
`seq`/`done` let a long reply span multiple `AssistResponse` messages without
relying solely on frame reassembly (radio airtime is scarce — see §1.4 cap).
### 1.2 Inbound handler (`listener/dispatch.rs`)
Add a match arm for `AssistQuery`, mirroring the **`TxRelay`** arm (169207):
validate → **gate** → spawn background work (never block the radio loop).
```rust
Some(MeshMessageType::AssistQuery) => {
let payload = decode_payload::<AssistQueryPayload>(&envelope.v)?;
if !assistant_enabled(state) { return; } // kill switch (config)
if !sender_is_allowed(state, sender_contact_id).await { warn!(..); return; }
if !rate_limit_ok(state, sender_contact_id).await { return; } // 1 in-flight / sender
let _ = state.event_tx.send(MeshEvent::AssistQueryReceived { from_contact_id, prompt });
let st = Arc::clone(state);
tokio::spawn(async move { run_assist(&st, sender_contact_id, payload).await; });
}
```
`run_assist`: POST `http://localhost:11434/api/generate`
(`{model, prompt, stream:false}`), cap + chunk the response (§1.4), and emit each
chunk back to the sender via `send_typed_wire(contact_id, …, "assist_response", …)`.
Also store via the existing `store_typed_message` path so it lands in history,
and emit `MeshEvent::AssistResponseReady`.
### 1.3 Trust gate (`sender_is_allowed`)
Reuse the federation trust list — no new store:
```rust
let nodes = federation::load_nodes(&data_dir).await.unwrap_or_default();
let peer = state.peers.read().await.get(&sender_contact_id).cloned();
let trusted = peer.and_then(|p| nodes.iter().find(|n|
Some(&n.pubkey) == p.pubkey_hex.as_ref() || Some(&n.did) == p.did.as_ref())
.map(|n| n.trust_level == TrustLevel::Trusted)).unwrap_or(false);
```
Plus honour `ContactEntry.blocked`. Config picks the policy:
**trusted-only** (default) | **specific contacts** | **anyone on channel** (opt-in).
### 1.4 Airtime discipline (meshcore reality)
Frames are ≤160 B and reassembly is automatic, but bandwidth is tiny. So:
- **Cap** the reply (default ~480 chars / ≤3 `AssistResponse` chunks); append
`…(truncated — reply '!more')` and keep the tail server-side for a `!more`.
- **Rate-limit**: one in-flight query per sender; drop/deny extras.
- **Timeout** the Ollama call (e.g. 60 s) and reply with a short error on failure
(`MeshEvent::AssistResponseReady { error }`).
### 1.5 Channel command parser
The killer entry point is a plain channel message, not a typed one. In the
inbound **`Text`** path, when a channel-0/1 message starts with the trigger
(default `!ai ` / `!ask `), synthesise an `AssistQuery` from the remainder and
run the same gated `run_assist`. This means **any meshcore client** (even a bare
Meshtastic-style sender) can ask, while typed `AssistQuery` is the rich path our
own UI uses. Trigger + enable are config.
### 1.6 UI events (`types.rs`)
```rust
AssistQueryReceived { from_contact_id: u32, prompt: String },
AssistResponseReady { req_id: u64, to_contact_id: u32, error: Option<String> },
ScheduledMessageFired { message_id: u64 }, // for Phase 1.7
```
Subscribers already flow through the single `event_tx` broadcast — no extra
wiring.
### 1.7 Scheduled / queued messaging
A small `AssistScheduler` owned by `MeshService` (sits beside `relay_tracker` /
`dead_man_switch` in `mod.rs`):
- Persisted queue `{ id, contact_id|channel, wire, fire_at, attempts }` under
`data_dir/mesh/scheduled.json`.
- A tokio task wakes at the earliest `fire_at`, sends via the normal
`send_typed_wire` / `MeshCommand::SendText` path, emits `ScheduledMessageFired`.
- **Offline queue**: on send failure (peer unreachable) keep the item and retry
when a `PeerDiscovered` / `PeerUpdated` event names that peer.
- RPC: `mesh.schedule-message { contact_id|channel, body, fire_at }`,
`mesh.list-scheduled`, `mesh.cancel-scheduled`.
---
## Phase 2 — killer Mesh-tab UX (ties into `project_mesh_telegram_plan`)
**Onboarding (one screen, three steps):**
1. *Model* — detect Ollama on :11434. If absent, a single "Install AI (Ollama)"
button deep-links to the App Store entry; if present, pick the model
(default the one already pulled).
2. *Who can ask* — Trusted nodes only (default) · Pick contacts · Anyone on the
mesh channel (with a clear "uses your node's compute / airtime" warning).
3. *Trigger word* — default `!ai`; toggle the whole feature on.
**Usage (Mesh tab):**
- An **Assistant** card: on/off, model, policy, trigger; live feed driven by
`AssistQueryReceived` / `AssistResponseReady`.
- Composer gains two actions: **Ask the mesh AI** (sends a typed `AssistQuery`)
and **Send later** (date/time → `mesh.schedule-message`), with a "Scheduled"
list (`mesh.list-scheduled`, cancel).
The 12 killer actions: *ask the island's AI from any radio*, and *queue a
message that sends itself when a peer comes back in range.*
---
## Verification
Needs **2 radios** (the .116 meshcore + a second) + Ollama running on the
answering node:
1. From radio B send `!ai what's the block height?` → node A (trusted) answers on
the channel; untrusted B is silently denied.
2. Typed `AssistQuery` from our UI → chunked `AssistResponse` renders in the feed.
3. Long reply → truncation + `!more` continues.
4. Schedule a message to an out-of-range peer → it fires when the peer reappears.
## Effort & order
Multi-day. Land in this order so each step is testable alone:
1.1 enum + payloads → 1.2/1.3/1.4 gated bridge → 1.5 channel trigger →
1.6 events → 1.7 scheduler → Phase 2 UI. Phases 1.11.4 are the minimum
demoable slice (ask over the mesh, get an answer).

View File

@ -0,0 +1,380 @@
# Phase 4+ — Paid swarm streaming & the IndeeHub "Archipelago" source
**Status:** PLAN / design (2026-06-17) · **Branch:** `agent-trust-wip` · not implemented
**Builds on:** `docs/dht-distribution-design.md` (Phases 03, swarm + Blossom), the
Phase 3 swarm work just landed (`swarm/`, `content_hash.rs`, `trust/`).
This plans three things the user asked for, in one coherent architecture:
1. **Pay sats (ecash) for transport** of streaming film data between nodes.
2. **Networking *through* nodes** — relaying/routing a stream via intermediate peers.
3. An **"Archipelago" content source in IndeeHub** that shows every film uploaded
to *backstage*, on every node running the IndeeHub app.
> ## Headline finding
> **Most of the primitives already exist.** This is ~80% integration glue, not
> greenfield. A full Cashu/ecash wallet, a metered streaming payment gate, a
> 4-tier transport layer, the iroh-blobs swarm (just added), signed Nostr
> advertisements, and the Ed25519 trust module are all already in the tree. The
> genuinely new code is: (a) a paid-serving hook on the iroh side, (b) a relay
> protocol, and (c) the IndeeHub film catalog + Archipelago-local API.
---
## 0. Inventory — what we can build on (all already in `core/archipelago/src`)
| Capability | Where | State |
| --- | --- | --- |
| **Cashu ecash wallet** (mint/melt/send/receive, BDHKE) | `wallet/ecash.rs`, `wallet/cashu.rs`, `wallet/mint_client.rs`, `wallet/bdhke.rs` | ✅ implemented |
| **Local mint** (Fedimint) backing the wallet | `apps/fedimint` (`http://127.0.0.1:8175`) | ✅ deployed |
| **Lightning** (invoices, pay, channels) for mint/melt | `api/rpc/lnd/*`, `container/lnd.rs`, `apps/lnd` | ✅ implemented |
| **Streaming payment gate** (accepts `cashuA` tokens, opens metered session) | `streaming/gate.rs` | ✅ implemented |
| **Metering & pricing** (sats per byte / ms / request; e.g. content-download = 1 sat/MB) | `streaming/meter.rs`, `streaming/pricing.rs`, `streaming/session.rs` | ✅ implemented |
| **Revenue/profit accounting** (incl. `StreamingRevenue` tx type) | `wallet/profits.rs` | ✅ implemented |
| **Paid-service discovery** on Nostr (kind 10021, TollGate TIP-01 shape) | `streaming/advertisement.rs` | ✅ implemented |
| **Content server** that verifies+receives payment before serving | `content_server.rs` (`verify_and_receive_payment()`) | ✅ implemented |
| **iroh-blobs swarm** (fetch content-addressed blobs from peers, verify, seed) | `swarm/` (`iroh-swarm` feature) | ✅ just added |
| **Signed seed adverts** (NIP-33 kind 30081, blake3→endpoint) | `swarm/seed_advert.rs` | ✅ just added |
| **BLAKE3 content addressing** | `content_hash.rs` | ✅ implemented |
| **Ed25519 trust / `did:key` / detached signatures** | `trust/` | ✅ implemented (anchor ceremony pending) |
| **4-tier transport** (Mesh > LAN > FIPS > Tor) + `last_transport` | `transport/*`, `fips/dial.rs` | ✅ implemented |
| **Node discovery + federation trust** (Trusted/Observer) | `nostr_handshake.rs`, `federation/*` | ✅ implemented |
What is **NOT** present and must be built:
- **A paid-serving hook on the iroh-blobs provider.** Today the swarm seeds to
anyone (`BlobsProtocol::new(&store, None)` — no authorization). To charge for
swarm bandwidth we need a per-request gate that consults `streaming/gate.rs`.
- **A relay protocol.** No "peer A asks peer B to forward traffic to peer C".
Transport is point-to-point; there is no multi-hop routing, TTL, or relay
accounting.
- **IndeeHub Archipelago catalog.** The shipped IndeeHub points at the external
`staging-api.indeehub.studio` + AWS S3/CloudFront. Nothing makes a film
uploaded on node A visible on node B. No *backstage* code exists yet.
---
## 1. Pay sats (ecash) for transport of streaming films
### Goal
When node B streams a film blob (an HLS `.ts` segment) *from* node A's swarm,
A earns sats for the bytes it serves — using the ecash gate that already meters
`content-download`.
### What exists vs. what's new
- ✅ The economic machinery is done: `streaming/pricing.rs` already ships a
`content-download` service priced per MB; `streaming/gate.rs` turns a `cashuA`
token into a metered session; `meter.rs` deducts bytes; `profits.rs` records
`StreamingRevenue`.
- ❌ The swarm serving path doesn't consult any of it. `IrohProvider::new`
spins up `BlobsProtocol` that answers every blob request unconditionally.
### Design — "paid swarm" as a gated blob protocol
The clean seam is the iroh-blobs **accept** side. Two viable shapes:
**(A) In-band gate via a custom ALPN (preferred).** Keep iroh-blobs for the raw
byte transfer but front it with a tiny request/grant exchange on a second ALPN
(`archy/paid-blobs/1`):
1. B wants `blake3:H`. It dials A's endpoint and sends `{want: H, token?: cashuA}`.
2. A calls `streaming::gate::check_gate("content-download", peer=B, bytes≈len(H), token)`.
- `PaymentRequired` → A replies with price + its accepted mints
(`streaming.list-mints`) and the sat amount; B mints/sends a `cashuA` and retries.
- `PaidAndAllowed` / `Allowed` (within existing session allotment) → A authorizes
the blob hash for this connection and hands off to iroh-blobs to stream it.
3. A meters served bytes via `meter::record_and_check` and records revenue.
**(B) Pre-paid session, then open serving.** B opens a metered session up front
(buys N MB of `content-download` allotment with one token), and A's blob protocol
checks "does this peer have remaining allotment?" before each blob. Simpler, fewer
round-trips, slightly looser accounting. Good first cut.
Recommend **(B) for v1** (least new protocol surface — reuses sessions verbatim),
graduating to **(A)** when we want per-blob price discovery.
### Free vs. paid policy (important)
- **OTA + app-catalog blobs stay FREE.** Charging for security updates is hostile
and breaks the "origin always wins" guarantee. Gating applies **only** to the
IndeeHub film scope (a per-blob or per-advert "monetized" flag).
- Trusted federation peers (`TrustLevel::Trusted`) can be configured to serve each
other free; payment is for untrusted/public swarm peers.
### Integration points
- **DONE (2026-06-17):** `swarm/paid.rs` — the accept-side gate. Builds the
iroh-blobs `EventSender` (intercept connect + GET, hard-disable `push`) and
authorizes each request through `streaming::gate::check_gate("content-download",
peer_endpoint, blob_size, None)`. Free when the service is disabled (default);
denies unpaid peers when enabled; fails OPEN on internal error. Wired into
`IrohProvider::new`; unit-tested. The Settings toggle the user just got drives it.
- Reuse: `streaming/gate.rs`, `meter.rs`, `session.rs`, `wallet/ecash.rs`,
`streaming/advertisement.rs` (advertise the node as a paid blob seeder).
- TODO (fetch side): `swarm::fetch_content_addressed` gains an optional
"willing-to-pay budget + token source" so a downloading node can auto-pay from
its ecash wallet up to a cap (opening a session via `streaming.pay`), then fall
back to origin if too expensive. This is where **cross-mint settlement (§2a)**
plugs in — the payer may need to swap into the seeder's accepted mint first.
---
## 2. Networking *through* nodes (relayed / routed streaming)
This is the largest genuinely-new piece. Two distinct meanings — both useful:
### 2a. iroh-native relays (cheap, already mostly free)
iroh 1.0 already hole-punches and falls back to **relay servers** for connectivity
when a direct QUIC path can't be established. So "streaming through a node that
can reach the seed when I can't" partly exists at the iroh layer. Action: run/seed
our **own** iroh relay(s) on the OVH/hub infrastructure and pin them in config, so
the swarm doesn't depend on n0's public relays. Low effort, high resilience.
### 2b. Application-level paid relay (the real gap)
"Node B pays node A to fetch a film from origin/swarm on B's behalf and forward it"
— useful when B is behind a censored/expensive link and A has good connectivity
(the beta-cellular-node scenario from memory). This needs a real protocol:
- **`relay.offer` advert** (Nostr kind 10021 with a `relay` tag + price/MB) — reuse
`streaming/advertisement.rs`; add a `relay-bandwidth` service to `pricing.rs`.
- **`relay.fetch` request** over the existing transport (`PeerRequest` in
`fips/dial.rs`): `{content: blake3:H | url, pay: cashuA}`. The relay runs the
normal `swarm::fetch_content_addressed` (swarm-assist, origin fallback), meters
the bytes through `streaming/gate`, and streams them back to the requester.
- **Accounting:** add a `RelayBytes` metric to `streaming/meter.rs` distinct from
origin `content-download`, so "relay provided" is tracked separately in
`profits.rs` (the doc already separates `routing_fees` from `streaming_revenue`).
- **Safety rails:** single-hop only for v1 (no A→B→C→D); TTL + loop guard before
any multi-hop; cap per-session bytes; only relay the **public film scope**, never
private user blobs or arbitrary URLs (prevent open-proxy abuse).
### Phasing for §2
1. Pin our own iroh relays (config only). — *days*
2. Single-hop paid `relay.fetch` for film blobs, gated by ecash. — *the core build*
3. Multi-hop routing + path discovery. — *deferred; only if single-hop proves out*
---
## 2a. Cross-mint ecash settlement — paying across *different* mints
**Problem (user, 2026-06-17):** payment must work when the payer and the seeder
use **different** mints — not only two nodes on the same Fedimint. A node holding
tokens on mint **A** must be able to pay a seeder that only accepts mint **B**,
automatically.
### Why this is mostly a generalization, not new crypto
The wallet already tracks proofs **per-mint**: `WalletData::balance_for_mint(url)`,
`select_proofs(url, amount)`, `add_proofs(url, proofs)` are all mint-scoped, and
`MintClient::new(url)` targets any mint. What's hardcoded is convenience: `mint_quote`
/ `melt_quote` / `mint_tokens` / `melt_tokens` always use the single home
`wallet.mint_url`. So the data model is multi-mint already; we add the *swap* and
parameterize the helpers by target mint.
### The swap primitive (Cashu/Fedimint settle over Lightning)
To move value **A → B**, both mints expose BOLT11 mint+melt quotes (already in
`mint_client.rs`), and Lightning bridges them:
1. `MintClient::new(B).mint_quote(amount)` → a BOLT11 invoice `inv_B` (pay it to get B tokens).
2. `MintClient::new(A).melt_quote(inv_B)` → cost in A tokens (`amount + fee_reserve`).
3. Select A proofs and `melt` them on A to pay `inv_B` over Lightning.
4. When `inv_B` settles, `MintClient::new(B).mint_tokens(quote_B)` → claim B tokens;
`wallet.add_proofs(B, …)`.
Net: value lands on B minus (A melt fee + LN routing + B mint fee). The node's LND
isn't strictly required — the mints' own LN gateways settle — but a healthy local
node/route improves success. Implementation = three thin `*_at(mint_url, …)`
variants of the existing helpers + one composer:
`swap_between_mints(data_dir, from, to, amount, max_fee_sats) -> Result<u64>`.
### Where the swap happens — two models
- **Payer-side swap (recommended default).** Before paying seeder S (whose
`accepted_mints` are advertised via `streaming.advertise` / the gate's
`PaymentRequired.pricing.accepted_mints`), the payer picks the cheapest path:
pay directly if it already holds a token on one of S's mints; otherwise
`swap_between_mints(A → S_mint)` then send a token denominated in S's mint. **S
never has to trust mint A** — it only ever receives its own mint's tokens. Clean.
- **Payee-side auto-consolidation (optional, more liberal).** S widens
`accepted_mints` to any mint it's willing to melt-swap from, accepts an A token,
then swaps A → home-mint in the background. Broader acceptance, but S briefly
carries mint-A counterparty risk.
A node can do both: advertise a broad accept list *and* have payers prefer
direct/cheap mints.
### Guardrails (these are the real design decisions)
- **Mint trust list.** Mints can be insolvent or rug. Only swap *into* / accept
mints on a configured allow-list (default: home mint + a small curated set, with
the local Fedimint always trusted). Surface this in the Settings UI alongside the
per-service pricing.
- **Fee/slippage cap.** Every swap costs sats. `max_fee_sats` (or a max %) refuses a
swap that would cost more than the content is worth; the payer then declines and
uses origin. Show the all-in cost (price + swap fee) before auto-paying.
- **Origin always wins.** If the LN swap fails (no route, mint offline, over
budget), fall back to the HTTP origin with no payment. A mint problem must never
block content.
- **Idempotency / crash-safety.** Persist in-flight swaps (`melt` quote id + `mint`
quote id) so a crash between "paid `inv_B`" and "claimed B tokens" resumes the
claim instead of double-paying. Reuse the wallet's tx log.
- **Liquidity.** Swaps need the mints to have inbound/outbound LN liquidity; cache
recent swap success per mint-pair and prefer routes that have worked.
### Phasing for §2a
1. `*_at(mint_url, …)` helpers + `swap_between_mints` + mint trust list + fee cap. — *the core*
2. Payer-side auto-swap in the payment builder (pick cheapest accepted mint). — *wires §1/§2 to it*
3. Idempotent resume + per-pair liquidity cache. — *hardening*
4. (Optional) payee-side auto-consolidation.
This keeps the headline promise intact: **pay anyone, on any trusted mint,
automatically — or fall back to free origin.**
---
## 3. IndeeHub "Archipelago" content source
### Goal
A new source tab inside the IndeeHub app, **"Archipelago"**, listing every film
uploaded to *backstage*, streamable on any node — independent of the external
`indeehub.studio` API.
### Today (from the research)
- IndeeHub frontend (Next.js) is built against `NEXT_PUBLIC_API_URL =
staging-api.indeehub.studio` and pulls media from AWS S3/CloudFront. It is
**not Archipelago-aware**. nginx proxies it at `/app/indeedhub/` and injects a
NIP-07 Nostr provider.
- A MinIO stack exists (`indeedhub-public` / `indeedhub-private` buckets); FFmpeg
produces HLS there. **No backstage upload UI/code exists yet.**
- The design doc's Phase 4 already describes the target: backstage → FFmpeg → HLS
→ each `.ts` is a BLAKE3 blob → signed Nostr "Blossom" catalog event → any node
resolves the content address and streams from the nearest holder; MinIO origin.
### Architecture — four pieces
**(i) Backstage upload + transcode (origin side).**
Minimal creator flow on a publisher node: upload master → FFmpeg → HLS
(`.m3u8` + `.ts`) into MinIO (reuse the existing `indeedhub-ffmpeg`/MinIO stack).
For each segment compute `blake3_hex` (`content_hash::blake3_hex`) and import it
into the iroh seed store (`IrohProvider::seed_and_advertise`, generalized beyond
releases). The playlist references segments by content hash.
**(ii) Signed film catalog on Nostr (the "Archipelago" source).**
Define a new addressable event — **kind 30082, `archy-film`** (sibling of the
30081 seed advert) — published by the publisher node, **signed via `trust/`**:
```jsonc
{
"title": "...", "creator_did": "did:key:z...", "duration_s": 5400,
"poster": "blake3:...", // poster image blob
"playlist": "blake3:...", // the .m3u8 (itself a blob)
"segments": ["blake3:...", ...], // ordered .ts segment hashes
"enc": { "scheme": "aes-128", "key_ref": "nip98" }, // see (iv)
"monetized": { "service": "content-download", "sats_per_mb": 1 } // optional
}
```
The signature uses `trust::sign_detached`; consumers verify with
`trust::verify_detached`. **Publisher trust:** films show in the Archipelago tab
only from publishers on the node's trusted/federation set (or a pinned
"Archipelago film-root" key, mirroring the release-root anchor concept). This is
the key that stops the shared catalog from being a spam vector.
**(iii) Archipelago-local film API (makes it appear on every node).**
New RPC + HTTP endpoints in `api/`:
- `film.catalog` / `GET /api/film-catalog` — query Nostr relays for kind-30082
events from trusted publishers, verify signatures, dedupe, return merged JSON.
Cache like `app_catalog.rs` does (mtime/TTL, atomic write).
- `GET /api/film/:blake3` — serve a segment: `swarm::fetch_content_addressed`
(swarm-assist → MinIO/OVH origin), BLAKE3-verified, with HTTP range support so
the player can seek. This is where §1 (paid serving) and §2 (relay) plug in.
- The IndeeHub frontend gets an **"Archipelago" source** that points at
`/api/film-catalog` instead of `indeehub.studio`. Cleanest: a small build/runtime
flag or an injected config (same nginx `sub_filter` mechanism already used to
inject the NIP-07 provider) that registers the Archipelago source alongside the
existing studio source — additive, not a replacement.
**(iv) Encryption / access (private films).**
Public films: plaintext segments, freely cacheable, swarm-distributable. Private
films: keep AES-128 HLS; **untrusted seeds cache only ciphertext** (they never see
plaintext), and the decryption key is delivered per-viewer via NIP-98 auth (the
mechanism IndeeHub already uses) or NIP-44 DM. Payment (§1) gates *bytes*; the
*key* gates *plaintext* — two independent locks. This lets us pay strangers to
seed encrypted blobs without leaking content.
### "On every node" — propagation
Propagation is **pull**, not push: every node's `film.catalog` periodically queries
the same Nostr relays (already configured for discovery) for trusted-publisher film
events. A film uploaded on node A is therefore visible on node B as soon as B
refreshes its catalog — exactly how `app_catalog.rs` already distributes app
updates fleet-wide. No central server; the relays carry only signed metadata, the
blobs flow peer-to-peer with MinIO/OVH as origin.
---
## 4. Suggested end-to-end phasing
| Step | Deliverable | Risk | Reuses |
| --- | --- | --- | --- |
| **A** | Generalize `seed_and_advertise` beyond releases → arbitrary public blob scope (films) | low | swarm/ |
| **B** | `film.catalog` RPC + signed kind-30082 events + trusted-publisher gating | lowmed | trust/, app_catalog.rs pattern |
| **C** | `GET /api/film/:blake3` range-streaming via swarm-assist + MinIO origin | med | swarm/, content_server.rs |
| **D** | IndeeHub "Archipelago" source wired to the local API (additive) | med (frontend, external repo) | nginx sub_filter |
| **E** | Backstage: upload → FFmpeg → HLS → blob import + catalog publish | med | MinIO/ffmpeg stack |
| **F** | **DONE** — paid swarm serving (`swarm/paid.rs` gates the blob protocol via `streaming/gate`); free by default | med | streaming/* |
| **F2** | Cross-mint settlement (§2a): `swap_between_mints` + payer-side auto-swap + mint trust list + fee cap | medhigh | wallet/ecash, mint_client, lnd |
| **G** | Pin our own iroh relays (config) | low | iroh |
| **H** | Single-hop paid `relay.fetch` for film blobs | high | transport/, streaming/* |
| **I** | Multi-hop routing | high / deferred | — |
A→E delivers "films on every node" with free volunteer seeding (the design-doc
vision). F→H layer the sats economy on top. I is genuinely future work.
> **Shipping directive (user, 2026-06-17):** the IndeeHub "Archipelago" change
> ships — after testing — as a **decoupled app-catalog update**, NOT a binary
> OTA. Publish the new IndeeHub image + bump `releases/app-catalog.json` so every
> node gets the per-app "Update" badge (the mechanism in
> `container/app_catalog.rs` / `package.check-updates`). Node-side API changes
> (steps B/C) that need the binary go through the normal OTA; the *app* (step D,
> the IndeeHub frontend image) goes through the app catalog. See memories
> `project_decoupled_app_updates` + `reference_indeehub_canonical_source`.
---
## 5. Open questions / decisions needed
1. **iroh-blobs authorization granularity.** ✅ **RESOLVED (2026-06-17 spike).**
iroh-blobs 0.103 exposes exactly the hook we need: `BlobsProtocol::new(&store,
Some(EventSender))`. With an `EventMask` set to intercept, the provider asks our
handler to authorize each request and we return `EventResult = Result<(),
AbortReason>`:
- `RequestMode::Intercept` / `InterceptLog` — per-blob-request allow/deny
(`Err(AbortReason::Permission)` denies, `Err(AbortReason::RateLimited)` defers).
- `ConnectMode::Intercept` — reject at the connection handshake (cheap pre-filter).
- `ThrottleMode::Intercept` — per-request throttle/meter hook for byte accounting.
- `RequestMode::Disabled` — hard-reject a whole request kind (e.g. disable `Push`
so peers can never write into our store).
**§1 shape (A) is the recommended path** (native, no fork): the accept-side
handler calls `streaming::gate::check_gate("content-download", peer_endpoint,
bytes, token)` and maps `PaymentRequired`/`InsufficientPayment` →
`Err(Permission)`, `Allowed`/`PaidAndAllowed``Ok(())`. Peer identity comes
from the `Connection`'s remote endpoint id. (See `iroh_blobs::provider::events`.)
2. **Film-publisher trust anchor.** One global "Archipelago film-root" key (curated
store, like release-root) vs. per-node trusted-publisher sets vs. both. Affects
spam resistance and who can publish to *everyone's* Archipelago tab.
3. **MinIO as origin across the fleet** — single canonical MinIO on the hub vs.
per-node MinIO with cross-seeding. The swarm makes per-node origin viable but
the *first* upload needs a home.
4. **IndeeHub frontend is an external repo** (`~/Projects/indeehub-frontend`,
built into `apps/indeedhub`). Adding an "Archipelago" source needs changes
there; scope whether it's a build-time source registration or a runtime-injected
config (preferred — keeps the node OS in control).
5. **Pricing defaults & free tier.** What's free (OTA, trusted peers, first N MB?)
vs. paid, and the default sats/MB. `pricing.json` already supports this; needs a
policy.
6. **Payment UX / auto-pay caps.** A downloading node auto-paying from its ecash
wallet needs a user-set ceiling and a "prefer free origin if peer wants > X"
rule, so streaming never silently drains the wallet.
---
## 6. Why this is tractable
The hard, slow-to-build substrate — an ecash wallet, a metered payment gate,
content addressing, a verifying swarm, signed discovery, a trust module, a
multi-transport stack — is **already in the tree and (for the swarm) just tested**.
The remaining work is wiring those together along the three axes above, with the
two new protocols (paid blob serving, single-hop relay) being the only substantial
net-new surface. Everything stays behind feature flags / opt-in config and obeys
the project's north star: **swarm-assist, origin always wins** — and now,
**free updates, optional paid films.**

View File

@ -1378,6 +1378,7 @@ ${ELECTRUMX_IMAGE} electrumx.tar
${MARIADB_IMAGE} mariadb-mempool.tar
${FEDIMINT_IMAGE} fedimint.tar
${FEDIMINT_GATEWAY_IMAGE} fedimint-gateway.tar
${FMCD_IMAGE} fmcd.tar
${FILEBROWSER_IMAGE} filebrowser.tar
${ALPINE_TOR_IMAGE} alpine-tor.tar
${NGINX_ALPINE_IMAGE} nginx-alpine.tar

View File

@ -2159,6 +2159,7 @@ app.post('/rpc/v1', (req, res) => {
// Mesh Networking (LoRa radio via Meshcore)
// =====================================================================
case 'mesh.status': {
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
return res.json({
result: {
enabled: true,
@ -2173,6 +2174,8 @@ app.post('/rpc/v1', (req, res) => {
messages_sent: 23,
messages_received: 47,
detected_devices: ['/dev/ttyUSB0'],
announce_block_headers: globalThis.__meshHeaders.announce_block_headers,
receive_block_headers: globalThis.__meshHeaders.receive_block_headers,
},
})
}
@ -2278,7 +2281,10 @@ app.post('/rpc/v1', (req, res) => {
case 'mesh.configure': {
console.log(`[Mesh] Configure:`, params)
return res.json({ result: { configured: true } })
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
if (params && typeof params.announce_block_headers === 'boolean') globalThis.__meshHeaders.announce_block_headers = params.announce_block_headers
if (params && typeof params.receive_block_headers === 'boolean') globalThis.__meshHeaders.receive_block_headers = params.receive_block_headers
return res.json({ result: { configured: true, ...globalThis.__meshHeaders } })
}
case 'mesh.send-invoice': {
@ -3015,6 +3021,55 @@ app.post('/rpc/v1', (req, res) => {
})
}
case 'seed.reveal': {
if (!params || !params.password) {
return res.json({ error: { code: -32000, message: 'Password is required to reveal the recovery phrase' } })
}
// Demo gate: accept any non-empty password; reject "wrong" to exercise the error path.
if (params.password === 'wrong') {
return res.json({ error: { code: -32000, message: 'Incorrect password' } })
}
const demo = 'legal winner thank year wave sausage worth useful legal winner thank yellow able cabin dad debris during dose talent layer crater proud drift movie'.split(' ')
return res.json({ result: { words: demo, word_count: demo.length } })
}
case 'update.list-mirrors': {
globalThis.__mockMirrors ||= [
{ url: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json', label: 'Origin (vps2)' },
]
return res.json({ result: { mirrors: globalThis.__mockMirrors } })
}
case 'update.get-source': {
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
return res.json({
result: {
source: globalThis.__swarmPrefs.source,
provide_dht: globalThis.__swarmPrefs.provide_dht,
swarm_available: false, // default build has no iroh-swarm feature
swarm_enabled: false,
},
})
}
case 'update.set-source': {
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
if (params && (params.source === 'origin' || params.source === 'swarm')) {
globalThis.__swarmPrefs.source = params.source
}
if (params && typeof params.provide === 'boolean') {
globalThis.__swarmPrefs.provide_dht = params.provide
}
return res.json({
result: {
source: globalThis.__swarmPrefs.source,
provide_dht: globalThis.__swarmPrefs.provide_dht,
swarm_available: false,
swarm_enabled: false,
},
})
}
case 'network.list-requests': {
return res.json({
result: {

View File

@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.98-alpha",
"version": "1.7.99-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.98-alpha",
"version": "1.7.99-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.98-alpha",
"version": "1.7.99-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",

View File

@ -290,6 +290,18 @@
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",

View File

@ -139,6 +139,41 @@ function deepElementFromPoint(x: number, y: number): Element | null {
return el
}
/**
* Find the nearest scrollable ancestor of `el` for the given delta, hopping out
* of same-origin iframes when needed. Synthetic WheelEvents are untrusted and
* never actually scroll the page, so two-finger scroll must call scrollBy on a
* real scroll container this locates it (e.g. the right-hand app frame). (#7)
*/
function findScrollable(el: Element | null, dx: number, dy: number): Element | null {
let node: Element | null = el
let guard = 0
while (node && guard++ < 60) {
const win = node.ownerDocument?.defaultView
const style = win?.getComputedStyle(node)
if (style) {
const oy = style.overflowY
const ox = style.overflowX
const isRoot = node === node.ownerDocument?.scrollingElement
const canY =
(oy === 'auto' || oy === 'scroll' || isRoot) &&
node.scrollHeight > node.clientHeight + 1
const canX =
(ox === 'auto' || ox === 'scroll' || isRoot) &&
node.scrollWidth > node.clientWidth + 1
if ((dy !== 0 && canY) || (dx !== 0 && canX)) return node
}
if (node.parentElement) {
node = node.parentElement
} else if (win?.frameElement) {
node = win.frameElement as Element // same-origin iframe → continue in parent doc
} else {
break
}
}
return null
}
/** The actually-focused element, descending through same-origin iframes. */
function deepActiveElement(): Element | null {
let el: Element | null = document.activeElement
@ -267,10 +302,19 @@ function handleMessage(data: string) {
break
}
case 's': {
const dy = msg.y ?? 0
document.dispatchEvent(new WheelEvent('wheel', {
bubbles: true, deltaY: dy * 100, deltaMode: WheelEvent.DOM_DELTA_PIXEL,
}))
// Scroll the element under the virtual cursor (incl. inside same-origin
// app frames like the right-hand panel), not the top document. A synthetic
// wheel event won't scroll — call scrollBy on a real scroll container. (#7)
const dy = (msg.y ?? 0) * 100
const dx = (msg.x ?? 0) * 100
const start = deepElementFromPoint(cursorX, cursorY)
const scroller = findScrollable(start, dx, dy)
if (scroller) {
scroller.scrollBy({ left: dx, top: dy })
} else {
const win = start?.ownerDocument?.defaultView ?? window
win.scrollBy(dx, dy)
}
break
}
}
@ -308,6 +352,30 @@ function doConnect() {
}
}
/**
* Ask the companion (phone) to open a URL in its own browser.
*
* "Open in external browser" apps can't be usefully opened on the kiosk when a
* companion is driving it `window.open` lands on the kiosk, which the phone
* user never sees. When a companion is active we forward the URL over the relay
* socket ({"t":"o","url"}); the backend routes it to the phone, which opens it.
*
* Returns true if the request was forwarded (caller should NOT open locally),
* false if there's no active companion (caller should open normally).
*/
export function requestExternalOpen(url: string): boolean {
if (!url || !/^https?:\/\//i.test(url)) return false
if (!companionActive.value) return false
if (!ws || ws.readyState !== WebSocket.OPEN) return false
try {
ws.send(JSON.stringify({ t: 'o', url }))
if (import.meta.env.DEV) console.log('[RemoteRelay] Forwarded external-open to companion:', url)
return true
} catch {
return false
}
}
/** Start the remote relay listener. Connects to /ws/remote-relay. */
export function startRemoteRelay() {
shouldReconnect = true

View File

@ -0,0 +1,49 @@
<template>
<!-- Desktop: subtle "frosted pill" link, sits at the top of the content flow
(the style from the Networking Profits page, now shared globally). -->
<button
type="button"
@click="$emit('click')"
:class="['hidden md:inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white text-sm transition-colors', desktopMargin]"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ label }}
</button>
<!-- Mobile: floating transparent button pinned 8px above the tab bar -->
<Teleport to="body">
<button
type="button"
@click="$emit('click')"
class="md:hidden mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ label }}</span>
</button>
</Teleport>
</template>
<script setup lang="ts">
/**
* Standard back button. Renders a transparent text link at the top on desktop
* and a floating transparent "glass" pill pinned above the tab bar on mobile
* the pattern set by the Cloud detail pages (PeerFiles/CloudFolder).
*
* Presentational only: it emits `click`; the parent keeps its own navigation
* logic (router.push / router.back / conditional goBack).
*/
withDefaults(
defineProps<{
label?: string
/** Desktop bottom-margin utility (views vary between mb-4 and mb-6). */
desktopMargin?: string
}>(),
{ label: 'Back', desktopMargin: 'mb-4' }
)
defineEmits<{ (e: 'click'): void }>()
</script>

View File

@ -0,0 +1,285 @@
<template>
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Protocol tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<!-- ===================== Cashu Mints ===================== -->
<div v-show="activeTab === 'cashu'">
<p class="text-white/60 text-sm mb-4">
Cashu ecash tokens can only be received from mints in this list. Add a mint's URL to accept tokens issued by it.
</p>
<div v-if="loadingMints" class="py-6 text-center text-white/50 text-sm">Loading mints</div>
<template v-else>
<div class="space-y-2 mb-4">
<div
v-for="(mint, idx) in mints"
:key="mint + idx"
class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-mono text-white/90 truncate">{{ mint }}</span>
</div>
<button
@click="removeMint(idx)"
:disabled="mints.length <= 1"
class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-red-400 transition-colors disabled:opacity-30 disabled:hover:text-white/50 disabled:hover:bg-transparent shrink-0"
aria-label="Remove mint"
title="Remove mint"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<p v-if="mints.length === 0" class="text-white/40 text-sm text-center py-2">No mints configured.</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Add a mint</label>
<div class="flex gap-2">
<input
v-model="newMint"
type="text"
placeholder="https://mint.example.com"
class="flex-1 input-glass font-mono"
@keydown.enter.prevent="addMint"
/>
<button @click="addMint" class="glass-button px-4 py-2 rounded-lg text-sm font-medium shrink-0">Add</button>
</div>
</div>
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="saveMints"
:disabled="savingMints || mints.length === 0"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ savingMints ? 'Saving…' : 'Save' }}
</button>
</div>
</template>
</div>
<!-- ===================== Fedimint Federations ===================== -->
<div v-show="activeTab === 'fedimint'">
<div class="flex items-start gap-2 mb-4">
<p class="text-white/60 text-sm flex-1">
Join a Fedimint federation by pasting its invite code. Federated ecash is held by a group of guardians rather than a single mint.
</p>
<span v-if="!fedimintBackendReady" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Coming soon</span>
</div>
<!-- Joined federations -->
<div class="space-y-2 mb-4">
<div
v-for="fed in federations"
:key="fed.federation_id"
class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm text-white/90 truncate">{{ fed.name || fed.federation_id }}</p>
<p class="text-[11px] text-white/40 font-mono truncate">{{ fed.federation_id }}</p>
</div>
</div>
<span class="text-sm text-blue-400 font-medium shrink-0">{{ fed.balance_sats.toLocaleString() }} sats</span>
</div>
<p v-if="federations.length === 0" class="text-white/40 text-sm text-center py-2">No federations joined yet.</p>
</div>
<!-- Join by invite code -->
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Invite code</label>
<textarea
v-model="inviteCode"
rows="3"
:disabled="!fedimintBackendReady"
placeholder="fed11jpr3lgm8t…"
class="w-full input-glass font-mono disabled:opacity-50"
></textarea>
</div>
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="joinFederation"
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ joiningFed ? 'Joining…' : 'Join federation' }}
</button>
</div>
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
Joining federations lands with the Fedimint client backend.
</p>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>()
const tabs = [
{ key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
]
const activeTab = ref<'cashu' | 'fedimint'>('cashu')
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
const fedimintBackendReady = true
// ---- Cashu mints ----
const mints = ref<string[]>([])
const newMint = ref('')
const loadingMints = ref(false)
const savingMints = ref(false)
const mintError = ref('')
const mintsSavedOk = ref(false)
// ---- Fedimint federations ----
interface Federation {
federation_id: string
name?: string
balance_sats: number
}
const federations = ref<Federation[]>([])
const inviteCode = ref('')
const joiningFed = ref(false)
const fedError = ref('')
watch(
() => props.show,
(open) => {
if (open) {
loadMints()
if (fedimintBackendReady) loadFederations()
}
},
)
async function loadMints() {
loadingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
newMint.value = ''
try {
const res = await rpcClient.call<{ mints: string[] }>({ method: 'streaming.list-mints' })
mints.value = res.mints || []
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to load mints'
mints.value = []
} finally {
loadingMints.value = false
}
}
function addMint() {
mintError.value = ''
mintsSavedOk.value = false
const url = newMint.value.trim().replace(/\/+$/, '')
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
mintError.value = 'Mint URL must start with http:// or https://'
return
}
if (mints.value.some((m) => m.replace(/\/+$/, '') === url)) {
mintError.value = 'That mint is already in the list'
return
}
mints.value.push(url)
newMint.value = ''
}
function removeMint(idx: number) {
if (mints.value.length <= 1) return
mints.value.splice(idx, 1)
mintsSavedOk.value = false
}
async function saveMints() {
if (mints.value.length === 0) return
savingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
try {
await rpcClient.call<{ mints: string[]; updated: boolean }>({
method: 'streaming.configure-mints',
params: { mints: mints.value },
})
mintsSavedOk.value = true
emit('changed')
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to save mints'
} finally {
savingMints.value = false
}
}
async function loadFederations() {
fedError.value = ''
try {
const res = await rpcClient.call<{ federations: Federation[] }>({ method: 'wallet.fedimint-list' })
federations.value = res.federations || []
} catch {
federations.value = []
}
}
async function joinFederation() {
if (!fedimintBackendReady || !inviteCode.value.trim()) return
joiningFed.value = true
fedError.value = ''
try {
await rpcClient.call<{ federation_id: string }>({
method: 'wallet.fedimint-join',
params: { invite_code: inviteCode.value.trim() },
})
inviteCode.value = ''
await loadFederations()
emit('changed')
} catch (err: unknown) {
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
} finally {
joiningFed.value = false
}
}
function close() {
mintError.value = ''
mintsSavedOk.value = false
fedError.value = ''
emit('close')
}
</script>

View File

@ -26,6 +26,7 @@
"back": "Back",
"done": "Done",
"manage": "Manage",
"settings": "Settings",
"connect": "Connect",
"connecting": "Connecting...",
"disconnect": "Disconnect",

View File

@ -26,6 +26,7 @@
"back": "Volver",
"done": "Listo",
"manage": "Administrar",
"settings": "Configuración",
"connect": "Conectar",
"connecting": "Conectando...",
"disconnect": "Desconectar",

View File

@ -4,6 +4,7 @@ import './style.css'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import { displayVersion } from '@/utils/version'
// Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable
if (!navigator.clipboard) {
@ -31,6 +32,11 @@ app.use(pinia)
app.use(router)
app.use(i18n)
// Global version formatter — normalizes version labels to a single "v" prefix
// (some sources already carry one, which produced "vv1.2.3"). Use `$ver(x)` in
// templates instead of hard-coding a `v` prefix.
app.config.globalProperties.$ver = displayVersion
app.config.errorHandler = (err, _instance, info) => {
console.error('[Vue Error]', err, info)
const { error } = useToast()

View File

@ -206,6 +206,11 @@ const router = createRouter({
name: 'credentials',
component: () => import('../views/Credentials.vue'),
},
{
path: 'web5/networking-profits',
name: 'networking-profits-settings',
component: () => import('../views/web5/Web5NetworkingProfitsSettings.vue'),
},
{
path: 'settings',
name: 'settings',

View File

@ -3,6 +3,26 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
/**
* Open a URL in a new browser tab but if a companion (phone) is currently
* driving this kiosk, hand the URL to the phone instead so it opens in the
* phone's browser rather than the (often headless / unattended) kiosk display.
* Falls back to a local `window.open` when no companion is active.
*/
function openExternal(launchUrl: string) {
// Resolve to an absolute URL so the phone can open it (window.open also
// handles absolute URLs fine).
let absolute = launchUrl
try {
absolute = new URL(launchUrl, window.location.origin).href
} catch {
/* keep as-is */
}
if (requestExternalOpen(absolute)) return
window.open(launchUrl, '_blank', 'noopener,noreferrer')
}
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
@ -29,8 +49,16 @@ const NEW_TAB_APP_IDS = new Set([
'nginx-proxy-manager',
'uptime-kuma',
'gitea',
// netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
// PKCE), so it's served over HTTPS and must open in a real tab — a
// self-signed-HTTPS iframe is blocked by the browser (you can't accept the
// cert warning inside an iframe).
'netbird',
])
// Apps served over HTTPS (self-signed) rather than plain HTTP.
const HTTPS_APP_IDS = new Set(['netbird'])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@ -127,12 +155,16 @@ const APP_ID_TO_PORT: Record<string, string> = {
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
// Without this, directAppUrl('netbird') returns null and netbird falls
// through to the iframe (and never gets its https URL) — issue #15.
netbird: '8087',
}
function directAppUrl(appId: string): string | null {
const port = APP_ID_TO_PORT[appId]
if (!port || typeof window === 'undefined') return null
return `http://${window.location.hostname}:${port}`
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
return `${scheme}://${window.location.hostname}:${port}`
}
@ -192,7 +224,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl && !mobile) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}
@ -212,12 +244,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Legacy: open app in iframe overlay (kept for backward compat) */
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
const titleHintId = inferAppIdFromTitle(payload.title)
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
// its OIDC dashboard) must be launched over https — a stale http URL hits
// the TLS port and 400s. Upgrade the scheme defensively in every path.
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
try {
const u = new URL(launchUrl, window.location.origin)
if (u.protocol === 'http:') {
u.protocol = 'https:'
launchUrl = u.href
}
} catch { /* leave as-is */ }
}
if (!isMobileViewport() && payload.openInNewTab) {
if (resolvedId) recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}
@ -226,7 +271,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// native launchers and keep the user inside Archipelago.
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}
@ -238,7 +283,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Unknown apps that block iframes — open directly in new tab
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}

View File

@ -16,6 +16,9 @@ export interface MeshStatus {
messages_sent: number
messages_received: number
detected_devices?: string[]
/** Bitcoin block-header send/receive prefs (issue #28). */
announce_block_headers?: boolean
receive_block_headers?: boolean
}
export interface MeshPeer {
@ -122,6 +125,26 @@ export interface BlockHeader {
announced_by: string
}
export interface AssistantStatus {
enabled: boolean
model: string | null
trusted_only: boolean
backend: string
default_model: string
ollama_detected: boolean
claude_available: boolean
models: string[]
}
export interface ScheduledMessage {
id: number
contact_id: number | null
channel: number | null
body: string
fire_at: number
attempts: number
}
export interface NodePosition {
lat: number
lng: number
@ -574,6 +597,59 @@ export const useMeshStore = defineStore('mesh', () => {
const blockHeaders = ref<BlockHeader[]>([])
const latestBlockHeight = ref(0)
// Mesh-AI assistant (issue #50)
const assistantStatus = ref<AssistantStatus | null>(null)
async function fetchAssistantStatus() {
try {
assistantStatus.value = await rpcClient.call<AssistantStatus>({ method: 'mesh.assistant-status' })
} catch {
// Assistant not available (mesh service down)
}
}
async function configureAssistant(config: {
enabled?: boolean
model?: string | null
trusted_only?: boolean
backend?: string
}) {
const res = await rpcClient.call<Partial<AssistantStatus>>({
method: 'mesh.assistant-configure',
params: config,
})
await fetchAssistantStatus()
return res
}
// Scheduled / queued mesh messages (issue #50, phase 1.7)
const scheduledMessages = ref<ScheduledMessage[]>([])
async function fetchScheduledMessages() {
try {
const res = await rpcClient.call<{ messages: ScheduledMessage[] }>({ method: 'mesh.list-scheduled' })
scheduledMessages.value = res.messages || []
} catch {
scheduledMessages.value = []
}
}
async function scheduleMessage(params: {
contact_id?: number
channel?: number
body: string
fire_at: number
}) {
const res = await rpcClient.call<ScheduledMessage>({ method: 'mesh.schedule-message', params })
await fetchScheduledMessages()
return res
}
async function cancelScheduledMessage(id: number) {
await rpcClient.call({ method: 'mesh.cancel-scheduled', params: { id } })
await fetchScheduledMessages()
}
async function fetchDeadmanStatus() {
try {
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
@ -693,6 +769,13 @@ export const useMeshStore = defineStore('mesh', () => {
fetchDeadmanStatus,
configureDeadman,
deadmanCheckin,
assistantStatus,
fetchAssistantStatus,
configureAssistant,
scheduledMessages,
fetchScheduledMessages,
scheduleMessage,
cancelScheduledMessage,
fetchBlockHeaders,
relayTransaction,
relayLightning,

View File

@ -729,6 +729,36 @@ input[type="radio"]:active + * {
min-height: 36px;
}
/* Transparent "frosted" variant for back buttons the light counterpart to
the solid black .glass-button. Used by the floating mobile back pill so it
reads as transparent over content rather than a black slab. */
.back-button-glass {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, border-color 0.2s ease;
}
.back-button-glass:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.26);
}
.back-button-glass:active {
transform: translateY(1px);
}
/* Glass button color variants */
.glass-button-warning {
background: rgba(251, 146, 60, 0.2);

View File

@ -0,0 +1,22 @@
/**
* Format a version string for display with exactly one leading "v".
*
* Version strings reach the UI from several sources manifests (bare like
* "1.7.96"), node/federation state and the update RPC (sometimes already
* "v1.7.96"). Templates used to hard-code a `v` prefix (`v{{ version }}`),
* which produced "vv1.7.96" whenever the source already carried a "v". This
* normalizes both shapes to a single "v".
*/
export function displayVersion(v?: string | null): string {
if (v === null || v === undefined) return ''
const bare = String(v).trim().replace(/^v+/i, '')
return bare ? `v${bare}` : ''
}
// Exposed globally as `$ver` (see main.ts) so templates can normalize version
// labels without a per-file import.
declare module 'vue' {
interface ComponentCustomProperties {
$ver: (v?: string | null) => string
}
}

View File

@ -1,25 +1,6 @@
<template>
<div class="app-details-container pb-16 md:pb-16">
<!-- Desktop Back Button -->
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ backButtonText }}
</button>
<!-- Mobile Full-Width Back Button (teleported to escape CSS transform containing block) -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ backButtonText }}</span>
</button>
</Teleport>
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
<div v-if="pkg">
<AppHeroSection
@ -101,6 +82,7 @@ import { useAppLauncherStore } from '../stores/appLauncher'
import { dummyApps } from '../utils/dummyApps'
import { rpcClient } from '@/api/rpc-client'
import type { AppCredentialsResponse } from '@/types/api'
import BackButton from '@/components/BackButton.vue'
import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'

View File

@ -1,5 +1,6 @@
<template>
<div class="pb-6">
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
<p class="text-white/70">
@ -115,17 +116,6 @@
</ul>
</div>
<!-- Back link -->
<RouterLink
to="/dashboard/settings"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Settings
</RouterLink>
<!-- Add-registry modal -->
<Teleport to="body">
<Transition name="fade">
@ -192,8 +182,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import BackButton from '@/components/BackButton.vue'
interface Registry {
url: string

View File

@ -8,7 +8,7 @@
<div class="mode-switcher hidden md:inline-flex">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
</div>
<div v-show="activeTab === 'apps' && categoriesWithApps.length > 1 && !collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex">
@ -73,7 +73,7 @@
<div class="app-header-inline-tabs mode-switcher mode-switcher-full mb-3">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mobile-category-strip mb-3" aria-label="My Apps categories">
<button
@ -401,11 +401,11 @@ const showStagger = !appsAnimationDone
// Tabs
const activeTab = ref<AppsTab>(
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
route.query.tab === 'websites' || route.query.tab === 'services' ? 'services' : 'apps'
)
watch(() => route.query.tab, (tab) => {
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
activeTab.value = tab === 'websites' || tab === 'services' ? 'services' : 'apps'
})
// Search (debounced)

View File

@ -2,25 +2,7 @@
<div class="cloud-folder-container flex flex-col h-full">
<!-- Desktop Back Button + Header -->
<div class="shrink-0 mb-4">
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ backLabel }}
</button>
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ backLabel }}</span>
</button>
</Teleport>
<BackButton :label="backLabel" @click="goBack" />
<!-- Folder Header -->
<div class="flex items-center justify-between">
@ -172,6 +154,7 @@ import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import BackButton from '@/components/BackButton.vue'
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
import FileGrid from '../components/cloud/FileGrid.vue'
import ShareModal from '../components/cloud/ShareModal.vue'
@ -288,9 +271,17 @@ const section = computed(() => {
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
const iframeUrl = computed(() => section.value?.iframeUrl || '')
// Whether we're at the section's root folder. Derived from the route (the URL
// is the source of truth) rather than cloudStore.currentPath, which is async
// and still holds the previous/blank path on first entry that staleness is
// what made entering e.g. "Photos and videos" wrongly show "Back to Parent
// Folder" and break the back action.
const atSectionRoot = computed(() =>
!section.value || routeFolderPath.value === section.value.initialPath
)
const backLabel = computed(() => {
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
return cloudStore.currentPath !== section.value.initialPath ? 'Back to Parent Folder' : 'Back to Cloud'
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
})
// Initialize native file browser when entering a native-UI section
@ -388,8 +379,8 @@ async function navigateCloudPath(path: string) {
}
function goBack() {
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
if (useNativeUI.value && !atSectionRoot.value) {
navigateCloudPath(parentCloudPath(routeFolderPath.value))
return
}
router.push('/dashboard/cloud')

Some files were not shown because too many files have changed in this diff Show More