From 075be5f8b77d824058cdfaa53a9db20251ff57da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 14:40:46 +0000 Subject: [PATCH] feat(napplet): wallet (NWC) payment + live relay query; defer inter-applet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 WALLET — PayInvoice now pays via the user's connected NWC wallet (account.sendZapPaymentRequestFor, wrapped suspend with a 60s timeout). The gateway returns the preimage on success and throws (→ Failed) on no-wallet, wallet error, or timeout, so an applet never wrongly believes a payment landed. The consent dialog decodes the invoice and shows the amount in sats (LnInvoiceUtil). Gated as before: must declare `value`/`wallet`, then consent. #3 live relay query — QueryEvents now does a bounded live fetch (INostrClient.fetchAll, EOSE/8s timeout) across the user's read relays, merged with LocalCache, deduped, newest-first, limit-respected — instead of cache-only. #2 inter-applet — deferred per design review. It needs new architecture (multi-applet hosting + an archetype registry), not just a gateway, and would risk forking the upstream NAP-INC/INTENT wire format. Surveyed upstream napplet/naps and wrote the design + prerequisites in amethyst/plans/2026-06-20-napplet-inter-applet.md. :amethyst:compileFdroidDebugKotlin passes; spotless clean. Wallet + live query need on-device verification (real NWC wallet / relays). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../plans/2026-06-19-napplet-sandbox-host.md | 13 ++- .../plans/2026-06-20-napplet-inter-applet.md | 94 +++++++++++++++++++ .../amethyst/napplet/NappletBrokerService.kt | 80 ++++++++++++++-- 3 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 amethyst/plans/2026-06-20-napplet-inter-applet.md diff --git a/amethyst/plans/2026-06-19-napplet-sandbox-host.md b/amethyst/plans/2026-06-19-napplet-sandbox-host.md index 1fc19f364d..ecd2cfbdc9 100644 --- a/amethyst/plans/2026-06-19-napplet-sandbox-host.md +++ b/amethyst/plans/2026-06-19-napplet-sandbox-host.md @@ -197,16 +197,19 @@ Deferred to v2; v1 nails the single-applet boundary first. (`NappletBrokerService`), `window.napplet.*` shim, consent UI (`NappletConsentActivity`), DataStore ledger. ✅ implemented. 4. **Capabilities beyond identity/publish:** - - **`relay` read** — `QueryEvents` one-shot query against `LocalCache`. ✅ + - **`relay` read** — `QueryEvents`: bounded live relay fetch (`fetchAll`, + EOSE/timeout) merged with `LocalCache`, newest-first. ✅ - **`storage`** — per-applet sandboxed KV store (`DataStoreNappletStorage`). ✅ - - **`value`/`wallet`** — protocol + `NappletWalletGateway` interface in place; - broker answers `Unsupported` until a verified payment path is wired. ⏳ + - **`value`/`wallet`** — `PayInvoice` wired to the user's NWC wallet + (`sendZapPaymentRequestFor`); consent shows the decoded sats amount; throws + (→ `Failed`) on no-wallet/error/timeout. ✅ (needs on-device verification) - **`net`** — CSP widening to approved origins. ⏳ 5. **Capability enforcement** — the broker refuses any request whose capability is not in the manifest's `requires` (passed host→broker as `declared`), before any consent prompt. ✅ -6. **Live relay query** (vs cache-only), **inter-applet messaging**, - **install-style up-front capability grant UI**. ⏳ (v2) +6. **Inter-applet (NAP-INC / NAP-INTENT)** — deferred; design + prerequisites in + `2026-06-20-napplet-inter-applet.md`. **`net`** capability and an install-style + up-front capability grant UI also remain. ⏳ ### Implemented Android components (amethyst `…/napplet/`) diff --git a/amethyst/plans/2026-06-20-napplet-inter-applet.md b/amethyst/plans/2026-06-20-napplet-inter-applet.md new file mode 100644 index 0000000000..cc974f9605 --- /dev/null +++ b/amethyst/plans/2026-06-20-napplet-inter-applet.md @@ -0,0 +1,94 @@ +# Napplet inter-applet communication (NAP-INC / NAP-INTENT) — design notes + +**Date:** 2026-06-20 +**Status:** Deferred — design only. Prereqs not yet built (see below). +**Parent:** `amethyst/plans/2026-06-19-napplet-sandbox-host.md` + +## Why this is deferred (not just "next") + +Inter-applet messaging is the one napplet capability that needs **new +architecture**, not just a new broker op + gateway. Two hard prerequisites are +missing today: + +1. **Multiple applets running at once.** `NappletHostActivity` is declared + `launchMode="singleTask"` and hosts exactly one applet. True live A↔B + messaging (the full NAP-INC request/result transport) requires either + multi-applet hosting (several iframes in one host, or several host processes) + plus a routing layer — none of which exists. +2. **An archetype / handler registry.** NAP-INTENT dispatches by *archetype* + (`note`, `feed`, `profile`, …) to a default-handler napplet. Our + `NappletManifest` has no `handles`/archetype declaration and there is no + "which napplet is the default handler for X" registry. + +Both are sizeable subsystems. Shipping a half-version would also risk **forking +the wire format** from upstream while it is still being defined. + +## Upstream model (napplet/naps survey, 2026-06-20) + +Inter-applet is split into two shell-mediated specs (applets never reach each +other directly — every message crosses the shell/broker): + +- **NAP-INC** (`inc`) — the transport. Messages are `{ type: "domain.action", + id, … }`, request/result correlated by `id`. Addressing is direct + (napplet→napplet) *or* archetype-mediated by the runtime. +- **NAP-INTENT** (`intent`) — invoke a napplet by **archetype** via + default-handler dispatch (`shell.supports("intent")`). The shell launches the + handler; napplets cannot invoke directly. +- **NAP-1…5** — concrete protocols on top of NAP-INC: `profile:*` (NAP-1), + `stream:*` (NAP-2), `chat:*` (NAP-3), `note:open` (NAP-4), `feed:*` (NAP-5). + Producer/consumer model. + +Discovery is capability-probe based: `shell.supports("inc")`, +`shell.supports("inc", "NAP-N")`. + +## How it would map onto our boundary + +The broker model fits "shell-mediated" naturally — every message would cross +`NappletBrokerService` exactly like every other capability, gated by the ledger. +The pieces: + +1. **Capability.** Add `NappletCapability.MESSAGING` mapped from NAP domains + `inc` / `intent` (default-deny like every other domain). Consent is a *link* + grant ("Applet A may message / open Applet B"), distinct from per-op consent. +2. **Protocol.** New `NappletRequest`/`NappletResponse` variants under + `MESSAGING`, shaped to mirror NAP-INC (`type = "domain.action"`, `id` + correlation) so we don't fork the wire format. +3. **Addressing.** Direct by napplet coordinate first; archetype dispatch only + after the registry (below) exists. + +### Two viable implementation shapes (pick at build time) + +- **NAP-INTENT, direct coordinate** — `napplet.intent({ target, payload })` → + consent → broker resolves `target` to a manifest in `LocalCache` → launches it + via a `NappletIntentLauncher` gateway, passing an initial payload the target + reads on startup (`napplet.intent` / `onIntent`). Fits the single-applet model + (you switch to the target). No simultaneous hosting needed. **Lowest lift; most + aligned with NAP-INTENT.** Result-return across the switch is awkward (fire-and- + forget, or a callback event). +- **NAP-INC brokered mailbox** — `napplet.sendTo(coordinate, msg)` / + `pollMessages()` with broker-persisted per-napplet inboxes, consent per link. + Works with no simultaneous hosting and is fully unit-testable, but it is async + fire-and-collect, not the request/result transport upstream describes. + +Full live NAP-INC (simultaneous A↔B, request/result) needs the multi-applet +hosting prereq regardless. + +## Prerequisites to build first + +1. **Multi-applet hosting** — either N iframes in one `NappletHostActivity` with + per-iframe origin isolation + routing, or a host-per-applet process model and + a cross-process router in the broker. Decide the model before coding NAP-INC. +2. **Archetype registry** — a manifest `handles`/archetype tag (align with + upstream naps), an index over installed napplets, and a user-set default + handler per archetype (mirror NIP-89 handler selection, which Amethyst already + models for app recommendations). +3. **Link-consent UX** — distinct from capability consent: "Allow *Chess* to open + *Wallet*?", revocable per pair in a permissions screen. + +## Recommendation + +When picked up: start with **NAP-INTENT direct-coordinate** (smallest, aligned, +no new hosting), build the archetype registry next (unlocks default-handler +dispatch + reuses NIP-89 patterns), and only then tackle live NAP-INC once +multi-applet hosting lands. Keep the wire `type`/`id` shape identical to upstream +NAP-INC throughout to avoid a fork. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 21bfea1b21..3c93a8c8f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -38,16 +38,25 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway +import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout /** * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this @@ -137,9 +146,7 @@ class NappletBrokerService : Service() { return relays.map { it.url } } - // Reads what the device already knows. The screen-level subscription keeps the - // cache warm; arbitrary live relay fetches per applet filter are a follow-up. - override suspend fun query(filter: Filter): List = account.cache.filter(filter).mapNotNull { it.event } + override suspend fun query(filter: Filter): List = queryEvents(account, filter) } val consent = @@ -150,8 +157,57 @@ class NappletBrokerService : Service() { ) } - // wallet is intentionally null: no payment path ships until it is verified end-to-end. - return NappletBroker(account.signer, ledger, consent, relay, storage) + val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(account, invoice) } + + return NappletBroker(account.signer, ledger, consent, relay, storage, wallet) + } + + /** Bounded live relay fetch (EOSE/timeout) merged with the local cache, newest-first. */ + private suspend fun queryEvents( + account: Account, + filter: Filter, + ): List { + val relays = account.homeRelays.flow.value + val fromRelays = + if (relays.isEmpty()) { + emptyList() + } else { + runCatching { + account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = QUERY_TIMEOUT_MS) + }.getOrDefault(emptyList()) + } + val fromCache = account.cache.filter(filter).mapNotNull { it.event } + + val merged = + (fromRelays + fromCache) + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + return filter.limit?.let { merged.take(it) } ?: merged + } + + /** + * Pays [invoice] via the user's connected NWC wallet, returning the preimage on success. + * Throws (→ `Failed`) when no wallet is connected, the wallet reports an error, or it does not + * respond in time — so the applet never silently believes a payment succeeded. + */ + private suspend fun payInvoiceViaNwc( + account: Account, + invoice: String, + ): String? { + if (account.nip47SignerState.defaultWalletUri.value == null) { + throw IllegalStateException("No Lightning wallet is connected.") + } + + val result = CompletableDeferred() + account.sendZapPaymentRequestFor(invoice, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage) + is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed.")) + is NwcErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Wallet error.")) + else -> result.completeExceptionally(RuntimeException("Unexpected wallet response.")) + } + } + return withTimeout(WALLET_TIMEOUT_MS) { result.await() } } private fun consentInfo( @@ -178,7 +234,14 @@ class NappletBrokerService : Service() { is NappletRequest.QueryEvents -> "This napplet wants to read events from your relays." is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove -> "This napplet wants to use its private storage." - is NappletRequest.PayInvoice -> "This napplet wants to pay a Lightning invoice." + is NappletRequest.PayInvoice -> { + val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull() + if (sats != null) { + "This napplet wants to pay a Lightning invoice for $sats sats." + } else { + "This napplet wants to pay a Lightning invoice." + } + } } private fun reply( @@ -200,4 +263,9 @@ class NappletBrokerService : Service() { Log.w("NappletBrokerService", "Applet host went away before reply could be delivered", e) } } + + companion object { + private const val QUERY_TIMEOUT_MS = 8_000L + private const val WALLET_TIMEOUT_MS = 60_000L + } }