feat(napplet): wallet (NWC) payment + live relay query; defer inter-applet

#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-20 14:40:46 +00:00
parent 86a19012b5
commit 075be5f8b7
3 changed files with 176 additions and 11 deletions
@@ -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/`)
@@ -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.
@@ -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<Event> = account.cache.filter(filter).mapNotNull { it.event }
override suspend fun query(filter: Filter): List<Event> = 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<Event> {
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<String?>()
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
}
}