mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user