From 5f41907149e826506592119863e686561cc6ef8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 19:44:21 +0000 Subject: [PATCH 01/75] docs(clink): add CLINK protocol implementation plan Plan to implement CLINK (Offers 21001 / Debits 21002 / Manage 21003) on Quartz (client + server) and Amethyst (consume-only), reusing NIP-44, bech32/TLV, the NWC encrypted-event pattern, and ZapPaymentHandler. Pointers parsed by a dedicated ClinkPointerParser (separate from NIP-19). --- quartz/plans/2026-06-09-clink.md | 131 +++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 quartz/plans/2026-06-09-clink.md diff --git a/quartz/plans/2026-06-09-clink.md b/quartz/plans/2026-06-09-clink.md new file mode 100644 index 0000000000..51a8a7a4dc --- /dev/null +++ b/quartz/plans/2026-06-09-clink.md @@ -0,0 +1,131 @@ +# CLINK on Quartz + Amethyst + +Status: proposed +Date: 2026-06-09 +Owner: TBD + +## Decisions (locked) + +1. **Scope:** implement all three CLINK specs (Offers, Debits, Manage). +2. **Sidedness:** Quartz implements **both client and server** for every spec + (so `amy` and interop tests can drive both ends). Amethyst is + **consume-only** — it never hosts offers or approves incoming debits. +3. **Wallet model:** CLINK plugs into Amethyst's wallet layer *like NWC*. The + only spec that backs a spendable wallet is **Debits** — a stored `ndebit` + pointer is the CLINK analogue of an NWC connection string. It lives in the + same wallet list and adds a payment route to `ZapPaymentHandler`. `noffer` + (pay others) and `nmanage` (offer admin) are not wallet connections. +4. **Pointer parsing:** `noffer` / `ndebit` / `nmanage` are parsed by a + **dedicated `ClinkPointerParser`**, NOT folded into `Nip19Parser`. We do not + loosen the NIP-19 regexes app-wide for these prefixes. + +## What CLINK is + +"Common Lightning Interface for Nostr Keys" — three Nostr-native Lightning +protocols from ShockNet. Where NWC (NIP-47) is RPC-style remote control of your +*own* wallet, CLINK is peer-to-peer / app-to-service: no HTTPS callbacks, no +LNURL web server, no onion messages. Each interaction is a NIP-44-encrypted +ephemeral request→response event over a relay, addressed by a bech32 pointer. + +| Spec | Kind | Pointer | Purpose | LN analogue | +|---------|---------|--------------|-----------------------------------------------------|----------------------| +| Offers | `21001` | `noffer1…` | Static code → request a fresh BOLT-11 over Nostr | LNURL-Pay / BOLT-12 | +| Debits | `21002` | `ndebit1…` | Authorize a counterparty to pull a payment | LNURL-withdraw | +| Manage | `21003` | `nmanage1…` | Delegate CRUD of offers to an external app | — | + +Shared envelope: ephemeral kind, `["p", recipient]` + `["clink_version","1"]` +tags, `["e", reqId]` on responses, NIP-44 JSON content, GFY error codes +(`{"res":"GFY","code":1..6,...}`), 30s freshness window. + +### Pointer TLV fields (verify against @shocknet/clink-sdk in Phase 0) + +- **noffer:** 0=receiver pubkey, 1=relay, 2=offer-id, 3=price-type + (0 fixed / 1 variable / 2 spontaneous), 4=price sats, 5=currency. +- **ndebit:** 0=service pubkey, 1=relay, 2=pointer-id (opt), 3=32-byte `k1` + session id (opt; single-use). +- **nmanage:** 0=server pubkey, 1=relay, 2=pointer-id (opt). + +## Reuse matrix + +| Component | Status | Location | Action | +|-----------------------------------|------------|----------------------------------------------------------------------|----------------------------------------------------| +| NIP-44 encrypt/decrypt | ✅ Reuse | `quartz/.../nip44Encryption/` + `signer.nip44Encrypt/Decrypt` | Use as-is | +| Bech32 + TLV codec | ✅ Reuse | `nip19Bech32/bech32/`, `tlv/TlvBuilder.kt`, `tlv/Tlv.kt` | Use the codec; do NOT reuse `Nip19Parser` | +| Entity TLV pattern (reference) | 📦 Mirror | `nip19Bech32/entities/NProfile.kt` | Copy shape into new Clink entities | +| Encrypted req/resp event pattern | 📦 Mirror | `nip47WalletConnect/events/LnZapPaymentRequestEvent.kt` (kind 23194) | Clone for kinds 21001/2/3, client + server | +| Request/response over relay | ✅ Reuse | `Nip47Client.responseFilter()`, `relayClient/reqCommand/nwc/` | Same filter-by-`e`-tag pattern | +| BOLT-11 parsing | ✅ Reuse | `quartz/.../lightning/LnInvoiceUtil.kt` | Use as-is | +| LN payment execution | ✅ Reuse | `amethyst/.../service/ZapPaymentHandler.kt`, `NwcSignerState` | Add CLINK routes | +| Wallet/zap account settings | 📦 Extend | `AccountSettings.kt` (`nwcWallets`, `defaultNwcWalletId`) | Add CLINK debit pointers to the wallet list | +| Rich-text inline detect/render | 📦 Extend | `commons/.../richtext/RichTextParser.kt`, `RichTextViewer.kt`, `InvoicePreview.kt` | Add `noffer1…` token → pay card | +| EventFactory registry | 📦 Extend | `quartz/.../utils/EventFactory.kt` | Register 21001/21002/21003 | + +Genuinely new: 3 event classes, 3 bech32 entities + `ClinkPointerParser`, JSON +DTOs, `ClinkClient` + `ClinkServer`, the debit-pointer wallet store, UI surfaces. + +## Phase 0 — Quartz foundation (client + server, all three) + +New package `quartz/.../nipClink/` (mirroring the single-package NWC layout), +with `events/`, `pointers/`, `rpc/` subfolders. + +1. **TLV constants** — CLINK-local TLV indices (do not overload NIP-19 + `TlvTypes`, which is NIP-19-specific). +2. **Pointers** — `NOffer`, `NDebit`, `NManage` data classes with + `parse(bytes)` / `create(...)` built like `NProfile.kt` (`Tlv.parse` + + `TlvBuilder`). A standalone `ClinkPointerParser` decodes/encodes the three + prefixes — NOT wired into `Nip19Parser`. Verify HRP + checksum and the + TLV namespace against `@shocknet/clink-sdk` with a round-trip test. +3. **Events** — `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, + each `isContentEncoded() = true`, with `createRequest()` / `createResponse()` + companions that NIP-44-encrypt JSON and set `p` / `clink_version` / `e` tags + (direct analogue of `LnZapPaymentRequestEvent`). Register in `EventFactory`. +4. **DTOs + errors** — Jackson request/response classes per spec, shared + `GfyError(code, message, range?, retryAfter?, delta?)`, Offers error model + (`code` 1..5, `range`, `latest`). +5. **ClinkClient / ClinkServer** — high-level: `decode(pointer)`, + `buildRequest(...)`, `responseFilter(reqId)`, `parseResponse(...)`; server + side validates freshness, `k1` single-use, app-scoped offer ownership. +6. **Tests** — quartz unit tests with spec TLV vectors + round-trip against + clink-sdk fixtures; `amy clink decode|offer-pay|debit|manage` verbs (thin + assembly only). + +## Phase 1 — Offers consume (Amethyst) + +Ship order: scan/paste → inline card → profile button. + +- **Scan/paste-to-pay:** QR scanner + clipboard handle `noffer1…` → decode → + send 21001 → await BOLT-11 → existing send sheet → `ZapPaymentHandler`. + Smallest correct slice; proves the client end-to-end. +- **Inline feed card (headline):** `RichTextParser` recognizes a `noffer1…` + token; render a "⚡ Pay" card next to the existing BOLT-11 `InvoicePreview`. + Tap reuses the same decode→21001→pay path. +- **Profile pay button:** read `noffer` from kind-0 metadata; profile Zap + button prefers CLINK offer over LNURL when present. + +## Phase 2 — Debits as a wallet (Amethyst, consume-only) + +This is the "add a wallet via CLINK" answer. + +- **Account store:** extend the wallet list so a saved `ndebit` pointer sits + alongside NWC connections (`AccountSettings`, parallel to `nwcWallets`; + selectable as default funding source). +- **Payment route:** `ZapPaymentHandler` gains a CLINK-debit route — send a + 21002 request, await `{"res":"ok",preimage}`, handle GFY. Always behind an + explicit confirmation; honor `k1` single-use; never auto-approve. +- **Out of scope (server side):** receiving/approving incoming 21002 requests + and session-`k1` scan-to-pull. Quartz has the server code; Amethyst does not + expose it (consume-only decision). + +## Phase 3 — Manage (Quartz only) + +Full client + server in Quartz + `amy clink manage …` (create/update/get/ +list/delete, app-scoped ownership, GFY errors). **No Amethyst UI** — Amethyst +is an offer *consumer*, not a host. Revisit an in-app "my offers" minter only +if users want to mint offers from inside the app later. + +## Risks / to verify in Phase 0 + +- Exact bech32 HRP + whether CLINK uses its own TLV namespace vs NIP-19's — + pin against `@shocknet/clink-sdk` source, not the client-rendered docs. +- Relay selection for ephemeral req/resp (pointer relay vs account relays). +- Response-timeout + retry UX (30s freshness window, GFY code 3 deltas). From 41b1b634827b0b4612eeb5ec2142d244e849b0e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:04:00 +0000 Subject: [PATCH 02/75] feat(clink): add CLINK bech32 pointer types and parser Implements the noffer/ndebit/nmanage pointers (CLINK Offers/Debits/Manage) as standard-bech32 TLV codes, with a dedicated ClinkPointerParser kept separate from NIP-19. Wire format (HRPs, TLV indices, single-byte priceType, 4-byte big-endian price) verified against @shocknet/clink-sdk 1.5.5. Adds round-trip + dispatch + reject tests in commonTest. --- .../clink/pointers/ClinkPointer.kt | 69 +++++++++++ .../clink/pointers/ClinkPointerParser.kt | 72 ++++++++++++ .../experimental/clink/pointers/NDebit.kt | 77 ++++++++++++ .../experimental/clink/pointers/NManage.kt | 68 +++++++++++ .../experimental/clink/pointers/NOffer.kt | 79 +++++++++++++ .../clink/pointers/OfferPriceType.kt | 45 +++++++ .../clink/pointers/ClinkPointerTest.kt | 110 ++++++++++++++++++ 7 files changed, 520 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt new file mode 100644 index 0000000000..79589453d6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointer.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * CLINK ("Common Lightning Interface for Nostr Keys") addresses a remote Lightning + * service through a bech32-encoded pointer that carries a small TLV payload. There + * are three pointer kinds — one per CLINK spec — and they all share the first three + * TLV fields (pubkey, relay, pointer-id). See https://github.com/shocknet/clink. + * + * Pointers use **standard** bech32 (not bech32m) with their own human-readable + * prefixes (`noffer`, `ndebit`, `nmanage`), so they are intentionally parsed by + * [ClinkPointerParser] rather than the NIP-19 [com.vitorpamplona.quartz.nip19Bech32.Nip19Parser]. + */ +sealed interface ClinkPointer { + /** TLV 0 — 32-byte public key of the service that listens for requests. */ + val pubKey: HexKey + + /** TLV 1 — relay(s) where the service listens. May be empty when shared out-of-band. */ + val relays: List + + /** TLV 2 — opaque routing pointer the service uses to disambiguate (optional). */ + val pointer: String? + + /** Re-encodes this pointer to its `noffer1…` / `ndebit1…` / `nmanage1…` string. */ + fun encode(): String +} + +/** + * Shared TLV field indices across the CLINK pointer specs. Indices 0–2 are common; + * 3–4 are reused with different meanings per spec (priceType/price for offers, k1 + * for debit sessions), mirroring `@shocknet/clink-sdk`. + */ +internal object ClinkTlv { + const val PUBKEY: Byte = 0 + const val RELAY: Byte = 1 + const val POINTER: Byte = 2 + + // Offers + const val PRICE_TYPE: Byte = 3 + const val PRICE: Byte = 4 + + // Debits + const val K1: Byte = 3 +} + +/** Encodes a small unsigned value as a single-byte hex string for one-byte TLV values. */ +internal fun Int.toSingleByteHex(): String = (this and 0xFF).toString(16).padStart(2, '0') diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt new file mode 100644 index 0000000000..5b39369124 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerParser.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException + +/** + * Decodes CLINK bech32 pointers (`noffer1…`, `ndebit1…`, `nmanage1…`). + * + * Kept deliberately separate from [com.vitorpamplona.quartz.nip19Bech32.Nip19Parser]: + * these prefixes are not NIP-19 entities, so we don't widen the app-wide NIP-19 + * matching to recognize them. + */ +object ClinkPointerParser { + /** All three HRPs, anchored at a `1` separator. Used to spot pointers inside free text. */ + val clinkRegex: Regex = + Regex( + "(noffer1|ndebit1|nmanage1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", + RegexOption.IGNORE_CASE, + ) + + /** + * Parses a single pointer string. Tolerates a leading `nostr:`/`lightning:` scheme + * and surrounding whitespace. Returns null on any malformed input. + */ + fun parse(pointer: String): ClinkPointer? { + val cleaned = + pointer + .trim() + .removePrefix("nostr:") + .removePrefix("lightning:") + .trim() + + return try { + val (hrp, bytes, _) = Bech32.decodeBytes(cleaned) + when (hrp.lowercase()) { + NOffer.HRP -> NOffer.parse(bytes) + NDebit.HRP -> NDebit.parse(bytes) + NManage.HRP -> NManage.parse(bytes) + else -> null + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Log.d("ClinkPointerParser") { "Failed to decode CLINK pointer $cleaned: ${e.message}" } + null + } + } + + /** Finds and decodes every CLINK pointer embedded in [content]. */ + fun parseAll(content: String): List = clinkRegex.findAll(content).mapNotNull { parse(it.value) }.toList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt new file mode 100644 index 0000000000..119dbda9ab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Debits pointer (`ndebit1…`, kind 21002): authorizes a counterparty to pull a + * payment from the pointed-to wallet. A pointer is either *static* (TLV 0–2, long-lived, + * app-initiated) or a single-use *session* carrying a 32-byte [k1] (TLV 3), used for the + * LNURL-withdraw-like scan-to-pull flow. + * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md + */ +@Immutable +data class NDebit( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, + /** TLV 3 — 32-byte single-use session id (lowercase hex). Null for static pointers. */ + val k1: HexKey?, +) : ClinkPointer { + /** True when this is a single-use session pointer (carries [k1]). */ + val isSession: Boolean get() = k1 != null + + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + addHexIfNotNull(ClinkTlv.K1, k1) + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "ndebit" + + fun parse(bytes: ByteArray): NDebit? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + val k1 = tlv.firstAsHex(ClinkTlv.K1) + + return NDebit(pubKey, relays, pointer, k1) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt new file mode 100644 index 0000000000..8f11f875c7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NManage.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Manage pointer (`nmanage1…`, kind 21003): a shareable token that grants an + * external app delegated CRUD over a user's offers on a wallet server. + * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md + */ +@Immutable +data class NManage( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, +) : ClinkPointer { + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "nmanage" + + fun parse(bytes: ByteArray): NManage? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + + return NManage(pubKey, relays, pointer) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt new file mode 100644 index 0000000000..58cafb972d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.Tlv +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * CLINK Offers pointer (`noffer1…`, kind 21001): a static payment code that lets a + * payer request a fresh BOLT-11 invoice over Nostr — the Nostr-native analogue of + * LNURL-Pay. See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md + */ +@Immutable +data class NOffer( + override val pubKey: HexKey, + override val relays: List, + override val pointer: String?, + /** TLV 3 — how the offer is priced. Absent means [OfferPriceType.SPONTANEOUS]. */ + val priceType: OfferPriceType?, + /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian per the SDK. */ + val price: Int?, +) : ClinkPointer { + override fun encode(): String = + TlvBuilder() + .apply { + addHex(ClinkTlv.PUBKEY, pubKey) + relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } + addStringIfNotNull(ClinkTlv.POINTER, pointer) + priceType?.let { addHex(ClinkTlv.PRICE_TYPE, it.code.toSingleByteHex()) } + price?.let { addInt(ClinkTlv.PRICE, it) } + }.build() + .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } + + companion object { + const val HRP = "noffer" + + fun parse(bytes: ByteArray): NOffer? { + if (bytes.isEmpty()) return null + + val tlv = Tlv.parse(bytes) + + val pubKey = tlv.firstAsHex(ClinkTlv.PUBKEY) ?: return null + if (pubKey.isBlank()) return null + + val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() + val pointer = tlv.firstAsString(ClinkTlv.POINTER) + val priceType = + tlv.data[ClinkTlv.PRICE_TYPE]?.firstOrNull()?.firstOrNull()?.let { + OfferPriceType.fromCode(it.toInt() and 0xFF) + } + val price = tlv.firstAsInt(ClinkTlv.PRICE) + + return NOffer(pubKey, relays, pointer, priceType, price) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt new file mode 100644 index 0000000000..a9157d7ad3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/OfferPriceType.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +/** + * How a CLINK Offer is priced, encoded in TLV field 3 of a `noffer` pointer. + * + * When the field is absent the pointer defaults to [SPONTANEOUS] (payer chooses + * the amount), per the CLINK Offers spec. + */ +enum class OfferPriceType( + val code: Int, +) { + /** A fixed amount the payer must match; the amount travels in TLV 4. */ + FIXED(0), + + /** A fiat-denominated amount that the service converts to sats at request time. */ + VARIABLE(1), + + /** No preset amount; the payer specifies `amount_sats` in the request. */ + SPONTANEOUS(2), + ; + + companion object { + fun fromCode(code: Int): OfferPriceType? = entries.firstOrNull { it.code == code } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt new file mode 100644 index 0000000000..42d99c8b09 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ClinkPointerTest { + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val k1 = "4caa9ee5f0f0a0b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.shocknet.dev")!! + + @Test + fun offerSpontaneousRoundTrip() { + val offer = NOffer(pubKey, listOf(relay), "my-offer-id", null, null) + val encoded = offer.encode() + + assertTrue(encoded.startsWith("noffer1"), "expected noffer1 prefix, got $encoded") + assertEquals(offer, NOffer.parse(Bech32.decodeBytes(encoded).second)) + assertEquals(offer, ClinkPointerParser.parse(encoded)) + } + + @Test + fun offerFixedPriceRoundTrip() { + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, 21_000) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(OfferPriceType.FIXED, parsed.priceType) + assertEquals(21_000, parsed.price) + assertEquals(offer, parsed) + } + + @Test + fun debitStaticRoundTrip() { + val debit = NDebit(pubKey, listOf(relay), "pointer-7", null) + val parsed = ClinkPointerParser.parse(debit.encode()) as NDebit + + assertEquals(debit, parsed) + assertTrue(!parsed.isSession) + } + + @Test + fun debitSessionRoundTrip() { + val debit = NDebit(pubKey, listOf(relay), null, k1) + val parsed = ClinkPointerParser.parse(debit.encode()) as NDebit + + assertEquals(k1, parsed.k1) + assertTrue(parsed.isSession) + assertEquals(debit, parsed) + } + + @Test + fun manageRoundTrip() { + val manage = NManage(pubKey, listOf(relay), null) + val encoded = manage.encode() + + assertTrue(encoded.startsWith("nmanage1")) + assertEquals(manage, ClinkPointerParser.parse(encoded)) + } + + @Test + fun parserStripsSchemeAndWhitespace() { + val offer = NOffer(pubKey, listOf(relay), null, null, null) + val encoded = offer.encode() + + assertEquals(offer, ClinkPointerParser.parse(" nostr:$encoded ")) + assertEquals(offer, ClinkPointerParser.parse("lightning:$encoded")) + } + + @Test + fun parserRejectsGarbageAndForeignPrefixes() { + assertNull(ClinkPointerParser.parse("not-a-pointer")) + assertNull(ClinkPointerParser.parse("npub1xxxxxxxxxxxxx")) + assertNull(ClinkPointerParser.parse("")) + } + + @Test + fun parseAllFindsEmbeddedPointers() { + val offer = NOffer(pubKey, listOf(relay), null, null, null).encode() + val debit = NDebit(pubKey, listOf(relay), null, null).encode() + val text = "Pay me at $offer or pull from $debit thanks" + + val found = ClinkPointerParser.parseAll(text) + assertEquals(2, found.size) + assertTrue(found[0] is NOffer) + assertTrue(found[1] is NDebit) + } +} From 8fa06f525fd0374ef27e0d41856b3b172a808dc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:19:17 +0000 Subject: [PATCH 03/75] feat(clink): add CLINK request/response event kinds and DTOs Adds the three CLINK message kinds to quartz (experimental/clink): - OfferEvent (21001), DebitEvent (21002), ManageEvent (21003), each carrying both request and response over one kind, NIP-44 encrypted, with p + clink_version tags and an e tag on responses - Request/response DTOs per spec (offers, debits, manage) plus shared SatRange/GfyDelta and GFY/offer error-code constants - Registers all three kinds in EventFactory Pure-logic + JSON (de)serialization covered by ClinkEventTest on JVM; the NIP-44 encrypt/decrypt round-trip will live in androidDeviceTest (lazysodium is unavailable in JVM unit tests). --- .../quartz/experimental/clink/Clink.kt | 33 +++++ .../experimental/clink/common/SatRange.kt | 48 ++++++ .../experimental/clink/debits/DebitEvent.kt | 112 ++++++++++++++ .../clink/debits/DebitMessages.kt | 68 +++++++++ .../experimental/clink/manage/ManageEvent.kt | 112 ++++++++++++++ .../clink/manage/ManageMessages.kt | 85 +++++++++++ .../experimental/clink/offers/OfferEvent.kt | 114 +++++++++++++++ .../clink/offers/OfferMessages.kt | 68 +++++++++ .../quartz/utils/EventFactory.kt | 6 + .../experimental/clink/ClinkEventTest.kt | 138 ++++++++++++++++++ 10 files changed, 784 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt new file mode 100644 index 0000000000..4fad7ab085 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink + +/** + * Constants shared by all three CLINK message kinds. Every CLINK request and + * response carries a `["clink_version", "1"]` tag and a `["p", recipient]` tag, + * and its content is a NIP-44 encrypted JSON payload. + */ +object Clink { + const val VERSION = "1" + const val VERSION_TAG_NAME = "clink_version" + + fun versionTag(): Array = arrayOf(VERSION_TAG_NAME, VERSION) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt new file mode 100644 index 0000000000..a5827690fb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/common/SatRange.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.common + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** Inclusive sats range returned with "Invalid Amount" errors so the payer can correct and retry. */ +class SatRange( + var min: Long? = null, + var max: Long? = null, +) : OptimizedSerializable + +/** Timing detail attached to GFY "Expired Request" (code 3) errors. */ +class GfyDelta( + var max_delta_ms: Long? = null, + var actual_delta_ms: Long? = null, +) : OptimizedSerializable + +/** + * Shared "GFY" failure codes used by Debits (21002) and Manage (21003). + * The accompanying payload may carry [SatRange] (5), `retry_after` (4), or [GfyDelta] (3). + */ +object GfyErrorCode { + const val REQUEST_DENIED = 1 + const val TEMPORARY_FAILURE = 2 + const val EXPIRED_REQUEST = 3 + const val RATE_LIMITED = 4 + const val INVALID_AMOUNT = 5 + const val INVALID_REQUEST = 6 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt new file mode 100644 index 0000000000..5c833523a4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.debits + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.Clink +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Debits event (kind 21002). The same kind carries both the request (requestor → + * service) and the response (service → requestor); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md + */ +@Immutable +class DebitEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + + fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun isResponse() = requestId() != null + + fun version() = tags.firstOrNull { it.size > 1 && it[0] == Clink.VERSION_TAG_NAME }?.get(1) + + private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey + + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey + + suspend fun decryptContent(signer: NostrSigner): String { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + } + + suspend fun decryptRequest(signer: NostrSigner): DebitRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): DebitResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21002 + const val ALT = "CLINK debit" + + /** Builds a request event (requestor side) addressed to the debit service. */ + suspend fun createRequest( + request: DebitRequest, + servicePubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val tags = + arrayOf( + arrayOf("p", servicePubKey), + Clink.versionTag(), + AltTag.assemble(ALT), + ) + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) + return signer.sign(createdAt, KIND, tags, encrypted) + } + + /** Builds a response event (service side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: DebitResponse, + requestEvent: DebitEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val requestorPubKey = requestEvent.pubKey + val tags = + arrayOf( + arrayOf("p", requestorPubKey), + arrayOf("e", requestEvent.id), + Clink.versionTag(), + AltTag.assemble(ALT), + ) + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), requestorPubKey) + return signer.sign(createdAt, KIND, tags, encrypted) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt new file mode 100644 index 0000000000..987ebabdf8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.debits + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request to a CLINK Debits service (kind 21002). Two shapes: + * - direct payment: [bolt11] (+ optional [amount_sats]); [k1] required for session pointers + * - budget approval: [amount_sats] + [frequency] (omit [frequency] for a one-time budget) + */ +class DebitRequest( + var pointer: String? = null, + var amount_sats: Long? = null, + var bolt11: String? = null, + var description: String? = null, + var k1: String? = null, + var frequency: DebitFrequency? = null, +) : OptimizedSerializable + +/** Recurring-budget cadence; [unit] is one of `day`, `week`, `month`. */ +class DebitFrequency( + var number: Int? = null, + var unit: String? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Debits service. [res] is `"ok"` on success (with + * [preimage] for Lightning payouts, absent for internal settlements/budget approvals) + * or `"GFY"` on failure, in which case [code] (see + * [com.vitorpamplona.quartz.experimental.clink.common.GfyErrorCode]) and [error] are set. + */ +class DebitResponse( + var res: String? = null, + var preimage: String? = null, + var code: Int? = null, + var error: String? = null, + var range: SatRange? = null, + var retry_after: Long? = null, + var delta: GfyDelta? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + const val GFY = "GFY" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt new file mode 100644 index 0000000000..ac51a82e86 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.manage + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.Clink +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Manage event (kind 21003). The same kind carries both the request (app → + * wallet server) and the response (server → app); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md + */ +@Immutable +class ManageEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + + fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun isResponse() = requestId() != null + + fun version() = tags.firstOrNull { it.size > 1 && it[0] == Clink.VERSION_TAG_NAME }?.get(1) + + private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey + + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey + + suspend fun decryptContent(signer: NostrSigner): String { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + } + + suspend fun decryptRequest(signer: NostrSigner): ManageRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): ManageResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21003 + const val ALT = "CLINK manage" + + /** Builds a request event (app side) addressed to the wallet server. */ + suspend fun createRequest( + request: ManageRequest, + serverPubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ManageEvent { + val tags = + arrayOf( + arrayOf("p", serverPubKey), + Clink.versionTag(), + AltTag.assemble(ALT), + ) + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), serverPubKey) + return signer.sign(createdAt, KIND, tags, encrypted) + } + + /** Builds a response event (server side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: ManageResponse, + requestEvent: ManageEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ManageEvent { + val appPubKey = requestEvent.pubKey + val tags = + arrayOf( + arrayOf("p", appPubKey), + arrayOf("e", requestEvent.id), + Clink.versionTag(), + AltTag.assemble(ALT), + ) + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), appPubKey) + return signer.sign(createdAt, KIND, tags, encrypted) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt new file mode 100644 index 0000000000..2ee2ee036a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.manage + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request to a CLINK Manage service (kind 21003) for delegated offer CRUD. + * [resource] is always `"offer"`; [action] is one of create/update/get/list/delete. + * `create` omits [id] (the server generates it); update/get/delete require [id]; + * list filters by the requesting app. + */ +class ManageRequest( + var resource: String? = null, + var action: String? = null, + var id: String? = null, + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: Map? = null, + var pointer: String? = null, +) : OptimizedSerializable { + companion object { + const val RESOURCE_OFFER = "offer" + const val ACTION_CREATE = "create" + const val ACTION_UPDATE = "update" + const val ACTION_GET = "get" + const val ACTION_LIST = "list" + const val ACTION_DELETE = "delete" + } +} + +/** A managed offer as returned by the service, including the server-generated `noffer` pointer. */ +class ManageOffer( + var id: String? = null, + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: Map? = null, + var noffer: String? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` with + * either [offer] (create/update/get) or [offers] (list) populated; on failure [res] + * is `"GFY"` with [code]/[error] set. + */ +class ManageResponse( + var res: String? = null, + var resource: String? = null, + var offer: ManageOffer? = null, + var offers: List? = null, + var code: Int? = null, + var error: String? = null, + var range: SatRange? = null, + var retry_after: Long? = null, + var delta: GfyDelta? = null, +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + const val GFY = "GFY" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt new file mode 100644 index 0000000000..8dd76b4499 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.offers + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.clink.Clink +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * CLINK Offers event (kind 21001). The same kind carries both the request (payer → + * service) and the response (service → payer); a response is distinguished by an `e` + * tag referencing the request. Content is NIP-44 encrypted between the two parties. + * + * See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md + */ +@Immutable +class OfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** The `p` tag — the counterparty this message is addressed to. */ + fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + + /** The `e` tag — present only on responses, referencing the request event id. */ + fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + + fun isResponse() = requestId() != null + + fun version() = tags.firstOrNull { it.size > 1 && it[0] == Clink.VERSION_TAG_NAME }?.get(1) + + private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey + + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey + + suspend fun decryptContent(signer: NostrSigner): String { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + } + + suspend fun decryptRequest(signer: NostrSigner): OfferRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + suspend fun decryptResponse(signer: NostrSigner): OfferResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + + companion object { + const val KIND = 21001 + const val ALT = "CLINK offer" + + /** Builds a request event (payer side) addressed to the offer service. */ + suspend fun createRequest( + request: OfferRequest, + servicePubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val tags = + arrayOf( + arrayOf("p", servicePubKey), + Clink.versionTag(), + AltTag.assemble(ALT), + ) + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) + return signer.sign(createdAt, KIND, tags, encrypted) + } + + /** Builds a response event (service side) referencing the original [requestEvent]. */ + suspend fun createResponse( + response: OfferResponse, + requestEvent: OfferEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val payerPubKey = requestEvent.pubKey + val tags = + arrayOf( + arrayOf("p", payerPubKey), + arrayOf("e", requestEvent.id), + Clink.versionTag(), + AltTag.assemble(ALT), + ) + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), payerPubKey) + return signer.sign(createdAt, KIND, tags, encrypted) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt new file mode 100644 index 0000000000..3cfde03efb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.offers + +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +/** + * Decrypted request payload sent to a CLINK Offers service (kind 21001) to obtain a + * fresh BOLT-11 invoice. `offer` echoes the pointer's offer-id; `amount_sats` is + * required for spontaneous/variable offers. `description` is capped at 100 chars. + */ +class OfferRequest( + var offer: String? = null, + var amount_sats: Long? = null, + var payer_data: Map? = null, + var zap: String? = null, + var expires_in_seconds: Long? = null, + var description: String? = null, +) : OptimizedSerializable + +/** + * Decrypted response from a CLINK Offers service. Either an invoice ([bolt11] set) or + * an error ([code] set). On "Expired or Moved" (code 3) the service may include + * [latest] with a replacement `noffer1…`; on "Invalid Amount" (code 5) it includes [range]. + */ +class OfferResponse( + var bolt11: String? = null, + var error: String? = null, + var code: Int? = null, + var range: SatRange? = null, + var latest: String? = null, +) : OptimizedSerializable { + fun isSuccess(): Boolean = bolt11 != null +} + +/** Optional post-settlement receipt (kind 21001). `preimage` is absent for internal settlements. */ +class OfferReceipt( + var res: String? = null, + var preimage: String? = null, +) : OptimizedSerializable + +/** Error codes returned by a CLINK Offers service in [OfferResponse.code]. */ +object OfferErrorCode { + const val INVALID_OFFER = 1 + const val TEMPORARY_FAILURE = 2 + const val EXPIRED_OR_MOVED = 3 + const val UNSUPPORTED_FEATURE = 4 + const val INVALID_AMOUNT = 5 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6356a1667a..8e4e1747cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.experimental.attestations.request.AttestationReq import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -462,6 +465,9 @@ class EventFactory { LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + OfferEvent.KIND -> OfferEvent(id, pubKey, createdAt, tags, content, sig) + DebitEvent.KIND -> DebitEvent(id, pubKey, createdAt, tags, content, sig) + ManageEvent.KIND -> ManageEvent(id, pubKey, createdAt, tags, content, sig) NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt new file mode 100644 index 0000000000..ff26963486 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pure-logic + JSON (de)serialization tests for the CLINK message kinds. The actual + * NIP-44 encrypt/decrypt round-trip lives in androidDeviceTest because NIP-44 crypto + * requires lazysodium, which is unavailable in JVM unit tests. + */ +class ClinkEventTest { + private val payer = NostrSignerInternal(KeyPair()) + private val service = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private fun buildEvent( + author: String, + recipient: String, + requestId: String? = null, + ) = OfferEvent( + id = "a".repeat(64), + pubKey = author, + createdAt = 1L, + tags = + buildList { + add(arrayOf("p", recipient)) + if (requestId != null) add(arrayOf("e", requestId)) + add(Clink.versionTag()) + }.toTypedArray(), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + + // --- tag logic --- + + @Test + fun requestHasNoEventTag() { + val request = buildEvent(payer.pubKey, service.pubKey) + assertFalse(request.isResponse()) + assertNull(request.requestId()) + assertEquals(service.pubKey, request.recipientPubKey()) + assertEquals(Clink.VERSION, request.version()) + } + + @Test + fun responseReferencesRequest() { + val response = buildEvent(service.pubKey, payer.pubKey, requestId = "c".repeat(64)) + assertTrue(response.isResponse()) + assertEquals("c".repeat(64), response.requestId()) + } + + @Test + fun canDecryptOnlyByEitherParty() { + val request = buildEvent(payer.pubKey, service.pubKey) + assertTrue(request.canDecrypt(payer)) + assertTrue(request.canDecrypt(service)) + assertFalse(request.canDecrypt(stranger)) + } + + // --- JSON DTOs --- + + @Test + fun offerRequestJsonRoundTrip() { + val request = OfferRequest(offer = "abc", amount_sats = 1500, description = "coffee") + val parsed = OptimizedJsonMapper.fromJsonTo(OptimizedJsonMapper.toJson(request)) + + assertEquals("abc", parsed.offer) + assertEquals(1500, parsed.amount_sats) + assertEquals("coffee", parsed.description) + } + + @Test + fun offerResponseInvoiceParses() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"bolt11":"lnbc1..."}""") + assertTrue(parsed.isSuccess()) + assertEquals("lnbc1...", parsed.bolt11) + } + + @Test + fun offerResponseInvalidAmountParsesRange() { + val parsed = + OptimizedJsonMapper.fromJsonTo( + """{"error":"Invalid Amount","code":5,"range":{"min":1000,"max":50000}}""", + ) + assertFalse(parsed.isSuccess()) + assertEquals(5, parsed.code) + assertEquals(1000, parsed.range?.min) + assertEquals(50000, parsed.range?.max) + } + + @Test + fun debitGfyResponseParses() { + val parsed = + OptimizedJsonMapper.fromJsonTo( + """{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1717000000}""", + ) + assertFalse(parsed.isOk()) + assertEquals(4, parsed.code) + assertEquals(1717000000, parsed.retry_after) + } + + @Test + fun debitOkResponseParses() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"ok","preimage":"deadbeef"}""") + assertTrue(parsed.isOk()) + assertEquals("deadbeef", parsed.preimage) + } +} From c6193382046b9b20242bba472f53f06957837615 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:24:48 +0000 Subject: [PATCH 04/75] feat(clink): add CLINK client and server facades Adds the high-level request/response orchestration over the CLINK pointers and event kinds (experimental/clink): - OfferClient / DebitClient / ManageClient: build the kind-21001/2/3 request from a decoded pointer, expose the relays to publish on, the response filter (kind + author + #e=requestId), and the response parser - ClinkServer: per-kind request filters (#p=service), 30s freshness check, plus K1Tracker for single-use debit session enforcement Filter construction, freshness window and k1 single-use covered by ClinkClientServerTest on JVM; request-building encryption round-trips will be added under androidDeviceTest (lazysodium constraint). --- .../experimental/clink/client/DebitClient.kt | 91 ++++++++++++++ .../experimental/clink/client/ManageClient.kt | 114 ++++++++++++++++++ .../experimental/clink/client/OfferClient.kt | 87 +++++++++++++ .../experimental/clink/server/ClinkServer.kt | 107 ++++++++++++++++ .../clink/ClinkClientServerTest.kt | 85 +++++++++++++ 5 files changed, 484 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt new file mode 100644 index 0000000000..6b9a1700d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Debits requestor. + * + * Wraps a decoded [NDebit] pointer. A session pointer's single-use `k1` (TLV 3) is + * carried automatically into every request; static pointers send no `k1`, per spec. + */ +class DebitClient( + val pointer: NDebit, + val signer: NostrSigner, +) { + val servicePubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + /** Asks the service to pay [bolt11] from the pointed-to wallet (kind 21002). */ + suspend fun payInvoice( + bolt11: String, + amountSats: Long? = null, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val request = + DebitRequest( + pointer = pointer.pointer, + amount_sats = amountSats, + bolt11 = bolt11, + description = description, + k1 = pointer.k1, + ) + return DebitEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + /** Requests a spending budget; omit [frequency] for a one-time budget. */ + suspend fun requestBudget( + amountSats: Long, + frequency: DebitFrequency? = null, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ): DebitEvent { + val request = + DebitRequest( + pointer = pointer.pointer, + amount_sats = amountSats, + description = description, + k1 = pointer.k1, + frequency = frequency, + ) + return DebitEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(DebitEvent.KIND), + authors = listOf(servicePubKey), + tags = mapOf("e" to listOf(requestId)), + ) + + suspend fun parseResponse(event: DebitEvent): DebitResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt new file mode 100644 index 0000000000..056b2997b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NManage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Manage app client for delegated offer CRUD (kind 21003) against a + * wallet server addressed by an [NManage] pointer. + */ +class ManageClient( + val pointer: NManage, + val signer: NostrSigner, +) { + val serverPubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + suspend fun createOffer( + label: String? = null, + priceSats: Long? = null, + callbackUrl: String? = null, + payerData: Map? = null, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = + send( + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + label = label, + price_sats = priceSats, + callback_url = callbackUrl, + payer_data = payerData, + pointer = pointer.pointer, + ), + createdAt, + ) + + suspend fun updateOffer( + id: String, + label: String? = null, + priceSats: Long? = null, + callbackUrl: String? = null, + payerData: Map? = null, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = + send( + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_UPDATE, + id = id, + label = label, + price_sats = priceSats, + callback_url = callbackUrl, + payer_data = payerData, + ), + createdAt, + ) + + suspend fun getOffer( + id: String, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, id = id), createdAt) + + suspend fun listOffers(createdAt: Long = TimeUtils.now()): ManageEvent = + send( + ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_LIST, pointer = pointer.pointer), + createdAt, + ) + + suspend fun deleteOffer( + id: String, + createdAt: Long = TimeUtils.now(), + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, id = id), createdAt) + + private suspend fun send( + request: ManageRequest, + createdAt: Long, + ): ManageEvent = ManageEvent.createRequest(request, serverPubKey, signer, createdAt) + + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(ManageEvent.KIND), + authors = listOf(serverPubKey), + tags = mapOf("e" to listOf(requestId)), + ) + + suspend fun parseResponse(event: ManageEvent): ManageResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt new file mode 100644 index 0000000000..f9481760d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.client + +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * High-level CLINK Offers payer. + * + * Wraps a decoded [NOffer] pointer: builds the request event, exposes the relays to + * publish on, the filter to await the reply, and the response parser. + * + * ```kotlin + * val offer = ClinkPointerParser.parse("noffer1...") as NOffer + * val client = OfferClient(offer, signer) + * val request = client.requestInvoice(amountSats = 1000) + * // publish `request` to client.relays, then subscribe with client.responseFilter(request.id) + * val response = client.parseResponse(replyEvent) // -> OfferResponse(bolt11=...) or error + * ``` + */ +class OfferClient( + val pointer: NOffer, + val signer: NostrSigner, +) { + val servicePubKey: HexKey get() = pointer.pubKey + val relays: List get() = pointer.relays + + /** + * Builds the kind-21001 request asking the service for a fresh BOLT-11. When + * [amountSats] is null it falls back to the pointer's embedded price (for fixed offers). + */ + suspend fun requestInvoice( + amountSats: Long? = null, + description: String? = null, + payerData: Map? = null, + zap: String? = null, + expiresInSeconds: Long? = null, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val request = + OfferRequest( + offer = pointer.pointer, + amount_sats = amountSats ?: pointer.price?.toLong(), + payer_data = payerData, + zap = zap, + expires_in_seconds = expiresInSeconds, + description = description, + ) + return OfferEvent.createRequest(request, servicePubKey, signer, createdAt) + } + + /** Subscribe with this on [relays] after publishing the request to await its reply. */ + fun responseFilter(requestId: HexKey): Filter = + Filter( + kinds = listOf(OfferEvent.KIND), + authors = listOf(servicePubKey), + tags = mapOf("e" to listOf(requestId)), + ) + + suspend fun parseResponse(event: OfferEvent): OfferResponse = event.decryptResponse(signer) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt new file mode 100644 index 0000000000..8f8f2b09b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/server/ClinkServer.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.server + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.math.abs + +/** + * Service-side helpers for the three CLINK protocols. A CLINK service listens for + * requests addressed to it (`#p` = its pubkey) on its relays, validates freshness, + * then replies with `Event.createResponse(...)`. + * + * Amethyst is consume-only and does not run a service; these live in quartz so `amy` + * and interop tests can exercise the responder side. + */ +object ClinkServer { + /** Requests older/newer than this (vs. the service clock) must be rejected as stale. */ + const val MAX_REQUEST_AGE_SECONDS = 30L + + fun isFresh( + requestCreatedAt: Long, + now: Long = TimeUtils.now(), + ): Boolean = abs(now - requestCreatedAt) <= MAX_REQUEST_AGE_SECONDS + + /** + * Subscribes for incoming requests addressed to [servicePubKey]. Note the kind also + * carries responses, so the caller should skip events where [OfferEvent.isResponse] + * is true (they reference a request via `e`). + */ + fun offerRequestFilter( + servicePubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(OfferEvent.KIND, servicePubKey, since) + + fun debitRequestFilter( + servicePubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(DebitEvent.KIND, servicePubKey, since) + + fun manageRequestFilter( + serverPubKey: HexKey, + since: Long? = null, + ): Filter = requestFilter(ManageEvent.KIND, serverPubKey, since) + + private fun requestFilter( + kind: Int, + recipient: HexKey, + since: Long?, + ): Filter = + Filter( + kinds = listOf(kind), + tags = mapOf("p" to listOf(recipient)), + since = since, + ) +} + +/** + * Tracks single-use Debit session identifiers (`k1`). Per the Debits spec, a `k1` is + * consumed once the service accepts a request for it, scoped per `pointer` (or per + * service pubkey when no pointer is present). Structural/validation failures should + * NOT consume it, so callers consume only after a request validates. + * + * In-memory and not synchronized; a real service should back this with durable, + * concurrency-safe storage. + */ +class K1Tracker { + private val consumed = mutableSetOf() + + private fun key( + scope: String, + k1: HexKey, + ) = "$scope:$k1" + + fun isConsumed( + scope: String, + k1: HexKey, + ): Boolean = consumed.contains(key(scope, k1)) + + /** Marks [k1] consumed for [scope]; returns false if it was already consumed. */ + fun consume( + scope: String, + k1: HexKey, + ): Boolean = consumed.add(key(scope, k1)) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt new file mode 100644 index 0000000000..2627d1ccf6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.server.ClinkServer +import com.vitorpamplona.quartz.experimental.clink.server.K1Tracker +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClinkClientServerTest { + private val signer = NostrSignerInternal(KeyPair()) + private val servicePubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.shocknet.dev")!! + + @Test + fun offerClientExposesPointerRoutingAndResponseFilter() { + val client = OfferClient(NOffer(servicePubKey, listOf(relay), "offer-id", null, null), signer) + + assertEquals(servicePubKey, client.servicePubKey) + assertEquals(listOf(relay), client.relays) + + val filter = client.responseFilter("d".repeat(64)) + assertEquals(listOf(OfferEvent.KIND), filter.kinds) + assertEquals(listOf(servicePubKey), filter.authors) + assertEquals(listOf("d".repeat(64)), filter.tags?.get("e")) + } + + @Test + fun serverRequestFilterTargetsRecipientByPTag() { + val filter = ClinkServer.debitRequestFilter(servicePubKey, since = 100L) + + assertEquals(listOf(DebitEvent.KIND), filter.kinds) + assertEquals(listOf(servicePubKey), filter.tags?.get("p")) + assertEquals(100L, filter.since) + } + + @Test + fun freshnessHonors30SecondWindow() { + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1000)) + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1030)) + assertTrue(ClinkServer.isFresh(requestCreatedAt = 1030, now = 1000)) + assertFalse(ClinkServer.isFresh(requestCreatedAt = 1000, now = 1031)) + } + + @Test + fun k1TrackerIsSingleUsePerScope() { + val tracker = K1Tracker() + val k1 = "4caa9ee5f0f0a0b1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899" + + assertFalse(tracker.isConsumed("pointer-7", k1)) + assertTrue(tracker.consume("pointer-7", k1)) + assertTrue(tracker.isConsumed("pointer-7", k1)) + // second consume of the same scope+k1 fails + assertFalse(tracker.consume("pointer-7", k1)) + // same k1 under a different scope is independent + assertTrue(tracker.consume("pointer-8", k1)) + } +} From ef7658ae09d3032296d5139afe889ea898e82d05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:39:45 +0000 Subject: [PATCH 05/75] test(clink): add cross-impl interop vectors from @shocknet/clink-sdk Adds ClinkInteropTest with bech32 pointer strings generated by the reference TypeScript SDK (clink-sdk 1.5.5) for noffer/ndebit/nmanage. Asserts our parser decodes the SDK's bytes into the expected fields and that re-encoding round-trips. TLV is order-independent on decode, so interop is functional (not byte-identical: we emit fields ascending, the SDK descending); the reverse direction (SDK decoding our output) was verified out-of-band against decodeBech32. --- .../experimental/clink/pointers/NDebit.kt | 1 + .../clink/pointers/ClinkInteropTest.kt | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt index 119dbda9ab..c8458f0bd7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt @@ -46,6 +46,7 @@ data class NDebit( /** True when this is a single-use session pointer (carries [k1]). */ val isSession: Boolean get() = k1 != null + // Note: SDK 1.5.5 does not encode k1; it is a spec-level session extension at TLV index 3. override fun encode(): String = TlvBuilder() .apply { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt new file mode 100644 index 0000000000..030dbedbd9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.pointers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Cross-implementation interop vectors. The bech32 strings below were produced by the + * reference TypeScript implementation @shocknet/clink-sdk@1.5.5 (`nofferEncode`, + * `ndebitEncode`, `nmanageEncode`) for a fixed pubkey/relay. + * + * TLV is order-independent on decode, and the two implementations emit their fields in + * different orders (we ascend 0→4, the SDK descends), so we assert *functional* interop + * rather than byte-identical strings: + * + * 1. Decode: our parser reads the SDK's bytes into the expected fields. + * 2. Round-trip: re-encoding then re-parsing our output preserves every field. + * + * The reverse direction — that the SDK decodes our (ascending-order) output back to the + * same fields — was verified out-of-band by feeding our encoding to `decodeBech32`. + */ +class ClinkInteropTest { + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + private val relay = "wss://relay.shocknet.dev" + + // --- noffer --- + + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + private val offerSpontaneous = + "noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2" + private val offerVariable = + "noffer1qszqqqqp7spszqgzq9mqzxrhwden5te0wfjkccte9eeksmmrddhx2apwv3jhvqpq0elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qhv7h2j" + + @Test + fun decodesAndRoundTripsOfferFixed() { + val offer = ClinkPointerParser.parse(offerFixed) as NOffer + assertEquals(pubKey, offer.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), offer.relays.single()) + assertEquals("offer-id", offer.pointer) + assertEquals(OfferPriceType.FIXED, offer.priceType) + assertEquals(21000, offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + @Test + fun decodesAndRoundTripsOfferSpontaneous() { + val offer = ClinkPointerParser.parse(offerSpontaneous) as NOffer + assertEquals("shop1", offer.pointer) + assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType) + assertNull(offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + @Test + fun decodesAndRoundTripsOfferVariable() { + val offer = ClinkPointerParser.parse(offerVariable) as NOffer + assertEquals("v", offer.pointer) + assertEquals(OfferPriceType.VARIABLE, offer.priceType) + assertEquals(500, offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + + // --- ndebit --- + + private val debitWithPointer = + "ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44" + private val debitWithoutPointer = + "ndebit1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wcrvl9u" + + @Test + fun decodesAndRoundTripsDebitWithPointer() { + val debit = ClinkPointerParser.parse(debitWithPointer) as NDebit + assertEquals(pubKey, debit.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), debit.relays.single()) + assertEquals("pointer-7", debit.pointer) + assertNull(debit.k1) + assertEquals(debit, ClinkPointerParser.parse(debit.encode())) + } + + @Test + fun decodesAndRoundTripsDebitWithoutPointer() { + val debit = ClinkPointerParser.parse(debitWithoutPointer) as NDebit + assertNull(debit.pointer) + assertEquals(pubKey, debit.pubKey) + assertEquals(debit, ClinkPointerParser.parse(debit.encode())) + } + + // --- nmanage --- + + private val manageWithPointer = + "nmanage1qgrxzurs956ryqgcwaehxw309aex2mrp0yh8x6r0vd4kuet59ejx2asqypl8a8zz4ydlauvl4y57tldpkuhqa0q6fsg5zee7y72zxnvx4h05ufx6vef" + private val manageWithoutPointer = + "nmanage1qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wr57t3u" + + @Test + fun decodesAndRoundTripsManageWithPointer() { + val manage = ClinkPointerParser.parse(manageWithPointer) as NManage + assertEquals(pubKey, manage.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), manage.relays.single()) + assertEquals("app-42", manage.pointer) + assertEquals(manage, ClinkPointerParser.parse(manage.encode())) + } + + @Test + fun decodesAndRoundTripsManageWithoutPointer() { + val manage = ClinkPointerParser.parse(manageWithoutPointer) as NManage + assertNull(manage.pointer) + assertEquals(pubKey, manage.pubKey) + assertEquals(manage, ClinkPointerParser.parse(manage.encode())) + } +} From 62e522ccc433b05b875d79856ae7333abe6d0d42 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:58:58 +0000 Subject: [PATCH 06/75] feat(clink): detect noffer pointers in rich-text as ClinkOfferSegment Teaches the commons RichTextParser to recognize an inline noffer1... token and emit a ClinkOfferSegment carrying the decoded NOffer, so a GUI front end can render a 'Pay' card in the note body (the feed-offer feature). Bare tokens only for now; nostr:/lightning: prefixed forms fall through. Covered by ClinkOfferSegmentTest on JVM. --- .../commons/richtext/RichTextParser.kt | 6 ++ .../richtext/RichTextParserSegments.kt | 7 +++ .../commons/richtext/ClinkOfferSegmentTest.kt | 62 +++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 959b8f4a31..98cd7c3129 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.util.isValidUrl +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip31Alts.AltTag @@ -361,6 +363,10 @@ class RichTextParser { if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word) + if (word.startsWith("noffer1", true)) { + (ClinkPointerParser.parse(word) as? NOffer)?.let { return ClinkOfferSegment(word, it) } + } + if (word.startsWith('#')) return parseHash(word, tags) if (EmojiCoder.isCoded(word)) return SecretEmoji(word) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 25d3eae75d..0c5e7c062a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -92,6 +93,12 @@ class CashuSegment( segment: String, ) : Segment(segment) +@Immutable +class ClinkOfferSegment( + segment: String, + val offer: NOffer, +) : Segment(segment) + @Immutable class EmailSegment( segment: String, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt new file mode 100644 index 0000000000..9925906bd8 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ClinkOfferSegmentTest { + // generated by @shocknet/clink-sdk@1.5.5 (see quartz ClinkInteropTest) + private val offerFixed = + "noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" + + private fun words(text: String) = + RichTextParser() + .parseText(text, EmptyTagList, null) + .paragraphs + .flatMap { it.words } + + @Test + fun detectsNofferInlineAsClinkOfferSegment() { + val segment = + words("Pay me here $offerFixed thanks") + .filterIsInstance() + .single() + + assertEquals(offerFixed, segment.segmentText) + assertEquals("offer-id", segment.offer.pointer) + assertEquals(OfferPriceType.FIXED, segment.offer.priceType) + assertEquals(21000, segment.offer.price) + } + + @Test + fun malformedNofferIsNotDetected() { + assertTrue(words("nope noffer1notvalidbech32 end").none { it is ClinkOfferSegment }) + } + + @Test + fun plainTextHasNoOfferSegment() { + assertTrue(words("just a normal sentence").none { it is ClinkOfferSegment }) + } +} From a7066784d4debf9f007abfed5012cd5fbb922bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:19:45 +0000 Subject: [PATCH 07/75] feat(clink): render noffer offers as a payable card in notes Wires the ClinkOfferSegment into RichTextViewer with a ClinkOfferPreview card (modeled on InvoicePreview): shows the offer + price and a Pay button. Pay runs ClinkOfferPayer, which publishes the kind-21001 request to the offer's relays and awaits the encrypted reply via a one-shot subscription, then hands the returned bolt11 to the existing payViaIntent wallet flow. Consume-only; Amethyst never answers offers. Compiles (:amethyst:compilePlayDebugKotlin); visual rendering and a live offer round-trip still need on-device verification. --- .../amethyst/service/ClinkOfferPayer.kt | 90 +++++++++ .../amethyst/ui/components/RichTextViewer.kt | 7 + .../creators/invoice/ClinkOfferPreview.kt | 171 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 270 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt new file mode 100644 index 0000000000..569ec04c1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Drives the CLINK Offers payer round-trip: publishes a kind-21001 request to the + * offer's relays and waits for the service's encrypted reply, returning the decrypted + * [OfferResponse] (an invoice or an error). UI then hands a successful `bolt11` to the + * existing pay path (e.g. `payViaIntent`). + * + * Consume-only: Amethyst never answers offer requests, it only asks. + */ +object ClinkOfferPayer { + const val DEFAULT_TIMEOUT_MS = 30_000L + + /** + * @param amountSats overrides the pointer's embedded price (required for spontaneous offers). + * @return the decrypted response, or null if no reply arrived before [timeoutMs] (or the + * pointer carried no relay to reach). + */ + suspend fun requestInvoice( + account: Account, + offer: NOffer, + amountSats: Long? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): OfferResponse? { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return null + + val client = OfferClient(offer, account.signer) + val request = client.requestInvoice(amountSats) + + val reply = CompletableDeferred() + val subId = "clink-offer-${request.id}" + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } + } + } + + account.client.subscribe(subId, filters, listener) + return try { + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + client.parseResponse(response) + } finally { + account.client.unsubscribe(subId) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 37319f0b51..0e617b8750 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment +import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment @@ -109,6 +110,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortDisplay import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType @@ -506,6 +508,10 @@ private fun RenderWordWithoutPreview( // as a wall of base64. is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + // Decoding is local and the network round-trip only fires on the Pay tap, + // so the offer card is safe to render even in the no-preview path. + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) + is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> Text(word.segmentText) @@ -552,6 +558,7 @@ private fun RenderWordWithPreview( is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt new file mode 100644 index 0000000000..30ef83f6cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.invoice + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.service.ClinkOfferPayer +import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import kotlinx.coroutines.launch + +/** + * Inline card for a CLINK Offers pointer (`noffer1…`) found in a note. Tapping "Pay" + * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, + * then hands it to the existing wallet intent. + */ +@Composable +fun ClinkOfferPreview( + offer: NOffer, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var requesting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + errorMessage?.let { + ErrorMessageDialog( + title = stringRes(context, R.string.error_dialog_pay_invoice_error), + textContent = it, + onDismiss = { errorMessage = null }, + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp) + .clip(shape = QuoteBorder) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + ) { + Icon( + imageVector = CustomHashTagIcons.Lightning, + contentDescription = null, + modifier = Size20Modifier, + tint = Color.Unspecified, + ) + + Text( + text = stringRes(R.string.clink_lightning_offer), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + HorizontalDivider(thickness = DividerThickness) + + offer.price?.let { + Text( + text = "$it ${stringRes(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + + Button( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + enabled = !requesting, + onClick = { + requesting = true + scope.launch { + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer) + requesting = false + + val bolt11 = response?.bolt11 + when { + bolt11 != null -> payViaIntent(bolt11, context, { }) { errorMessage = it } + response?.error != null -> errorMessage = response.error + else -> errorMessage = stringRes(context, R.string.error_dialog_pay_invoice_error) + } + } + }, + shape = QuoteBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = stringRes(if (requesting) R.string.clink_requesting_invoice else R.string.pay), + color = Color.White, + fontSize = 20.sp, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5845409944..30da551d72 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -112,6 +112,8 @@ Logout Show More Lightning Invoice + Lightning Offer + Requesting invoice… Pay Lightning Tips Note to Receiver From f0276f1e045d5359a817888ecb38f32c1f5339e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:52:05 +0000 Subject: [PATCH 08/75] feat(clink): debit payment-source model + unified default resolver Adds the verifiable core for using a CLINK debit pointer as a spend rail alongside NWC: - ClinkDebitWalletEntry (commons): a saved ndebit pointer, the spend-only counterpart of NwcWalletEntry (no secret, no balance/history) - PaymentSource + PaymentSourceResolver (commons): unifies NWC wallets and CLINK debits into one list with a single default id spanning both types; no explicit default falls back to first (NWC before debits), preserving today's behavior. canShowBalance marks NWC vs debit honestly. - ClinkDebitPayer (amethyst): publishes the kind-21002 pay request and awaits the preimage via a one-shot subscription, mirroring ClinkOfferPayer. Resolver logic covered by PaymentSourceResolverTest on JVM (7 cases incl. cross-type default + stale-id fallback); amethyst compiles. Persisting the new fields in AccountSettings and the Wallet-screen rows/confirm dialog are the next (compile-only) step. --- .../amethyst/service/ClinkDebitPayer.kt | 93 +++++++++++++++++++ .../model/clink/ClinkDebitWalletEntry.kt | 54 +++++++++++ .../commons/model/payments/PaymentSource.kt | 55 +++++++++++ .../model/payments/PaymentSourceResolver.kt | 51 ++++++++++ .../payments/PaymentSourceResolverTest.kt | 89 ++++++++++++++++++ 5 files changed, 342 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt new file mode 100644 index 0000000000..9044420cd9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Drives the CLINK Debits payer round-trip: publishes a kind-21002 request asking the + * pointed-to wallet to pay a BOLT-11, and waits for the encrypted reply. The wallet + * authorizes against the account's own identity (no shared secret). + * + * This is the CLINK-debit spend rail that the zap button / offer card route through + * when a debit pointer is the selected default payment source. It MUST only be invoked + * after an explicit user confirmation — a debit pulls real sats. + * + * Consume-only: Amethyst sends debit requests, it never answers them. + */ +object ClinkDebitPayer { + const val DEFAULT_TIMEOUT_MS = 30_000L + + /** + * @return the decrypted response (`res:"ok"` with optional preimage, or a `GFY` + * failure), or null if no reply arrived in time or the pointer carried no relay. + */ + suspend fun payInvoice( + account: Account, + pointer: NDebit, + bolt11: String, + amountSats: Long? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): DebitResponse? { + val relays = pointer.relays.toSet() + if (relays.isEmpty()) return null + + val client = DebitClient(pointer, account.signer) + val request = client.payInvoice(bolt11, amountSats) + + val reply = CompletableDeferred() + val subId = "clink-debit-${request.id}" + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is DebitEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } + } + } + + account.client.subscribe(subId, filters, listener) + return try { + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + client.parseResponse(response) + } finally { + account.client.unsubscribe(subId) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt new file mode 100644 index 0000000000..88ae93fe20 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/clink/ClinkDebitWalletEntry.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.clink + +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * A saved CLINK Debits pointer the user can spend from — the `ndebit` counterpart of + * [com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry]. + * + * Unlike NWC, a debit carries no secret (authorization is the account's own identity, + * pre-approved on the wallet service) and exposes no balance or transaction history — + * it is a spend-only payment source. The persisted form keeps the raw `ndebit1…` + * string; [normalize] decodes it for use. + */ +@OptIn(ExperimentalUuidApi::class) +@Serializable +data class ClinkDebitWalletEntry( + val id: String = Uuid.random().toString(), + val name: String, + val ndebit: String, +) { + fun normalize(): ClinkDebitWalletEntryNorm? = (ClinkPointerParser.parse(ndebit) as? NDebit)?.let { ClinkDebitWalletEntryNorm(id, name, it) } +} + +data class ClinkDebitWalletEntryNorm( + val id: String, + val name: String, + val pointer: NDebit, +) { + fun denormalize(): ClinkDebitWalletEntry = ClinkDebitWalletEntry(id, name, pointer.encode()) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt new file mode 100644 index 0000000000..1c751d519e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSource.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm + +/** + * A user-configured way to pay a BOLT-11 — the unit the zap button (and any other + * "pay this invoice" path) selects a default from. Today either a NIP-47 NWC wallet + * or a CLINK Debits pointer; absence of any source means falling back to an external + * wallet app (intent). + * + * [canShowBalance] is the honest capability marker: NWC can report balance/history, + * a CLINK debit cannot, so the UI renders the two rows differently. + */ +sealed interface PaymentSource { + val id: String + val name: String + val canShowBalance: Boolean + + data class Nwc( + val wallet: NwcWalletEntryNorm, + ) : PaymentSource { + override val id: String get() = wallet.id + override val name: String get() = wallet.name + override val canShowBalance: Boolean get() = true + } + + data class ClinkDebit( + val wallet: ClinkDebitWalletEntryNorm, + ) : PaymentSource { + override val id: String get() = wallet.id + override val name: String get() = wallet.name + override val canShowBalance: Boolean get() = false + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt new file mode 100644 index 0000000000..90c9a79c1d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolver.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm + +/** + * Builds the unified list of configured [PaymentSource]s (NWC + CLINK debit) and + * resolves which one is the default for "pay this invoice" paths. + * + * The default is a single id spanning both lists (ids are random UUIDs, unique across + * types), so one selector picks the spend rail regardless of type. When no explicit + * default is set, the first configured source wins — NWC wallets are listed before + * debits, preserving today's "first NWC wallet" fallback. + */ +object PaymentSourceResolver { + fun all( + nwcWallets: List, + debitWallets: List, + ): List = nwcWallets.map { PaymentSource.Nwc(it) } + debitWallets.map { PaymentSource.ClinkDebit(it) } + + fun resolveDefault( + nwcWallets: List, + debitWallets: List, + defaultId: String?, + ): PaymentSource? = resolveDefault(all(nwcWallets, debitWallets), defaultId) + + fun resolveDefault( + sources: List, + defaultId: String?, + ): PaymentSource? = defaultId?.let { id -> sources.firstOrNull { it.id == id } } ?: sources.firstOrNull() +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt new file mode 100644 index 0000000000..9f4f317af5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentSourceResolverTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.payments + +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PaymentSourceResolverTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + private val pubKey = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + + private fun nwc(id: String) = NwcWalletEntryNorm(id, "nwc-$id", Nip47WalletConnect.Nip47URINorm(pubKey, relay, secret = "ab".repeat(32))) + + private fun debit(id: String) = ClinkDebitWalletEntryNorm(id, "debit-$id", NDebit(pubKey, listOf(relay), "pointer-$id", null)) + + @Test + fun allListsNwcBeforeDebits() { + val sources = PaymentSourceResolver.all(listOf(nwc("a")), listOf(debit("b"))) + assertTrue(sources[0] is PaymentSource.Nwc) + assertTrue(sources[1] is PaymentSource.ClinkDebit) + } + + @Test + fun explicitDefaultSelectsAcrossEitherType() { + val nwcWallets = listOf(nwc("a")) + val debits = listOf(debit("b")) + + // a debit can be the unified default even when an NWC wallet exists + val resolved = PaymentSourceResolver.resolveDefault(nwcWallets, debits, defaultId = "b") + assertTrue(resolved is PaymentSource.ClinkDebit) + assertEquals("b", resolved.id) + } + + @Test + fun fallsBackToFirstNwcWhenNoExplicitDefault() { + val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = null) + assertTrue(resolved is PaymentSource.Nwc) + assertEquals("a", resolved.id) + } + + @Test + fun fallsBackToFirstDebitWhenNoNwc() { + val resolved = PaymentSourceResolver.resolveDefault(emptyList(), listOf(debit("b"), debit("c")), defaultId = null) + assertTrue(resolved is PaymentSource.ClinkDebit) + assertEquals("b", resolved.id) + } + + @Test + fun staleDefaultIdFallsBackToFirst() { + val resolved = PaymentSourceResolver.resolveDefault(listOf(nwc("a")), listOf(debit("b")), defaultId = "deleted") + assertEquals("a", resolved?.id) + } + + @Test + fun noSourcesResolvesToNull() { + assertNull(PaymentSourceResolver.resolveDefault(emptyList(), emptyList(), defaultId = null)) + } + + @Test + fun debitSourceCannotShowBalance() { + assertTrue(PaymentSource.Nwc(nwc("a")).canShowBalance) + assertTrue(!PaymentSource.ClinkDebit(debit("b")).canShowBalance) + } +} From 71dc036889366088274e501cbd4711cde8eb7caf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:06:22 +0000 Subject: [PATCH 09/75] feat(clink): persist debit wallets + unify zap default payment source Wires the payment-source model into AccountSettings + LocalPreferences: - AccountSettings gains clinkDebitWallets and add/remove/rename methods mirroring the NWC ones, plus defaultPaymentSource() resolving the unified default via PaymentSourceResolver. - Renames defaultNwcWalletId -> defaultPaymentSourceId: one default id spans both NWC wallets and CLINK debits. First configured source of any kind auto-defaults; adding more never silently changes it; removing the default falls back to the first remaining source. - LocalPreferences persists clinkDebitWallets and defaultPaymentSourceId, migrating the legacy defaultNwcWalletId key on load. - NwcSignerState/WalletViewModel read the unified default; NWC zap routing is unchanged for NWC-only users (falls back to first NWC wallet). :amethyst compiles. The Wallet-screen rows/confirm dialog and routing the zap button through ClinkDebitPayer are the next (compile-only) step. --- .../amethyst/LocalPreferences.kt | 37 +++++++- .../amethyst/model/AccountSettings.kt | 93 ++++++++++++++++--- .../nip47WalletConnect/NwcSignerState.kt | 10 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 4 +- 4 files changed, 116 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 3bf5f7eeca..720d8d6375 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,6 +25,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.AccountSettings @@ -123,7 +124,9 @@ private object PrefKeys { const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration const val NWC_WALLETS = "nwcWallets" - const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" + const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID + const val CLINK_DEBIT_WALLETS = "clinkDebitWallets" + const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId" const val LATEST_USER_METADATA = "latestUserMetadata" const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_DM_RELAY_LIST = "latestDMRelayList" @@ -401,9 +404,19 @@ object LocalPreferences { } else { remove(PrefKeys.NWC_WALLETS) } - settings.defaultNwcWalletId.value?.let { - putString(PrefKeys.DEFAULT_NWC_WALLET_ID, it) - } ?: remove(PrefKeys.DEFAULT_NWC_WALLET_ID) + + val debitEntries = settings.clinkDebitWallets.value.map { it.denormalize() } + if (debitEntries.isNotEmpty()) { + putString(PrefKeys.CLINK_DEBIT_WALLETS, JsonMapper.toJson(debitEntries)) + } else { + remove(PrefKeys.CLINK_DEBIT_WALLETS) + } + + settings.defaultPaymentSourceId.value?.let { + putString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, it) + } ?: remove(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID) + // Legacy NWC-only default key is superseded by DEFAULT_PAYMENT_SOURCE_ID. + remove(PrefKeys.DEFAULT_NWC_WALLET_ID) // Remove legacy key after migration remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) @@ -565,6 +578,8 @@ object LocalPreferences { val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null) + val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null) + val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null) val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) @@ -619,6 +634,10 @@ object LocalPreferences { } } } + val clinkDebitsLoaded = + async { + parseOrNull>(clinkDebitWalletsStr)?.mapNotNull { it.normalize() } ?: emptyList() + } val defaultFileServer = async { parseOrNull(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] } val viewedPollResultNoteIds = async { parseOrNull>(viewedPollResultNoteIdsStr) ?: mapOf() } @@ -694,7 +713,15 @@ object LocalPreferences { defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities), defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks), nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first), - defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second), + clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()), + // Prefer the new unified default; migrate from the legacy NWC default; + // else fall back to the first configured source (NWC before debits). + defaultPaymentSourceId = + MutableStateFlow( + defaultPaymentSourceIdStr + ?: nwcWalletsLoaded.await().second + ?: clinkDebitsLoaded.await().firstOrNull()?.id, + ), hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a899b593e8..89e3159f35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -193,7 +196,10 @@ class AccountSettings( val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), - val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), + val clinkDebitWallets: MutableStateFlow> = MutableStateFlow(emptyList()), + // The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a + // new key, migrated from the legacy NWC-only `defaultNwcWalletId`. + val defaultPaymentSourceId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, @@ -335,14 +341,17 @@ class AccountSettings( return false } + /** The selected default spend rail across both NWC wallets and CLINK debits. */ + fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value) + + /** + * The NWC wallet to use for NWC-only flows (balance display, mint top-up). Resolves + * the unified default when it points at an NWC wallet, otherwise falls back to the + * first NWC wallet so those flows keep working even when a debit is the zap default. + */ fun defaultNwcWallet(): NwcWalletEntryNorm? { - val id = defaultNwcWalletId.value val wallets = nwcWallets.value - return if (id != null) { - wallets.firstOrNull { it.id == id } - } else { - wallets.firstOrNull() - } + return wallets.firstOrNull { it.id == defaultPaymentSourceId.value } ?: wallets.firstOrNull() } fun defaultZapPaymentRequest(): Nip47WalletConnect.Nip47URINorm? = defaultNwcWallet()?.uri @@ -353,8 +362,10 @@ class AccountSettings( nwcWallets.tryEmit(nwcWallets.value.toMutableList().apply { set(existing, wallet) }) } else { nwcWallets.tryEmit(nwcWallets.value + wallet) - if (nwcWallets.value.size == 1) { - defaultNwcWalletId.tryEmit(wallet.id) + // First configured source of any kind becomes the default; adding more never + // silently changes an existing default. + if (defaultPaymentSourceId.value == null) { + defaultPaymentSourceId.tryEmit(wallet.id) } } saveAccountSettings() @@ -364,16 +375,68 @@ class AccountSettings( fun removeNwcWallet(walletId: String): Boolean { val wallets = nwcWallets.value.filter { it.id != walletId } nwcWallets.tryEmit(wallets) - if (defaultNwcWalletId.value == walletId) { - defaultNwcWalletId.tryEmit(wallets.firstOrNull()?.id) + reassignDefaultIfRemoved(walletId) + saveAccountSettings() + return true + } + + fun addClinkDebitWallet(wallet: ClinkDebitWalletEntryNorm): Boolean { + val existing = clinkDebitWallets.value.indexOfFirst { it.id == wallet.id } + if (existing >= 0) { + clinkDebitWallets.tryEmit(clinkDebitWallets.value.toMutableList().apply { set(existing, wallet) }) + } else { + clinkDebitWallets.tryEmit(clinkDebitWallets.value + wallet) + if (defaultPaymentSourceId.value == null) { + defaultPaymentSourceId.tryEmit(wallet.id) + } } saveAccountSettings() return true } - fun setDefaultNwcWallet(walletId: String): Boolean { - if (defaultNwcWalletId.value != walletId && nwcWallets.value.any { it.id == walletId }) { - defaultNwcWalletId.tryEmit(walletId) + fun removeClinkDebitWallet(walletId: String): Boolean { + clinkDebitWallets.tryEmit(clinkDebitWallets.value.filter { it.id != walletId }) + reassignDefaultIfRemoved(walletId) + saveAccountSettings() + return true + } + + fun renameClinkDebitWallet( + walletId: String, + newName: String, + ): Boolean { + val wallets = clinkDebitWallets.value.toMutableList() + val index = wallets.indexOfFirst { it.id == walletId } + if (index >= 0) { + wallets[index] = wallets[index].copy(name = newName) + clinkDebitWallets.tryEmit(wallets) + saveAccountSettings() + return true + } + return false + } + + /** When the removed source was the default, fall back to the first remaining source. */ + private fun reassignDefaultIfRemoved(walletId: String) { + if (defaultPaymentSourceId.value == walletId) { + defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id) + } + } + + /** Resets the default to the first remaining source if it no longer points at anything. */ + private fun reassignDefaultIfMissing() { + val id = defaultPaymentSourceId.value ?: return + val exists = nwcWallets.value.any { it.id == id } || clinkDebitWallets.value.any { it.id == id } + if (!exists) { + defaultPaymentSourceId.tryEmit(PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, null)?.id) + } + } + + /** Selects the unified default across both NWC wallets and CLINK debits. */ + fun setDefaultPaymentSource(sourceId: String): Boolean { + val exists = nwcWallets.value.any { it.id == sourceId } || clinkDebitWallets.value.any { it.id == sourceId } + if (defaultPaymentSourceId.value != sourceId && exists) { + defaultPaymentSourceId.tryEmit(sourceId) saveAccountSettings() return true } @@ -399,7 +462,7 @@ class AccountSettings( if (newServer == null) { if (nwcWallets.value.isNotEmpty()) { nwcWallets.tryEmit(emptyList()) - defaultNwcWalletId.tryEmit(null) + reassignDefaultIfMissing() saveAccountSettings() return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d7abdecf83..8b06eee55b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -65,12 +65,10 @@ class NwcSignerState( * Flow of the default wallet's NWC URI, derived from multi-wallet settings. */ val defaultWalletUri: StateFlow = - combine(settings.nwcWallets, settings.defaultNwcWalletId) { wallets, defaultId -> - if (defaultId != null) { - wallets.firstOrNull { it.id == defaultId }?.uri - } else { - wallets.firstOrNull()?.uri - } + combine(settings.nwcWallets, settings.defaultPaymentSourceId) { wallets, defaultId -> + // Use the NWC wallet the unified default points at; otherwise fall back to the + // first NWC wallet so NWC zap routing is unchanged for NWC-only users. + (wallets.firstOrNull { it.id == defaultId } ?: wallets.firstOrNull())?.uri }.flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, settings.defaultZapPaymentRequest()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index e335b033a6..4b6bfca561 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -218,7 +218,7 @@ class WalletViewModel : ViewModel() { fun refreshWalletList() { val acc = account ?: return _wallets.value = acc.settings.nwcWallets.value - _defaultWalletId.value = acc.settings.defaultNwcWalletId.value + _defaultWalletId.value = acc.settings.defaultPaymentSourceId.value _hasWalletSetup.value = _wallets.value.isNotEmpty() } @@ -258,7 +258,7 @@ class WalletViewModel : ViewModel() { fun setDefaultWallet(walletId: String) { val acc = account ?: return - acc.settings.setDefaultNwcWallet(walletId) + acc.settings.setDefaultPaymentSource(walletId) _defaultWalletId.value = walletId } From 7533c9f34f177c05f3bcbb3328bf7827ad1c3479 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:16:52 +0000 Subject: [PATCH 10/75] feat(clink): route zap payments through the selected default source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZapPaymentHandler now dispatches on account.settings.defaultPaymentSource(): a CLINK debit default pays each zap invoice via the new payViaClinkDebit (kind-21002 round-trip through ClinkDebitPayer, surfacing the service's GFY error text); an NWC default keeps the existing payViaNWC path; no configured source falls back to the external wallet intent. NWC-only users are unaffected. The two secondary single-invoice sites (profile LN-address pay, DVM pay) still use NWC/intent and are left as follow-ups. :amethyst compiles. The debit payout path is untested end-to-end — it needs a live debit service to verify a real payment. --- .../amethyst/service/ZapPaymentHandler.kt | 68 ++++++++++++++++--- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 9eb62ea3b7..e25e8946a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -23,12 +23,14 @@ package com.vitorpamplona.amethyst.service import android.content.Context import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -206,14 +208,27 @@ class ZapPaymentHandler( onProgress(0.75f) } - if (account.nip47SignerState.hasWalletConnectSetup()) { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) - // onProgress(1f) - } else { - onPayViaIntent(payables.toImmutableList()) - onProgress(0f) + // Route through the user's selected default payment source. A CLINK debit takes + // precedence over NWC when it is the chosen default; NWC-only users are unaffected + // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) + }, context) + } + + is PaymentSource.Nwc -> { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + // onProgress(1f) + } + + null -> { + onPayViaIntent(payables.toImmutableList()) + onProgress(0f) + } } } @@ -374,6 +389,43 @@ class ZapPaymentHandler( ) } + /** + * Pays each zap invoice by asking the user's CLINK debit service (kind 21002) to + * settle the BOLT-11. The service authorizes against the account identity; a failure + * surfaces the service's `GFY` error text. Untested end-to-end — needs a live debit + * service to verify a real payout. + */ + suspend fun payViaClinkDebit( + payables: List, + pointer: NDebit, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + context: Context, + ): List { + var progressAllPayments = 0.00f + + return mapNotNullAsync( + items = payables, + runRequestFor = { payable: Payable -> + val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) + + progressAllPayments += 1f / payables.size + onProgress(progressAllPayments) + + val paid = response?.isOk() == true + if (!paid) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response), + payable.info.user, + ) + } + Paid(payable, paid) + }, + ) + } + private suspend fun assembleInvoice( lud16: String, splitSetup: MyZapSplitSetup, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 30da551d72..d9e9596484 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -114,6 +114,7 @@ Lightning Invoice Lightning Offer Requesting invoice… + The debit service did not complete the payment. Pay Lightning Tips Note to Receiver From 002cd3cceb07d60ceb337adcda6febd997b4a32d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:24:11 +0000 Subject: [PATCH 11/75] feat(clink): add CLINK debit as a payment source in the Wallet screen Surfaces debits in the existing wallet list and add flow: - WalletViewModel reads clinkDebitWallets and emits them as spend-only rows (canShowBalance=false) in the unified walletInfoList; the default radio spans both types via setDefaultPaymentSource; remove/rename route by type. - WalletScreen renders debit rows with a Pay only badge instead of a balance and disables the NWC-only detail navigation for them. - AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes or scans an ndebit1 pointer (no secret) and saves it as a payment source. - New Route.WalletAddClinkDebit + AppNavigation registration. :amethyst compiles. UI rendering and the end-to-end debit payout remain untested on device. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../wallet/AddClinkDebitWalletScreen.kt | 214 ++++++++++++++++++ .../screen/loggedIn/wallet/AddWalletScreen.kt | 6 + .../ui/screen/loggedIn/wallet/WalletScreen.kt | 19 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 87 +++++-- amethyst/src/main/res/values/strings.xml | 5 + 7 files changed, 316 insertions(+), 21 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 95264c003d..b6b99019e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -197,6 +197,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen @@ -317,6 +318,7 @@ fun BuildNavigation( composableFromEnd { AddWalletScreen(accountViewModel, nav) } composableFromEndArgs { AddNwcWalletScreen(accountViewModel, nav, it.nip47) } composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } + composableFromEndArgs { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) } composableFromEnd { CashuWalletScreen(accountViewModel, nav) } composableFromEnd { CashuWalletSettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9c592b4e8a..748a84b465 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -210,6 +210,10 @@ sealed class Route { @Serializable object WalletAddCashu : Route() + @Serializable data class WalletAddClinkDebit( + val ndebit: String? = null, + ) : Route() + @Serializable object CashuWallet : Route() @Serializable object CashuWalletSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt new file mode 100644 index 0000000000..9de69a8906 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.getText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import kotlinx.coroutines.launch + +/** + * Adds a CLINK Debits pointer (`ndebit1…`) as a spend-only payment source. Unlike NWC + * there is no secret to paste — authorization is the account's own identity, pre-approved + * on the wallet service — so this screen only collects a name and the pointer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddClinkDebitWalletScreen( + accountViewModel: AccountViewModel, + nav: INav, + ndebit: String? = null, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel) + + var walletName by remember { mutableStateOf("") } + var ndebitUri by remember { mutableStateOf(ndebit.orEmpty()) } + var error by remember { mutableStateOf(null) } + var qrScanning by remember { mutableStateOf(false) } + + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_add_clink_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = walletName, + onValueChange = { walletName = it }, + label = { Text(stringRes(R.string.wallet_name)) }, + placeholder = { Text(stringRes(R.string.wallet_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Paste from clipboard + IconButton( + onClick = { + scope.launch { + val clipText = clipboardManager.getText() + if (clipText != null) { + ndebitUri = clipText + error = null + } + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentPaste, + contentDescription = stringRes(id = R.string.paste_from_clipboard), + modifier = Size24Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } + + // QR code scanner + IconButton(onClick = { qrScanning = true }) { + Icon( + painter = painterRes(R.drawable.ic_qrcode, 3), + contentDescription = stringRes(id = R.string.accessibility_scan_qr_code), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + ndebitUri = it + error = null + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = ndebitUri, + onValueChange = { + ndebitUri = it + error = null + }, + label = { Text(stringRes(R.string.wallet_paste_ndebit)) }, + placeholder = { Text("ndebit1...") }, + minLines = 3, + maxLines = 5, + modifier = Modifier.fillMaxWidth(), + ) + + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + val invalidMessage = stringRes(R.string.wallet_add_clink_invalid) + Button( + onClick = { + if (walletViewModel.addClinkDebitWallet(walletName.trim(), ndebitUri.trim())) { + nav.popBack() + } else { + error = invalidMessage + } + }, + enabled = ndebitUri.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.wallet_save)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt index 0c4d4e90de..2c33e6523f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt @@ -105,6 +105,12 @@ fun AddWalletScreen( description = stringRes(R.string.wallet_add_cashu_description), onClick = { nav.popUpTo(Route.WalletAddCashu, Route.WalletAdd::class) }, ) + WalletTypeCard( + icon = MaterialSymbols.Bolt, + title = stringRes(R.string.wallet_add_clink_title), + description = stringRes(R.string.wallet_add_clink_description), + onClick = { nav.popUpTo(Route.WalletAddClinkDebit(), Route.WalletAdd::class) }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index cf096aa501..16a91b6d84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -234,8 +234,11 @@ private fun MultiWalletHomeContent( WalletCard( walletInfo = walletInfo, onSelect = { - walletViewModel.selectWallet(walletInfo.walletId) - nav.nav(Route.WalletDetail(walletInfo.walletId)) + // The detail screen is NWC-only (balance/transactions); debits have neither. + if (walletInfo.canShowBalance) { + walletViewModel.selectWallet(walletInfo.walletId) + nav.nav(Route.WalletDetail(walletInfo.walletId)) + } }, onSetDefault = { walletViewModel.setDefaultWallet(walletInfo.walletId) @@ -325,7 +328,7 @@ private fun WalletCard( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onSelect), + .clickable(enabled = walletInfo.canShowBalance, onClick = onSelect), shape = RoundedCornerShape(16.dp), border = if (walletInfo.isDefault) { @@ -377,8 +380,14 @@ private fun WalletCard( } } - // Balance - if (walletInfo.isLoading && walletInfo.balanceSats == null) { + // Balance — debits are spend-only, so show a capability badge instead. + if (!walletInfo.canShowBalance) { + Text( + text = stringRes(R.string.clink_debit_pay_only), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (walletInfo.isLoading && walletInfo.balanceSats == null) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else { Column(horizontalAlignment = Alignment.End) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 4b6bfca561..3b30e3d020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod @@ -94,6 +97,8 @@ data class WalletInfo( val isDefault: Boolean = false, val isLoading: Boolean = false, val error: String? = null, + // CLINK debits are spend-only: no balance/transactions to fetch or show. + val canShowBalance: Boolean = true, ) private const val NWC_TIMEOUT_MS = 30_000L @@ -111,23 +116,43 @@ class WalletViewModel : ViewModel() { private val _wallets = MutableStateFlow>(emptyList()) val wallets = _wallets.asStateFlow() + private val _debitWallets = MutableStateFlow>(emptyList()) + val debitWallets = _debitWallets.asStateFlow() + private val _defaultWalletId = MutableStateFlow(null) val defaultWalletId = _defaultWalletId.asStateFlow() val walletInfoList = - combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap -> - wallets.map { wallet -> - val info = infoMap[wallet.id] - WalletInfo( - walletId = wallet.id, - name = wallet.name, - alias = info?.alias, - balanceSats = info?.balanceSats, - isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()), - isLoading = info?.isLoading == true, - error = info?.error, - ) - } + combine(_wallets, _debitWallets, _defaultWalletId, walletInfoMap) { wallets, debits, defaultId, infoMap -> + // The unified default falls back to the first source overall (NWC before debits). + val effectiveDefault = defaultId ?: wallets.firstOrNull()?.id ?: debits.firstOrNull()?.id + + val nwcRows = + wallets.map { wallet -> + val info = infoMap[wallet.id] + WalletInfo( + walletId = wallet.id, + name = wallet.name, + alias = info?.alias, + balanceSats = info?.balanceSats, + isDefault = wallet.id == effectiveDefault, + isLoading = info?.isLoading == true, + error = info?.error, + canShowBalance = true, + ) + } + + val debitRows = + debits.map { debit -> + WalletInfo( + walletId = debit.id, + name = debit.name, + isDefault = debit.id == effectiveDefault, + canShowBalance = false, + ) + } + + nwcRows + debitRows }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // Selected wallet for detail view @@ -218,8 +243,9 @@ class WalletViewModel : ViewModel() { fun refreshWalletList() { val acc = account ?: return _wallets.value = acc.settings.nwcWallets.value + _debitWallets.value = acc.settings.clinkDebitWallets.value _defaultWalletId.value = acc.settings.defaultPaymentSourceId.value - _hasWalletSetup.value = _wallets.value.isNotEmpty() + _hasWalletSetup.value = _wallets.value.isNotEmpty() || _debitWallets.value.isNotEmpty() } fun refreshWalletSetup() { @@ -264,10 +290,35 @@ class WalletViewModel : ViewModel() { fun removeWallet(walletId: String) { val acc = account ?: return - acc.settings.removeNwcWallet(walletId) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.removeClinkDebitWallet(walletId) + } else { + acc.settings.removeNwcWallet(walletId) + } refreshWalletList() } + /** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */ + fun addClinkDebitWallet( + name: String, + ndebit: String, + ): Boolean { + val acc = account ?: return false + val pointer = ClinkPointerParser.parse(ndebit.trim()) as? NDebit ?: return false + val entry = + ClinkDebitWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Debit" }, + pointer = pointer, + ) + acc.settings.addClinkDebitWallet(entry) + refreshWalletList() + return true + } + fun addWallet( name: String, uri: Nip47WalletConnect.Nip47URINorm, @@ -292,7 +343,11 @@ class WalletViewModel : ViewModel() { newName: String, ) { val acc = account ?: return - acc.settings.renameNwcWallet(walletId, newName) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.renameClinkDebitWallet(walletId, newName) + } else { + acc.settings.renameNwcWallet(walletId, newName) + } refreshWalletList() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d9e9596484..7b05d87c4c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -115,6 +115,11 @@ Lightning Offer Requesting invoice… The debit service did not complete the payment. + Pay only + CLINK Debit + Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. + Invalid CLINK debit pointer. Expected an ndebit1… string. + Paste ndebit pointer Pay Lightning Tips Note to Receiver From 11891ace8ce8024bca11131a3470802a02679239 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:31:26 +0000 Subject: [PATCH 12/75] feat(clink): route profile + DVM single-invoice pays through the default source The two remaining 'pay this bolt11' sites now honor the selected default payment source instead of the binary NWC-or-intent check: - AccountViewModel gains payInvoiceViaClinkDebit, the single-invoice debit-rail counterpart of sendZapPaymentRequestFor. - DisplayLNAddress (profile LN-address pay) and DvmContentDiscoveryScreen (DVM invoice pay) dispatch on defaultPaymentSource(): CLINK debit -> 21002 round trip, NWC -> existing pay_invoice, none -> external wallet intent. NWC-only users are unaffected. :amethyst compiles; the debit payout path stays untested end-to-end. --- .../ui/screen/loggedIn/AccountViewModel.kt | 17 ++++ .../dvms/DvmContentDiscoveryScreen.kt | 77 ++++++++++++------- .../profile/header/DisplayLNAddress.kt | 43 +++++++---- 3 files changed, 95 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index dcfca17eca..48c8e94492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor @@ -87,6 +88,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -2027,6 +2030,20 @@ class AccountViewModel( onSent() } + /** + * Pays a single BOLT-11 through a CLINK debit pointer (kind 21002) — the debit-rail + * counterpart of [sendZapPaymentRequestFor]. [onResult] receives the decrypted + * response (`isOk()` with optional preimage, or a GFY failure), or null on timeout. + * Untested end-to-end. + */ + fun payInvoiceViaClinkDebit( + pointer: NDebit, + bolt11: String, + onResult: (DebitResponse?) -> Unit, + ) = launchSigner { + onResult(ClinkDebitPayer.payInvoice(account, pointer, bolt11)) + } + fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) fun updateInteractiveStoryReadingState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index b112e5264a..02b4cdc9ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription @@ -379,39 +380,57 @@ fun DvmPaymentActions( if (invoice != null) { val context = LocalContext.current Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - onStatusUpdate(nwcPaymentRequest) - }, - onResponse = { response -> + when (val source = accountViewModel.account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + onStatusUpdate(nwcPaymentRequest) + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response -> onStatusUpdate( - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { + if (response?.isOk() == true) { thankYou + } else { + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response) }, ) - }, - ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - onStatusUpdate(thankYou) - }, - onError = { - onStatusUpdate(it) - }, - ) + } + } + + is PaymentSource.Nwc -> { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou + }, + ) + }, + ) + } + + null -> { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } } }) { val amountInInvoice = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index 2b2ac59469..38337579a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText @@ -109,22 +110,38 @@ fun DisplayLNAddress( lud16, user, accountViewModel, - onSuccess = { + onSuccess = { invoice -> zapExpanded = false - // pay directly - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor(it, null) { response -> - if (response is PayInvoiceSuccessResponse) { - showInfoMessageDialog = stringRes(context, R.string.payment_successful) - } else if (response is PayInvoiceErrorResponse) { - showErrorMessageDialog = - response.error?.message - ?: response.error?.code?.toString() - ?: stringRes(context, R.string.error_parsing_error_message) + // pay directly through the selected default payment source + when (val source = accountViewModel.account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response -> + if (response?.isOk() == true) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else { + showErrorMessageDialog = + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response) + } } } - } else { - payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + + is PaymentSource.Nwc -> { + accountViewModel.sendZapPaymentRequestFor(invoice, null) { response -> + if (response is PayInvoiceSuccessResponse) { + showInfoMessageDialog = stringRes(context, R.string.payment_successful) + } else if (response is PayInvoiceErrorResponse) { + showErrorMessageDialog = + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message) + } + } + } + + null -> { + payViaIntent(invoice, context, { zapExpanded = false }, { showErrorMessageDialog = it }) + } } }, onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, From 503f33eb2ee4ec4632314e53e2d2073c2123c0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:49:32 +0000 Subject: [PATCH 13/75] feat(clink): pay offer/invoice cards via default source, confirmed in-app In-post payment cards now route their 'Pay' button through the selected default payment source instead of always opening an external wallet: - New shared InvoicePaymentDispatcher: resolves defaultPaymentSource() for a bolt11. External-wallet path fires the intent (the wallet app confirms); NWC and CLINK-debit paths show a ConfirmPaymentDialog first, because a card pay (unlike a deliberate small zap tap) can be a larger/variable amount. - ClinkOfferPreview and InvoicePreview both drive it via a pending-invoice state; InvoicePreview now takes accountViewModel. NWC-only and intent-only users are unaffected. :amethyst compiles; the in-app pay paths remain untested end-to-end. --- .../creators/invoice/ClinkOfferPreview.kt | 14 +- .../invoice/InvoicePaymentDispatcher.kt | 146 ++++++++++++++++++ .../note/creators/invoice/InvoicePreview.kt | 14 +- amethyst/src/main/res/values/strings.xml | 3 + 4 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 30ef83f6cc..fbd5a617b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Lightning import com.vitorpamplona.amethyst.service.ClinkOfferPayer import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -63,7 +62,8 @@ import kotlinx.coroutines.launch /** * Inline card for a CLINK Offers pointer (`noffer1…`) found in a note. Tapping "Pay" * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, - * then hands it to the existing wallet intent. + * then pays it through the user's default payment source (confirmed for in-app wallets, + * see [InvoicePaymentDispatcher]). */ @Composable fun ClinkOfferPreview( @@ -75,6 +75,7 @@ fun ClinkOfferPreview( var requesting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } errorMessage?.let { ErrorMessageDialog( @@ -84,6 +85,13 @@ fun ClinkOfferPreview( ) } + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { errorMessage = it }, + ) + Column( modifier = Modifier @@ -148,7 +156,7 @@ fun ClinkOfferPreview( val bolt11 = response?.bolt11 when { - bolt11 != null -> payViaIntent(bolt11, context, { }) { errorMessage = it } + bolt11 != null -> payingInvoice = bolt11 response?.error != null -> errorMessage = response.error else -> errorMessage = stringRes(context, R.string.error_dialog_pay_invoice_error) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt new file mode 100644 index 0000000000..9115b3fbb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.invoice + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.ui.note.payViaIntent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse + +/** + * Pays a single BOLT-11 from an in-post card (offer card, invoice card) through the + * user's selected default payment source. + * + * Unlike the zap button — a deliberate small-amount tap that fires immediately — a + * card "Pay" can be a larger or variable amount, so an **in-app** payment (NWC or CLINK + * debit) is gated behind a confirmation dialog. The external-wallet path needs no extra + * confirmation: the wallet app presents its own. + * + * Drive it from a nullable `bolt11` state: set it to trigger, [onClear] resets it. + */ +@Composable +fun InvoicePaymentDispatcher( + bolt11: String?, + accountViewModel: AccountViewModel, + onClear: () -> Unit, + onError: (String) -> Unit, + onSuccess: () -> Unit = {}, +) { + if (bolt11 == null) return + val context = LocalContext.current + + val source = remember(bolt11) { accountViewModel.account.settings.defaultPaymentSource() } + + if (source == null) { + // No in-app wallet configured -> hand off to an external wallet app (it confirms). + LaunchedEffect(bolt11) { + payViaIntent(bolt11, context, onSuccess, onError) + onClear() + } + return + } + + val amountSats = + remember(bolt11) { + try { + LnInvoiceUtil.getAmountInSats(bolt11).toLong().takeIf { it > 0 } + } catch (_: Exception) { + null + } + } + + ConfirmPaymentDialog( + amountSats = amountSats, + sourceName = source.name, + onConfirm = { + when (source) { + is PaymentSource.Nwc -> + accountViewModel.sendZapPaymentRequestFor(bolt11, null) { response -> + when (response) { + is PayInvoiceSuccessResponse -> onSuccess() + is PayInvoiceErrorResponse -> + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + else -> {} + } + } + + is PaymentSource.ClinkDebit -> + accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, bolt11) { response -> + if (response?.isOk() == true) { + onSuccess() + } else { + onError( + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + } + } + onClear() + }, + onDismiss = onClear, + ) +} + +@Composable +private fun ConfirmPaymentDialog( + amountSats: Long?, + sourceName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val message = + if (amountSats != null) { + val amountText = "$amountSats ${stringRes(context, R.string.sats)}" + stringRes(context, R.string.clink_confirm_pay_amount_via_source, amountText, sourceName) + } else { + stringRes(context, R.string.clink_confirm_pay_via_source, sourceName) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_confirm_payment_title)) }, + text = { Text(message) }, + confirmButton = { + Button(onClick = onConfirm) { Text(stringRes(R.string.pay)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt index ae6c23419f..23b5be1417 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePreview.kt @@ -54,7 +54,6 @@ import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -89,7 +88,7 @@ fun MayBeInvoicePreview( LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> CrossfadeIfEnabled(targetState = invoiceAmount, label = "MayBeInvoicePreview", accountViewModel = accountViewModel) { if (it != null) { - InvoicePreview(it.invoice, it.amount) + InvoicePreview(it.invoice, it.amount, accountViewModel) } else { Text( text = lnbcWord, @@ -104,10 +103,12 @@ fun MayBeInvoicePreview( fun InvoicePreview( lnInvoice: String, amount: String?, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current var showErrorMessageDialog by remember { mutableStateOf(null) } + var payingInvoice by remember { mutableStateOf(null) } if (showErrorMessageDialog != null) { ErrorMessageDialog( @@ -117,6 +118,13 @@ fun InvoicePreview( ) } + InvoicePaymentDispatcher( + bolt11 = payingInvoice, + accountViewModel = accountViewModel, + onClear = { payingInvoice = null }, + onError = { showErrorMessageDialog = it }, + ) + Column( modifier = Modifier @@ -172,7 +180,7 @@ fun InvoicePreview( Modifier .fillMaxWidth() .padding(vertical = 10.dp), - onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } }, + onClick = { payingInvoice = lnInvoice }, shape = QuoteBorder, colors = ButtonDefaults.buttonColors( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7b05d87c4c..14507ecc85 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -115,6 +115,9 @@ Lightning Offer Requesting invoice… The debit service did not complete the payment. + Confirm payment + Pay %1$s via %2$s? + Pay this invoice via %1$s? Pay only CLINK Debit Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. From 94ee192641d4336b8e76c16611f5bc5344e22edd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:00:12 +0000 Subject: [PATCH 14/75] feat(clink): support variable-amount offers in the offer card The offer card no longer assumes a fixed price: - FIXED offers show their preset price (unchanged). - SPONTANEOUS offers (and the spec default when the pointer omits a price type) now render an amount field; Pay is disabled until a positive amount is entered, and that amount is sent as amount_sats. - An INVALID_AMOUNT (code 5) response reveals/refines the amount field and shows the service's allowed range, so variable offers recover gracefully even when the price type was ambiguous. :amethyst compiles; the offer round-trip remains untested end-to-end. --- .../creators/invoice/ClinkOfferPreview.kt | 63 ++++++++++++++++--- amethyst/src/main/res/values/strings.xml | 3 + 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index fbd5a617b7..43c7584237 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -25,11 +25,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -43,6 +45,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R @@ -56,7 +59,10 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType import kotlinx.coroutines.launch /** @@ -76,6 +82,9 @@ fun ClinkOfferPreview( var requesting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } var payingInvoice by remember { mutableStateOf(null) } + var amountInput by remember { mutableStateOf("") } + var needsAmount by remember { mutableStateOf((offer.priceType ?: OfferPriceType.SPONTANEOUS) == OfferPriceType.SPONTANEOUS) } + var amountRange by remember { mutableStateOf(null) } errorMessage?.let { ErrorMessageDialog( @@ -130,11 +139,41 @@ fun ClinkOfferPreview( HorizontalDivider(thickness = DividerThickness) - offer.price?.let { - Text( - text = "$it ${stringRes(id = R.string.sats)}", - fontSize = 25.sp, - fontWeight = FontWeight.W500, + // FIXED offers display their preset price; SPONTANEOUS offers (and the default + // when the pointer omits a price type) require the payer to enter an amount. + val effectiveType = offer.priceType ?: OfferPriceType.SPONTANEOUS + + if (effectiveType == OfferPriceType.FIXED) { + offer.price?.let { + Text( + text = "$it ${stringRes(id = R.string.sats)}", + fontSize = 25.sp, + fontWeight = FontWeight.W500, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + } + + if (needsAmount) { + OutlinedTextField( + value = amountInput, + onValueChange = { new -> amountInput = new.filter(Char::isDigit) }, + label = { Text(stringRes(R.string.clink_offer_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = + amountRange?.let { range -> + val min = range.min + val max = range.max + if (min != null && max != null) { + { Text(stringRes(R.string.clink_offer_amount_range, min.toString(), max.toString())) } + } else { + null + } + }, modifier = Modifier .fillMaxWidth() @@ -142,21 +181,31 @@ fun ClinkOfferPreview( ) } + val amountRequired = needsAmount Button( modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp), - enabled = !requesting, + enabled = !requesting && (!amountRequired || (amountInput.toLongOrNull() ?: 0L) > 0L), onClick = { requesting = true scope.launch { - val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer) + val amount = if (amountRequired) amountInput.toLongOrNull() else null + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer, amountSats = amount) requesting = false val bolt11 = response?.bolt11 when { bolt11 != null -> payingInvoice = bolt11 + response?.code == OfferErrorCode.INVALID_AMOUNT -> { + // Reveal the amount field (or refine it) with the service's range. + needsAmount = true + amountRange = response.range + errorMessage = + response.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_offer_invalid_amount) + } response?.error != null -> errorMessage = response.error else -> errorMessage = stringRes(context, R.string.error_dialog_pay_invoice_error) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 14507ecc85..d08a064603 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -118,6 +118,9 @@ Confirm payment Pay %1$s via %2$s? Pay this invoice via %1$s? + Amount (sats) + Enter a valid amount for this offer. + Allowed range: %1$s–%2$s sats Pay only CLINK Debit Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. From db2f65d3000df3aeece91ce5e1bcd94ace3a4998 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:13:26 +0000 Subject: [PATCH 15/75] feat(clink): make offer payments zappable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a noffer is rendered in someone's note, paying it now attaches a NIP-57 zap request so the offer service issues a zappable invoice and publishes a zap receipt — turning the payment into a real zap on the author instead of a silent invoice: - ClinkOfferPayer.requestInvoice forwards a serialized zap request via OfferClient's existing zap field. - ClinkOfferPreview builds the 9734 from the threaded authorPubKey using the account's default zap type (skipped for NONZAP), at the resolved amount. - RichTextViewer threads authorPubKey into the offer-segment renderers; the markdown/secret paths default to null (plain invoice, no target). Falls back to a plain invoice when there's no author or the user opted out of zaps. :amethyst compiles; the round-trip and receipt remain untested end-to-end. --- .../amethyst/service/ClinkOfferPayer.kt | 3 +- .../amethyst/ui/components/RichTextViewer.kt | 8 +++-- .../creators/invoice/ClinkOfferPreview.kt | 30 +++++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 569ec04c1d..d511ee9d29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -52,13 +52,14 @@ object ClinkOfferPayer { account: Account, offer: NOffer, amountSats: Long? = null, + zap: String? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, ): OfferResponse? { val relays = offer.relays.toSet() if (relays.isEmpty()) return null val client = OfferClient(offer, account.signer) - val request = client.requestInvoice(amountSats) + val request = client.requestInvoice(amountSats = amountSats, zap = zap) val reply = CompletableDeferred() val subId = "clink-offer-${request.id}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 0e617b8750..9fd0a2aa74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -368,6 +368,7 @@ private fun RenderRegular( callbackUri, accountViewModel, nav, + authorPubKey, ) } } @@ -381,6 +382,7 @@ private fun RenderRegular( backgroundColor, accountViewModel, nav, + authorPubKey, ) } } @@ -476,6 +478,7 @@ private fun RenderWordWithoutPreview( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + authorPubKey: String? = null, ) { when (word) { // Don't preview Images @@ -510,7 +513,7 @@ private fun RenderWordWithoutPreview( // Decoding is local and the network round-trip only fires on the Pay tap, // so the offer card is safe to render even in the no-preview path. - is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey) is EmailSegment -> ClickableEmail(word.segmentText) @@ -547,6 +550,7 @@ private fun RenderWordWithPreview( callbackUri: String? = null, accountViewModel: AccountViewModel, nav: INav, + authorPubKey: String? = null, ) { when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) @@ -558,7 +562,7 @@ private fun RenderWordWithPreview( is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 43c7584237..fac3d2a7eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.quartz.experimental.clink.common.SatRange import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.launch /** @@ -70,11 +71,18 @@ import kotlinx.coroutines.launch * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, * then pays it through the user's default payment source (confirmed for in-app wallets, * see [InvoicePaymentDispatcher]). + * + * When [authorPubKey] is known (the offer appears in someone's note) and the user's + * default zap type isn't NONZAP, the request carries a NIP-57 zap request so the offer + * service issues a zappable invoice and publishes a zap receipt — making the payment a + * real zap on the author rather than a silent invoice. With no author it falls back to + * a plain invoice. */ @Composable fun ClinkOfferPreview( offer: NOffer, accountViewModel: AccountViewModel, + authorPubKey: String? = null, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -191,8 +199,26 @@ fun ClinkOfferPreview( onClick = { requesting = true scope.launch { - val amount = if (amountRequired) amountInput.toLongOrNull() else null - val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer, amountSats = amount) + val amount = if (amountRequired) amountInput.toLongOrNull() else offer.price?.toLong() + + // Attach a NIP-57 zap request so paying the offer becomes a real zap + // on the author (skipped when there's no author or the user opted out + // of zaps via a NONZAP default). + val zapType = accountViewModel.defaultZapType() + val zapRequest = + if (authorPubKey != null && zapType != LnZapEvent.ZapType.NONZAP) { + val author = accountViewModel.account.cache.getOrCreateUser(authorPubKey) + accountViewModel.account + .createZapRequestFor( + user = author, + zapType = zapType, + amountMillisats = amount?.times(1000), + ).toJson() + } else { + null + } + + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer, amountSats = amount, zap = zapRequest) requesting = false val bolt11 = response?.bolt11 From 50b4ed8c1ef7b664c417db77c94511250f6df59f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:22:36 +0000 Subject: [PATCH 16/75] feat(clink): kind-0 clink_offer metadata field Adds the CLINK Offers discovery pointer to profile metadata, mirroring the NIP-05 `clink_offer` key: - UserMetadata.clinkOffer (@SerialName clink_offer) + clinkOffer() accessor, with trim/blank cleanup alongside the other fields. - MetadataEvent.createNew/updateFromPast gain a clinkOffer param written into kind-0 content via the new CLINK_OFFER_PROPERTY key. Covered by UpdateMetadataTest (write + parse round-trip) on JVM. --- .../nip01Core/metadata/MetadataEvent.kt | 9 ++++++++ .../quartz/nip01Core/metadata/UserMetadata.kt | 8 +++++++ .../nip01Core/metadata/UpdateMetadataTest.kt | 21 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index d7084aad9e..5fa10c421f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -88,6 +88,9 @@ class MetadataEvent( const val KIND = 0 const val FIXED_D_TAG = "" + // CLINK Offers discovery key in kind-0 content (mirrors the NIP-05 `clink_offer` key). + const val CLINK_OFFER_PROPERTY = "clink_offer" + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) @@ -127,6 +130,7 @@ class MetadataEvent( twitter: String? = null, mastodon: String? = null, github: String? = null, + clinkOffer: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate { @@ -145,6 +149,7 @@ class MetadataEvent( lnAddress, lnURL, pronouns, + clinkOffer, ) val newJsonObject = JsonObject(currentMetadata) @@ -182,6 +187,7 @@ class MetadataEvent( twitter: String? = null, mastodon: String? = null, github: String? = null, + clinkOffer: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ): EventTemplate { @@ -200,6 +206,7 @@ class MetadataEvent( lnAddress, lnURL, pronouns, + clinkOffer, ) val newJsonObject = JsonObject(currentMetadata) @@ -233,6 +240,7 @@ class MetadataEvent( lnAddress: String? = null, lnURL: String? = null, pronouns: String? = null, + clinkOffer: String? = null, ) { name?.let { addIfNotBlank(currentMetadata, NameTag.TAG_NAME, it) } displayName?.let { addIfNotBlank(currentMetadata, DisplayNameTag.TAG_NAME, it) } @@ -244,6 +252,7 @@ class MetadataEvent( nip05?.let { addIfNotBlank(currentMetadata, Nip05Tag.TAG_NAME, it) } lnAddress?.let { addIfNotBlank(currentMetadata, Lud16Tag.TAG_NAME, it) } lnURL?.let { addIfNotBlank(currentMetadata, Lud06Tag.TAG_NAME, it) } + clinkOffer?.let { addIfNotBlank(currentMetadata, CLINK_OFFER_PROPERTY, it) } } // For https://github.com/nostr-protocol/nips/pull/1770 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 6ebd2ad25c..b902f1604b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -56,12 +56,18 @@ class UserMetadata { var lud06: String? = null var lud16: String? = null + /** CLINK Offers pointer (`noffer1…`) the user advertises to receive payments over Nostr. */ + @SerialName("clink_offer") + var clinkOffer: String? = null + var twitter: String? = null fun anyName(): String? = displayName ?: name fun lnAddress(): String? = lud16 ?: lud06 + fun clinkOffer(): String? = clinkOffer?.takeIf { it.isNotBlank() } + fun bestName(): String? = displayName ?: name fun firstName(): String? { @@ -93,6 +99,7 @@ class UserMetadata { if (name?.isNotEmpty() == true) name = name?.trim() if (lud06?.isNotEmpty() == true) lud06 = lud06?.trim() if (lud16?.isNotEmpty() == true) lud16 = lud16?.trim() + if (clinkOffer?.isNotEmpty() == true) clinkOffer = clinkOffer?.trim() if (pronouns?.isNotEmpty() == true) pronouns = pronouns?.trim() if (banner?.isNotEmpty() == true) banner = banner?.trim() @@ -105,6 +112,7 @@ class UserMetadata { if (name?.isBlank() == true) name = null if (lud06?.isBlank() == true) lud06 = null if (lud16?.isBlank() == true) lud16 = null + if (clinkOffer?.isBlank() == true) clinkOffer = null if (banner?.isBlank() == true) banner = null if (website?.isBlank() == true) website = null diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt index 18b408439d..5470364ffa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -197,6 +197,27 @@ class UpdateMetadataTest { assertEquals(expected3, test3) } + @Test + fun clinkOfferRoundTrip() { + val noffer = "noffer1qqsxexampleclinkofferpointer" + val event = signer.sign(MetadataEvent.createNew(name = "Vitor", clinkOffer = noffer, createdAt = 1740669816)) + + // written into kind-0 content under the spec's `clink_offer` key + assertEquals(true, event.content.contains("\"clink_offer\":\"$noffer\"")) + + // and parses back out + val metadata = event.contactMetaData() + assertNotNull(metadata) + assertEquals(noffer, metadata.clinkOffer) + assertEquals(noffer, metadata.clinkOffer()) + } + + @Test + fun parseClinkOffer() { + val metadata = JsonMapper.fromJson("""{"name":"Test","clink_offer":"noffer1abc"}""") + assertEquals("noffer1abc", metadata.clinkOffer) + } + @Test fun parseBirthdayFull() { val json = """{"name":"Test","birthday":{"year":1990,"month":6,"day":15}}""" From def5cbc4e93a1ce9558314e8fe04c9f0d3fdeca1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:28:26 +0000 Subject: [PATCH 17/75] feat(clink): let users set a noffer on their profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'CLINK Offer (noffer)' field to the profile editor so users can advertise a payment offer in their kind-0 metadata: - UserMetadataState.sendNewUserMetadata threads clinkOffer into the kind-0 build. - NewUserMetadataViewModel loads/saves/clears the clinkOffer field. - NewUserMetadataScreen renders the input (placeholder noffer1…). :amethyst compiles. The read side (NIP-05 clink_offer discovery + preferring it) is the next step. --- .../model/nip01UserMetadata/UserMetadataState.kt | 3 +++ .../amethyst/ui/actions/NewUserMetadataScreen.kt | 16 ++++++++++++++++ .../ui/actions/NewUserMetadataViewModel.kt | 4 ++++ amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 24 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index d3179dd07f..3bacb99eb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -68,6 +68,7 @@ class UserMetadataState( nip05: String? = null, lnAddress: String? = null, lnURL: String? = null, + clinkOffer: String? = null, ): MetadataEvent { val latest = getUserMetadataEvent() @@ -85,6 +86,7 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + clinkOffer = clinkOffer, ) } else { MetadataEvent.createNew( @@ -98,6 +100,7 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + clinkOffer = clinkOffer, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index c83719af68..4c1f3263d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -303,6 +303,22 @@ fun NewUserMetadataScreen( singleLine = true, ) + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.clink_offer_label)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.clinkOffer.value, + onValueChange = { postViewModel.clinkOffer.value = it }, + placeholder = { + Text( + text = "noffer1…", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + // -- Social Proofs -- ExpandableSection( title = stringRes(R.string.social_proof), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 826b849028..4d7a908ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -60,6 +60,7 @@ class NewUserMetadataViewModel : ViewModel() { val nip05 = mutableStateOf("") val lnAddress = mutableStateOf("") val lnURL = mutableStateOf("") + val clinkOffer = mutableStateOf("") val twitter = mutableStateOf("") val github = mutableStateOf("") @@ -85,6 +86,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = it.info.nip05 ?: "" lnAddress.value = it.info.lud16 ?: "" lnURL.value = it.info.lud06 ?: "" + clinkOffer.value = it.info.clinkOffer ?: "" } twitter.value = "" @@ -124,6 +126,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05 = nip05.value, lnAddress = lnAddress.value, lnURL = lnURL.value, + clinkOffer = clinkOffer.value, ) val identities = @@ -149,6 +152,7 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = "" lnAddress.value = "" lnURL.value = "" + clinkOffer.value = "" twitter.value = "" github.value = "" mastodon.value = "" diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d08a064603..6b5c6dff75 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -121,6 +121,7 @@ Amount (sats) Enter a valid amount for this offer. Allowed range: %1$s–%2$s sats + CLINK Offer (noffer) Pay only CLINK Debit Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. From 6b9184bf500656e27d09fff8d406439f3cc1b9b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:39:36 +0000 Subject: [PATCH 18/75] feat(clink): read + surface a profile's noffer (kind-0 + NIP-05) Completes the receive side: a payable CLINK Offer card now appears on a profile that advertises one, preferring the kind-0 clink_offer and falling back to the NIP-05 .well-known clink_offer. - Nip05Parser.parseClinkOffer + INip05Client.loadClinkOffer fetch/parse the well-known clink_offer (keyed by local name, mirroring the names map; exact shape isn't a finalized spec so a mismatch yields null). JVM-tested. - DrawAdditionalInfo.DisplayClinkOffer resolves kind-0 first, else fetches NIP-05 on IO, parses the noffer, and renders ClinkOfferPreview zapping the profile. quartz tests pass; :amethyst compiles. Network fetch + card render untested end-to-end. --- .../profile/header/DrawAdditionalInfo.kt | 51 +++++++++++++++++++ .../nip05DnsIdentifiers/INip05Client.kt | 3 ++ .../quartz/nip05DnsIdentifiers/Nip05Client.kt | 8 +++ .../quartz/nip05DnsIdentifiers/Nip05Parser.kt | 23 +++++++++ .../quartz/nip05DnsIdentifiers/Nip05Test.kt | 28 ++++++++++ 5 files changed, 113 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index f10296ca4a..46d8dbdb57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -33,10 +33,12 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,6 +54,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.User @@ -63,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.DrawPlayName import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.creators.invoice.ClinkOfferPreview import com.vitorpamplona.amethyst.ui.note.lastSeenSentence import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -76,8 +80,11 @@ import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity @@ -87,6 +94,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext private const val IDENTITY_ICON_CACHE_KEY = 0 @@ -218,6 +226,8 @@ fun DrawAdditionalInfo( } DisplayLNAddress(lud16, baseUser, accountViewModel, nav) + DisplayClinkOffer(baseUser, user, accountViewModel) + DisplayPaymentTargets(baseUser, accountViewModel) val website = user.info.website @@ -377,3 +387,44 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = is GitHubIdentity -> R.string.github else -> R.string.github } + +/** + * Shows a payable CLINK Offer card when the profile advertises one, preferring the + * kind-0 `clink_offer` field and falling back to the user's NIP-05 `.well-known` + * `clink_offer`. Paying zaps this profile (see [ClinkOfferPreview]). + */ +@Composable +private fun DisplayClinkOffer( + baseUser: User, + userInfo: UserInfo, + accountViewModel: AccountViewModel, +) { + val kind0Offer = + remember(userInfo) { + userInfo.info.clinkOffer()?.let { ClinkPointerParser.parse(it) as? NOffer } + } + + var offer by remember(userInfo) { mutableStateOf(kind0Offer) } + + val nip05 = userInfo.info.nip05 + LaunchedEffect(kind0Offer, nip05) { + if (kind0Offer != null) { + offer = kind0Offer + return@LaunchedEffect + } + // Fall back to the NIP-05 .well-known clink_offer. + val id = nip05?.let { Nip05Id.parse(it) } + offer = + if (id != null) { + withContext(Dispatchers.IO) { + accountViewModel.nip05ClientBuilder().loadClinkOffer(id)?.let { ClinkPointerParser.parse(it) as? NOffer } + } + } else { + null + } + } + + offer?.let { + ClinkOfferPreview(it, accountViewModel, baseUser.pubkeyHex) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt index b47acf1f26..1991c87660 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/INip05Client.kt @@ -33,6 +33,9 @@ interface INip05Client { suspend fun load(nip05: Nip05Id): KeyInfoSet? suspend fun list(domain: String): KeyInfoSet + + /** The CLINK Offers pointer advertised in the NIP-05 `.well-known/nostr.json`, if any. */ + suspend fun loadClinkOffer(nip05: Nip05Id): String? = null } class EmptyNip05Client : INip05Client { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index c7520138d9..c4c3b1944f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -73,6 +73,14 @@ class Nip05Client( override suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain)) + override suspend fun loadClinkOffer(nip05: Nip05Id): String? = + try { + parser.parseClinkOffer(nip05, fetchNip05Data(nip05)) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + suspend fun fetchNip05Data(nip05: Nip05Id): String { val url = nip05.toUserUrl() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt index 1614c056d3..467d6493d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt @@ -43,6 +43,29 @@ class Nip05Parser { ?.jsonPrimitive ?.content + /** + * Reads a CLINK Offers pointer (`noffer1…`) from a NIP-05 `.well-known/nostr.json`, + * mirroring the `names` map. ShockNet's clink-demo advertises offers here keyed by the + * local name (queried as `?name=`). The exact key shape is not yet a finalized + * spec, so a missing or differently-shaped `clink_offer` entry simply yields null. + */ + fun parseClinkOffer( + nip05: Nip05Id, + json: String, + ): String? = + try { + Json + .parseToJsonElement(json) + .jsonObject["clink_offer"] + ?.jsonObject + ?.get(nip05.name) + ?.jsonPrimitive + ?.content + ?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + fun parseHexKeyAndRelays( nip05: Nip05Id, json: String, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 2f9e17c34b..e9d5bad6ad 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -45,6 +45,34 @@ class Nip05Test { } """.trimIndent() + @Test + fun `parse clink_offer for a name`() = + runTest { + val noffer = "noffer1qqsexampleclinkoffer" + val json = """{ "names": { "bob": "abc" }, "clink_offer": { "bob": "$noffer" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertEquals(noffer, parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer absent yields null`() = + runTest { + val json = """{ "names": { "bob": "abc" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertNull(parser.parseClinkOffer(nip05, json)) + } + + @Test + fun `parse clink_offer for a missing name yields null`() = + runTest { + val json = """{ "clink_offer": { "alice": "noffer1abc" } }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertNull(parser.parseClinkOffer(nip05, json)) + } + @Test fun `test with matching case on user name`() = runTest { From c24215c8361678e6f23034746bfadf25040b2d6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:18:58 +0000 Subject: [PATCH 19/75] feat(clink): follow a moved offer (Expired or Moved, code 3) When the offer service replies EXPIRED_OR_MOVED with a replacement noffer in 'latest', the card now parses it, swaps to the new pointer, and retries the request once (paying the relocated offer) instead of dead-ending on an error. Request handling is refactored into a single helper so the amount field and zap-request attachment apply to the retry too. :amethyst compiles; offer round-trip remains untested end-to-end. --- .../creators/invoice/ClinkOfferPreview.kt | 104 +++++++++++------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index fac3d2a7eb..a42f9eae73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.experimental.clink.common.SatRange import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -93,6 +94,9 @@ fun ClinkOfferPreview( var amountInput by remember { mutableStateOf("") } var needsAmount by remember { mutableStateOf((offer.priceType ?: OfferPriceType.SPONTANEOUS) == OfferPriceType.SPONTANEOUS) } var amountRange by remember { mutableStateOf(null) } + // The pointer actually paid: starts as the rendered offer, swapped if the service + // replies "Expired or Moved" (code 3) with a replacement noffer. + var activeOffer by remember(offer) { mutableStateOf(offer) } errorMessage?.let { ErrorMessageDialog( @@ -190,6 +194,67 @@ fun ClinkOfferPreview( } val amountRequired = needsAmount + + suspend fun runOfferRequest( + useOffer: NOffer, + followMoved: Boolean, + ) { + val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price?.toLong() + + // Attach a NIP-57 zap request so paying the offer becomes a real zap on the + // author (skipped when there's no author or the user opted out via NONZAP). + val zapType = accountViewModel.defaultZapType() + val zapRequest = + if (authorPubKey != null && zapType != LnZapEvent.ZapType.NONZAP) { + val author = accountViewModel.account.cache.getOrCreateUser(authorPubKey) + accountViewModel.account + .createZapRequestFor( + user = author, + zapType = zapType, + amountMillisats = amount?.times(1000), + ).toJson() + } else { + null + } + + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount, zap = zapRequest) + + val bolt11 = response?.bolt11 + val movedTo = + if (response?.code == OfferErrorCode.EXPIRED_OR_MOVED && followMoved) { + response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + } else { + null + } + + when { + bolt11 != null -> { + requesting = false + payingInvoice = bolt11 + } + // Follow a relocated offer once, paying the replacement pointer. + movedTo != null -> { + activeOffer = movedTo + runOfferRequest(movedTo, followMoved = false) + } + response?.code == OfferErrorCode.INVALID_AMOUNT -> { + // Reveal the amount field (or refine it) with the service's range. + requesting = false + needsAmount = true + amountRange = response.range + errorMessage = + response.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.clink_offer_invalid_amount) + } + else -> { + requesting = false + errorMessage = + response?.error?.takeIf { it.isNotBlank() } + ?: stringRes(context, R.string.error_dialog_pay_invoice_error) + } + } + } + Button( modifier = Modifier @@ -198,44 +263,7 @@ fun ClinkOfferPreview( enabled = !requesting && (!amountRequired || (amountInput.toLongOrNull() ?: 0L) > 0L), onClick = { requesting = true - scope.launch { - val amount = if (amountRequired) amountInput.toLongOrNull() else offer.price?.toLong() - - // Attach a NIP-57 zap request so paying the offer becomes a real zap - // on the author (skipped when there's no author or the user opted out - // of zaps via a NONZAP default). - val zapType = accountViewModel.defaultZapType() - val zapRequest = - if (authorPubKey != null && zapType != LnZapEvent.ZapType.NONZAP) { - val author = accountViewModel.account.cache.getOrCreateUser(authorPubKey) - accountViewModel.account - .createZapRequestFor( - user = author, - zapType = zapType, - amountMillisats = amount?.times(1000), - ).toJson() - } else { - null - } - - val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, offer, amountSats = amount, zap = zapRequest) - requesting = false - - val bolt11 = response?.bolt11 - when { - bolt11 != null -> payingInvoice = bolt11 - response?.code == OfferErrorCode.INVALID_AMOUNT -> { - // Reveal the amount field (or refine it) with the service's range. - needsAmount = true - amountRange = response.range - errorMessage = - response.error?.takeIf { it.isNotBlank() } - ?: stringRes(context, R.string.clink_offer_invalid_amount) - } - response?.error != null -> errorMessage = response.error - else -> errorMessage = stringRes(context, R.string.error_dialog_pay_invoice_error) - } - } + scope.launch { runOfferRequest(activeOffer, followMoved = true) } }, shape = QuoteBorder, colors = From 46225bf5b4a72d76138ad28400e3d6bd5f04cbc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:37:19 +0000 Subject: [PATCH 20/75] feat(clink): post-level zaps for offers + enable offer zaps in the feed Threads the note's Event through the rich-text render chain (TranslatableRichTextViewer [play+fdroid] -> ExpandableRichTextViewer -> RichTextViewer -> word renderers -> ClinkOfferPreview) as a default-null zapEvent. The offer card now prefers an e-tag zap on the post when the event is present, falling back to the author (p-tag) zap, then a plain invoice. Also fixes coverage: the main note body (Text.kt) didn't pass author context at all, so offer zaps previously only fired in chat. It now passes both authorPubKey and the note event, so paying a noffer in a feed post lands as a zap on that post. Both flavors compile. Zap round-trip/receipt untested end-to-end. --- .../components/TranslatableRichTextViewer.kt | 3 ++ .../ui/components/ExpandableRichTextViewer.kt | 3 ++ .../amethyst/ui/components/RichTextViewer.kt | 13 +++++-- .../creators/invoice/ClinkOfferPreview.kt | 36 ++++++++++++------- .../amethyst/ui/note/types/Text.kt | 3 ++ .../components/TranslatableRichTextViewer.kt | 3 ++ 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b6c79e0a67..1d436d9a65 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp +import com.vitorpamplona.quartz.nip01Core.core.Event @Composable fun TranslatableRichTextViewer( @@ -44,6 +45,7 @@ fun TranslatableRichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, + zapEvent: Event? = null, ) = ExpandableRichTextViewer( content, canPreview, @@ -56,6 +58,7 @@ fun TranslatableRichTextViewer( authorPubKey, accountViewModel, nav, + zapEvent, ) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index a2e6d52ca9..7d1ea670ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.StdTopPadding +import com.vitorpamplona.quartz.nip01Core.core.Event object ShowFullTextCache { val cache = LruCache(10) @@ -67,6 +68,7 @@ fun ExpandableRichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, + zapEvent: Event? = null, ) { var showFullText by rememberSaveable { @@ -104,6 +106,7 @@ fun ExpandableRichTextViewer( authorPubKey, accountViewModel, nav, + zapEvent, ) if (content.length > whereToCut && !showFullText) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 9fd0a2aa74..c2d6d39c06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -122,6 +122,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlinx.coroutines.Dispatchers @@ -139,12 +140,13 @@ fun RichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, + zapEvent: Event? = null, ) { Column(modifier = modifier) { if (remember(content) { CachedRichTextParser.isMarkdown(content) }) { RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) } else { - RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav) + RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav, zapEvent) } } } @@ -347,6 +349,7 @@ private fun RenderRegular( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, + zapEvent: Event? = null, ) { if (canPreview) { RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier -> @@ -369,6 +372,7 @@ private fun RenderRegular( accountViewModel, nav, authorPubKey, + zapEvent, ) } } @@ -383,6 +387,7 @@ private fun RenderRegular( accountViewModel, nav, authorPubKey, + zapEvent, ) } } @@ -479,6 +484,7 @@ private fun RenderWordWithoutPreview( accountViewModel: AccountViewModel, nav: INav, authorPubKey: String? = null, + zapEvent: Event? = null, ) { when (word) { // Don't preview Images @@ -513,7 +519,7 @@ private fun RenderWordWithoutPreview( // Decoding is local and the network round-trip only fires on the Pay tap, // so the offer card is safe to render even in the no-preview path. - is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey, zapEvent) is EmailSegment -> ClickableEmail(word.segmentText) @@ -551,6 +557,7 @@ private fun RenderWordWithPreview( accountViewModel: AccountViewModel, nav: INav, authorPubKey: String? = null, + zapEvent: Event? = null, ) { when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) @@ -562,7 +569,7 @@ private fun RenderWordWithPreview( is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey, zapEvent) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index a42f9eae73..384c504657 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.launch @@ -84,6 +85,7 @@ fun ClinkOfferPreview( offer: NOffer, accountViewModel: AccountViewModel, authorPubKey: String? = null, + zapEvent: Event? = null, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -201,20 +203,30 @@ fun ClinkOfferPreview( ) { val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price?.toLong() - // Attach a NIP-57 zap request so paying the offer becomes a real zap on the - // author (skipped when there's no author or the user opted out via NONZAP). + // Attach a NIP-57 zap request so paying the offer becomes a real zap. With the + // note's event we zap the post (e-tag); otherwise we fall back to an author + // (profile) zap. Skipped when there's no target or the user opted out (NONZAP). val zapType = accountViewModel.defaultZapType() val zapRequest = - if (authorPubKey != null && zapType != LnZapEvent.ZapType.NONZAP) { - val author = accountViewModel.account.cache.getOrCreateUser(authorPubKey) - accountViewModel.account - .createZapRequestFor( - user = author, - zapType = zapType, - amountMillisats = amount?.times(1000), - ).toJson() - } else { - null + when { + zapType == LnZapEvent.ZapType.NONZAP -> null + zapEvent != null -> + accountViewModel.account + .createZapRequestFor( + event = zapEvent, + pollOption = null, + zapType = zapType, + toUser = null, + amountMillisats = amount?.times(1000), + ).toJson() + authorPubKey != null -> + accountViewModel.account + .createZapRequestFor( + user = accountViewModel.account.cache.getOrCreateUser(authorPubKey), + zapType = zapType, + amountMillisats = amount?.times(1000), + ).toJson() + else -> null } val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount, zap = zapRequest) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index 4ca9f66c3e..a44aa153ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -202,8 +202,11 @@ fun RenderTextEvent( note.idHex }, callbackUri = callbackUri, + authorPubKey = note.author?.pubkeyHex, accountViewModel = accountViewModel, nav = nav, + // Lets a CLINK offer in the body zap this very post (e-tag). + zapEvent = noteEvent, ) } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 76b6265417..2c82f43ac0 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.service.lang.TranslationsCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp +import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive @@ -58,6 +59,7 @@ fun TranslatableRichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, + zapEvent: Event? = null, ) { TranslatableRichTextViewer( content = content, @@ -76,6 +78,7 @@ fun TranslatableRichTextViewer( authorPubKey, accountViewModel, nav, + zapEvent, ) } } From 57850c3bcf4682d014a61557eaac5931d1da6d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:43:51 +0000 Subject: [PATCH 21/75] revert(clink): drop zap requests from the offer card Paying a CLINK offer is already a direct payment to the recipient; wrapping it in a NIP-57 zap request conflated two different things. Removes the zappable- offers and post-level-zap behavior: - ClinkOfferPreview no longer builds a zap request or takes authorPubKey/zapEvent; it just pays the fetched invoice via the default source. - ClinkOfferPayer.requestInvoice drops the zap param. - Unwinds the zapEvent threading through RichTextViewer / ExpandableRichTextViewer / TranslatableRichTextViewer (both flavors) and reverts Text.kt. Keeps the rest of the offer card (variable amount, moved-offer follow, default payment-source dispatch) and the profile receive side intact. Both flavors compile. --- .../components/TranslatableRichTextViewer.kt | 3 -- .../amethyst/service/ClinkOfferPayer.kt | 3 +- .../ui/components/ExpandableRichTextViewer.kt | 3 -- .../amethyst/ui/components/RichTextViewer.kt | 17 ++------- .../creators/invoice/ClinkOfferPreview.kt | 38 +------------------ .../amethyst/ui/note/types/Text.kt | 3 -- .../profile/header/DrawAdditionalInfo.kt | 5 +-- .../components/TranslatableRichTextViewer.kt | 3 -- 8 files changed, 7 insertions(+), 68 deletions(-) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 1d436d9a65..b6c79e0a67 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp -import com.vitorpamplona.quartz.nip01Core.core.Event @Composable fun TranslatableRichTextViewer( @@ -45,7 +44,6 @@ fun TranslatableRichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, - zapEvent: Event? = null, ) = ExpandableRichTextViewer( content, canPreview, @@ -58,7 +56,6 @@ fun TranslatableRichTextViewer( authorPubKey, accountViewModel, nav, - zapEvent, ) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index d511ee9d29..39e6603650 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -52,14 +52,13 @@ object ClinkOfferPayer { account: Account, offer: NOffer, amountSats: Long? = null, - zap: String? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, ): OfferResponse? { val relays = offer.relays.toSet() if (relays.isEmpty()) return null val client = OfferClient(offer, account.signer) - val request = client.requestInvoice(amountSats = amountSats, zap = zap) + val request = client.requestInvoice(amountSats = amountSats) val reply = CompletableDeferred() val subId = "clink-offer-${request.id}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 7d1ea670ee..a2e6d52ca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -49,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.StdTopPadding -import com.vitorpamplona.quartz.nip01Core.core.Event object ShowFullTextCache { val cache = LruCache(10) @@ -68,7 +67,6 @@ fun ExpandableRichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, - zapEvent: Event? = null, ) { var showFullText by rememberSaveable { @@ -106,7 +104,6 @@ fun ExpandableRichTextViewer( authorPubKey, accountViewModel, nav, - zapEvent, ) if (content.length > whereToCut && !showFullText) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index c2d6d39c06..0e617b8750 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -122,7 +122,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlinx.coroutines.Dispatchers @@ -140,13 +139,12 @@ fun RichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, - zapEvent: Event? = null, ) { Column(modifier = modifier) { if (remember(content) { CachedRichTextParser.isMarkdown(content) }) { RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav) } else { - RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav, zapEvent) + RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav) } } } @@ -349,7 +347,6 @@ private fun RenderRegular( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, - zapEvent: Event? = null, ) { if (canPreview) { RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier -> @@ -371,8 +368,6 @@ private fun RenderRegular( callbackUri, accountViewModel, nav, - authorPubKey, - zapEvent, ) } } @@ -386,8 +381,6 @@ private fun RenderRegular( backgroundColor, accountViewModel, nav, - authorPubKey, - zapEvent, ) } } @@ -483,8 +476,6 @@ private fun RenderWordWithoutPreview( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, - authorPubKey: String? = null, - zapEvent: Event? = null, ) { when (word) { // Don't preview Images @@ -519,7 +510,7 @@ private fun RenderWordWithoutPreview( // Decoding is local and the network round-trip only fires on the Pay tap, // so the offer card is safe to render even in the no-preview path. - is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey, zapEvent) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) @@ -556,8 +547,6 @@ private fun RenderWordWithPreview( callbackUri: String? = null, accountViewModel: AccountViewModel, nav: INav, - authorPubKey: String? = null, - zapEvent: Event? = null, ) { when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) @@ -569,7 +558,7 @@ private fun RenderWordWithPreview( is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) is WithdrawSegment -> MayBeWithdrawal(word.segmentText, accountViewModel) is CashuSegment -> CashuPreview(word.segmentText, accountViewModel) - is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel, authorPubKey, zapEvent) + is ClinkOfferSegment -> ClinkOfferPreview(word.offer, accountViewModel) is EmailSegment -> ClickableEmail(word.segmentText) is SecretEmoji -> DisplaySecretEmoji(word, state, callbackUri, true, quotesLeft, backgroundColor, accountViewModel, nav) is MathSegment -> LatexEquation(word.latex, word.displayMode, word.leading, word.trailing) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 384c504657..5fc7aa994a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -64,8 +64,6 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.launch /** @@ -73,19 +71,11 @@ import kotlinx.coroutines.launch * runs the offer round-trip ([ClinkOfferPayer]) to fetch a fresh BOLT-11 over Nostr, * then pays it through the user's default payment source (confirmed for in-app wallets, * see [InvoicePaymentDispatcher]). - * - * When [authorPubKey] is known (the offer appears in someone's note) and the user's - * default zap type isn't NONZAP, the request carries a NIP-57 zap request so the offer - * service issues a zappable invoice and publishes a zap receipt — making the payment a - * real zap on the author rather than a silent invoice. With no author it falls back to - * a plain invoice. */ @Composable fun ClinkOfferPreview( offer: NOffer, accountViewModel: AccountViewModel, - authorPubKey: String? = null, - zapEvent: Event? = null, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -203,33 +193,7 @@ fun ClinkOfferPreview( ) { val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price?.toLong() - // Attach a NIP-57 zap request so paying the offer becomes a real zap. With the - // note's event we zap the post (e-tag); otherwise we fall back to an author - // (profile) zap. Skipped when there's no target or the user opted out (NONZAP). - val zapType = accountViewModel.defaultZapType() - val zapRequest = - when { - zapType == LnZapEvent.ZapType.NONZAP -> null - zapEvent != null -> - accountViewModel.account - .createZapRequestFor( - event = zapEvent, - pollOption = null, - zapType = zapType, - toUser = null, - amountMillisats = amount?.times(1000), - ).toJson() - authorPubKey != null -> - accountViewModel.account - .createZapRequestFor( - user = accountViewModel.account.cache.getOrCreateUser(authorPubKey), - zapType = zapType, - amountMillisats = amount?.times(1000), - ).toJson() - else -> null - } - - val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount, zap = zapRequest) + val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount) val bolt11 = response?.bolt11 val movedTo = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index a44aa153ab..4ca9f66c3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -202,11 +202,8 @@ fun RenderTextEvent( note.idHex }, callbackUri = callbackUri, - authorPubKey = note.author?.pubkeyHex, accountViewModel = accountViewModel, nav = nav, - // Lets a CLINK offer in the body zap this very post (e-tag). - zapEvent = noteEvent, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 46d8dbdb57..9fbb21933d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -226,7 +226,7 @@ fun DrawAdditionalInfo( } DisplayLNAddress(lud16, baseUser, accountViewModel, nav) - DisplayClinkOffer(baseUser, user, accountViewModel) + DisplayClinkOffer(user, accountViewModel) DisplayPaymentTargets(baseUser, accountViewModel) @@ -395,7 +395,6 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = */ @Composable private fun DisplayClinkOffer( - baseUser: User, userInfo: UserInfo, accountViewModel: AccountViewModel, ) { @@ -425,6 +424,6 @@ private fun DisplayClinkOffer( } offer?.let { - ClinkOfferPreview(it, accountViewModel, baseUser.pubkeyHex) + ClinkOfferPreview(it, accountViewModel) } } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 2c82f43ac0..76b6265417 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.service.lang.TranslationsCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp -import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive @@ -59,7 +58,6 @@ fun TranslatableRichTextViewer( authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, - zapEvent: Event? = null, ) { TranslatableRichTextViewer( content = content, @@ -78,7 +76,6 @@ fun TranslatableRichTextViewer( authorPubKey, accountViewModel, nav, - zapEvent, ) } } From c1a0e707a07c5aefdbe9e216fd7caf78ea2bd143 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:10:49 +0000 Subject: [PATCH 22/75] refactor(clink): clink_offer metadata via ClinkOfferTag + DSL builder Brings the kind-0 clink_offer field in line with the sibling fields' structure instead of a raw string constant written to content only: - New ClinkOfferTag (TAG_NAME/assemble/parse) under nip01Core/metadata/tags. - clinkOffer() TagArrayBuilder DSL extension in TagArrayBuilderExt. - MetadataEvent uses ClinkOfferTag.TAG_NAME and dual-writes it as a kind-0 tag in updateOrDeleteTagNames (NIP-1770 pattern), like lud16/nip05; drops the ad-hoc CLINK_OFFER_PROPERTY constant. UpdateMetadataTest now also asserts the tag is emitted. quartz tests pass. --- .../nip01Core/metadata/MetadataEvent.kt | 7 ++-- .../nip01Core/metadata/TagArrayBuilderExt.kt | 3 ++ .../nip01Core/metadata/tags/ClinkOfferTag.kt | 39 +++++++++++++++++++ .../nip01Core/metadata/UpdateMetadataTest.kt | 5 ++- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 5fa10c421f..08f00d54ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag @@ -88,9 +89,6 @@ class MetadataEvent( const val KIND = 0 const val FIXED_D_TAG = "" - // CLINK Offers discovery key in kind-0 content (mirrors the NIP-05 `clink_offer` key). - const val CLINK_OFFER_PROPERTY = "clink_offer" - fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) @@ -252,7 +250,7 @@ class MetadataEvent( nip05?.let { addIfNotBlank(currentMetadata, Nip05Tag.TAG_NAME, it) } lnAddress?.let { addIfNotBlank(currentMetadata, Lud16Tag.TAG_NAME, it) } lnURL?.let { addIfNotBlank(currentMetadata, Lud06Tag.TAG_NAME, it) } - clinkOffer?.let { addIfNotBlank(currentMetadata, CLINK_OFFER_PROPERTY, it) } + clinkOffer?.let { addIfNotBlank(currentMetadata, ClinkOfferTag.TAG_NAME, it) } } // For https://github.com/nostr-protocol/nips/pull/1770 @@ -267,6 +265,7 @@ class MetadataEvent( currentMetadata[Nip05Tag.TAG_NAME]?.let { nip05(it.text) } ?: run { remove(Nip05Tag.TAG_NAME) } currentMetadata[Lud16Tag.TAG_NAME]?.let { lud16(it.text) } ?: run { remove(Lud16Tag.TAG_NAME) } currentMetadata[Lud06Tag.TAG_NAME]?.let { lud06(it.text) } ?: run { remove(Lud06Tag.TAG_NAME) } + currentMetadata[ClinkOfferTag.TAG_NAME]?.let { clinkOffer(it.text) } ?: run { remove(ClinkOfferTag.TAG_NAME) } } private fun addIfNotBlank( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt index 51e37b3d84..163e0df3de 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TagArrayBuilderExt.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag +import com.vitorpamplona.quartz.nip01Core.metadata.tags.ClinkOfferTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud06Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.Lud16Tag @@ -51,3 +52,5 @@ fun TagArrayBuilder.lud06(lud06: String) = addUnique(Lud06Tag.ass fun TagArrayBuilder.banner(banner: String) = addUnique(BannerTag.assemble(banner)) fun TagArrayBuilder.pronouns(pronouns: String) = addUnique(PronounsTag.assemble(pronouns)) + +fun TagArrayBuilder.clinkOffer(offer: String) = addUnique(ClinkOfferTag.assemble(offer)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt new file mode 100644 index 0000000000..a744501813 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/tags/ClinkOfferTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.metadata.tags + +import com.vitorpamplona.quartz.utils.ensure + +/** CLINK Offers pointer (`noffer1…`) advertised in a kind-0 profile, mirroring the NIP-05 `clink_offer` key. */ +class ClinkOfferTag { + companion object { + const val TAG_NAME = "clink_offer" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(offer: String) = arrayOf(TAG_NAME, offer) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt index 5470364ffa..86aa586878 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UpdateMetadataTest.kt @@ -205,7 +205,10 @@ class UpdateMetadataTest { // written into kind-0 content under the spec's `clink_offer` key assertEquals(true, event.content.contains("\"clink_offer\":\"$noffer\"")) - // and parses back out + // and dual-written as a kind-0 tag (NIP-1770 pattern), like the other fields + assertContentEquals(arrayOf("clink_offer", noffer), event.tags.firstOrNull { it.firstOrNull() == "clink_offer" }) + + // and parses back out of the content val metadata = event.contactMetaData() assertNotNull(metadata) assertEquals(noffer, metadata.clinkOffer) From 03b091d3ec8eb063c17b874becbd70004de71fa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:26:06 +0000 Subject: [PATCH 23/75] refactor(clink): model event tags via PTag/ETag classes + builder DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings OfferEvent/DebitEvent/ManageEvent (21001-3) in line with the codebase tag conventions, replacing raw inline tags: - Build via eventTemplate(KIND, content) { pTag(...); eTag/add; alt(...) } and signer.sign(template), instead of hand-rolled arrayOf("p"/"e", ...) + sign(). - Accessors use PTag.parseKey / ETag.parseId instead of matching "p"/"e" literals. Behavior-preserving: PTag.assemble(x, null) yields the identical ["p", x] bytes and tag order is unchanged, so signed events are byte-identical. All CLINK tests pass (ClinkEventTest, ClinkClientServerTest, pointer/interop). Note: these are NIP-44-encrypted request/response events, so create*() stays a suspend factory that encrypts then signs the template — matching NIP-47; a pure pre-signing template isn't possible without the signer. --- .../experimental/clink/debits/DebitEvent.kt | 40 ++++++++++--------- .../experimental/clink/manage/ManageEvent.kt | 40 ++++++++++--------- .../experimental/clink/offers/OfferEvent.kt | 40 ++++++++++--------- 3 files changed, 66 insertions(+), 54 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt index 5c833523a4..61384743ec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt @@ -27,7 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -48,9 +52,9 @@ class DebitEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { override fun isContentEncoded() = true - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) - fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) fun isResponse() = requestId() != null @@ -80,14 +84,14 @@ class DebitEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): DebitEvent { - val tags = - arrayOf( - arrayOf("p", servicePubKey), - Clink.versionTag(), - AltTag.assemble(ALT), - ) val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) - return signer.sign(createdAt, KIND, tags, encrypted) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(servicePubKey, null) + add(Clink.versionTag()) + alt(ALT) + }, + ) } /** Builds a response event (service side) referencing the original [requestEvent]. */ @@ -98,15 +102,15 @@ class DebitEvent( createdAt: Long = TimeUtils.now(), ): DebitEvent { val requestorPubKey = requestEvent.pubKey - val tags = - arrayOf( - arrayOf("p", requestorPubKey), - arrayOf("e", requestEvent.id), - Clink.versionTag(), - AltTag.assemble(ALT), - ) val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), requestorPubKey) - return signer.sign(createdAt, KIND, tags, encrypted) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(requestorPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + add(Clink.versionTag()) + alt(ALT) + }, + ) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt index ac51a82e86..48ab85d044 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt @@ -27,7 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -48,9 +52,9 @@ class ManageEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { override fun isContentEncoded() = true - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) - fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) fun isResponse() = requestId() != null @@ -80,14 +84,14 @@ class ManageEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): ManageEvent { - val tags = - arrayOf( - arrayOf("p", serverPubKey), - Clink.versionTag(), - AltTag.assemble(ALT), - ) val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), serverPubKey) - return signer.sign(createdAt, KIND, tags, encrypted) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(serverPubKey, null) + add(Clink.versionTag()) + alt(ALT) + }, + ) } /** Builds a response event (server side) referencing the original [requestEvent]. */ @@ -98,15 +102,15 @@ class ManageEvent( createdAt: Long = TimeUtils.now(), ): ManageEvent { val appPubKey = requestEvent.pubKey - val tags = - arrayOf( - arrayOf("p", appPubKey), - arrayOf("e", requestEvent.id), - Clink.versionTag(), - AltTag.assemble(ALT), - ) val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), appPubKey) - return signer.sign(createdAt, KIND, tags, encrypted) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(appPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + add(Clink.versionTag()) + alt(ALT) + }, + ) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt index 8dd76b4499..040e7ed9d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -27,7 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions -import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -49,10 +53,10 @@ class OfferEvent( override fun isContentEncoded() = true /** The `p` tag — the counterparty this message is addressed to. */ - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) /** The `e` tag — present only on responses, referencing the request event id. */ - fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1) + fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) fun isResponse() = requestId() != null @@ -82,14 +86,14 @@ class OfferEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): OfferEvent { - val tags = - arrayOf( - arrayOf("p", servicePubKey), - Clink.versionTag(), - AltTag.assemble(ALT), - ) val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(request), servicePubKey) - return signer.sign(createdAt, KIND, tags, encrypted) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(servicePubKey, null) + add(Clink.versionTag()) + alt(ALT) + }, + ) } /** Builds a response event (service side) referencing the original [requestEvent]. */ @@ -100,15 +104,15 @@ class OfferEvent( createdAt: Long = TimeUtils.now(), ): OfferEvent { val payerPubKey = requestEvent.pubKey - val tags = - arrayOf( - arrayOf("p", payerPubKey), - arrayOf("e", requestEvent.id), - Clink.versionTag(), - AltTag.assemble(ALT), - ) val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(response), payerPubKey) - return signer.sign(createdAt, KIND, tags, encrypted) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(payerPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + add(Clink.versionTag()) + alt(ALT) + }, + ) } } } From 7bac8cfd4636c08a115e088d6fbede46884812e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 01:41:04 +0000 Subject: [PATCH 24/75] refactor(clink): clink_version as a shared ClinkVersionTag class Models the protocol-version tag the way other tags are modeled, instead of a loose helper on the Clink object: - New ClinkVersionTag (TAG_NAME/CURRENT/assemble/parse) under clink/tags, with a clinkVersion() TagArrayBuilder DSL extension, reused by all three events. - OfferEvent/DebitEvent/ManageEvent read version() via ClinkVersionTag::parse and build via clinkVersion() in their templates. - Retires the now-empty Clink object (its KDoc moved to the tag class). Behavior-preserving: assemble() emits the identical ["clink_version", "1"] tag in the same position. All CLINK tests pass. --- .../experimental/clink/debits/DebitEvent.kt | 9 ++-- .../experimental/clink/manage/ManageEvent.kt | 9 ++-- .../experimental/clink/offers/OfferEvent.kt | 9 ++-- .../clink/tags/ClinkVersionTag.kt | 44 +++++++++++++++++++ .../{Clink.kt => tags/TagArrayBuilderExt.kt} | 15 ++----- .../experimental/clink/ClinkEventTest.kt | 5 ++- 6 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/{Clink.kt => tags/TagArrayBuilderExt.kt} (71%) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt index 61384743ec..f25458c108 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.quartz.experimental.clink.debits import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.clink.Clink +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper @@ -58,7 +59,7 @@ class DebitEvent( fun isResponse() = requestId() != null - fun version() = tags.firstOrNull { it.size > 1 && it[0] == Clink.VERSION_TAG_NAME }?.get(1) + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey @@ -88,7 +89,7 @@ class DebitEvent( return signer.sign( eventTemplate(KIND, encrypted, createdAt) { pTag(servicePubKey, null) - add(Clink.versionTag()) + clinkVersion() alt(ALT) }, ) @@ -107,7 +108,7 @@ class DebitEvent( eventTemplate(KIND, encrypted, createdAt) { pTag(requestorPubKey, null) add(ETag.assemble(requestEvent.id, null, null)) - add(Clink.versionTag()) + clinkVersion() alt(ALT) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt index 48ab85d044..3ce4b6cc36 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.quartz.experimental.clink.manage import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.clink.Clink +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper @@ -58,7 +59,7 @@ class ManageEvent( fun isResponse() = requestId() != null - fun version() = tags.firstOrNull { it.size > 1 && it[0] == Clink.VERSION_TAG_NAME }?.get(1) + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey @@ -88,7 +89,7 @@ class ManageEvent( return signer.sign( eventTemplate(KIND, encrypted, createdAt) { pTag(serverPubKey, null) - add(Clink.versionTag()) + clinkVersion() alt(ALT) }, ) @@ -107,7 +108,7 @@ class ManageEvent( eventTemplate(KIND, encrypted, createdAt) { pTag(appPubKey, null) add(ETag.assemble(requestEvent.id, null, null)) - add(Clink.versionTag()) + clinkVersion() alt(ALT) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt index 040e7ed9d1..9350c64980 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -21,7 +21,8 @@ package com.vitorpamplona.quartz.experimental.clink.offers import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.experimental.clink.Clink +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag +import com.vitorpamplona.quartz.experimental.clink.tags.clinkVersion import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper @@ -60,7 +61,7 @@ class OfferEvent( fun isResponse() = requestId() != null - fun version() = tags.firstOrNull { it.size > 1 && it[0] == Clink.VERSION_TAG_NAME }?.get(1) + fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey @@ -90,7 +91,7 @@ class OfferEvent( return signer.sign( eventTemplate(KIND, encrypted, createdAt) { pTag(servicePubKey, null) - add(Clink.versionTag()) + clinkVersion() alt(ALT) }, ) @@ -109,7 +110,7 @@ class OfferEvent( eventTemplate(KIND, encrypted, createdAt) { pTag(payerPubKey, null) add(ETag.assemble(requestEvent.id, null, null)) - add(Clink.versionTag()) + clinkVersion() alt(ALT) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt new file mode 100644 index 0000000000..4d3c727cda --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/ClinkVersionTag.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink.tags + +import com.vitorpamplona.quartz.utils.ensure + +/** + * Protocol-version tag shared by all three CLINK message kinds (21001-3). Every CLINK + * request and response carries a `["clink_version", "1"]` tag alongside its `p` tag, + * with the content NIP-44 encrypted between the two parties. + */ +class ClinkVersionTag { + companion object { + const val TAG_NAME = "clink_version" + const val CURRENT = "1" + + fun parse(tag: Array): String? { + ensure(tag.size > 1) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(version: String = CURRENT) = arrayOf(TAG_NAME, version) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt similarity index 71% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt index 4fad7ab085..8c0d2da7b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/Clink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/tags/TagArrayBuilderExt.kt @@ -18,16 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.experimental.clink +package com.vitorpamplona.quartz.experimental.clink.tags -/** - * Constants shared by all three CLINK message kinds. Every CLINK request and - * response carries a `["clink_version", "1"]` tag and a `["p", recipient]` tag, - * and its content is a NIP-44 encrypted JSON payload. - */ -object Clink { - const val VERSION = "1" - const val VERSION_TAG_NAME = "clink_version" +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder - fun versionTag(): Array = arrayOf(VERSION_TAG_NAME, VERSION) -} +fun TagArrayBuilder.clinkVersion(version: String = ClinkVersionTag.CURRENT) = addUnique(ClinkVersionTag.assemble(version)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt index ff26963486..39539aff19 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.experimental.clink.tags.ClinkVersionTag import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -55,7 +56,7 @@ class ClinkEventTest { buildList { add(arrayOf("p", recipient)) if (requestId != null) add(arrayOf("e", requestId)) - add(Clink.versionTag()) + add(ClinkVersionTag.assemble()) }.toTypedArray(), content = "encrypted-placeholder", sig = "b".repeat(128), @@ -69,7 +70,7 @@ class ClinkEventTest { assertFalse(request.isResponse()) assertNull(request.requestId()) assertEquals(service.pubKey, request.recipientPubKey()) - assertEquals(Clink.VERSION, request.version()) + assertEquals(ClinkVersionTag.CURRENT, request.version()) } @Test From 084c7be23a103441ac70838a9b26004bc16da8ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 02:37:46 +0000 Subject: [PATCH 25/75] feat(clink): debit spending budgets (one-time + recurring) Exposes the CLINK Debits budget capability (requestBudget) the spec describes: - ClinkDebitPayer.requestBudget publishes the kind-21002 budget request and awaits the reply; the publish/await machinery is factored out of payInvoice into a shared sendAndAwait helper. - DebitFrequency gains UNIT_DAY/WEEK/MONTH constants. - WalletViewModel.requestDebitBudget resolves the debit pointer and runs it. - A 'Budget' action on CLINK debit rows opens ClinkBudgetDialog (amount + one-time/daily/weekly/monthly cadence); the result is surfaced as a toast. :amethyst compiles. The 21002 budget round-trip is untested end-to-end. --- .../amethyst/service/ClinkDebitPayer.kt | 44 +++++- .../loggedIn/wallet/ClinkBudgetDialog.kt | 135 ++++++++++++++++++ .../ui/screen/loggedIn/wallet/WalletScreen.kt | 44 ++++++ .../screen/loggedIn/wallet/WalletViewModel.kt | 21 +++ amethyst/src/main/res/values/strings.xml | 9 ++ .../clink/debits/DebitMessages.kt | 10 +- 6 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index 9044420cd9..4282332b53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.experimental.clink.client.DebitClient import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.Event @@ -33,13 +34,13 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull /** - * Drives the CLINK Debits payer round-trip: publishes a kind-21002 request asking the - * pointed-to wallet to pay a BOLT-11, and waits for the encrypted reply. The wallet + * Drives the CLINK Debits payer round-trips: publishes a kind-21002 request (pay an + * invoice, or authorize a spending budget) and waits for the encrypted reply. The wallet * authorizes against the account's own identity (no shared secret). * * This is the CLINK-debit spend rail that the zap button / offer card route through * when a debit pointer is the selected default payment source. It MUST only be invoked - * after an explicit user confirmation — a debit pulls real sats. + * after an explicit user confirmation — a debit moves real sats. * * Consume-only: Amethyst sends debit requests, it never answers them. */ @@ -47,6 +48,8 @@ object ClinkDebitPayer { const val DEFAULT_TIMEOUT_MS = 30_000L /** + * Asks the wallet to pay [bolt11]. + * * @return the decrypted response (`res:"ok"` with optional preimage, or a `GFY` * failure), or null if no reply arrived in time or the pointer carried no relay. */ @@ -57,11 +60,38 @@ object ClinkDebitPayer { amountSats: Long? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, ): DebitResponse? { - val relays = pointer.relays.toSet() - if (relays.isEmpty()) return null + val client = clientFor(pointer, account) ?: return null + return sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) + } - val client = DebitClient(pointer, account.signer) - val request = client.payInvoice(bolt11, amountSats) + /** + * Asks the wallet to authorize a spending budget. Omit [frequency] for a one-time + * budget; otherwise it recurs every `frequency` (day/week/month). + */ + suspend fun requestBudget( + account: Account, + pointer: NDebit, + amountSats: Long, + frequency: DebitFrequency? = null, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): DebitResponse? { + val client = clientFor(pointer, account) ?: return null + return sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) + } + + private fun clientFor( + pointer: NDebit, + account: Account, + ): DebitClient? = if (pointer.relays.isEmpty()) null else DebitClient(pointer, account.signer) + + /** Publishes [request] to the pointer's relays and awaits the matching kind-21002 reply. */ + private suspend fun sendAndAwait( + account: Account, + client: DebitClient, + request: DebitEvent, + timeoutMs: Long, + ): DebitResponse? { + val relays = client.pointer.relays.toSet() val reply = CompletableDeferred() val subId = "clink-debit-${request.id}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt new file mode 100644 index 0000000000..735a2f70ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ClinkBudgetDialog.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency + +/** + * Asks the user for a CLINK debit spending budget: an amount and a cadence (one-time, or + * recurring per day/week/month). [onConfirm] receives the amount in sats and the chosen + * [DebitFrequency] (null for one-time). + */ +@Composable +fun ClinkBudgetDialog( + onConfirm: (amountSats: Long, frequency: DebitFrequency?) -> Unit, + onDismiss: () -> Unit, +) { + var amount by remember { mutableStateOf("") } + var cadence by remember { mutableStateOf(BudgetCadence.ONE_TIME) } + + val parsedAmount = amount.toLongOrNull() ?: 0L + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.clink_budget_title)) }, + text = { + Column { + OutlinedTextField( + value = amount, + onValueChange = { new -> amount = new.filter(Char::isDigit) }, + label = { Text(stringRes(R.string.clink_budget_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + BudgetCadence.entries.forEach { option -> + CadenceRow( + option = option, + selected = cadence == option, + onSelect = { cadence = option }, + ) + } + } + }, + confirmButton = { + Button( + enabled = parsedAmount > 0, + onClick = { onConfirm(parsedAmount, cadence.toFrequency()) }, + ) { + Text(stringRes(R.string.clink_budget_request)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + +@Composable +private fun CadenceRow( + option: BudgetCadence, + selected: Boolean, + onSelect: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .height(44.dp) + .selectable(selected = selected, onClick = onSelect), + ) { + RadioButton(selected = selected, onClick = onSelect) + Text(stringRes(option.labelRes)) + } +} + +private enum class BudgetCadence( + val labelRes: Int, +) { + ONE_TIME(R.string.clink_budget_one_time), + DAILY(R.string.clink_budget_daily), + WEEKLY(R.string.clink_budget_weekly), + MONTHLY(R.string.clink_budget_monthly), + ; + + fun toFrequency(): DebitFrequency? = + when (this) { + ONE_TIME -> null + DAILY -> DebitFrequency(1, DebitFrequency.UNIT_DAY) + WEEKLY -> DebitFrequency(1, DebitFrequency.UNIT_WEEK) + MONTHLY -> DebitFrequency(1, DebitFrequency.UNIT_MONTH) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 16a91b6d84..182042781b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -66,6 +67,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -82,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency import kotlinx.coroutines.launch import java.text.NumberFormat import androidx.compose.material3.Icon as Material3Icon @@ -214,6 +217,7 @@ private fun MultiWalletHomeContent( cashuMintCount: Int, ) { val walletInfoList by walletViewModel.walletInfoList.collectAsState() + val context = LocalContext.current LaunchedEffect(Unit) { walletViewModel.fetchAllBalances() @@ -249,6 +253,24 @@ private fun MultiWalletHomeContent( onRemove = { walletViewModel.removeWallet(walletInfo.walletId) }, + // Spending-budget authorization is a CLINK-debit-only capability. + onSetBudget = + if (!walletInfo.canShowBalance) { + { amount, frequency -> + walletViewModel.requestDebitBudget(walletInfo.walletId, amount, frequency) { response -> + val error = response?.error + val msg = + when { + response?.isOk() == true -> context.getString(R.string.clink_budget_approved) + !error.isNullOrBlank() -> error + else -> context.getString(R.string.clink_debit_no_response) + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + } else { + null + }, ) } @@ -288,9 +310,21 @@ private fun WalletCard( onSetDefault: () -> Unit, onRename: (String) -> Unit, onRemove: () -> Unit, + onSetBudget: ((Long, DebitFrequency?) -> Unit)? = null, ) { var showRemoveDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } + var showBudgetDialog by remember { mutableStateOf(false) } + + if (showBudgetDialog && onSetBudget != null) { + ClinkBudgetDialog( + onConfirm = { amount, frequency -> + showBudgetDialog = false + onSetBudget(amount, frequency) + }, + onDismiss = { showBudgetDialog = false }, + ) + } if (showRemoveDialog) { AlertDialog( @@ -449,6 +483,16 @@ private fun WalletCard( Text(stringRes(R.string.wallet_rename), style = MaterialTheme.typography.bodySmall) } + if (onSetBudget != null) { + OutlinedButton( + onClick = { showBudgetDialog = true }, + modifier = Modifier.height(36.dp), + shape = RoundedCornerShape(8.dp), + ) { + Text(stringRes(R.string.clink_budget_set), style = MaterialTheme.typography.bodySmall) + } + } + Spacer(modifier = Modifier.weight(1f)) IconButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 3b30e3d020..b6d3ae1600 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -25,7 +25,10 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -319,6 +322,24 @@ class WalletViewModel : ViewModel() { return true } + /** + * Asks a CLINK debit wallet to authorize a spending budget (kind-21002). Omit + * [frequency] for a one-time budget; otherwise it recurs every day/week/month. + * [onResult] reports the wallet's decision (ok, GFY error text, or null on timeout). + */ + fun requestDebitBudget( + walletId: String, + amountSats: Long, + frequency: DebitFrequency?, + onResult: (DebitResponse?) -> Unit, + ) { + val acc = account ?: return + val pointer = _debitWallets.value.firstOrNull { it.id == walletId }?.pointer ?: return + viewModelScope.launch { + onResult(ClinkDebitPayer.requestBudget(acc, pointer, amountSats, frequency)) + } + } + fun addWallet( name: String, uri: Nip47WalletConnect.Nip47URINorm, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6b5c6dff75..788213b6a6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -122,6 +122,15 @@ Enter a valid amount for this offer. Allowed range: %1$s–%2$s sats CLINK Offer (noffer) + Budget + Spending budget + Amount (sats) + Request + Budget approved + One-time + Daily + Weekly + Monthly Pay only CLINK Debit Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt index 987ebabdf8..32af44338c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt @@ -38,11 +38,17 @@ class DebitRequest( var frequency: DebitFrequency? = null, ) : OptimizedSerializable -/** Recurring-budget cadence; [unit] is one of `day`, `week`, `month`. */ +/** Recurring-budget cadence; [unit] is one of [UNIT_DAY], [UNIT_WEEK], [UNIT_MONTH]. */ class DebitFrequency( var number: Int? = null, var unit: String? = null, -) : OptimizedSerializable +) : OptimizedSerializable { + companion object { + const val UNIT_DAY = "day" + const val UNIT_WEEK = "week" + const val UNIT_MONTH = "month" + } +} /** * Decrypted response from a CLINK Debits service. [res] is `"ok"` on success (with From a481fa5beaa06d884072ab59629a31f19b102a2e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:13:54 +0000 Subject: [PATCH 26/75] fix(clink): harden payer round-trips + two review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a correctness review of the CLINK branch: - ClinkOfferPayer/ClinkDebitPayer now catch decrypt/parse failures from parseResponse and treat an undecryptable reply as no response (return null) instead of throwing. An uncaught SerializationException/NIP-44 failure escaped launchSigner (which only catches signer exceptions), hanging the UI: the offer card stuck on 'Requesting…', the DVM status stuck, the budget toast never shown, and a split zap silently cancelling sibling payments. - ClinkOfferPreview now renders the active (possibly moved) offer's price/type, not the original pointer's, after an Expired-or-Moved redirect. - WalletViewModel.setDefaultWallet only updates local state when the persist actually succeeds, so the default star can't diverge from the stored value. :amethyst compiles. --- .../amethyst/service/ClinkDebitPayer.kt | 11 ++++++++++- .../amethyst/service/ClinkOfferPayer.kt | 12 +++++++++++- .../ui/note/creators/invoice/ClinkOfferPreview.kt | 6 ++++-- .../ui/screen/loggedIn/wallet/WalletViewModel.kt | 7 +++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index 4282332b53..85f48dc31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull @@ -115,7 +116,15 @@ object ClinkDebitPayer { return try { account.client.publish(request, relays) val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null - client.parseResponse(response) + // Treat an undecryptable/malformed reply as no usable response rather than + // throwing — callers only handle null, and an uncaught decode error would + // leave the calling UI hung (spinner stuck, no toast, sibling zaps cancelled). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } } finally { account.client.unsubscribe(subId) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 39e6603650..9d6eba4125 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull @@ -82,7 +83,16 @@ object ClinkOfferPayer { return try { account.client.publish(request, relays) val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null - client.parseResponse(response) + // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON + // from a buggy or hostile relay) is treated as no usable response rather than + // thrown — callers only handle null, and an uncaught decode error would hang + // the UI (the Pay button stuck on "Requesting…"). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } } finally { account.client.unsubscribe(subId) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 5fc7aa994a..0af1691a46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -145,10 +145,12 @@ fun ClinkOfferPreview( // FIXED offers display their preset price; SPONTANEOUS offers (and the default // when the pointer omits a price type) require the payer to enter an amount. - val effectiveType = offer.priceType ?: OfferPriceType.SPONTANEOUS + // Reflect the pointer actually being charged (which may have changed if the + // service redirected us to a replacement noffer via "Expired or Moved"). + val effectiveType = activeOffer.priceType ?: OfferPriceType.SPONTANEOUS if (effectiveType == OfferPriceType.FIXED) { - offer.price?.let { + activeOffer.price?.let { Text( text = "$it ${stringRes(id = R.string.sats)}", fontSize = 25.sp, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index b6d3ae1600..8d05d74d01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -287,8 +287,11 @@ class WalletViewModel : ViewModel() { fun setDefaultWallet(walletId: String) { val acc = account ?: return - acc.settings.setDefaultPaymentSource(walletId) - _defaultWalletId.value = walletId + // Only reflect the change locally if it actually persisted (the id must exist + // in one of the lists); otherwise the star and the stored default would diverge. + if (acc.settings.setDefaultPaymentSource(walletId)) { + _defaultWalletId.value = walletId + } } fun removeWallet(walletId: String) { From b74d769f60686f8968b0861c31b95fd9c0b5327b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:43:33 +0000 Subject: [PATCH 27/75] fix(clink): honor default payment source in App Functions + main-safe debit callback Resolves two review findings: - App Functions (Assistant) pay path gated on hasWalletConnectSetup() and so ignored a debit-only user's chosen default. payViaNwcOrNull is generalized to payViaDefaultSourceOrNull, routing through account.settings.defaultPaymentSource() (NWC wallet or CLINK debit), matching the rest of the app; NwcOutcome -> PayOutcome. - AccountViewModel.payInvoiceViaClinkDebit now delivers onResult on Dispatchers.Main (was Dispatchers.IO via launchSigner), consistent with requestDebitBudget and safe for UI callbacks (toasts/dialogs). :amethyst (play) compiles. --- .../ui/screen/loggedIn/AccountViewModel.kt | 6 +- .../appfunctions/AmethystAppFunctions.kt | 98 ++++++++++--------- 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 48c8e94492..aa62aa3f65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2033,7 +2033,8 @@ class AccountViewModel( /** * Pays a single BOLT-11 through a CLINK debit pointer (kind 21002) — the debit-rail * counterpart of [sendZapPaymentRequestFor]. [onResult] receives the decrypted - * response (`isOk()` with optional preimage, or a GFY failure), or null on timeout. + * response (`isOk()` with optional preimage, or a GFY failure), or null on timeout, + * delivered on the main dispatcher so UI callbacks (toasts, dialogs) are safe. * Untested end-to-end. */ fun payInvoiceViaClinkDebit( @@ -2041,7 +2042,8 @@ class AccountViewModel( bolt11: String, onResult: (DebitResponse?) -> Unit, ) = launchSigner { - onResult(ClinkDebitPayer.payInvoice(account, pointer, bolt11)) + val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11) + withContext(Dispatchers.Main) { onResult(response) } } fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index ed03deb8e7..a9b1ef80cd 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -31,9 +31,12 @@ import com.vitorpamplona.amethyst.commons.actions.FollowActions import com.vitorpamplona.amethyst.commons.actions.SearchActions import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -1408,7 +1411,7 @@ class AmethystAppFunctions { // "I zapped Alice 21 sats" instead of "here's a BOLT11 invoice // for you to paste somewhere." Falls back to manual when NWC // isn't set up or the wallet declines. - val nwc = payViaNwcOrNull(account, invoice, null) + val nwc = payViaDefaultSourceOrNull(account, invoice, null) return ZapResult( chain = "lightning", @@ -1633,7 +1636,7 @@ class AmethystAppFunctions { // Try NWC for every invoice that came back. Failed splits // stay as a manual invoice with nwcError set — the others // still go through. - val nwc = invoice?.let { payViaNwcOrNull(account, it, note) } + val nwc = invoice?.let { payViaDefaultSourceOrNull(account, it, note) } ZapInvoice( recipientNpub = req.recipient.pubkey?.let { NPub.create(it) }, recipientPubkeyHex = req.recipient.pubkey, @@ -1694,42 +1697,65 @@ class AmethystAppFunctions { ?.lnAddress() } - /** Internal result of [payViaNwcOrNull]. */ - private data class NwcOutcome( + /** Internal result of [payViaDefaultSourceOrNull]. */ + private data class PayOutcome( val success: Boolean, val preimage: String?, val errorMessage: String?, ) /** - * Try to pay [bolt11] through the active account's Nostr Wallet - * Connect setup. Returns null when no NWC wallet is configured — - * caller should fall back to surfacing the invoice for manual - * payment. Returns an outcome with [NwcOutcome.success] = true on - * a wallet-confirmed payment, false (with [NwcOutcome.errorMessage] - * set) on rejection or timeout. - * - * The wallet's response can take a few seconds; bounded by - * [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall the - * dispatch. + * Try to pay [bolt11] through the account's selected default payment source — an NWC + * wallet or a CLINK debit. Returns null when no in-app source is configured (caller + * should fall back to surfacing the invoice for manual payment), otherwise an outcome + * with [PayOutcome.success] = true on a wallet-confirmed payment, or false (with + * [PayOutcome.errorMessage]) on rejection or timeout. */ - private suspend fun payViaNwcOrNull( + private suspend fun payViaDefaultSourceOrNull( account: com.vitorpamplona.amethyst.model.Account, bolt11: String, zappedNote: com.vitorpamplona.amethyst.model.Note?, - ): NwcOutcome? { - if (!account.nip47SignerState.hasWalletConnectSetup()) return null + ): PayOutcome? = + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.Nwc -> payViaNwc(account, bolt11, zappedNote) + is PaymentSource.ClinkDebit -> payViaClinkDebit(account, source.wallet.pointer, bolt11) + null -> null + } + /** Pays [bolt11] via a CLINK debit pointer, mapping the kind-21002 reply to a [PayOutcome]. */ + private suspend fun payViaClinkDebit( + account: com.vitorpamplona.amethyst.model.Account, + pointer: NDebit, + bolt11: String, + ): PayOutcome { + val response = ClinkDebitPayer.payInvoice(account, pointer, bolt11) + return when { + response == null -> + PayOutcome(false, null, "CLINK debit wallet didn't respond within ${ClinkDebitPayer.DEFAULT_TIMEOUT_MS / 1000}s") + response.isOk() -> PayOutcome(true, response.preimage, null) + else -> + PayOutcome(false, null, response.error?.takeIf { it.isNotBlank() } ?: "debit declined (code ${response.code})") + } + } + + /** + * Pays [bolt11] via the default NWC wallet. The wallet's response can take a few + * seconds; bounded by [NWC_PAYMENT_TIMEOUT_MS] so a hung wallet can't stall dispatch. + */ + private suspend fun payViaNwc( + account: com.vitorpamplona.amethyst.model.Account, + bolt11: String, + zappedNote: com.vitorpamplona.amethyst.model.Note?, + ): PayOutcome { val deferred = CompletableDeferred() - // sendZapPaymentRequestFor fires onResponse exactly once when - // the wallet replies (success, error, or NwcError). On timeout - // we discard the late response. + // sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies + // (success, error, or NwcError). On timeout we discard the late response. account.sendZapPaymentRequestFor(bolt11, zappedNote) { response -> if (!deferred.isCompleted) deferred.complete(response) } val response = withTimeoutOrNull(NWC_PAYMENT_TIMEOUT_MS) { deferred.await() } - ?: return NwcOutcome( + ?: return PayOutcome( success = false, preimage = null, errorMessage = @@ -1739,35 +1765,13 @@ class AmethystAppFunctions { return when (response) { is PayInvoiceSuccessResponse -> - NwcOutcome( - success = true, - preimage = response.result?.preimage, - errorMessage = null, - ) + PayOutcome(true, response.result?.preimage, null) is PayInvoiceErrorResponse -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = - response.error?.message - ?: response.error?.code?.name - ?: "wallet returned an unspecified pay_invoice error", - ) + PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an unspecified pay_invoice error") is NwcErrorResponse -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = - response.error?.message - ?: response.error?.code?.name - ?: "wallet returned an NWC error", - ) + PayOutcome(false, null, response.error?.message ?: response.error?.code?.name ?: "wallet returned an NWC error") else -> - NwcOutcome( - success = false, - preimage = null, - errorMessage = "Unexpected NWC response type: ${response::class.simpleName}", - ) + PayOutcome(false, null, "Unexpected NWC response type: ${response::class.simpleName}") } } From 3968790db112ddd565bdb71a0599df9c749067b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:20:47 +0000 Subject: [PATCH 28/75] =?UTF-8?q?fix(clink):=20audit=20fixes=20=E2=80=94?= =?UTF-8?q?=20unsigned=20offer=20price,=20Manage=20shape,=20NIP-05=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a spec/SDK audit (verified against the CLINK spec, not just SDK 1.5.5): - NOffer.price: decode as UNSIGNED 4-byte big-endian (now Long) — the SDK reads price via parseInt(hex); reading it signed turned prices >= 2^31 sats negative and broke encode/decode idempotency for high-bit prices. - Manage (21003) messages corrected to the nested spec shape: request nests offer data under offer{id,fields}, payer_data is a string list (not a map), and the response uses details + field (was offer/offers). Documented the single-object details limitation (Manage is consume-unused). - DisplayClinkOffer: cache NIP-05 .well-known clink_offer lookups (incl. negative results) so profile visits / kind-0 refreshes don't refetch nostr.json. Deliberately NOT changed: the offer 'latest' (code 3) field and ndebit k1 at TLV-3 — both are SPEC-defined; the SDK 1.5.5 merely lags, as the code comments already noted. CLINK tests pass; app compiles. --- .../creators/invoice/ClinkOfferPreview.kt | 2 +- .../profile/header/DrawAdditionalInfo.kt | 34 +++++++++-- .../commons/richtext/ClinkOfferSegmentTest.kt | 2 +- .../experimental/clink/client/ManageClient.kt | 21 +++---- .../experimental/clink/client/OfferClient.kt | 2 +- .../clink/manage/ManageMessages.kt | 58 +++++++++++++------ .../experimental/clink/pointers/NOffer.kt | 21 +++++-- .../clink/pointers/ClinkInteropTest.kt | 4 +- 8 files changed, 99 insertions(+), 45 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 0af1691a46..c46185bf78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -193,7 +193,7 @@ fun ClinkOfferPreview( useOffer: NOffer, followMoved: Boolean, ) { - val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price?.toLong() + val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 9fbb21933d..342f0afe6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData +import android.util.LruCache import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -388,10 +389,23 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = else -> R.string.github } +/** + * Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the nip05 + * address. Without it, every profile visit (and every relay-pushed kind-0 refresh while a + * profile is open) would re-fetch the domain's nostr.json. Caches "no offer" results too so + * profiles without one aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable result + * (LruCache can't store nulls); absence means "not fetched yet". + */ +private class ResolvedClinkOffer( + val noffer: String?, +) + +private val clinkOfferNip05Cache = LruCache(256) + /** * Shows a payable CLINK Offer card when the profile advertises one, preferring the * kind-0 `clink_offer` field and falling back to the user's NIP-05 `.well-known` - * `clink_offer`. Paying zaps this profile (see [ClinkOfferPreview]). + * `clink_offer` (cached). Paying pays the advertised offer (see [ClinkOfferPreview]). */ @Composable private fun DisplayClinkOffer( @@ -411,13 +425,21 @@ private fun DisplayClinkOffer( offer = kind0Offer return@LaunchedEffect } - // Fall back to the NIP-05 .well-known clink_offer. + // Fall back to the NIP-05 .well-known clink_offer (cached per address). val id = nip05?.let { Nip05Id.parse(it) } offer = - if (id != null) { - withContext(Dispatchers.IO) { - accountViewModel.nip05ClientBuilder().loadClinkOffer(id)?.let { ClinkPointerParser.parse(it) as? NOffer } - } + if (id != null && nip05 != null) { + // Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch. + val cached = clinkOfferNip05Cache.get(nip05) + val nofferStr = + if (cached != null) { + cached.noffer + } else { + val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) } + clinkOfferNip05Cache.put(nip05, ResolvedClinkOffer(fetched)) + fetched + } + nofferStr?.let { ClinkPointerParser.parse(it) as? NOffer } } else { null } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt index 9925906bd8..a10963ede4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/ClinkOfferSegmentTest.kt @@ -47,7 +47,7 @@ class ClinkOfferSegmentTest { assertEquals(offerFixed, segment.segmentText) assertEquals("offer-id", segment.offer.pointer) assertEquals(OfferPriceType.FIXED, segment.offer.priceType) - assertEquals(21000, segment.offer.price) + assertEquals(21000L, segment.offer.price) } @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt index 056b2997b1..8f9693ac10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.quartz.experimental.clink.client import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields import com.vitorpamplona.quartz.experimental.clink.pointers.NManage import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -45,18 +47,15 @@ class ManageClient( label: String? = null, priceSats: Long? = null, callbackUrl: String? = null, - payerData: Map? = null, + payerData: List? = null, createdAt: Long = TimeUtils.now(), ): ManageEvent = send( ManageRequest( resource = ManageRequest.RESOURCE_OFFER, action = ManageRequest.ACTION_CREATE, - label = label, - price_sats = priceSats, - callback_url = callbackUrl, - payer_data = payerData, pointer = pointer.pointer, + offer = ManageOffer(fields = OfferFields(label, priceSats, callbackUrl, payerData)), ), createdAt, ) @@ -66,18 +65,14 @@ class ManageClient( label: String? = null, priceSats: Long? = null, callbackUrl: String? = null, - payerData: Map? = null, + payerData: List? = null, createdAt: Long = TimeUtils.now(), ): ManageEvent = send( ManageRequest( resource = ManageRequest.RESOURCE_OFFER, action = ManageRequest.ACTION_UPDATE, - id = id, - label = label, - price_sats = priceSats, - callback_url = callbackUrl, - payer_data = payerData, + offer = ManageOffer(id = id, fields = OfferFields(label, priceSats, callbackUrl, payerData)), ), createdAt, ) @@ -85,7 +80,7 @@ class ManageClient( suspend fun getOffer( id: String, createdAt: Long = TimeUtils.now(), - ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, id = id), createdAt) + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_GET, offer = ManageOffer(id = id)), createdAt) suspend fun listOffers(createdAt: Long = TimeUtils.now()): ManageEvent = send( @@ -96,7 +91,7 @@ class ManageClient( suspend fun deleteOffer( id: String, createdAt: Long = TimeUtils.now(), - ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, id = id), createdAt) + ): ManageEvent = send(ManageRequest(ManageRequest.RESOURCE_OFFER, ManageRequest.ACTION_DELETE, offer = ManageOffer(id = id)), createdAt) private suspend fun send( request: ManageRequest, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt index f9481760d4..68ca6472c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -66,7 +66,7 @@ class OfferClient( val request = OfferRequest( offer = pointer.pointer, - amount_sats = amountSats ?: pointer.price?.toLong(), + amount_sats = amountSats ?: pointer.price, payer_data = payerData, zap = zap, expires_in_seconds = expiresInSeconds, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt index 2ee2ee036a..455f3487ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt @@ -24,21 +24,39 @@ import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta import com.vitorpamplona.quartz.experimental.clink.common.SatRange import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +/** + * The editable fields of a managed offer. `payer_data` is a list of field NAMES the offer + * requests from the payer (e.g. `["email", "shipping_address"]`), per the CLINK Manage spec. + */ +class OfferFields( + var label: String? = null, + var price_sats: Long? = null, + var callback_url: String? = null, + var payer_data: List? = null, +) : OptimizedSerializable + +/** + * The `offer` envelope on a Manage request: [id] identifies the target for update/get/delete, + * [fields] carries the values for create/update. + */ +class ManageOffer( + var id: String? = null, + var fields: OfferFields? = null, +) : OptimizedSerializable + /** * Decrypted request to a CLINK Manage service (kind 21003) for delegated offer CRUD. - * [resource] is always `"offer"`; [action] is one of create/update/get/list/delete. - * `create` omits [id] (the server generates it); update/get/delete require [id]; - * list filters by the requesting app. + * The offer data is NESTED under [offer] (not flat): `create` sets `offer.fields`, `update` + * sets `offer.id` + `offer.fields`, `get`/`delete` set `offer.id`, and `list` filters by + * [pointer]. (The published spec shows `create` with fields directly under `offer`; the + * reference SDK nests both create and update under `offer.fields`, which we follow for + * interop with SDK-built services.) */ class ManageRequest( var resource: String? = null, var action: String? = null, - var id: String? = null, - var label: String? = null, - var price_sats: Long? = null, - var callback_url: String? = null, - var payer_data: Map? = null, var pointer: String? = null, + var offer: ManageOffer? = null, ) : OptimizedSerializable { companion object { const val RESOURCE_OFFER = "offer" @@ -50,28 +68,34 @@ class ManageRequest( } } -/** A managed offer as returned by the service, including the server-generated `noffer` pointer. */ -class ManageOffer( +/** A managed offer as returned by the service: [OfferFields] plus its [id] and server-generated [noffer]. */ +class OfferData( var id: String? = null, + var noffer: String? = null, var label: String? = null, var price_sats: Long? = null, var callback_url: String? = null, - var payer_data: Map? = null, - var noffer: String? = null, + var payer_data: List? = null, ) : OptimizedSerializable /** - * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` with - * either [offer] (create/update/get) or [offers] (list) populated; on failure [res] - * is `"GFY"` with [code]/[error] set. + * Decrypted response from a CLINK Manage service. On success [res] is `"ok"` and [details] + * carries the affected offer(s); on failure [res] is `"GFY"` with [code]/[error] (and [field] + * naming the invalid input for validation errors). + * + * NOTE: the spec types `details` as `OfferData | OfferData[]` (a single object for + * create/update/get, an array for list). We model it as a list; a single-object response is + * only fully parsed when the JSON mapper coerces single values to arrays. Amethyst is + * consume-only for Manage (it never drives offer CRUD), so this is a documented limitation + * rather than a live path. */ class ManageResponse( var res: String? = null, var resource: String? = null, - var offer: ManageOffer? = null, - var offers: List? = null, + var details: List? = null, var code: Int? = null, var error: String? = null, + var field: String? = null, var range: SatRange? = null, var retry_after: Long? = null, var delta: GfyDelta? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt index 58cafb972d..eb35516ace 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -40,8 +40,8 @@ data class NOffer( override val pointer: String?, /** TLV 3 — how the offer is priced. Absent means [OfferPriceType.SPONTANEOUS]. */ val priceType: OfferPriceType?, - /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian per the SDK. */ - val price: Int?, + /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian *unsigned* per the SDK. */ + val price: Long?, ) : ClinkPointer { override fun encode(): String = TlvBuilder() @@ -50,7 +50,9 @@ data class NOffer( relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } addStringIfNotNull(ClinkTlv.POINTER, pointer) priceType?.let { addHex(ClinkTlv.PRICE_TYPE, it.code.toSingleByteHex()) } - price?.let { addInt(ClinkTlv.PRICE, it) } + // addInt writes the low 32 bits big-endian; for an unsigned price up to + // 2^32-1 that is the correct 4-byte field even when it overflows a signed Int. + price?.let { addInt(ClinkTlv.PRICE, it.toInt()) } }.build() .let { Bech32.encodeBytes(HRP, it, Bech32.Encoding.Bech32) } @@ -71,7 +73,18 @@ data class NOffer( tlv.data[ClinkTlv.PRICE_TYPE]?.firstOrNull()?.firstOrNull()?.let { OfferPriceType.fromCode(it.toInt() and 0xFF) } - val price = tlv.firstAsInt(ClinkTlv.PRICE) + // The SDK decodes price as an UNSIGNED big-endian integer (parseInt of the hex); + // reading it as a signed Int would turn prices >= 2^31 sats into negative amounts. + val price = + tlv.data[ClinkTlv.PRICE] + ?.firstOrNull() + ?.takeIf { it.size == 4 } + ?.let { + ((it[0].toLong() and 0xFF) shl 24) or + ((it[1].toLong() and 0xFF) shl 16) or + ((it[2].toLong() and 0xFF) shl 8) or + (it[3].toLong() and 0xFF) + } return NOffer(pubKey, relays, pointer, priceType, price) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt index 030dbedbd9..6f11a7b5a1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -60,7 +60,7 @@ class ClinkInteropTest { assertEquals(RelayUrlNormalizer.normalizeOrNull(relay), offer.relays.single()) assertEquals("offer-id", offer.pointer) assertEquals(OfferPriceType.FIXED, offer.priceType) - assertEquals(21000, offer.price) + assertEquals(21000L, offer.price) assertEquals(offer, ClinkPointerParser.parse(offer.encode())) } @@ -78,7 +78,7 @@ class ClinkInteropTest { val offer = ClinkPointerParser.parse(offerVariable) as NOffer assertEquals("v", offer.pointer) assertEquals(OfferPriceType.VARIABLE, offer.priceType) - assertEquals(500, offer.price) + assertEquals(500L, offer.price) assertEquals(offer, ClinkPointerParser.parse(offer.encode())) } From 488d4599843285a983f35e9e024036f582dc0002 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 04:32:51 +0000 Subject: [PATCH 29/75] harden(clink): no self-decrypt fallback + #p on response filters Two robustness fixes from the audit: - OfferEvent/DebitEvent/ManageEvent: replace talkingWith() (which fell back to self when an authored event lacked its p tag, deriving a NIP-44 key with myself) with conversationPeer(), which returns null when I'm neither the author nor the addressed recipient; decryptContent then fails cleanly with UnauthorizedDecryptionException. canDecrypt() is now exactly 'a valid peer exists'. - OfferClient/DebitClient/ManageClient responseFilter now also requires #p == my pubkey, so a service reply that e-tags my request but is addressed to a different payer no longer matches my subscription. Valid request/response round-trips are unchanged (CLINK tests pass). --- .../experimental/clink/client/DebitClient.kt | 2 +- .../experimental/clink/client/ManageClient.kt | 2 +- .../experimental/clink/client/OfferClient.kt | 2 +- .../experimental/clink/debits/DebitEvent.kt | 23 +++++++++++----- .../experimental/clink/manage/ManageEvent.kt | 23 +++++++++++----- .../experimental/clink/offers/OfferEvent.kt | 27 +++++++++++++------ 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt index 6b9a1700d4..8ede27b411 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt @@ -84,7 +84,7 @@ class DebitClient( Filter( kinds = listOf(DebitEvent.KIND), authors = listOf(servicePubKey), - tags = mapOf("e" to listOf(requestId)), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), ) suspend fun parseResponse(event: DebitEvent): DebitResponse = event.decryptResponse(signer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt index 8f9693ac10..a27d5fe985 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/ManageClient.kt @@ -102,7 +102,7 @@ class ManageClient( Filter( kinds = listOf(ManageEvent.KIND), authors = listOf(serverPubKey), - tags = mapOf("e" to listOf(requestId)), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), ) suspend fun parseResponse(event: ManageEvent): ManageResponse = event.decryptResponse(signer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt index 68ca6472c6..20548296b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -80,7 +80,7 @@ class OfferClient( Filter( kinds = listOf(OfferEvent.KIND), authors = listOf(servicePubKey), - tags = mapOf("e" to listOf(requestId)), + tags = mapOf("e" to listOf(requestId), "p" to listOf(signer.pubKey)), ) suspend fun parseResponse(event: OfferEvent): OfferResponse = event.decryptResponse(signer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt index f25458c108..d5dc713ef3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitEvent.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** - * CLINK Debits event (kind 21002). The same kind carries both the request (requestor → - * service) and the response (service → requestor); a response is distinguished by an `e` + * CLINK Debits event (kind 21002). The same kind carries both the request (requestor → + * service) and the response (service → requestor); a response is distinguished by an `e` * tag referencing the request. Content is NIP-44 encrypted between the two parties. * * See https://github.com/shocknet/clink/blob/master/specs/clink-debits.md @@ -61,13 +61,24 @@ class DebitEvent( fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) - private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } - fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null suspend fun decryptContent(signer: NostrSigner): String { - if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - return signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) } suspend fun decryptRequest(signer: NostrSigner): DebitRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt index 3ce4b6cc36..c2901a8669 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageEvent.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** - * CLINK Manage event (kind 21003). The same kind carries both the request (app → - * wallet server) and the response (server → app); a response is distinguished by an `e` + * CLINK Manage event (kind 21003). The same kind carries both the request (app → + * wallet server) and the response (server → app); a response is distinguished by an `e` * tag referencing the request. Content is NIP-44 encrypted between the two parties. * * See https://github.com/shocknet/clink/blob/master/specs/clink-manage.md @@ -61,13 +61,24 @@ class ManageEvent( fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) - private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } - fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null suspend fun decryptContent(signer: NostrSigner): String { - if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - return signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) } suspend fun decryptRequest(signer: NostrSigner): ManageRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt index 9350c64980..ba35a6592e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils /** - * CLINK Offers event (kind 21001). The same kind carries both the request (payer → - * service) and the response (service → payer); a response is distinguished by an `e` + * CLINK Offers event (kind 21001). The same kind carries both the request (payer → + * service) and the response (service → payer); a response is distinguished by an `e` * tag referencing the request. Content is NIP-44 encrypted between the two parties. * * See https://github.com/shocknet/clink/blob/master/specs/clink-offers.md @@ -53,23 +53,34 @@ class OfferEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { override fun isContentEncoded() = true - /** The `p` tag — the counterparty this message is addressed to. */ + /** The `p` tag — the counterparty this message is addressed to. */ fun recipientPubKey() = tags.firstNotNullOfOrNull(PTag::parseKey) - /** The `e` tag — present only on responses, referencing the request event id. */ + /** The `e` tag — present only on responses, referencing the request event id. */ fun requestId() = tags.firstNotNullOfOrNull(ETag::parseId) fun isResponse() = requestId() != null fun version() = tags.firstNotNullOfOrNull(ClinkVersionTag::parse) - private fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) recipientPubKey() ?: pubKey else pubKey + /** + * The NIP-44 conversation peer for [myPubKey]: the counterparty, or null if I am neither + * the author nor the addressed recipient. No self-fallback — a malformed event (e.g. an + * authored request missing its `p` tag) yields null and fails cleanly instead of deriving + * a conversation key with myself. + */ + private fun conversationPeer(myPubKey: HexKey): HexKey? = + when (myPubKey) { + pubKey -> recipientPubKey() + recipientPubKey() -> pubKey + else -> null + } - fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || recipientPubKey() == signer.pubKey + fun canDecrypt(signer: NostrSigner) = conversationPeer(signer.pubKey) != null suspend fun decryptContent(signer: NostrSigner): String { - if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - return signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + val peer = conversationPeer(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return signer.nip44Decrypt(content, peer) } suspend fun decryptRequest(signer: NostrSigner): OfferRequest = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) From 2bd18eb50df5ce723f9551f5b95cd4aba9c22704 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 05:10:54 +0000 Subject: [PATCH 30/75] test(clink): regression tests for the audit fixes Locks in the protocol-layer fixes that were previously only compile-checked: - offerLargePriceRoundTripIsUnsigned: a price > Int.MAX_VALUE round-trips as a positive Long (guards the unsigned-decode fix). - cannotDecryptAuthoredEventMissingRecipient: an authored event with no p tag can't be decrypted by anyone (guards the no-self-fallback conversationPeer). - manageCreateRequestSerializesNested + manageFailureResponseParsesField: the Manage request nests under offer.fields, payer_data is a string list, and the failure response carries field (guards the 21003 shape fix). All CLINK tests pass. --- .../experimental/clink/ClinkEventTest.kt | 49 +++++++++++++++++++ .../clink/pointers/ClinkPointerTest.kt | 13 +++++ 2 files changed, 62 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt index 39539aff19..8fb62cb979 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkEventTest.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.experimental.clink import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse @@ -88,6 +92,23 @@ class ClinkEventTest { assertFalse(request.canDecrypt(stranger)) } + @Test + fun cannotDecryptAuthoredEventMissingRecipient() { + // A malformed event I authored but with no `p` tag: my own key must NOT be used as + // the conversation peer (no self-fallback), so neither party can derive a key. + val noRecipient = + OfferEvent( + id = "a".repeat(64), + pubKey = payer.pubKey, + createdAt = 1L, + tags = arrayOf(ClinkVersionTag.assemble()), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + assertFalse(noRecipient.canDecrypt(payer)) + assertFalse(noRecipient.canDecrypt(service)) + } + // --- JSON DTOs --- @Test @@ -136,4 +157,32 @@ class ClinkEventTest { assertTrue(parsed.isOk()) assertEquals("deadbeef", parsed.preimage) } + + @Test + fun manageCreateRequestSerializesNested() { + val request = + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_CREATE, + offer = ManageOffer(fields = OfferFields("Coffee", 1500L, "https://x/cb", listOf("email", "name"))), + ) + val json = OptimizedJsonMapper.toJson(request) + + // Offer data is nested under offer.fields (not flat). + assertTrue(json.contains("\"offer\""), json) + assertTrue(json.contains("\"fields\""), json) + + val parsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("Coffee", parsed.offer?.fields?.label) + assertEquals(1500L, parsed.offer?.fields?.price_sats) + // payer_data is a list of field names, not a map. + assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data) + } + + @Test + fun manageFailureResponseParsesField() { + val parsed = OptimizedJsonMapper.fromJsonTo("""{"res":"GFY","code":5,"error":"bad","field":"price_sats"}""") + assertFalse(parsed.isOk()) + assertEquals("price_sats", parsed.field) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt index 42d99c8b09..07c4971f36 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -52,6 +52,19 @@ class ClinkPointerTest { assertEquals(offer, parsed) } + @Test + fun offerLargePriceRoundTripIsUnsigned() { + // A price with the high bit set (> Int.MAX_VALUE) must round-trip as a positive + // Long — the price is an unsigned 4-byte big-endian integer, so reading it signed + // would wrap it negative. + val price = 3_000_000_000L + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, price) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(price, parsed.price) + assertEquals(offer, parsed) + } + @Test fun debitStaticRoundTrip() { val debit = NDebit(pubKey, listOf(relay), "pointer-7", null) From 7d9a42a51b899845906438748477b8570e928831 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 06:03:07 +0000 Subject: [PATCH 31/75] feat(cli): amy offer command for CLINK offers (info + request) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds headless CLINK Offers support to amy for interop testing against real offer services: - offer info NOFFER: local decode of a noffer1… pointer (pubkey, relays, pointer id, price type/amount), no network. - offer request NOFFER [--amount SATS] [--timeout MS]: the kind-21001 round trip — publishes the request to the pointer's relays and prints the returned BOLT11, or the service's error. Thin-assembly per the CLI contract: pointer decode + request/response events live in quartz (ClinkPointerParser, OfferClient); the round-trip uses a new Context.requestResponse primitive (publish then await the first matching live reply — unlike drain, which returns at EOSE). Verified: 'offer info' runs end-to-end against an interop vector (correct pubkey/relays/price-type, text + --json modes, bad-pointer error contract + exit 1). The request round-trip needs a live service to exercise fully. --- cli/README.md | 7 + .../com/vitorpamplona/amethyst/cli/Context.kt | 38 ++++++ .../com/vitorpamplona/amethyst/cli/Main.kt | 10 ++ .../amethyst/cli/commands/Commands.kt | 5 + .../amethyst/cli/commands/OfferCommands.kt | 126 ++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt diff --git a/cli/README.md b/cli/README.md index b90d86d480..b6d81bd225 100644 --- a/cli/README.md +++ b/cli/README.md @@ -244,6 +244,13 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 | `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. | | `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. | +### CLINK Offers + +| Command | What it does | +|---|---| +| `amy offer info NOFFER` | Decode a `noffer1…` pointer (pubkey, relays, price type/amount). Local, no network. | +| `amy offer request NOFFER [--amount SATS] [--timeout MS]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. | + ### Wait-for-condition (`await`) Every `await` verb blocks until the condition holds, then prints the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index f8c4a21ee6..b50689a36d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.selects.select @@ -310,6 +311,43 @@ class Context( return collected } + /** + * Publish [request] to [relays], then wait for the FIRST event matching [responseFilter] + * — a live reply that arrives after our own EOSE, which [drain] would miss (it returns at + * EOSE). Verifies and stores the reply. Returns it, or null on timeout; always tears the + * subscription down. Used for request/response round-trips (e.g. a CLINK offer invoice). + */ + suspend fun requestResponse( + request: Event, + relays: Set, + responseFilter: Filter, + timeoutMs: Long = 15_000, + ): Event? { + if (relays.isEmpty()) return null + val reply = CompletableDeferred() + val subId = newSubId() + val filters = relays.associateWith { listOf(responseFilter) } + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (!reply.isCompleted) reply.complete(event) + } + } + client.subscribe(subId, filters, listener) + return try { + publish(request, relays) + val event = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null + if (verifyAndStore(event)) event else null + } finally { + client.unsubscribe(subId) + } + } + /** * Verify [event]'s NIP-01 id+signature and, if valid, persist it * to [store]. Returns `true` when the event was accepted (and diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 1ea7b3d0e3..153a573a18 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -182,6 +182,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.zap(dataDir, tail) } + "offer" -> { + Commands.offer(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -363,6 +367,12 @@ private fun printUsage() { | [--comment X] [--anon|--private] event (must be in local store) | [--timeout SECS] | + |CLINK Offers: + | offer info NOFFER decode a noffer1… pointer (local, no network) + | offer request NOFFER [--amount SATS] kind:21001 round-trip: ask the service for a + | [--timeout MS] fresh BOLT11 (amount required for spontaneous + | offers; defaults to the pointer's fixed price) + | |Search (NIP-50): | search user QUERY [--limit N] search kind:0 profiles | [--timeout SECS] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 446b3acf1c..461c5f4d08 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -115,4 +115,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = ZapCommand.dispatch(dataDir, tail) + + suspend fun offer( + dataDir: DataDir, + tail: Array, + ): Int = OfferCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt new file mode 100644 index 0000000000..917ac95508 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer + +/** + * `amy offer …` — CLINK Offers (`noffer1…`) from the command line, for headless interop + * testing against a real offer service. + * + * - `info ` decodes a pointer locally (no network). + * - `request [--amount N] [--timeout MS]` runs the kind-21001 round-trip: + * publishes the request to the pointer's relays and prints the returned BOLT-11. + * + * Thin assembly only: pointer decode + the request/response event live in `quartz` + * (`ClinkPointerParser`, `OfferClient`); the relay round-trip uses `Context.requestResponse`. + */ +object OfferCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "offer ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "info" -> info(rest) + "request" -> request(dataDir, rest) + else -> Output.error("bad_args", "offer ${tail[0]} (expected info|request)") + } + } + + /** Local decode of a `noffer` pointer — no network, no account needed. */ + private fun info(rest: Array): Int { + val args = Args(rest) + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + + Output.emit( + mapOf( + "pubkey" to offer.pubKey, + "relays" to offer.relays.map { it.url }, + "pointer" to offer.pointer, + "price_type" to offer.priceType?.name?.lowercase(), + "price_sats" to offer.price, + ), + ) + return 0 + } + + /** Request a fresh BOLT-11 from the offer service (kind-21001 round-trip). */ + private suspend fun request( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + val relays = offer.relays.toSet() + if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val client = OfferClient(offer, ctx.signer) + val requestEvent = client.requestInvoice(amountSats = amount) + + val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) + if (reply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + + val response = + (reply as? OfferEvent)?.let { client.parseResponse(it) } + ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") + + return if (response.isSuccess()) { + Output.emit( + mapOf( + "bolt11" to response.bolt11, + "request_id" to requestEvent.id, + "service" to offer.pubKey, + ), + ) + 0 + } else { + Output.error( + "offer_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + ) + } + } finally { + ctx.close() + } + } +} From 904032cea897d291b184282c2207b6fcc7e5d42b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 06:44:45 +0000 Subject: [PATCH 32/75] feat(cli): amy debit command for CLINK debits (info + pay + budget) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes amy's CLINK coverage alongside 'amy offer', reusing the Context.requestResponse round-trip primitive: - debit info NDEBIT: local decode of an ndebit1… pointer (pubkey, relays, pointer id, session flag), no network. - debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]: kind-21002 round trip asking the wallet to pay the invoice; prints preimage or GFY error. - debit budget NDEBIT --amount SATS [--frequency day|week|month] [--timeout MS]: authorize a one-time or recurring spending budget. Thin-assembly: ClinkPointerParser + DebitClient (quartz) do the protocol; the command shares one roundTrip helper. Verified: 'debit info' decodes an interop vector correctly (text + --json), and budget arg validation returns exit 1. pay/budget need a live debit service to exercise fully. --- cli/README.md | 8 + .../com/vitorpamplona/amethyst/cli/Main.kt | 11 ++ .../amethyst/cli/commands/Commands.kt | 5 + .../amethyst/cli/commands/DebitCommands.kt | 166 ++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt diff --git a/cli/README.md b/cli/README.md index b6d81bd225..1cd4fe0f82 100644 --- a/cli/README.md +++ b/cli/README.md @@ -251,6 +251,14 @@ $ amy relay publish-lists # broadcast updated kind:10002/10050/10051 | `amy offer info NOFFER` | Decode a `noffer1…` pointer (pubkey, relays, price type/amount). Local, no network. | | `amy offer request NOFFER [--amount SATS] [--timeout MS]` | kind:21001 round-trip: publish the request to the pointer's relays and print the returned BOLT11. `--amount` is required for spontaneous offers; fixed offers default to the pointer's price. | +### CLINK Debits + +| Command | What it does | +|---|---| +| `amy debit info NDEBIT` | Decode an `ndebit1…` pointer (pubkey, relays, pointer id, session flag). Local, no network. | +| `amy debit pay NDEBIT BOLT11 [--amount SATS] [--timeout MS]` | kind:21002 round-trip: ask the pointed-to wallet to pay the invoice; print the preimage or the service's GFY error. | +| `amy debit budget NDEBIT --amount SATS [--frequency day\|week\|month] [--timeout MS]` | Authorize a spending budget; omit `--frequency` for a one-time budget. | + ### Wait-for-condition (`await`) Every `await` verb blocks until the condition holds, then prints the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 153a573a18..03b6ac97d8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -186,6 +186,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.offer(dataDir, tail) } + "debit" -> { + Commands.debit(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -373,6 +377,13 @@ private fun printUsage() { | [--timeout MS] fresh BOLT11 (amount required for spontaneous | offers; defaults to the pointer's fixed price) | + |CLINK Debits: + | debit info NDEBIT decode an ndebit1… pointer (local, no network) + | debit pay NDEBIT BOLT11 [--amount SATS] kind:21002 round-trip: ask the wallet to pay the + | [--timeout MS] invoice; prints the preimage or a GFY error + | debit budget NDEBIT --amount SATS authorize a spending budget; omit --frequency + | [--frequency day|week|month] [--timeout MS] for a one-time budget + | |Search (NIP-50): | search user QUERY [--limit N] search kind:0 profiles | [--timeout SECS] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 461c5f4d08..527101e9ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -120,4 +120,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = OfferCommands.dispatch(dataDir, tail) + + suspend fun debit( + dataDir: DataDir, + tail: Array, + ): Int = DebitCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt new file mode 100644 index 0000000000..e8ab3de2e5 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient +import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit + +/** + * `amy debit …` — CLINK Debits (`ndebit1…`) from the command line, for headless interop + * testing against a real debit service (e.g. a Lightning.Pub that pre-authorized this + * account's npub). + * + * - `info ` decodes a pointer locally (no network). + * - `pay [--amount SATS]` runs the kind-21002 pay round-trip. + * - `budget --amount SATS [--frequency day|week|month]` authorizes a budget. + * + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `DebitClient`); the round-trip uses `Context.requestResponse`. + */ +object DebitCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "debit ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "info" -> info(rest) + "pay" -> pay(dataDir, rest) + "budget" -> budget(dataDir, rest) + else -> Output.error("bad_args", "debit ${tail[0]} (expected info|pay|budget)") + } + } + + /** Local decode of an `ndebit` pointer — no network, no account needed. */ + private fun info(rest: Array): Int { + val args = Args(rest) + val debit = + ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit + ?: return Output.error("bad_args", "not a valid ndebit pointer") + + Output.emit( + mapOf( + "pubkey" to debit.pubKey, + "relays" to debit.relays.map { it.url }, + "pointer" to debit.pointer, + "session" to debit.isSession, + ), + ) + return 0 + } + + /** Ask the wallet to pay [bolt11] (kind-21002 round-trip). */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val bolt11 = args.positional(1, "bolt11") + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + return roundTrip(dataDir, args, timeoutMs) { client -> client.payInvoice(bolt11, amount) } + } + + /** Ask the wallet to authorize a spending budget; omit --frequency for a one-time budget. */ + private suspend fun budget( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = + args.flag("amount")?.toLongOrNull() + ?: return Output.error("bad_args", "--amount SATS is required for a budget") + val frequency = + when (val f = args.flag("frequency")?.lowercase()) { + null, "once", "one-time" -> null + "day", "daily" -> DebitFrequency(1, DebitFrequency.UNIT_DAY) + "week", "weekly" -> DebitFrequency(1, DebitFrequency.UNIT_WEEK) + "month", "monthly" -> DebitFrequency(1, DebitFrequency.UNIT_MONTH) + else -> return Output.error("bad_args", "unknown --frequency '$f' (day|week|month)") + } + val timeoutMs = args.longFlag("timeout", 15_000) + + return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency) } + } + + /** + * Shared 21002 round-trip: decode the pointer (positional 0), build the request via + * [buildRequest], publish, await the reply, and emit the preimage or GFY error. + */ + private suspend fun roundTrip( + dataDir: DataDir, + args: Args, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Int { + val debit = + ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit + ?: return Output.error("bad_args", "not a valid ndebit pointer") + val relays = debit.relays.toSet() + if (relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val client = DebitClient(debit, ctx.signer) + val requestEvent = buildRequest(client) + + val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) + if (reply == null) { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + return 124 + } + + val response: DebitResponse = + (reply as? DebitEvent)?.let { client.parseResponse(it) } + ?: return Output.error("bad_response", "service reply was not a kind-21002 debit event") + + return if (response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to response.preimage, + "request_id" to requestEvent.id, + "service" to debit.pubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + ) + } + } finally { + ctx.close() + } + } +} From 545018a6a37853a0a70d5fe85d1a00f3773e0dde Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 07:01:43 +0000 Subject: [PATCH 33/75] test(cli): clink-headless harness for amy offer/debit info Local-only shell suite (no relay): asserts amy decodes the canonical CLINK interop vectors (same fixtures as quartz ClinkInteropTest) to the right fields for 'offer info' and 'debit info', plus the argument-error paths (bad pointer, unknown budget frequency, missing --amount). The round-trip verbs need a live service and stay out of scope. 12/12 assertions pass locally (amy init -> decode, no network). --- cli/tests/.gitignore | 1 + cli/tests/README.md | 5 ++ cli/tests/clink/clink-headless.sh | 117 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100755 cli/tests/clink/clink-headless.sh diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 1b89e7da4f..3d5dc27cf2 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -2,3 +2,4 @@ marmot/state/ marmot/state-headless/ dm/state-dm-headless/ nests/state/ +clink/state-clink-headless/ diff --git a/cli/tests/README.md b/cli/tests/README.md index 4b1aeeb171..0fc0100f90 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -25,6 +25,11 @@ cli/tests/ └── README.md # operator brief + per-test matrix ``` +The CLINK suite is local-only (no relay): `clink/clink-headless.sh` asserts that +`amy offer info` / `amy debit info` decode the canonical interop vectors to the +right fields, plus the argument-error paths. The round-trip verbs (`offer +request`, `debit pay/budget`) need a live CLINK service and aren't covered here. + The Marmot harnesses come in two flavours, same scenarios: - **`marmot/marmot-interop.sh`** — interactive. Drives B/C via `wn` and diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh new file mode 100755 index 0000000000..888faff7a0 --- /dev/null +++ b/cli/tests/clink/clink-headless.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# clink-headless.sh — local-decode checks for `amy offer info` / `amy debit info`. +# +# These verbs are pure pointer decode (no network), so this suite needs no relay: +# it asserts that amy decodes the canonical CLINK interop vectors (the same fixtures +# the quartz ClinkInteropTest uses) to the right fields, in both success and error +# paths. The round-trip verbs (`offer request`, `debit pay`, `debit budget`) need a +# live CLINK service and are out of scope here. +# +# Usage: ./clink-headless.sh [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-clink-headless" +LOG_DIR="$STATE_DIR/logs" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +NO_BUILD=0 +[[ "${1:-}" == "--no-build" ]] && NO_BUILD=1 + +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" +# shellcheck source=../headless/helpers.sh +source "$TESTS_DIR/headless/helpers.sh" + +cleanup() { + local rc=$? + trap - EXIT INT TERM HUP + print_summary + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# Canonical interop vectors (fixed pubkey/relay), from quartz ClinkInteropTest. +EXPECTED_PUB="7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" +NOFFER_FIXED="noffer1qszqqqzjpqpszqqzpphkven9wgkkjeqprpmhxue69uhhyetvv9ujuumgda3kkmn9wshxgetkqqs8ul5ug253hlh3n75jne0a5xmjur4urfxpzst88cnegg6ds6ka7nsx7zr9c" +NOFFER_SPONT="noffer1qvqsyqs9wd5x7up3qyv8wumn8ghj7un9d3shjtnndphkx6mwv46zuer9wcqzqln7n3p2jxl77x06j209lksmwtswhsdycy2pvulz09prfkr2mh6wexeyu2" +NDEBIT_STATIC="ndebit1qgyhqmmfde6x2u3dxuq3samnwvaz7tmjv4kxz7fwwd5x7cmtdejhgtnyv4mqqgr706wy92gmlmcel2ffuh76rdewp67p5nq3g9nnufu5ydxcdtwlfcg94z44" + +banner "CLINK pointer decode headless ($RUN_TS)" + +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist >>"$LOG_FILE" 2>&1) || + { fail_msg "amy build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing: $AMY_BIN"; exit 1; } + +# Bare local account — `init` does no relay traffic, which is all we need. +rm -rf "${STATE_DIR:?}/.amy" +amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "amy init failed (see $LOG_FILE)"; exit 1; } + +# --- offer info: fixed-price --- +step "offer info decodes a fixed-price noffer" +OUT=$(amy_json offer info "$NOFFER_FIXED") || true +assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" offer.info.pubkey && + record_result offer.info.pubkey pass "pubkey decoded" +assert_eq "$(jq -r '.pointer' <<<"$OUT")" "offer-id" offer.info.pointer && + record_result offer.info.pointer pass "pointer=offer-id" +assert_eq "$(jq -r '.price_type' <<<"$OUT")" "fixed" offer.info.price_type && + record_result offer.info.price_type pass "price_type=fixed" +assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "21000" offer.info.price_sats && + record_result offer.info.price_sats pass "price_sats=21000" + +# --- offer info: spontaneous (no price) --- +step "offer info decodes a spontaneous noffer (no price)" +OUT=$(amy_json offer info "$NOFFER_SPONT") || true +assert_eq "$(jq -r '.price_type' <<<"$OUT")" "spontaneous" offer.info.spont_type && + record_result offer.info.spont_type pass "price_type=spontaneous" +assert_eq "$(jq -r '.price_sats' <<<"$OUT")" "null" offer.info.spont_price && + record_result offer.info.spont_price pass "price_sats=null" + +# --- offer info: bad pointer => non-zero exit --- +step "offer info rejects a non-noffer string" +if amy_a offer info "definitely-not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result offer.info.bad fail "bad pointer should exit non-zero" +else + record_result offer.info.bad pass "bad pointer exits non-zero" +fi + +# --- debit info: static pointer --- +step "debit info decodes a static ndebit" +OUT=$(amy_json debit info "$NDEBIT_STATIC") || true +assert_eq "$(jq -r '.pubkey' <<<"$OUT")" "$EXPECTED_PUB" debit.info.pubkey && + record_result debit.info.pubkey pass "pubkey decoded" +assert_eq "$(jq -r '.pointer' <<<"$OUT")" "pointer-7" debit.info.pointer && + record_result debit.info.pointer pass "pointer=pointer-7" +assert_eq "$(jq -r '.session' <<<"$OUT")" "false" debit.info.session && + record_result debit.info.session pass "session=false (no k1)" + +# --- debit budget: argument validation (no network needed) --- +step "debit budget rejects an unknown frequency" +if amy_a debit budget "$NDEBIT_STATIC" --amount 1000 --frequency fortnight >>"$LOG_FILE" 2>&1; then + record_result debit.budget.badfreq fail "unknown frequency should exit non-zero" +else + record_result debit.budget.badfreq pass "unknown frequency exits non-zero" +fi + +step "debit budget requires --amount" +if amy_a debit budget "$NDEBIT_STATIC" >>"$LOG_FILE" 2>&1; then + record_result debit.budget.noamount fail "missing --amount should exit non-zero" +else + record_result debit.budget.noamount pass "missing --amount exits non-zero" +fi From 33b3eb5b6f5e3bf35d2dbb88d2b7168aefd6e425 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 07:18:02 +0000 Subject: [PATCH 34/75] docs(clink): record final implementation state, audit results, spec-vs-SDK notes Append a 'Final implementation state' section to the CLINK plan capturing what shipped across Phases 0-3 + the receive side + CLI, the audit findings and their fixes, the three-level verification matrix, and the critical spec-vs-SDK gotchas (offer 'latest' at GFY code 3 and ndebit k1 at TLV-3 are spec-defined and must not be removed). Flip the doc status to implemented. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- quartz/plans/2026-06-09-clink.md | 117 ++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/quartz/plans/2026-06-09-clink.md b/quartz/plans/2026-06-09-clink.md index 51a8a7a4dc..93d65a7878 100644 --- a/quartz/plans/2026-06-09-clink.md +++ b/quartz/plans/2026-06-09-clink.md @@ -1,6 +1,10 @@ # CLINK on Quartz + Amethyst -Status: proposed +Status: implemented (Phases 0–3 + receive side + CLI) — see "Final +implementation state" at the bottom for what actually shipped, the audit +results, and the spec-vs-SDK gotchas. The body below is the original +(proposed) design and is kept for context; where it disagrees with the final +state, the final state wins. Date: 2026-06-09 Owner: TBD @@ -129,3 +133,114 @@ if users want to mint offers from inside the app later. pin against `@shocknet/clink-sdk` source, not the client-rendered docs. - Relay selection for ephemeral req/resp (pointer relay vs account relays). - Response-timeout + retry UX (30s freshness window, GFY code 3 deltas). + +--- + +## Final implementation state + +Everything below reflects what is on the branch now, not the proposal above. +Read this section first if you're touching CLINK. + +### What shipped + +**Phase 0 — Quartz foundation** (`quartz/.../experimental/clink/`) +- Pointers (`pointers/NOffer.kt`, `NDebit.kt`, `NManage.kt`) decoded/encoded + via a dedicated `ClinkPointerParser` (bech32 + TLV), **not** wired into + `Nip19Parser`. Round-trip tested against the canonical interop vectors + (`ClinkPointerTest`, `ClinkInteropTest`). +- Events `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, + refactored to the codebase convention: `eventTemplate(KIND, content, …){…}` + + tag-class DSL (`pTag`, `eTag`, `alt`, `clinkVersion`) and typed accessors + (`PTag::parseKey`, `ETag::parseId`). Registered in `EventFactory`. +- High-level clients `OfferClient` / `DebitClient` / `ManageClient` build the + request event, expose a `responseFilter` (filtered by **both** `e=reqId` + and `p=self`), and parse the NIP-44-decrypted response DTO. +- Shared `clink_version` is its own tag class (`tags/ClinkVersionTag.kt`, + `CURRENT="1"`) reused by all three events, with a `clinkVersion()` builder + extension. The old `Clink.kt` constants object was retired. + +**Phase 1 — Offers consume** (Amethyst): inline feed card +(`ClinkOfferPreview.kt`) renders a payable "⚡" card for a `noffer1…` token, +with a variable-amount field for SPONTANEOUS offers, moved-offer follow +(GFY code 3 `latest`), and the resolved `activeOffer` price. Payment routes +through the shared `InvoicePaymentDispatcher` (confirm-then-pay) into +`ZapPaymentHandler`. **Zappable offers were explicitly reverted** — do not +re-add NIP-57 zaps to offer payment. + +**Phase 2 — Debits as a wallet** (Amethyst, consume-only): a stored `ndebit` +pointer is a first-class payment source alongside NWC. `PaymentSource` +(sealed: `Nwc` / `ClinkDebit`), `PaymentSourceResolver`, and +`AccountSettings.defaultPaymentSource()` unify default selection; +`defaultNwcWalletId` was migrated to `defaultPaymentSourceId`. +`ZapPaymentHandler` dispatches on the resolved source. Budgets/recurring via +`DebitClient.requestBudget` + `DebitFrequency` units (day/week/month). + +**Phase 3 — Manage** (Quartz only, no Amethyst UI): nested request/response +shape — `ManageRequest(resource, action, pointer, offer: ManageOffer?)`, +`ManageOffer(id, fields: OfferFields)`, `OfferData(...)`, `ManageResponse(...)`. + +**Receive side** (advertise your own `noffer`): kind-0 `clink_offer` field +(`UserMetadata.clinkOffer` + dual-written `clink_offer` tag via +`ClinkOfferTag`, NIP-1770 pattern) **and** NIP-05 `.well-known` discovery +(`Nip05Parser.parseClinkOffer`, `INip05Client.loadClinkOffer`). The profile +header (`DisplayClinkOffer`) prefers whichever is present, with a +256-entry `LruCache` over the NIP-05 lookups. + +**CLI** (`amy`): `offer info|request`, `debit info|pay|budget` (thin assembly +only). New `Context.requestResponse(...)` does subscribe→publish→await-first- +matching-live-reply (vs `drain`, which returns at EOSE). + +### Audit findings & resolutions + +- **Offer price is an UNSIGNED 4-byte BE integer.** `NOffer.price` is `Long?`; + decode reads the 4 bytes as unsigned (SDK does `parseInt(hex)`), encode + writes the low 32 bits. The earlier `Int` typing produced a negative price + for any amount ≥ 2^31. Regression: + `ClinkPointerTest.offerLargePriceRoundTripIsUnsigned` (3_000_000_000L). +- **Decrypt guard.** `OfferEvent`/`DebitEvent`/`ManageEvent` replaced the old + self-fallback `talkingWith()` with `conversationPeer(myPubKey)` that returns + `null` when the signer is neither author nor recipient; `decryptContent` + then throws `UnauthorizedDecryptionException`. Regression: + `ClinkEventTest.cannotDecryptAuthoredEventMissingRecipient`. +- **Payer hang fix.** `ClinkOfferPayer` / `ClinkDebitPayer` wrap `parseResponse` + in try/catch and return `null` on a decode failure — an uncaught + `SerializationException` previously hung the UI waiting on a coroutine that + never completed. `payInvoiceViaClinkDebit` now delivers `onResult` on + `Dispatchers.Main`. +- **NIP-05 cache correctness.** The offer cache distinguishes a cache-miss + from a cached-`null` (explicit presence check), so a profile with no offer + isn't re-fetched on every recomposition. + +### CRITICAL — spec vs SDK (do NOT "fix" these) + +The `@shocknet/clink-sdk` (1.5.5) **lags the published spec**. Two things look +like bugs against the SDK but are correct against the spec +(`raw.githubusercontent.com/shocknet/CLINK/main/specs/clink-*.md`) and were +verified there directly: + +1. **Offer moved → GFY `code 3` carries `latest`** (a fresh pointer). The + client follows it. The SDK omits this; the spec defines it. Keep the + follow logic in `ClinkOfferPreview` / `OfferClient`. +2. **ndebit session `k1` lives at TLV index 3.** The SDK doesn't read it; the + spec defines it as the optional single-use session id. Keep decoding it. + +Other shape notes for future maintainers: +- **Manage uses the nested `offer.fields` shape** (above), not a flat object. +- **`ManageResponse.details` is parsed as a single object**, not an array — + Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY` is OFF in this repo, and the + reference service returns one object. Documented as a known limitation in + `ManageMessages.kt`; revisit if a service returns a list. + +### Verification matrix + +| Level | What it covers | Status | +|-------|----------------|--------| +| JVM unit tests | pointer codecs (incl. unsigned price), decrypt guard, Manage nested shape, DTOs, metadata `clink_offer`, NIP-05 discovery, `PaymentSourceResolver` | green | +| `amy` CLI | `offer info`/`request`, `debit info`/`pay`/`budget` — local decode verified end-to-end | green | +| Shell harness | `cli/tests/clink/clink-headless.sh` — decode of canonical vectors + arg-error paths | 12/12 | + +**Remaining gap:** the live NIP-44 request→response round-trip over a relay +against a real CLINK service is not automated (needs a device or a +mock/live service). Feasible later via quartz's in-process relay server +(`nip01Core/relay/server/`) plus a mock CLINK responder; flagged but not +built. From fdc09ad03991435b8546f140ec8202fb1dadbdfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 14:30:02 +0000 Subject: [PATCH 35/75] perf(clink): cache parsed NOffer for NIP-05 lookups + case-insensitive key The profile-header NIP-05 clink_offer cache stored the raw noffer string, re-running ClinkPointerParser on every cache hit; it now stores the parsed NOffer. The cache key is lowercased since NIP-05 identifiers are case-insensitive, so casing variants no longer trigger duplicate fetches. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../profile/header/DrawAdditionalInfo.kt | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 342f0afe6c..e3ee115acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -390,14 +390,15 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int = } /** - * Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the nip05 - * address. Without it, every profile visit (and every relay-pushed kind-0 refresh while a - * profile is open) would re-fetch the domain's nostr.json. Caches "no offer" results too so - * profiles without one aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable result + * Process-wide cache of NIP-05 `.well-known` `clink_offer` lookups, keyed by the + * lowercased nip05 address (NIP-05 identifiers are case-insensitive). Without it, every + * profile visit (and every relay-pushed kind-0 refresh while a profile is open) would + * re-fetch the domain's nostr.json. Caches "no offer" results too so profiles without one + * aren't re-hit. A [ResolvedClinkOffer] wrapper holds the nullable parsed pointer * (LruCache can't store nulls); absence means "not fetched yet". */ private class ResolvedClinkOffer( - val noffer: String?, + val noffer: NOffer?, ) private val clinkOfferNip05Cache = LruCache(256) @@ -430,16 +431,16 @@ private fun DisplayClinkOffer( offer = if (id != null && nip05 != null) { // Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch. - val cached = clinkOfferNip05Cache.get(nip05) - val nofferStr = - if (cached != null) { - cached.noffer - } else { - val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) } - clinkOfferNip05Cache.put(nip05, ResolvedClinkOffer(fetched)) - fetched - } - nofferStr?.let { ClinkPointerParser.parse(it) as? NOffer } + val cacheKey = nip05.lowercase() + val cached = clinkOfferNip05Cache.get(cacheKey) + if (cached != null) { + cached.noffer + } else { + val fetched = withContext(Dispatchers.IO) { accountViewModel.nip05ClientBuilder().loadClinkOffer(id) } + val parsed = fetched?.let { ClinkPointerParser.parse(it) as? NOffer } + clinkOfferNip05Cache.put(cacheKey, ResolvedClinkOffer(parsed)) + parsed + } } else { null } From f9ed2e0ab7dc6d0c9d9df4495a300f1ed16f0305 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:16:44 +0000 Subject: [PATCH 36/75] feat(clink): close ecosystem interop gaps (manage list, TLV3, NIP-05, receipts, payer privacy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interop review against the shocknet/CLINK ecosystem (Lightning.Pub, clink-sdk, ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) surfaced five fixes: 1. Manage `details` single-object responses now parse. Lightning.Pub returns a bare OfferData object for create/update/get and an array only for list; enable Jackson ACCEPT_SINGLE_VALUE_AS_ARRAY so both shapes coerce into the list field. 2. NOffer.encode() always emits the price-type TLV (3), even for spontaneous offers — the reference SDK and bridgelet decoders throw on a missing TLV 3, so an absent field made our pointers undecodable by every JS consumer. Decode now defaults an absent/unknown price-type to SPONTANEOUS, per the spec. 3. Nip05Parser.parseClinkOffer accepts bridgelet's flat top-level `"clink_offer":"noffer1…"` string in addition to the spec's per-name map. 4. Offer payment receipts: OfferEvent.createReceipt/decryptReceipt + OfferClient.parseReceipt + OfferReceipt.isOk() make the post-settlement receipt (the SDK's onReceipt) a parseable primitive instead of a dead DTO. 5. ClinkOfferPayer signs offer requests with an ephemeral key, like the SDK / Zeus / Stacker News, so paying an offer no longer reveals the user's Nostr identity to the service. Debits keep the persistent account key (budgets need a stable app identity). Adds regression tests for each: always-emit TLV3, flat-string NIP-05 discovery, and a receipt round-trip. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/service/ClinkOfferPayer.kt | 10 ++++++- .../experimental/clink/client/OfferClient.kt | 7 +++++ .../clink/manage/ManageMessages.kt | 9 +++--- .../experimental/clink/offers/OfferEvent.kt | 25 ++++++++++++++++ .../clink/offers/OfferMessages.kt | 14 +++++++-- .../experimental/clink/pointers/NOffer.kt | 14 +++++++-- .../quartz/nip05DnsIdentifiers/Nip05Parser.kt | 30 +++++++++++-------- .../clink/ClinkClientServerTest.kt | 19 ++++++++++++ .../clink/pointers/ClinkPointerTest.kt | 17 +++++++++-- .../quartz/nip05DnsIdentifiers/Nip05Test.kt | 10 +++++++ .../quartz/nip01Core/jackson/JacksonMapper.kt | 4 +++ 11 files changed, 133 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 9d6eba4125..cce3b7356e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeoutOrNull @@ -58,7 +60,13 @@ object ClinkOfferPayer { val relays = offer.relays.toSet() if (relays.isEmpty()) return null - val client = OfferClient(offer, account.signer) + // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker + // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this + // key and decrypted with it), so there is no reason to expose the user's real identity + // to every offer service they pay. Payer identity, when needed, travels in the request + // body (payer_data / a signed zap request), not the transport key. + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val client = OfferClient(offer, ephemeralSigner) val request = client.requestInvoice(amountSats = amountSats) val reply = CompletableDeferred() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt index 20548296b7..6fad6ba34a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.clink.client import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer @@ -84,4 +85,10 @@ class OfferClient( ) suspend fun parseResponse(event: OfferEvent): OfferResponse = event.decryptResponse(signer) + + /** + * Parses a post-settlement receipt — the optional second kind-21001 event the service + * sends (on the same `e`+`p` filter as [responseFilter]) once the returned invoice is paid. + */ + suspend fun parseReceipt(event: OfferEvent): OfferReceipt = event.decryptReceipt(signer) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt index 455f3487ed..42dc289b8d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/manage/ManageMessages.kt @@ -83,11 +83,10 @@ class OfferData( * carries the affected offer(s); on failure [res] is `"GFY"` with [code]/[error] (and [field] * naming the invalid input for validation errors). * - * NOTE: the spec types `details` as `OfferData | OfferData[]` (a single object for - * create/update/get, an array for list). We model it as a list; a single-object response is - * only fully parsed when the JSON mapper coerces single values to arrays. Amethyst is - * consume-only for Manage (it never drives offer CRUD), so this is a documented limitation - * rather than a live path. + * NOTE: the spec types `details` as `OfferData | OfferData[]` — a single object for + * create/update/get, an array for list. We model it as a list and rely on the JSON mapper + * coercing a lone object into a one-element list (Jackson `ACCEPT_SINGLE_VALUE_AS_ARRAY` on + * JVM/Android), so both shapes parse. */ class ManageResponse( var res: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt index ba35a6592e..fb35b0b57f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferEvent.kt @@ -87,6 +87,8 @@ class OfferEvent( suspend fun decryptResponse(signer: NostrSigner): OfferResponse = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + suspend fun decryptReceipt(signer: NostrSigner): OfferReceipt = OptimizedJsonMapper.fromJsonTo(decryptContent(signer)) + companion object { const val KIND = 21001 const val ALT = "CLINK offer" @@ -126,5 +128,28 @@ class OfferEvent( }, ) } + + /** + * Builds a post-settlement receipt event (service side). Like [createResponse] it + * references the original [requestEvent] by `e` tag and is addressed to the payer, but + * carries an [OfferReceipt] (`{"res":"ok"[,"preimage"]}`) sent after the invoice is paid. + */ + suspend fun createReceipt( + receipt: OfferReceipt, + requestEvent: OfferEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): OfferEvent { + val payerPubKey = requestEvent.pubKey + val encrypted = signer.nip44Encrypt(OptimizedJsonMapper.toJson(receipt), payerPubKey) + return signer.sign( + eventTemplate(KIND, encrypted, createdAt) { + pTag(payerPubKey, null) + add(ETag.assemble(requestEvent.id, null, null)) + clinkVersion() + alt(ALT) + }, + ) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt index 3cfde03efb..075aa723f0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/offers/OfferMessages.kt @@ -52,11 +52,21 @@ class OfferResponse( fun isSuccess(): Boolean = bolt11 != null } -/** Optional post-settlement receipt (kind 21001). `preimage` is absent for internal settlements. */ +/** + * Optional post-settlement receipt (kind 21001): a second event the service sends after the + * invoice it returned is paid, confirming receipt out-of-band. `preimage` is absent for + * internal (non-Lightning) settlements — the payer's own wallet already holds it for LN pays. + */ class OfferReceipt( var res: String? = null, var preimage: String? = null, -) : OptimizedSerializable +) : OptimizedSerializable { + fun isOk(): Boolean = res == OK + + companion object { + const val OK = "ok" + } +} /** Error codes returned by a CLINK Offers service in [OfferResponse.code]. */ object OfferErrorCode { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt index eb35516ace..1e4faab75a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -38,7 +38,10 @@ data class NOffer( override val pubKey: HexKey, override val relays: List, override val pointer: String?, - /** TLV 3 — how the offer is priced. Absent means [OfferPriceType.SPONTANEOUS]. */ + /** + * TLV 3 — how the offer is priced. A decoded pointer always reports a concrete type + * ([OfferPriceType.SPONTANEOUS] when the wire field was absent, per the CLINK spec). + */ val priceType: OfferPriceType?, /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian *unsigned* per the SDK. */ val price: Long?, @@ -49,7 +52,10 @@ data class NOffer( addHex(ClinkTlv.PUBKEY, pubKey) relays.forEach { addStringIfNotNull(ClinkTlv.RELAY, it.url) } addStringIfNotNull(ClinkTlv.POINTER, pointer) - priceType?.let { addHex(ClinkTlv.PRICE_TYPE, it.code.toSingleByteHex()) } + // Always emit TLV 3, even for spontaneous offers: the reference SDK and + // bridgelet decoders throw on a missing price-type field, so an absent TLV 3 + // would make our pointers undecodable by every JS consumer. + addHex(ClinkTlv.PRICE_TYPE, (priceType ?: OfferPriceType.SPONTANEOUS).code.toSingleByteHex()) // addInt writes the low 32 bits big-endian; for an unsigned price up to // 2^32-1 that is the correct 4-byte field even when it overflows a signed Int. price?.let { addInt(ClinkTlv.PRICE, it.toInt()) } @@ -69,10 +75,12 @@ data class NOffer( val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() val pointer = tlv.firstAsString(ClinkTlv.POINTER) + // Per the CLINK Offers spec, an absent (or unrecognized) price-type defaults to + // spontaneous — the payer chooses the amount. val priceType = tlv.data[ClinkTlv.PRICE_TYPE]?.firstOrNull()?.firstOrNull()?.let { OfferPriceType.fromCode(it.toInt() and 0xFF) - } + } ?: OfferPriceType.SPONTANEOUS // The SDK decodes price as an UNSIGNED big-endian integer (parseInt of the hex); // reading it as a signed Int would turn prices >= 2^31 sats into negative amounts. val price = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt index 467d6493d9..bc80d7c79b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Parser.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.text import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -44,24 +46,28 @@ class Nip05Parser { ?.content /** - * Reads a CLINK Offers pointer (`noffer1…`) from a NIP-05 `.well-known/nostr.json`, - * mirroring the `names` map. ShockNet's clink-demo advertises offers here keyed by the - * local name (queried as `?name=`). The exact key shape is not yet a finalized - * spec, so a missing or differently-shaped `clink_offer` entry simply yields null. + * Reads a CLINK Offers pointer (`noffer1…`) from a NIP-05 `.well-known/nostr.json`. + * + * Two shapes are seen in the wild and both are accepted: + * - the spec/SDK map, keyed by the local name like `names` — + * `{"clink_offer": {"bob": "noffer1…"}}` (queried as `?name=`); + * - bridgelet's flat top-level string — `{"clink_offer": "noffer1…"}` (one alias per file). + * + * A missing or differently-shaped entry simply yields null. */ fun parseClinkOffer( nip05: Nip05Id, json: String, ): String? = try { - Json - .parseToJsonElement(json) - .jsonObject["clink_offer"] - ?.jsonObject - ?.get(nip05.name) - ?.jsonPrimitive - ?.content - ?.takeIf { it.isNotBlank() } + val element = Json.parseToJsonElement(json).jsonObject["clink_offer"] + val raw = + when (element) { + is JsonObject -> element[nip05.name]?.jsonPrimitive?.content + is JsonPrimitive -> element.content + else -> null + } + raw?.takeIf { it.isNotBlank() } } catch (_: Exception) { null } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt index 2627d1ccf6..4480ffe728 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.experimental.clink import com.vitorpamplona.quartz.experimental.clink.client.OfferClient import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.server.ClinkServer import com.vitorpamplona.quartz.experimental.clink.server.K1Tracker @@ -52,6 +53,24 @@ class ClinkClientServerTest { assertEquals(listOf("d".repeat(64)), filter.tags?.get("e")) } + @Test + fun offerReceiptRoundTripsFromServiceToPayer() = + kotlinx.coroutines.test.runTest { + val payer = NostrSignerInternal(KeyPair()) + val service = NostrSignerInternal(KeyPair()) + val offer = NOffer(service.pubKey, listOf(relay), "offer-id", null, null) + val client = OfferClient(offer, payer) + + // payer asks, service settles and sends a receipt referencing the request + val request = client.requestInvoice(amountSats = 1000) + val receiptEvent = OfferEvent.createReceipt(OfferReceipt(res = OfferReceipt.OK, preimage = "ab".repeat(32)), request, service) + + assertEquals(request.id, receiptEvent.requestId()) + val receipt = client.parseReceipt(receiptEvent) + assertTrue(receipt.isOk()) + assertEquals("ab".repeat(32), receipt.preimage) + } + @Test fun serverRequestFilterTargetsRecipientByPTag() { val filter = ClinkServer.debitRequestFilter(servicePubKey, since = 100L) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt index 07c4971f36..1a9204e68d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -34,7 +34,8 @@ class ClinkPointerTest { @Test fun offerSpontaneousRoundTrip() { - val offer = NOffer(pubKey, listOf(relay), "my-offer-id", null, null) + // A spontaneous offer always carries TLV 3 on the wire and decodes back to SPONTANEOUS. + val offer = NOffer(pubKey, listOf(relay), "my-offer-id", OfferPriceType.SPONTANEOUS, null) val encoded = offer.encode() assertTrue(encoded.startsWith("noffer1"), "expected noffer1 prefix, got $encoded") @@ -42,6 +43,16 @@ class ClinkPointerTest { assertEquals(offer, ClinkPointerParser.parse(encoded)) } + @Test + fun offerAlwaysEncodesPriceTypeTlv() { + // Even when the model leaves priceType null, encode must emit TLV 3 (the SDK and + // bridgelet decoders reject a noffer without it); it decodes back as SPONTANEOUS. + val offer = NOffer(pubKey, listOf(relay), null, null, null) + val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer + + assertEquals(OfferPriceType.SPONTANEOUS, parsed.priceType) + } + @Test fun offerFixedPriceRoundTrip() { val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, 21_000) @@ -95,7 +106,7 @@ class ClinkPointerTest { @Test fun parserStripsSchemeAndWhitespace() { - val offer = NOffer(pubKey, listOf(relay), null, null, null) + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.SPONTANEOUS, null) val encoded = offer.encode() assertEquals(offer, ClinkPointerParser.parse(" nostr:$encoded ")) @@ -111,7 +122,7 @@ class ClinkPointerTest { @Test fun parseAllFindsEmbeddedPointers() { - val offer = NOffer(pubKey, listOf(relay), null, null, null).encode() + val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.SPONTANEOUS, null).encode() val debit = NDebit(pubKey, listOf(relay), null, null).encode() val text = "Pay me at $offer or pull from $debit thanks" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index e9d5bad6ad..fac6835a5e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -55,6 +55,16 @@ class Nip05Test { assertEquals(noffer, parser.parseClinkOffer(nip05, json)) } + @Test + fun `parse clink_offer as a flat top-level string (bridgelet shape)`() = + runTest { + val noffer = "noffer1qqsexampleclinkoffer" + val json = """{ "names": { "bob": "abc" }, "clink_offer": "$noffer" }""" + val nip05 = Nip05Id.parse("bob@domain.com") + assertNotNull(nip05) + assertEquals(noffer, parser.parseClinkOffer(nip05, json)) + } + @Test fun `parse clink_offer absent yields null`() = runTest { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index b8abc79d82..b73047c0e5 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -75,6 +75,10 @@ class JacksonMapper { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, false) .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, false) + // Tolerate a single JSON object where a list is declared. Several Nostr-native + // RPCs (e.g. CLINK Manage `details`, typed `OfferData | OfferData[]`) return a + // bare object for single-item results and an array for lists; accept both. + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) .setDefaultPrettyPrinter(defaultPrettyPrinter) .registerModule( From 638486ea1f0aabe6e6283684f3fbfa5e2b8476bc Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 15:01:09 +0200 Subject: [PATCH 37/75] Compute legacy NIP-71 video addresses with their d tag: Extend BaseAddressableEvent instead so dTag() reads the real `d` tag. --- .../nip71Video/ReplaceableVideoEvent.kt | 4 +- .../ReplaceableVideoEventAddressTest.kt | 99 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt index dca8125f51..a129863164 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip71Video import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags @@ -45,7 +45,7 @@ abstract class ReplaceableVideoEvent( tags: Array>, content: String, sig: HexKey, -) : BaseReplaceableEvent(id, pubKey, createdAt, kind, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), PublishedAtProvider, VideoEvent, RootScope { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt new file mode 100644 index 0000000000..9ee3c8dcf2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video + +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Kinds 34235/34236 (legacy NIP-71 videos) are parameterized replaceable + * events: their address MUST include the `d` tag. A wrong (empty-dTag) + * address makes LocalCache consume the event into a different + * AddressableNote than the one `a` tags point to, so quotes/reposts of + * these videos never resolve on screen. + */ +class ReplaceableVideoEventAddressTest { + // Fixture from a real kind-34236 event published by the Divine client + // (bfe2f224…, "Lunchtime for our Koi"); the horizontal test reuses the + // same data synthetically. + private val pubkey = "3b6187c08b9dd5617150ea047e788a0fdd44b4394cb5566cba76f683ddc027d2" + private val dTag = "7af7cae314483a84dcc204824cef10aace246a69c819734412330e2a25f459a1" + + private fun assertAddressUsesDTag( + kind: Int, + event: ReplaceableVideoEvent, + ) { + assertEquals(dTag, event.dTag()) + assertEquals(Address(kind, pubkey, dTag), event.address()) + // The kind:pubkey:dTag wire format is fixed by NIP-01, so it is + // asserted literally instead of via Address.assemble (which is + // what addressTag() calls internally). + assertEquals("$kind:$pubkey:$dTag", event.addressTag()) + } + + @Test + fun verticalVideoAddressUsesDTag() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag), arrayOf("title", "Lunchtime for our Koi")), + content = "Lunchtime for our Koi", + sig = "", + ) + + assertAddressUsesDTag(VideoVerticalEvent.KIND, event) + } + + @Test + fun horizontalVideoAddressUsesDTag() { + val event = + VideoHorizontalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("d", dTag)), + content = "", + sig = "", + ) + + assertAddressUsesDTag(VideoHorizontalEvent.KIND, event) + } + + @Test + fun videoWithoutDTagFallsBackToEmptyAddress() { + val event = + VideoVerticalEvent( + id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", + pubKey = pubkey, + createdAt = 1780894816, + tags = arrayOf(arrayOf("title", "No d tag")), + content = "", + sig = "", + ) + + assertEquals("", event.dTag()) + assertEquals(Address(VideoVerticalEvent.KIND, pubkey, ""), event.address()) + assertEquals("${VideoVerticalEvent.KIND}:$pubkey:", event.addressTag()) + } +} From 5e465410b27169fdcbc5d87ab1ae6f8089866f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:26:03 +0000 Subject: [PATCH 38/75] test(clink): add clink-demo DEFAULT_NOFFER interop vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clinkme.dev demo (shocknet/clink-demo, public domain) hard-codes a live default noffer. Adds it as an 8th cross-impl vector — a real-world spontaneous, relay-bearing, no-price offer with a 64-char-hex offer-id — decoded and round-tripped through our parser. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../clink/pointers/ClinkInteropTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt index 6f11a7b5a1..c48191c623 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -73,6 +73,23 @@ class ClinkInteropTest { assertEquals(offer, ClinkPointerParser.parse(offer.encode())) } + // The live default offer hard-coded in shocknet/clink-demo (clinkme.dev), src/index.ts + // `DEFAULT_NOFFER`. Public domain (the demo is Unlicensed). A real-world spontaneous, + // relay-bearing, no-price offer whose offer-id is a 64-char hex string. + private val clinkDemoDefaultOffer = + "noffer1qvqsyqjqxuurvwpcxc6rvvrxxsurqep5vfjk2wf4v33nsenrxumnyvesxfnrswfkvycrwdp3x93xydf5xg6rzce4vv6xgdfh8quxgct9x5erxvspremhxue69uhhgetnwskhyetvv9ujumrfva58gmnfdenjuur4vgqzpccxc30wpf78wf2q78wg3vq008fd8ygtl4qy06gstpye3h5unc47xmee6z" + + @Test + fun decodesClinkDemoDefaultOffer() { + val offer = ClinkPointerParser.parse(clinkDemoDefaultOffer) as NOffer + assertEquals("e306c45ee0a7c772540f1dc88b00f79d2d3910bfd4047e910584998de9c9e2be", offer.pubKey) + assertEquals(RelayUrlNormalizer.normalizeOrNull("wss://test-relay.lightning.pub"), offer.relays.single()) + assertEquals("786886460f480d4bee95dc8fc772302f896a07411bb54241c5c4d5788dae5232", offer.pointer) + assertEquals(OfferPriceType.SPONTANEOUS, offer.priceType) + assertNull(offer.price) + assertEquals(offer, ClinkPointerParser.parse(offer.encode())) + } + @Test fun decodesAndRoundTripsOfferVariable() { val offer = ClinkPointerParser.parse(offerVariable) as NOffer From 84e91833c4141d3ab5a7878c0c68beee9ef25a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:35:03 +0000 Subject: [PATCH 39/75] feat: add @-mention user search and tagging to Marmot group chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the MLS/Marmot message composer to parity with NIP-17 DMs: typing @ shows the shared user-suggestion dropdown (local cache + NIP-05 resolution), selecting a user inserts @npub…, and on send NewMessageTagger rewrites mentions into nostr: URIs and collects the referenced users as p-tags on the inner kind:9 rumor. Mentions stay inside the MLS ciphertext; the outer kind:445 is unchanged. Also applies MentionPreservingInputTransformation and UrlUserTagOutputTransformation to the field so mentions render highlighted while composing, matching the DM editor. https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n --- .../ui/screen/loggedIn/AccountViewModel.kt | 3 ++ .../chats/marmotGroup/MarmotGroupChatView.kt | 45 ++++++++++++++++++- .../amethyst/commons/marmot/MarmotManager.kt | 9 ++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index dcfca17eca..b0b62c39e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -106,6 +106,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -1626,6 +1627,7 @@ class AccountViewModel( text: String, replyToInnerEventId: HexKey? = null, replyToInnerAuthorPubKey: HexKey? = null, + mentions: List = emptyList(), ) { // Inner event construction lives on MarmotManager so CLI and UI don't drift. // persistOwn=false because Account.sendMarmotGroupMessage routes the outer @@ -1638,6 +1640,7 @@ class AccountViewModel( replyToEventId = replyToInnerEventId, replyToAuthorPubKey = replyToInnerAuthorPubKey, persistOwn = false, + mentions = mentions, ) ?: return val relays = account.marmotGroupRelays(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index b5936e8f2c..16952c4a7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -50,12 +50,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender @@ -69,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @@ -181,6 +188,15 @@ fun MarmotGroupMessageComposer( ) } + val userSuggestions = + remember(nostrGroupId) { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(nostrGroupId) { + onDispose { userSuggestions.reset() } + } + // Upload dialog uploadState.multiOrchestrator?.let { MarmotGroupFileUploadDialog( @@ -200,11 +216,33 @@ fun MarmotGroupMessageComposer( } Column(modifier = EditFieldModifier) { + ShowUserSuggestionList( + userSuggestions, + onSelect = { user -> + userSuggestions.replaceCurrentWord(messageState, messageState.currentWord(), user) + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + ) + ThinPaddingTextField( state = messageState, + onTextChanged = { + if (messageState.selection.collapsed) { + val lastWord = messageState.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions.processCurrentWord(lastWord) + } else { + userSuggestions.reset() + } + } + }, onContentReceived = { uri, mimeType -> uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth(), shape = EditFieldBorder, placeholder = { @@ -235,11 +273,16 @@ fun MarmotGroupMessageComposer( val replyAuthor = parentEvent?.pubKey scope.launch(Dispatchers.IO) { try { + // Rewrites @npub…/@nprofile… mentions into nostr: URIs and + // collects the referenced users as p-tags for the inner event. + val tagger = NewMessageTagger(text, null, null, accountViewModel) + tagger.run() accountViewModel.sendMarmotGroupMessage( nostrGroupId = nostrGroupId, - text = text, + text = tagger.message, replyToInnerEventId = replyId, replyToInnerAuthorPubKey = replyAuthor, + mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) messageState.clearText() replyTo.value = null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b4a0634e1f..12264f99e5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.utils.Log @@ -178,6 +180,11 @@ class MarmotManager( * `persistOwn = false`. Headless callers (CLI) should leave it at * the default. * + * [mentions] become p-tags on the inner kind:9 (users referenced via + * `nostr:npub…`/`nostr:nprofile…` in [text]), mirroring how NIP-17 + * chat messages tag mentioned users. They stay inside the MLS + * ciphertext — the outer kind:445 never carries member pubkeys. + * * @return the signed kind:445 outer event together with the inner kind:9 * rumor id, so the caller can reference it for replies/reactions. */ @@ -187,10 +194,12 @@ class MarmotManager( replyToEventId: HexKey? = null, replyToAuthorPubKey: HexKey? = null, persistOwn: Boolean = true, + mentions: List = emptyList(), ): TextMessageBundle { val template = com.vitorpamplona.quartz.nip01Core.signers .eventTemplate(kind = 9, description = text) { + pTags(mentions) if (replyToEventId != null) { // Mirror ChatEvent.reply(): NIP-18 q-tag references the // parent inner kind:9 by id (+ optional author, no From eb7b3bad07ef7f7b66910a9c96e3a51c71483bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:46:51 +0000 Subject: [PATCH 40/75] test(clink): golden wire-shape fixtures from the public-domain specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ClinkWireShapeTest: the literal decrypted JSON payload bodies documented in shocknet/CLINK/specs/clink-{offers,debits,manage}.md (public domain) must deserialize into our DTOs with the right fields. Covers the encrypted-content half the bech32 pointer vectors don't: offer request + success/error codes 1-5 (incl. code-3 latest, code-5 range) + receipts; debit direct/budget requests, success, and GFY 1-6 (incl. delta, retry_after, range); manage nested offer.fields requests and responses — including the single-object 'details' coercing to a list, which exercises the Manage list/single interop fix. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../experimental/clink/ClinkWireShapeTest.kt | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt new file mode 100644 index 0000000000..d3a2215b77 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.clink + +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Golden wire-shape fixtures: the literal decrypted JSON payload bodies documented in the + * CLINK specs (`shocknet/CLINK/specs/clink-{offers,debits,manage}.md`, declared public domain) + * must deserialize into our DTOs with the right fields. These guard the request/response shapes + * we exchange with every CLINK service against drift — they are the encrypted-content half that + * the bech32 pointer vectors (`ClinkInteropTest`) don't cover. + * + * Placeholders shown as `<…>` in the spec markdown are substituted with realistic concrete + * values; numeric placeholders (`actual_delta_ms`, `retry_after`) use numbers, not the + * markdown's quoted strings, matching what real services emit. + */ +class ClinkWireShapeTest { + private inline fun parse(json: String): T = OptimizedJsonMapper.fromJsonTo(json) + + // ---------- Offers (kind 21001) ---------- + + @Test + fun offerRequest() { + val req = + parse( + """{"offer":"coffee","amount_sats":21000,"payer_data":{},"zap":"{...}","expires_in_seconds":3600,"description":"A coffee"}""", + ) + assertEquals("coffee", req.offer) + assertEquals(21000L, req.amount_sats) + assertEquals(3600L, req.expires_in_seconds) + assertEquals("A coffee", req.description) + assertEquals("{...}", req.zap) + assertNotNull(req.payer_data) + } + + @Test + fun offerSuccessBolt11() { + val res = parse("""{"bolt11":"lnbc10u1pexample"}""") + assertTrue(res.isSuccess()) + assertEquals("lnbc10u1pexample", res.bolt11) + } + + @Test + fun offerErrorInvalidOffer() { + val res = parse("""{"error":"Invalid Offer","code":1}""") + assertFalse(res.isSuccess()) + assertEquals(1, res.code) + assertEquals("Invalid Offer", res.error) + assertNull(res.bolt11) + } + + @Test + fun offerErrorExpiredNoForwarding() { + val res = parse("""{"error":"Offer has expired.","code":3}""") + assertEquals(3, res.code) + assertNull(res.latest) + } + + @Test + fun offerErrorMovedWithLatest() { + val res = + parse( + """{"error":"Offer has been replaced or moved.","code":3,"latest":"noffer1qqsmoved"}""", + ) + assertEquals(3, res.code) + assertEquals("noffer1qqsmoved", res.latest) + } + + @Test + fun offerErrorInvalidAmountRange() { + val res = parse("""{"error":"Invalid Amount","code":5,"range":{"min":10,"max":10000000}}""") + assertEquals(5, res.code) + assertEquals(10L, res.range?.min) + assertEquals(10000000L, res.range?.max) + } + + @Test + fun offerReceiptWithPreimage() { + val receipt = parse("""{"res":"ok","preimage":"${"ab".repeat(32)}"}""") + assertTrue(receipt.isOk()) + assertEquals("ab".repeat(32), receipt.preimage) + } + + @Test + fun offerReceiptInternalSettlement() { + val receipt = parse("""{"res":"ok"}""") + assertTrue(receipt.isOk()) + assertNull(receipt.preimage) + } + + // ---------- Debits (kind 21002) ---------- + + @Test + fun debitDirectPaymentRequest() { + val req = + parse( + """{"pointer":"app-7","amount_sats":10000,"bolt11":"lnbc100n1pexample","description":"zap","k1":"${"4c".repeat(32)}"}""", + ) + assertEquals("app-7", req.pointer) + assertEquals(10000L, req.amount_sats) + assertEquals("lnbc100n1pexample", req.bolt11) + assertEquals("4c".repeat(32), req.k1) + assertNull(req.frequency) + } + + @Test + fun debitBudgetRequest() { + val req = + parse( + """{"pointer":"app-7","amount_sats":50000,"frequency":{"number":1,"unit":"month"},"description":"sub"}""", + ) + assertEquals(50000L, req.amount_sats) + assertEquals(1, req.frequency?.number) + assertEquals(DebitFrequency.UNIT_MONTH, req.frequency?.unit) + assertNull(req.bolt11) + } + + @Test + fun debitSuccessWithPreimage() { + val res = parse("""{"res":"ok","preimage":"${"cd".repeat(32)}"}""") + assertTrue(res.isOk()) + assertEquals("cd".repeat(32), res.preimage) + } + + @Test + fun debitSuccessInternalOrBudgetApproval() { + val res = parse("""{"res":"ok"}""") + assertTrue(res.isOk()) + assertNull(res.preimage) + } + + @Test + fun debitGfyRequestDenied() { + val res = parse("""{"res":"GFY","code":1,"error":"Request Denied"}""") + assertFalse(res.isOk()) + assertEquals(1, res.code) + assertEquals("Request Denied", res.error) + } + + @Test + fun debitGfyExpiredWithDelta() { + val res = + parse( + """{"res":"GFY","code":3,"error":"Expired Request","delta":{"max_delta_ms":30000,"actual_delta_ms":31200}}""", + ) + assertEquals(3, res.code) + assertEquals(30000L, res.delta?.max_delta_ms) + assertEquals(31200L, res.delta?.actual_delta_ms) + } + + @Test + fun debitGfyRateLimitedRetryAfter() { + val res = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""") + assertEquals(4, res.code) + assertEquals(1750000000L, res.retry_after) + } + + @Test + fun debitGfyInvalidAmountRange() { + val res = parse("""{"res":"GFY","code":5,"error":"Invalid Amount","range":{"min":1000,"max":500000}}""") + assertEquals(5, res.code) + assertEquals(1000L, res.range?.min) + assertEquals(500000L, res.range?.max) + } + + @Test + fun debitGfyInvalidRequest() { + val res = parse("""{"res":"GFY","code":6,"error":"Invalid Request: K1 already processed"}""") + assertEquals(6, res.code) + assertEquals("Invalid Request: K1 already processed", res.error) + } + + // ---------- Manage (kind 21003) ---------- + // Requests use the nested `offer.fields` shape (the form the reference SDK, Lightning.Pub, + // and clink-demo exchange — see ManageMessages.kt), not the spec's inline-create example. + + @Test + fun manageUpdateRequestNestedFields() { + val req = + parse( + """{"resource":"offer","action":"update","offer":{"id":"off-1","fields":{"label":"Updated Product X","price_sats":23456,"callback_url":"https://m.app/cb","payer_data":["email","shipping_address"]}}}""", + ) + assertEquals(ManageRequest.RESOURCE_OFFER, req.resource) + assertEquals(ManageRequest.ACTION_UPDATE, req.action) + assertEquals("off-1", req.offer?.id) + assertEquals("Updated Product X", req.offer?.fields?.label) + assertEquals(23456L, req.offer?.fields?.price_sats) + assertEquals(listOf("email", "shipping_address"), req.offer?.fields?.payer_data) + } + + @Test + fun manageListRequest() { + val req = parse("""{"resource":"offer","action":"list"}""") + assertEquals(ManageRequest.ACTION_LIST, req.action) + assertNull(req.offer) + } + + @Test + fun manageDeleteRequest() { + val req = parse("""{"resource":"offer","action":"delete","offer":{"id":"off-1"}}""") + assertEquals(ManageRequest.ACTION_DELETE, req.action) + assertEquals("off-1", req.offer?.id) + } + + @Test + fun manageSuccessSingleDetailsObjectCoercesToList() { + // create/update/get return a bare object for `details`; it must coerce into our list. + val res = + parse( + """{"res":"ok","resource":"offer","details":{"id":"off-1","label":"Product X","price_sats":12345,"callback_url":"https://m.app/cb","payer_data":["email"],"noffer":"noffer1qqsabc"}}""", + ) + assertTrue(res.isOk()) + assertEquals(1, res.details?.size) + assertEquals("off-1", res.details?.first()?.id) + assertEquals("Product X", res.details?.first()?.label) + assertEquals(12345L, res.details?.first()?.price_sats) + assertEquals("noffer1qqsabc", res.details?.first()?.noffer) + } + + @Test + fun manageSuccessListDetailsArray() { + val res = + parse( + """{"res":"ok","resource":"offer","details":[{"id":"off-1","label":"Product X","price_sats":12345,"noffer":"noffer1qqsabc"}]}""", + ) + assertTrue(res.isOk()) + assertEquals(1, res.details?.size) + assertEquals("off-1", res.details?.first()?.id) + } + + @Test + fun manageDeleteSuccessNoDetails() { + val res = parse("""{"res":"ok","resource":"offer"}""") + assertTrue(res.isOk()) + assertNull(res.details) + } + + @Test + fun manageGfyInvalidFieldWithFieldAndRange() { + val res = + parse( + """{"res":"GFY","code":5,"error":"Invalid Field/Value","field":"price_sats","range":{"min":1000,"max":1000000}}""", + ) + assertFalse(res.isOk()) + assertEquals(5, res.code) + assertEquals("price_sats", res.field) + assertEquals(1000L, res.range?.min) + assertEquals(1000000L, res.range?.max) + } + + @Test + fun manageGfyRateLimitedRetryAfter() { + val res = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":600}""") + assertEquals(4, res.code) + assertEquals(600L, res.retry_after) + } +} From c78ba27c8606185ebd842cdcae18cc6c46f58fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:15:25 +0000 Subject: [PATCH 41/75] test(clink): correct provenance of the default-offer interop vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vector is the canonical @shocknet/clink-sdk example — both its MIT README usage snippet and clink-demo's public-domain DEFAULT_NOFFER are the same string. Confirmed the published npm tarball ships only build output (no test vectors), so this is the one real codec vector the ecosystem exposes. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../quartz/experimental/clink/pointers/ClinkInteropTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt index c48191c623..bc313a356e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkInteropTest.kt @@ -73,9 +73,9 @@ class ClinkInteropTest { assertEquals(offer, ClinkPointerParser.parse(offer.encode())) } - // The live default offer hard-coded in shocknet/clink-demo (clinkme.dev), src/index.ts - // `DEFAULT_NOFFER`. Public domain (the demo is Unlicensed). A real-world spontaneous, - // relay-bearing, no-price offer whose offer-id is a 64-char hex string. + // The canonical example offer shipped by @shocknet/clink-sdk: it is both the README usage + // example (MIT) and clink-demo / clinkme.dev's `DEFAULT_NOFFER` (public domain). A + // real-world spontaneous, relay-bearing, no-price offer whose offer-id is a 64-char hex string. private val clinkDemoDefaultOffer = "noffer1qvqsyqjqxuurvwpcxc6rvvrxxsurqep5vfjk2wf4v33nsenrxumnyvesxfnrswfkvycrwdp3x93xydf5xg6rzce4vv6xgdfh8quxgct9x5erxvspremhxue69uhhgetnwskhyetvv9ujumrfva58gmnfdenjuur4vgqzpccxc30wpf78wf2q78wg3vq008fd8ygtl4qy06gstpye3h5unc47xmee6z" From bf0fa0c57ecf2df5ec652b67390db3f68bb1edf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:34:09 +0000 Subject: [PATCH 42/75] feat: rank conversation participants first in @-mention suggestions Adds an optional priorityPubkeys supplier to UserSuggestionState that stable-sorts search results so the current conversation's participants appear before network-wide matches (ranking only, never filters). Wired per chat context: - MLS/Marmot groups: live MLS member list - NIP-17 DMs/groups: the room's users - Public chats (NIP-28): authors who have posted in the channel - Nests audio rooms: MeetingSpaceEvent participants + host https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n --- .../userSuggestions/UserSuggestionState.kt | 19 ++++++++++++++++++- .../chats/marmotGroup/MarmotGroupChatView.kt | 7 ++++++- .../privateDM/send/ChatNewMessageViewModel.kt | 7 ++++++- .../send/ChannelNewMessageViewModel.kt | 9 ++++++++- .../room/chat/NestNewMessageViewModel.kt | 11 ++++++++++- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index f066303433..22b4ef5515 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client @@ -58,10 +59,20 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Drives the @-mention autocomplete dropdown: searches the local cache, + * relays, and NIP-05 identifiers for the word currently being typed. + * + * [priorityPubkeys] is a live supplier of pubkeys to rank first in the + * results — pass the current conversation's participants (NIP-17 room + * users, public-chat authors, MLS group members, …) so they beat + * network-wide matches. Ranking only; it never filters anyone out. + */ @Stable class UserSuggestionState( val account: Account, val nip05Client: INip05Client, + val priorityPubkeys: () -> Set = { emptySet() }, ) { val invalidations = MutableStateFlow(0) val currentWord = MutableStateFlow("") @@ -158,7 +169,13 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - account.cache.findUsersStartingWith(prefix, account) + val found = account.cache.findUsersStartingWith(prefix, account) + val priority = priorityPubkeys() + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } } } else { emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 16952c4a7f..b9b0f2d1c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -190,7 +190,12 @@ fun MarmotGroupMessageComposer( val userSuggestions = remember(nostrGroupId) { - UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + val group = accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) + UserSuggestionState( + accountViewModel.account, + accountViewModel.nip05ClientBuilder(), + priorityPubkeys = { group.members.value.mapTo(mutableSetOf()) { it.pubkey } }, + ) } DisposableEffect(nostrGroupId) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 45d481b0af..8bbc1f2fe6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -261,7 +261,12 @@ class ChatNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { room.value?.users ?: emptySet() }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index f826189c6d..0364ce4dcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -188,7 +188,14 @@ open class ChannelNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + channel?.participatingAuthors(0)?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 4357b1eda9..878db35bce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -194,7 +194,16 @@ open class NestNewMessageViewModel : this.canAddZapRaiser = hasLnAddress() this.userSuggestions?.reset() - this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { + (room?.event as? MeetingSpaceEvent)?.let { space -> + space.participantKeys().toMutableSet().apply { add(space.pubKey) } + } ?: emptySet() + }, + ) this.emojiSuggestions?.reset() this.emojiSuggestions = EmojiSuggestionState(accountVM.account) From f4e0bcf73ddb864d6a24b84e5cd5fdf07d5e5d34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:44:00 +0000 Subject: [PATCH 43/75] fix(clink): spec-conformance hardening (k1 length, frequency, description, GFY detail) Follow-ups from the line-by-line spec audit, scoped to the consume-only client: - NDebit.parse rejects a TLV-3 session id that isn't exactly 32 bytes (64 hex), per clink-debits: a wrong-length k1 is a malformed session pointer. - DebitClient.requestBudget validates frequency.unit is one of day/week/month (DebitFrequency.VALID_UNITS) instead of sending a unit a node service will GFY. - OfferClient caps the invoice description at 100 chars per clink-offers. - DebitResponse.failureDetail() composes the GFY error with its actionable extra (allowed range for code 5, retry_after for code 4); the debit zap path now surfaces that instead of the bare error string. Adds regression tests for each (malformed-k1 rejection, invalid-unit throw, description truncation, failureDetail range/retry_after). https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/service/ZapPaymentHandler.kt | 2 +- .../experimental/clink/client/DebitClient.kt | 5 ++++ .../experimental/clink/client/OfferClient.kt | 4 +++- .../clink/debits/DebitMessages.kt | 22 +++++++++++++++++ .../experimental/clink/pointers/NDebit.kt | 4 ++++ .../clink/ClinkClientServerTest.kt | 24 +++++++++++++++++++ .../experimental/clink/ClinkWireShapeTest.kt | 13 ++++++++++ .../clink/pointers/ClinkPointerTest.kt | 9 +++++++ 8 files changed, 81 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index e25e8946a7..9c250325d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -416,7 +416,7 @@ class ZapPaymentHandler( if (!paid) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), - response?.error?.takeIf { it.isNotBlank() } + response?.failureDetail() ?: stringRes(context, R.string.clink_debit_no_response), payable.info.user, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt index 8ede27b411..b6b5fd2704 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/DebitClient.kt @@ -69,6 +69,11 @@ class DebitClient( description: String? = null, createdAt: Long = TimeUtils.now(), ): DebitEvent { + // Per spec the recurring cadence unit is one of day/week/month; reject anything else + // rather than sending a request a node service will GFY. + require(frequency == null || frequency.unit in DebitFrequency.VALID_UNITS) { + "Invalid budget frequency unit: ${frequency?.unit}" + } val request = DebitRequest( pointer = pointer.pointer, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt index 6fad6ba34a..cd7fc87edf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/client/OfferClient.kt @@ -71,7 +71,9 @@ class OfferClient( payer_data = payerData, zap = zap, expires_in_seconds = expiresInSeconds, - description = description, + // The spec caps the invoice description at 100 chars; trim so an over-long + // value doesn't get the whole request rejected by the service. + description = description?.take(100), ) return OfferEvent.createRequest(request, servicePubKey, signer, createdAt) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt index 32af44338c..8d4fd67381 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/debits/DebitMessages.kt @@ -47,6 +47,9 @@ class DebitFrequency( const val UNIT_DAY = "day" const val UNIT_WEEK = "week" const val UNIT_MONTH = "month" + + /** The only cadence units the spec defines. */ + val VALID_UNITS = setOf(UNIT_DAY, UNIT_WEEK, UNIT_MONTH) } } @@ -67,6 +70,25 @@ class DebitResponse( ) : OptimizedSerializable { fun isOk(): Boolean = res == OK + /** + * A failure detail string for a GFY response: the service's [error] plus the actionable + * structured extra the spec attaches per code — the allowed [range] (code 5) or + * [retry_after] timestamp (code 4). Returns null for a success ([isOk]) response. The + * text is protocol-neutral (English, like the service's own `error`), meant to be shown + * as the detail line under a localized "payment failed" title. + */ + fun failureDetail(): String? { + if (isOk()) return null + val base = error?.takeIf { it.isNotBlank() } + val extra = + when { + range != null -> "allowed ${range?.min ?: "?"} to ${range?.max ?: "?"} sats" + retry_after != null -> "retry after $retry_after" + else -> null + } + return listOfNotNull(base, extra?.let { "($it)" }).joinToString(" ").ifBlank { null } + } + companion object { const val OK = "ok" const val GFY = "GFY" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt index c8458f0bd7..3e884ed7a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NDebit.kt @@ -71,6 +71,10 @@ data class NDebit( val relays = tlv.asString(ClinkTlv.RELAY)?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?: emptyList() val pointer = tlv.firstAsString(ClinkTlv.POINTER) val k1 = tlv.firstAsHex(ClinkTlv.K1) + // A session id (TLV 3) MUST be exactly 32 bytes (64 hex chars) per the spec; a + // wrong-length value means a malformed session pointer, so reject the whole thing + // rather than silently treating it as static or sending a bad k1. + if (k1 != null && k1.length != 64) return null return NDebit(pubKey, relays, pointer, k1) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt index 4480ffe728..9caef1d4f4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.quartz.experimental.clink +import com.vitorpamplona.quartz.experimental.clink.client.DebitClient import com.vitorpamplona.quartz.experimental.clink.client.OfferClient import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.server.ClinkServer import com.vitorpamplona.quartz.experimental.clink.server.K1Tracker @@ -71,6 +74,27 @@ class ClinkClientServerTest { assertEquals("ab".repeat(32), receipt.preimage) } + @Test + fun requestBudgetRejectsInvalidFrequencyUnit() = + kotlinx.coroutines.test.runTest { + val client = DebitClient(NDebit(servicePubKey, listOf(relay), null, null), signer) + kotlin.test.assertFailsWith { + client.requestBudget(1000, DebitFrequency(1, "fortnight")) + } + // valid units do not throw + client.requestBudget(1000, DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + } + + @Test + fun offerRequestTruncatesDescriptionTo100Chars() = + kotlinx.coroutines.test.runTest { + val service = NostrSignerInternal(KeyPair()) + val client = OfferClient(NOffer(service.pubKey, listOf(relay), "o", null, null), signer) + val event = client.requestInvoice(amountSats = 100, description = "x".repeat(150)) + val decrypted = event.decryptRequest(service) + assertEquals(100, decrypted.description?.length) + } + @Test fun serverRequestFilterTargetsRecipientByPTag() { val filter = ClinkServer.debitRequestFilter(servicePubKey, since = 100L) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt index d3a2215b77..11182bf76a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkWireShapeTest.kt @@ -197,6 +197,19 @@ class ClinkWireShapeTest { assertEquals(500000L, res.range?.max) } + @Test + fun debitFailureDetailSurfacesRangeAndRetryAfter() { + val range = parse("""{"res":"GFY","code":5,"error":"Invalid Amount","range":{"min":1000,"max":500000}}""").failureDetail()!! + assertTrue(range.contains("Invalid Amount")) + assertTrue(range.contains("1000") && range.contains("500000")) + + val rateLimited = parse("""{"res":"GFY","code":4,"error":"Rate Limited","retry_after":1750000000}""").failureDetail()!! + assertTrue(rateLimited.contains("Rate Limited")) + assertTrue(rateLimited.contains("1750000000")) + + assertNull(parse("""{"res":"ok"}""").failureDetail()) + } + @Test fun debitGfyInvalidRequest() { val res = parse("""{"res":"GFY","code":6,"error":"Invalid Request: K1 already processed"}""") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt index 1a9204e68d..8b4d2617d0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -95,6 +95,15 @@ class ClinkPointerTest { assertEquals(debit, parsed) } + @Test + fun debitRejectsMalformedK1Length() { + // A session id (TLV 3) must be exactly 32 bytes; a wrong-length k1 is a malformed + // session pointer and the parser must reject it rather than accept a bad session. + val shortK1 = "abcd" + val encoded = NDebit(pubKey, listOf(relay), null, shortK1).encode() + assertNull(ClinkPointerParser.parse(encoded)) + } + @Test fun manageRoundTrip() { val manage = NManage(pubKey, listOf(relay), null) From b79ab1214c1fa8ffab31be66529e60b163fec006 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:24:27 +0000 Subject: [PATCH 44/75] refactor: address review findings on mention tagging and suggestions - Move NewMessageTagger from the Marmot composer into AccountViewModel.sendMarmotGroupMessage so every send path gets mention rewriting + p-tagging, not just the chat composer. - Pass the parent's MarmotGroupChatroom into the composer instead of re-fetching it from the group list. - Bound the public-channel participant scan with a one-month cutoff (matches the recency-cutoff convention in ChannelObservers). - Simplify the nests participant-set construction. --- .../ui/screen/loggedIn/AccountViewModel.kt | 12 ++++++++---- .../chats/marmotGroup/MarmotGroupChatView.kt | 14 +++++--------- .../send/ChannelNewMessageViewModel.kt | 4 +++- .../nests/room/chat/NestNewMessageViewModel.kt | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index b0b62c39e7..fc1b395991 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk +import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -106,7 +107,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -1627,8 +1627,12 @@ class AccountViewModel( text: String, replyToInnerEventId: HexKey? = null, replyToInnerAuthorPubKey: HexKey? = null, - mentions: List = emptyList(), ) { + // Rewrites @npub…/@nprofile… mentions into nostr: URIs and collects + // the referenced users as p-tags. Lives here (not in the composer) so + // every send path gets mention handling. + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() // Inner event construction lives on MarmotManager so CLI and UI don't drift. // persistOwn=false because Account.sendMarmotGroupMessage routes the outer // event through LocalCache which already handles own-message display. @@ -1636,11 +1640,11 @@ class AccountViewModel( account.marmotManager ?.buildTextMessage( nostrGroupId = nostrGroupId, - text = text, + text = tagger.message, replyToEventId = replyToInnerEventId, replyToAuthorPubKey = replyToInnerAuthorPubKey, persistOwn = false, - mentions = mentions, + mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) ?: return val relays = account.marmotGroupRelays(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index b9b0f2d1c7..d548c1c5c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -50,10 +50,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation -import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -155,6 +155,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, + chatroom = chatroom, messageState = messageState, replyTo = replyTo, accountViewModel = accountViewModel, @@ -169,6 +170,7 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, + chatroom: MarmotGroupChatroom, messageState: TextFieldState, replyTo: MutableState, accountViewModel: AccountViewModel, @@ -190,11 +192,10 @@ fun MarmotGroupMessageComposer( val userSuggestions = remember(nostrGroupId) { - val group = accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) UserSuggestionState( accountViewModel.account, accountViewModel.nip05ClientBuilder(), - priorityPubkeys = { group.members.value.mapTo(mutableSetOf()) { it.pubkey } }, + priorityPubkeys = { chatroom.members.value.mapTo(mutableSetOf()) { it.pubkey } }, ) } @@ -278,16 +279,11 @@ fun MarmotGroupMessageComposer( val replyAuthor = parentEvent?.pubKey scope.launch(Dispatchers.IO) { try { - // Rewrites @npub…/@nprofile… mentions into nostr: URIs and - // collects the referenced users as p-tags for the inner event. - val tagger = NewMessageTagger(text, null, null, accountViewModel) - tagger.run() accountViewModel.sendMarmotGroupMessage( nostrGroupId = nostrGroupId, - text = tagger.message, + text = text, replyToInnerEventId = replyId, replyToInnerAuthorPubKey = replyAuthor, - mentions = tagger.pTags?.map { it.toPTag() } ?: emptyList(), ) messageState.clearText() replyTo.value = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 0364ce4dcc..6a89bb8fe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -193,7 +193,9 @@ open class ChannelNewMessageViewModel : accountVM.account, accountVM.nip05ClientBuilder(), priorityPubkeys = { - channel?.participatingAuthors(0)?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() + // Public channels have no membership; recent posters are the + // closest thing. The cutoff also bounds the note scan. + channel?.participatingAuthors(TimeUtils.oneMonthAgo())?.mapTo(mutableSetOf()) { it.pubkeyHex } ?: emptySet() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 878db35bce..a9e7993a97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -200,7 +200,7 @@ open class NestNewMessageViewModel : accountVM.nip05ClientBuilder(), priorityPubkeys = { (room?.event as? MeetingSpaceEvent)?.let { space -> - space.participantKeys().toMutableSet().apply { add(space.pubKey) } + space.participantKeys().toSet() + space.pubKey } ?: emptySet() }, ) From 61387ba12ce45e28e424afc81290817d4b3b79d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:40:29 +0000 Subject: [PATCH 45/75] docs(clink): record interop review + spec-conformance pass results https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- quartz/plans/2026-06-09-clink.md | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/quartz/plans/2026-06-09-clink.md b/quartz/plans/2026-06-09-clink.md index 93d65a7878..d7b91db15e 100644 --- a/quartz/plans/2026-06-09-clink.md +++ b/quartz/plans/2026-06-09-clink.md @@ -244,3 +244,55 @@ against a real CLINK service is not automated (needs a device or a mock/live service). Feasible later via quartz's in-process relay server (`nip01Core/relay/server/`) plus a mock CLINK responder; flagged but not built. + +--- + +## Interop review & spec-conformance pass (2026-06-10) + +Reviewed against the whole `shocknet/CLINK` ecosystem (Lightning.Pub, clink-sdk, +ShockWallet, Zeus, Stacker News, bridgelet, clinkme.dev) and re-audited every +spec file line-by-line against the code. **Verdict: the consume-only +payer/requestor role is conformant and interoperable; no real correctness bugs.** + +**Interop fixes shipped:** +1. Manage `details` single-object responses parse (Jackson + `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — Lightning.Pub returns a bare object for + create/update/get, an array only for `list`. +2. `NOffer.encode()` always emits the price-type TLV (3); decode defaults an + absent/unknown type to SPONTANEOUS. The SDK + bridgelet decoders throw on a + missing TLV 3, so omitting it made our pointers undecodable by JS consumers. +3. `Nip05Parser.parseClinkOffer` accepts bridgelet's flat-string shape as well + as the spec's per-name map. +4. Offer receipts are a parseable primitive (`OfferEvent.createReceipt` / + `decryptReceipt`, `OfferClient.parseReceipt`, `OfferReceipt.isOk`). +5. `ClinkOfferPayer` signs offer requests with an ephemeral key (privacy parity + with the SDK/Zeus/Stacker News). Debits keep the persistent account key + (budgets need a stable app identity). + +**Conformance hardening shipped:** `NDebit.parse` rejects a non-32-byte k1; +`DebitClient.requestBudget` validates `frequency.unit ∈ {day,week,month}`; +`OfferClient` caps `description` at 100 chars; `DebitResponse.failureDetail()` +surfaces GFY `range`/`retry_after` in the debit zap error path. + +**New tests:** `ClinkWireShapeTest` (golden JSON payloads from the public-domain +specs), a clink-demo/SDK canonical `DEFAULT_NOFFER` vector, plus regressions for +each fix above. + +**Three "CRITICAL" review findings were verified FALSE — do NOT re-chase them:** +- *Offer price encoding for ≥2³¹ sats* is correct: `addInt` writes the low 32 + bits, which are bit-identical to the unsigned 4-byte BE (proven by + `offerLargePriceRoundTripIsUnsigned`, 3e9 sats). Only ≥2³² loses data, which + the spec's 4-byte field can't represent anyway. +- *Parser "wrongly accepts `nostr:`/`lightning:` wrappers"* is not a violation: + the MUST-NOT-wrap rule governs producing QR (our `encode()` is bare); lenient + decode is Postel-legal. +- *Manage "create shape deviation"* — the spec's inline-create example is the + outlier; Lightning.Pub/SDK/clink-demo all use nested `offer.fields` for create, + which we match. + +**Remaining non-bugs (intentional / out-of-role):** we don't reject responses +lacking `clink_version` (Lightning.Pub omits it — rejecting breaks interop); the +offer Payment Receipt UI subscription is unwired (primitive ships); `clink_debit` +discovery and the unrestricted-access debit request are creditor/app-side +features outside the consume-only role; noffer TLV 5 currency is dormant +(unimplemented by the SDK and Lightning.Pub too). From 6f797798850f710d6ef9d4135cba94bb34699d2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:40:52 +0000 Subject: [PATCH 46/75] feat: mark conversation participants with an 'In this chat' chip in mention suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a small chip on suggestion rows whose pubkey is in the suggestion state's priorityPubkeys set, unless the caller supplies its own trailingContent. Priority keys only reorder and label the users that already matched the search — they never inject results. --- .../userSuggestions/ShowUserSuggestionList.kt | 33 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index cfa7304b62..dc8a448dce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +46,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User @@ -52,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize @@ -116,13 +121,23 @@ fun WatchResponses( val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) if (suggestions.isNotEmpty()) { + // Snapshot once per result list, not per row. + val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } + LazyColumn( contentPadding = PaddingValues(top = 10.dp), modifier = modifier, state = listState, ) { itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, item -> - UserLine(item, accountViewModel, trailingContent) { onSelect(item) } + val trailing = + trailingContent + ?: if (item.pubkeyHex in priority) { + { InThisChatChip() } + } else { + null + } + UserLine(item, accountViewModel, trailing) { onSelect(item) } HorizontalDivider( thickness = DividerThickness, ) @@ -133,6 +148,22 @@ fun WatchResponses( } } +@Composable +private fun InThisChatChip() { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = stringRes(R.string.user_suggestion_in_this_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + @Composable fun UserLine( baseUser: User, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5845409944..ad5044f7e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -214,6 +214,7 @@ Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners. User does not have a lightning address set up to receive sats "reply here… " + In this chat Copies the Note ID to the clipboard for sharing in Nostr Copy Channel ID (Note) to the Clipboard Edits the Channel Metadata From fde5818044c41fd25bf5ebd76f1b3aa72459b27d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:43:30 +0000 Subject: [PATCH 47/75] refactor: extract MarmotNewMessageViewModel for the MLS composer Moves the Marmot composer's inline state (message TextFieldState, reply state, upload state, @-mention suggestion wiring, send) into a ViewModel mirroring ChatNewMessageViewModel / ChannelNewMessageViewModel / NestNewMessageViewModel, so all four chat types share the same init/load structure. No behavior change. --- .../chats/marmotGroup/MarmotGroupChatView.kt | 154 ++++++------------ .../send/MarmotNewMessageViewModel.kt | 147 +++++++++++++++++ 2 files changed, 196 insertions(+), 105 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index d548c1c5c9..24d1dc92d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -27,16 +27,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,9 +46,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom -import com.vitorpamplona.amethyst.commons.ui.text.currentWord -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -61,11 +54,11 @@ import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote @@ -103,19 +96,15 @@ fun MarmotGroupChatView( WatchLifecycleAndUpdateModel(feedViewModel) - val chatroom = - remember(nostrGroupId) { - accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) - } + val newMessageModel: MarmotNewMessageViewModel = viewModel(key = nostrGroupId + "MarmotNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(nostrGroupId) DisposableEffect(nostrGroupId) { - chatroom.markAsRead() + newMessageModel.chatroom?.markAsRead() onDispose { } } - val messageState = remember(nostrGroupId) { TextFieldState() } - val replyTo = remember(nostrGroupId) { mutableStateOf(null) } - // Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS // message in the Notifications screen) into the actual Note once it has // landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids. @@ -123,14 +112,14 @@ fun MarmotGroupChatView( LaunchedEffect(replyToInnerNote) { val parent = accountViewModel.checkGetOrCreateNote(replyToInnerNote) if (parent != null) { - replyTo.value = parent + newMessageModel.reply(parent) } } } if (draftMessage != null) { LaunchedEffect(draftMessage) { - messageState.setTextAndPlaceCursorAtEnd(draftMessage) + newMessageModel.editFromDraft(draftMessage) } } @@ -146,7 +135,7 @@ fun MarmotGroupChatView( accountViewModel = accountViewModel, nav = nav, routeForLastRead = "MarmotGroup/$nostrGroupId", - onWantsToReply = { note -> replyTo.value = note }, + onWantsToReply = { note -> newMessageModel.reply(note) }, onWantsToEditDraft = { }, ) } @@ -155,9 +144,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, - chatroom = chatroom, - messageState = messageState, - replyTo = replyTo, + newMessageModel = newMessageModel, accountViewModel = accountViewModel, nav = nav, onMessageSent = { @@ -170,82 +157,56 @@ fun MarmotGroupChatView( @Composable fun MarmotGroupMessageComposer( nostrGroupId: HexKey, - chatroom: MarmotGroupChatroom, - messageState: TextFieldState, - replyTo: MutableState, + newMessageModel: MarmotNewMessageViewModel, accountViewModel: AccountViewModel, nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() - val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } val context = LocalContext.current var isUploading by remember { mutableStateOf(false) } - val uploadState = - remember { - ChatFileUploadState( - defaultServer = accountViewModel.account.settings.defaultFileServer, - defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload, - ) - } - - val userSuggestions = - remember(nostrGroupId) { - UserSuggestionState( - accountViewModel.account, - accountViewModel.nip05ClientBuilder(), - priorityPubkeys = { chatroom.members.value.mapTo(mutableSetOf()) { it.pubkey } }, - ) - } DisposableEffect(nostrGroupId) { - onDispose { userSuggestions.reset() } + onDispose { newMessageModel.userSuggestions?.reset() } } // Upload dialog - uploadState.multiOrchestrator?.let { - MarmotGroupFileUploadDialog( - nostrGroupId = nostrGroupId, - state = uploadState, - accountViewModel = accountViewModel, - nav = nav, - onUpload = { onMessageSent() }, - onCancel = uploadState::reset, - ) + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + MarmotGroupFileUploadDialog( + nostrGroupId = nostrGroupId, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } } - replyTo.value?.let { + newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { - replyTo.value = null + newMessageModel.clearReply() } } Column(modifier = EditFieldModifier) { - ShowUserSuggestionList( - userSuggestions, - onSelect = { user -> - userSuggestions.replaceCurrentWord(messageState, messageState.currentWord(), user) - userSuggestions.reset() - }, - accountViewModel = accountViewModel, - modifier = SuggestionListDefaultHeightChat, - ) + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, + ) + } ThinPaddingTextField( - state = messageState, - onTextChanged = { - if (messageState.selection.collapsed) { - val lastWord = messageState.currentWord() - if (lastWord.startsWith("@")) { - userSuggestions.processCurrentWord(lastWord) - } else { - userSuggestions.reset() - } - } - }, + state = newMessageModel.message, + onTextChanged = { newMessageModel.onMessageChanged() }, onContentReceived = { uri, mimeType -> - uploadState.load(persistentListOf(SelectedMedia(uri, mimeType))) + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) }, inputTransformation = MentionPreservingInputTransformation, outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), @@ -260,9 +221,7 @@ fun MarmotGroupMessageComposer( leadingIcon = { MarmotGalleryLeadingIcon( isUploading = isUploading, - onImageChosen = { selectedMedia -> - uploadState.load(selectedMedia) - }, + onImageChosen = newMessageModel::pickedMedia, ) }, trailingIcon = { @@ -270,33 +229,18 @@ fun MarmotGroupMessageComposer( isActive = canPost, modifier = EditFieldTrailingIconModifier, ) { - val text = messageState.text.toString().trim() - if (text.isNotEmpty()) { - // Capture id+pubKey snapshot under the value? guard so - // a slow send doesn't race a user-cleared reply state. - val parentEvent = replyTo.value?.event - val replyId = parentEvent?.id - val replyAuthor = parentEvent?.pubKey - scope.launch(Dispatchers.IO) { - try { - accountViewModel.sendMarmotGroupMessage( - nostrGroupId = nostrGroupId, - text = text, - replyToInnerEventId = replyId, - replyToInnerAuthorPubKey = replyAuthor, - ) - messageState.clearText() - replyTo.value = null - onMessageSent() - } catch (e: Exception) { - launch(Dispatchers.Main) { - Toast - .makeText( - context, - "Failed to send message: ${e.message}", - Toast.LENGTH_SHORT, - ).show() - } + scope.launch(Dispatchers.IO) { + try { + newMessageModel.sendPost() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to send message: ${e.message}", + Toast.LENGTH_SHORT, + ).show() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt new file mode 100644 index 0000000000..d5501a39cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList + +/** + * Composition state for the Marmot/MLS group message field, mirroring the + * structure of the other chat composers (ChatNewMessageViewModel, + * ChannelNewMessageViewModel, NestNewMessageViewModel): @-mention + * suggestions, reply state, and file-upload state. Sending goes through + * AccountViewModel.sendMarmotGroupMessage, which owns mention tagging. + */ +@Stable +open class MarmotNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var nostrGroupId: HexKey? = null + var chatroom: MarmotGroupChatroom? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + var userSuggestions: UserSuggestionState? = null + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + priorityPubkeys = { chatroom?.members?.value?.mapTo(mutableSetOf()) { it.pubkey } ?: emptySet() }, + ) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + open fun load(nostrGroupId: HexKey) { + if (this.nostrGroupId != nostrGroupId) { + this.nostrGroupId = nostrGroupId + this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + } + + fun clearReply() { + replyTo.value = null + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) + } + + /** Sends the field's text. Mention rewriting and p-tagging happen in + * AccountViewModel.sendMarmotGroupMessage. Throws on send failure so + * the caller can surface the error. */ + suspend fun sendPost() { + val groupId = nostrGroupId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + // Capture id+pubKey snapshot before suspending so a slow send + // doesn't race a user-cleared reply state. + val parentEvent = replyTo.value?.event + + accountViewModel.sendMarmotGroupMessage( + nostrGroupId = groupId, + text = text, + replyToInnerEventId = parentEvent?.id, + replyToInnerAuthorPubKey = parentEvent?.pubKey, + ) + + message.clearText() + replyTo.value = null + userSuggestions?.reset() + } +} From 15b12e7f13d57be8c546add81a461623ab1eaea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:54:09 +0000 Subject: [PATCH 48/75] refactor(clink): make the debit zap rail fire-and-forget like NWC payViaClinkDebit blocked the zap on the debit service's res:ok/GFY reply (up to 30s) before reporting a result. Mirror the NWC rail instead: dispatch the debit on the account scope and report each payable paid optimistically so the zap UI completes promptly; a GFY/failure (or no reply) surfaces asynchronously through onError rather than blocking. The programmatic App Functions debit path is unchanged (it still awaits the real result). https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/service/ZapPaymentHandler.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 9c250325d5..cd0a1b09b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -46,6 +46,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import kotlin.math.round @@ -391,9 +392,12 @@ class ZapPaymentHandler( /** * Pays each zap invoice by asking the user's CLINK debit service (kind 21002) to - * settle the BOLT-11. The service authorizes against the account identity; a failure - * surfaces the service's `GFY` error text. Untested end-to-end — needs a live debit - * service to verify a real payout. + * settle the BOLT-11. The service authorizes against the account identity. + * + * Fire-and-forget, like the NWC rail ([payViaNWC]): the request is dispatched on the + * account scope and each payable is reported paid optimistically so the zap UI completes + * promptly. A `GFY`/failure (or no reply within the debit timeout) surfaces later through + * [onError] rather than blocking the zap on the service's response. */ suspend fun payViaClinkDebit( payables: List, @@ -407,21 +411,22 @@ class ZapPaymentHandler( return mapNotNullAsync( items = payables, runRequestFor = { payable: Payable -> - val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) + account.scope.launch { + val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) + if (response?.isOk() != true) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + payable.info.user, + ) + } + } progressAllPayments += 1f / payables.size onProgress(progressAllPayments) - val paid = response?.isOk() == true - if (!paid) { - onError( - stringRes(context, R.string.error_dialog_pay_invoice_error), - response?.failureDetail() - ?: stringRes(context, R.string.clink_debit_no_response), - payable.info.user, - ) - } - Paid(payable, paid) + Paid(payable, true) }, ) } From 14cb06d0818710bb031f73df8616e42079cf4296 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:23:15 +0000 Subject: [PATCH 49/75] fix: treat kind-9 ChatEvent as a chat kind for inline chat-style quotes The MLS/Marmot inner message kind was missing from isChatEvent, so a chat message quoted inside an MLS chatroom message still rendered as the default NoteCompose card instead of the chat reply design. https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC --- .../ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt index 4c4ce3f2b6..23f2fcbcbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatInlineQuoteRenderer.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** * Inline-quote renderer provided by [ChatroomMessageCompose] so that a chat @@ -74,6 +75,7 @@ fun chatInlineQuoteRenderer( private fun isChatEvent(event: Event?) = event is ChatroomKeyable || + event is ChatEvent || event is IsInPublicChatChannel || event is LiveActivitiesChatMessageEvent || event is EphemeralChatEvent From 908bc58190ac32f684171354ce078507d76e1732 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:25:57 +0000 Subject: [PATCH 50/75] test: lock in reorder-only semantics of mention priority ranking Extracts the priority sort into rankPriorityFirst() and covers: priority users move to the top, stable order within both groups, no injection of non-matching priority keys, and untouched list when priority is empty. --- .../userSuggestions/UserSuggestionState.kt | 27 ++++-- .../UserSuggestionPriorityRankingTest.kt | 84 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 22b4ef5515..76858b0776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -59,6 +59,22 @@ val userUriPrefixes = DualCase("nostr:nprofile"), ) +/** + * Moves users whose pubkey is in [priority] to the top of [found], + * preserving the relative order everywhere else (stable sort). Reorders + * only — it never adds or removes entries, so priority keys whose users + * didn't match the search have no effect. + */ +fun rankPriorityFirst( + found: List, + priority: Set, +): List = + if (priority.isEmpty()) { + found + } else { + found.sortedByDescending { it.pubkeyHex in priority } + } + /** * Drives the @-mention autocomplete dropdown: searches the local cache, * relays, and NIP-05 identifiers for the word currently being typed. @@ -169,13 +185,10 @@ class UserSuggestionState( } if (prefix != null) { logTime("UserSuggestionState Search $prefix version $version") { - val found = account.cache.findUsersStartingWith(prefix, account) - val priority = priorityPubkeys() - if (priority.isEmpty()) { - found - } else { - found.sortedByDescending { it.pubkeyHex in priority } - } + rankPriorityFirst( + account.cache.findUsersStartingWith(prefix, account), + priorityPubkeys(), + ) } } else { emptyList() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt new file mode 100644 index 0000000000..fed8c9f63b --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/UserSuggestionPriorityRankingTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserContext +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.rankPriorityFirst +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Locks in the @-mention priority semantics: priority pubkeys only move + * users that already matched the search to the top of the list — they + * never inject new entries, never remove any, and never disturb the + * search's relevance order within the priority / non-priority groups. + */ +class UserSuggestionPriorityRankingTest { + // User eagerly pins a few addressable note shells on construction; + // empty shells are enough since the ranking never reads them. + private val noContext = UserContext { addr -> AddressableNote(addr) } + + private fun user(hex: String) = User(hex, noContext) + + private val alice = user("aa".repeat(32)) + private val bob = user("bb".repeat(32)) + private val carol = user("cc".repeat(32)) + private val dave = user("dd".repeat(32)) + + @Test + fun emptyPriorityKeepsTheListUntouched() { + val found = listOf(alice, bob, carol) + + assertSame(found, rankPriorityFirst(found, emptySet())) + } + + @Test + fun priorityUsersMoveToTheTop() { + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex)) + + assertEquals(listOf(carol, alice, bob, dave), ranked) + } + + @Test + fun relativeOrderIsPreservedWithinBothGroups() { + // findUsersStartingWith returns relevance order; the stable sort + // must keep alice-before-carol (priority) and bob-before-dave (rest). + val found = listOf(alice, bob, carol, dave) + + val ranked = rankPriorityFirst(found, setOf(alice.pubkeyHex, carol.pubkeyHex)) + + assertEquals(listOf(alice, carol, bob, dave), ranked) + } + + @Test + fun priorityKeysThatDidNotMatchTheSearchAreNotInjected() { + val found = listOf(alice, bob) + + val ranked = rankPriorityFirst(found, setOf(carol.pubkeyHex, dave.pubkeyHex)) + + assertEquals(found, ranked) + } +} From 33f1d20a21bb639fc0d2d0861c01322bcc3689ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:29:46 +0000 Subject: [PATCH 51/75] fix(clink): mirror NWC's split progress on the debit zap rail Advance half the per-payable progress on dispatch and the other half when the async debit response arrives, exactly like payViaNWC, instead of jumping the full share on dispatch. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index cd0a1b09b9..2f0c9ad60a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -413,6 +413,8 @@ class ZapPaymentHandler( runRequestFor = { payable: Payable -> account.scope.launch { val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) + progressAllPayments += 0.5f / payables.size + onProgress(progressAllPayments) if (response?.isOk() != true) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), @@ -423,7 +425,7 @@ class ZapPaymentHandler( } } - progressAllPayments += 1f / payables.size + progressAllPayments += 0.5f / payables.size onProgress(progressAllPayments) Paid(payable, true) From d90574c4e9a00f5bc1ea47ab8cccc6cd2d59b81e Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 19:45:09 +0200 Subject: [PATCH 52/75] refactor: rename ReplaceableVideoEvent to AddressableVideoEvent --- .../profile/gallery/dal/UserProfileGalleryFeedFilter.kt | 4 ++-- .../{ReplaceableVideoEvent.kt => AddressableVideoEvent.kt} | 2 +- .../vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt | 2 +- .../com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt | 2 +- ...ventAddressTest.kt => AddressableVideoEventAddressTest.kt} | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/{ReplaceableVideoEvent.kt => AddressableVideoEvent.kt} (98%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/{ReplaceableVideoEventAddressTest.kt => AddressableVideoEventAddressTest.kt} (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index bcb315079c..579f6c07a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent -import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class UserProfileGalleryFeedFilter( @@ -81,7 +81,7 @@ class UserProfileGalleryFeedFilter( ( noteEvent is PictureEvent || noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is AddressableVideoEvent && it is AddressableNote) || (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt index a129863164..486753cef2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEvent.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag @Immutable -abstract class ReplaceableVideoEvent( +abstract class AddressableVideoEvent( id: HexKey, pubKey: HexKey, createdAt: Long, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt index 2facb76e93..dbd5434a59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt @@ -40,7 +40,7 @@ class VideoHorizontalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34235 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt index e293129f49..0f46b0433e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt @@ -40,7 +40,7 @@ class VideoVerticalEvent( tags: Array>, content: String, sig: HexKey, -) : ReplaceableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : AddressableVideoEvent(id, pubKey, createdAt, KIND, tags, content, sig), RootScope { companion object { const val KIND = 34236 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt index 9ee3c8dcf2..0f878d0ee0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/ReplaceableVideoEventAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt @@ -31,7 +31,7 @@ import kotlin.test.assertEquals * AddressableNote than the one `a` tags point to, so quotes/reposts of * these videos never resolve on screen. */ -class ReplaceableVideoEventAddressTest { +class AddressableVideoEventAddressTest { // Fixture from a real kind-34236 event published by the Divine client // (bfe2f224…, "Lunchtime for our Koi"); the horizontal test reuses the // same data synthetically. @@ -40,7 +40,7 @@ class ReplaceableVideoEventAddressTest { private fun assertAddressUsesDTag( kind: Int, - event: ReplaceableVideoEvent, + event: AddressableVideoEvent, ) { assertEquals(dTag, event.dTag()) assertEquals(Address(kind, pubkey, dTag), event.address()) From 572f4005e10c5524412478c0a2c820d321e87934 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 20:16:24 +0200 Subject: [PATCH 53/75] test: guard kind-range vs class-hierarchy invariant in EventFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps every typed kind: addressable kinds (30000..39999) must read their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray ones — the invariant the kind-34235/34236 fix restores. --- .../quartz/utils/EventFactoryKindRangeTest.kt | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt new file mode 100644 index 0000000000..f6dd98567b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKindRangeTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the kind-range vs class-hierarchy invariant across the whole + * EventFactory: parameterized replaceables (30000..39999) must derive their + * address from the `d` tag, while plain replaceables (10000..19999, plus + * kinds 0 and 3) must ignore stray `d` tags. A typed class extending the + * wrong base (the kind-34235/34236 bug, fixed in this branch) splits the + * cache between the event's own address and the address `a` tags reference. + * + * Known offenders that predate this guard are allowlisted below so the test + * catches NEW mismatches; shrink the lists as they get fixed. + */ +class EventFactoryKindRangeTest { + /** + * NIP-87 ecash kinds that are addressable per spec but currently extend + * plain Event, so they have no address at all and never replace older + * versions in the cache. + */ + private val knownNonAddressable = setOf(38000, 38172, 38173) + + /** + * NIP-51-style list kinds whose shared PrivateTagArrayEvent hierarchy + * reads `d` tags even though plain replaceables must ignore them; a + * stray `d` tag on a malformed event fragments their cache address. + */ + private val knownDTagReaders = + setOf( + 10004, + 10005, + 10006, + 10007, + 10009, + 10012, + 10013, + 10015, + 10017, + 10018, + 10020, + 10023, + 10040, + 10054, + 10081, + 10086, + 10087, + 10088, + 10089, + 10090, + 10101, + 10102, + ) + + private val probeDTag = "probe-d-tag" + + private fun probe(kind: Int) = EventFactory.create("", "", 0L, kind, arrayOf(arrayOf("d", probeDTag)), "", "") + + @Test + fun addressableKindsReadTheirDTag() { + val violations = + (30000 until 40000).mapNotNull { kind -> + val event = probe(kind) + when { + // Unknown kinds parse as a bare Event; only typed classes are checked. + event::class == Event::class -> null + kind in knownNonAddressable -> null + event !is AddressableEvent -> "kind $kind (${event::class.simpleName}) does not implement AddressableEvent" + event.dTag() != probeDTag -> "kind $kind (${event::class.simpleName}) ignores its d tag" + else -> null + } + } + + assertEquals(emptyList(), violations) + } + + @Test + fun plainReplaceableKindsIgnoreStrayDTags() { + val violations = + (listOf(0, 3) + (10000 until 20000)).mapNotNull { kind -> + val event = probe(kind) + if (kind !in knownDTagReaders && event::class != Event::class && event is AddressableEvent && event.dTag() != "") { + "kind $kind (${event::class.simpleName}) addresses itself by a stray d tag" + } else { + null + } + } + + assertEquals(emptyList(), violations) + } +} From e1835e5584cc5268f8064f0883d0d39aae67124c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 10 Jun 2026 18:38:37 +0000 Subject: [PATCH 54/75] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a4c303f918..ca3a27e48f 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -267,8 +267,13 @@ Wygeneruj nowy klucz Wczytywanie zawartości Ładowanie konta + zaszyfrowane + starsza wersja + Szukam oryginalnej wiadomości… + Nie można znaleźć tej wiadomości + Przeszukano wszystkie transmitery · kliknij, by wyświetlić "Błąd wczytywania odpowiedzi: " Spróbuj ponownie Brak powiadomień. @@ -2104,6 +2109,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Odtwórz wideo w pływającym oknie (opcja ukryta, jeśli nie jest obsługiwana) Przenieś na urządzenie Prześlij film do urządzenia Chromecast podłączonego do sieci Wi-Fi (opcja ukryta w przypadku plików lokalnych) + Wizualizator dźwięku + Wybierz animację wyświetlaną podczas odtwarzania plików audio. + Wyłączone + Spektrogram + Barwa fali + Pierścień promieniowy + Aurora Glow + Fala klasyczna + Obraz statyczny Zdjęcie profilowe %1$s Transmiter %1$s Rozwiń listę transmiterów @@ -2857,6 +2871,18 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Sfinansowano %1$s z %2$s satoszów Kończy się %1$s Darowizna on-chain + + Birdex · %1$d gatunek + Birdex · %1$d gatunków + Birdex · %1$d gatunków + Birdex · %1$d gatunki + + + %1$s +%2$d więcej + %1$s +%2$d więcej + %1$s +%2$d więcej + %1$s +%2$d więcej + Kwota zbiorki (w satoszach) 100000 Opisz cel zbiórki From 9fd53b646156c5b2989aa2e994ee4d1862727ca1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 10 Jun 2026 20:45:28 +0200 Subject: [PATCH 55/75] feat: add cs, de, sv, pt-BR translations for chat history and reply search strings Co-Authored-By: Claude Opus 4.8 (1M context) --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 6 ++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 6 ++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8a89516664..f3c25d7ad5 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -3067,4 +3067,10 @@ Nowhere Drop Nowhere Umění Nowhere Fórum + zastaralé + šifrované + Tuto zprávu se nepodařilo najít + Prohledány všechny relaye · klepnutím zobrazíte + Hledání původní zprávy… + V tomto chatu diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index f3a137fd77..e63e120ee6 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3016,4 +3016,10 @@ anz der Bedingungen ist erforderlich Nowhere Drop Nowhere Kunst Nowhere Forum + veraltet + verschlüsselt + Diese Nachricht konnte nicht gefunden werden + Alle Relays durchsucht · zum Anzeigen tippen + Suche nach der ursprünglichen Nachricht… + In diesem Chat diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8a79361319..7cf9a0deeb 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3011,4 +3011,10 @@ Nowhere Drop Nowhere Arte Nowhere Fórum + legado + criptografado + Não foi possível encontrar esta mensagem + Pesquisado em todos os relays · toque para ver + Procurando a mensagem original… + Neste chat diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e6c38d7673..5f6c23691e 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3010,4 +3010,10 @@ Nowhere Drop Nowhere Konst Nowhere Forum + föråldrat + krypterat + Kunde inte hitta detta meddelande + Sökte på alla relayer · tryck för att visa + Letar efter ursprungsmeddelandet… + I den här chatten From b94803b701c3b85d61f94aaa412dd943627fe95c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 10 Jun 2026 18:49:25 +0000 Subject: [PATCH 56/75] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ------ amethyst/src/main/res/values-de-rDE/strings.xml | 6 ------ amethyst/src/main/res/values-pt-rBR/strings.xml | 6 ------ amethyst/src/main/res/values-sv-rSE/strings.xml | 6 ------ 4 files changed, 24 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index f3c25d7ad5..8a89516664 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -3067,10 +3067,4 @@ Nowhere Drop Nowhere Umění Nowhere Fórum - zastaralé - šifrované - Tuto zprávu se nepodařilo najít - Prohledány všechny relaye · klepnutím zobrazíte - Hledání původní zprávy… - V tomto chatu diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index e63e120ee6..f3a137fd77 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3016,10 +3016,4 @@ anz der Bedingungen ist erforderlich Nowhere Drop Nowhere Kunst Nowhere Forum - veraltet - verschlüsselt - Diese Nachricht konnte nicht gefunden werden - Alle Relays durchsucht · zum Anzeigen tippen - Suche nach der ursprünglichen Nachricht… - In diesem Chat diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7cf9a0deeb..8a79361319 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3011,10 +3011,4 @@ Nowhere Drop Nowhere Arte Nowhere Fórum - legado - criptografado - Não foi possível encontrar esta mensagem - Pesquisado em todos os relays · toque para ver - Procurando a mensagem original… - Neste chat diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 5f6c23691e..e6c38d7673 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3010,10 +3010,4 @@ Nowhere Drop Nowhere Konst Nowhere Forum - föråldrat - krypterat - Kunde inte hitta detta meddelande - Sökte på alla relayer · tryck för att visa - Letar efter ursprungsmeddelandet… - I den här chatten From 7e7898bf77d4964d89b1d5c46e7c5c4ae2972c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:55:57 +0000 Subject: [PATCH 57/75] =?UTF-8?q?refactor(clink):=20audit=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20consistent=20error=20detail,=20non-null=20priceType?= =?UTF-8?q?,=20budget=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the audit of this session's changes: - Error surfacing: the budget (WalletScreen) and offer/invoice card (InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just the bare error string. - NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS' fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant always-emit-TLV3 test (covered by the spontaneous round-trip). - WalletViewModel.requestDebitBudget catches the budget-validation IllegalArgumentException so a malformed frequency dismisses the dialog instead of hanging the spinner. - Document why ClinkDebitPayer signs with the persistent account key (stable identity for budgets) while ClinkOfferPayer uses an ephemeral key. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../vitorpamplona/amethyst/service/ClinkDebitPayer.kt | 3 +++ .../ui/note/creators/invoice/ClinkOfferPreview.kt | 4 ++-- .../note/creators/invoice/InvoicePaymentDispatcher.kt | 2 +- .../amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt | 2 +- .../ui/screen/loggedIn/wallet/WalletViewModel.kt | 10 +++++++++- .../quartz/experimental/clink/pointers/NOffer.kt | 8 ++++---- .../quartz/experimental/clink/ClinkClientServerTest.kt | 7 ++++--- .../experimental/clink/pointers/ClinkPointerTest.kt | 10 ---------- 8 files changed, 24 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index 85f48dc31e..d6f4c2f95f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -80,6 +80,9 @@ object ClinkDebitPayer { return sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) } + // Debits sign with the persistent account identity (unlike offer requests, which use a + // throwaway key — see ClinkOfferPayer): the service must see one stable app identity so a + // budget authorization can cover repeat debits instead of prompting on every payment. private fun clientFor( pointer: NDebit, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index c46185bf78..726434be0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -84,7 +84,7 @@ fun ClinkOfferPreview( var errorMessage by remember { mutableStateOf(null) } var payingInvoice by remember { mutableStateOf(null) } var amountInput by remember { mutableStateOf("") } - var needsAmount by remember { mutableStateOf((offer.priceType ?: OfferPriceType.SPONTANEOUS) == OfferPriceType.SPONTANEOUS) } + var needsAmount by remember { mutableStateOf(offer.priceType == OfferPriceType.SPONTANEOUS) } var amountRange by remember { mutableStateOf(null) } // The pointer actually paid: starts as the rendered offer, swapped if the service // replies "Expired or Moved" (code 3) with a replacement noffer. @@ -147,7 +147,7 @@ fun ClinkOfferPreview( // when the pointer omits a price type) require the payer to enter an amount. // Reflect the pointer actually being charged (which may have changed if the // service redirected us to a replacement noffer via "Expired or Moved"). - val effectiveType = activeOffer.priceType ?: OfferPriceType.SPONTANEOUS + val effectiveType = activeOffer.priceType if (effectiveType == OfferPriceType.FIXED) { activeOffer.price?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt index 9115b3fbb7..3487f7fd44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/InvoicePaymentDispatcher.kt @@ -104,7 +104,7 @@ fun InvoicePaymentDispatcher( onSuccess() } else { onError( - response?.error?.takeIf { it.isNotBlank() } + response?.failureDetail() ?: stringRes(context, R.string.clink_debit_no_response), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 182042781b..2f99a9c5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -258,7 +258,7 @@ private fun MultiWalletHomeContent( if (!walletInfo.canShowBalance) { { amount, frequency -> walletViewModel.requestDebitBudget(walletInfo.walletId, amount, frequency) { response -> - val error = response?.error + val error = response?.failureDetail() val msg = when { response?.isOk() == true -> context.getString(R.string.clink_budget_approved) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 8d05d74d01..e988daeed4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -339,7 +339,15 @@ class WalletViewModel : ViewModel() { val acc = account ?: return val pointer = _debitWallets.value.firstOrNull { it.id == walletId }?.pointer ?: return viewModelScope.launch { - onResult(ClinkDebitPayer.requestBudget(acc, pointer, amountSats, frequency)) + // A malformed budget (e.g. an out-of-spec frequency unit) makes requestBudget throw; + // treat it as "no response" so the dialog dismisses instead of hanging on a spinner. + val response = + try { + ClinkDebitPayer.requestBudget(acc, pointer, amountSats, frequency) + } catch (_: IllegalArgumentException) { + null + } + onResult(response) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt index 1e4faab75a..3e1956f8a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/NOffer.kt @@ -39,10 +39,10 @@ data class NOffer( override val relays: List, override val pointer: String?, /** - * TLV 3 — how the offer is priced. A decoded pointer always reports a concrete type - * ([OfferPriceType.SPONTANEOUS] when the wire field was absent, per the CLINK spec). + * TLV 3 — how the offer is priced. Always a concrete type: when the wire field is + * absent it is [OfferPriceType.SPONTANEOUS], per the CLINK spec. */ - val priceType: OfferPriceType?, + val priceType: OfferPriceType, /** TLV 4 — price in sats (display/fixed offers), 4-byte big-endian *unsigned* per the SDK. */ val price: Long?, ) : ClinkPointer { @@ -55,7 +55,7 @@ data class NOffer( // Always emit TLV 3, even for spontaneous offers: the reference SDK and // bridgelet decoders throw on a missing price-type field, so an absent TLV 3 // would make our pointers undecodable by every JS consumer. - addHex(ClinkTlv.PRICE_TYPE, (priceType ?: OfferPriceType.SPONTANEOUS).code.toSingleByteHex()) + addHex(ClinkTlv.PRICE_TYPE, priceType.code.toSingleByteHex()) // addInt writes the low 32 bits big-endian; for an unsigned price up to // 2^32-1 that is the correct 4-byte field even when it overflows a signed Int. price?.let { addInt(ClinkTlv.PRICE, it.toInt()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt index 9caef1d4f4..083c015cdc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkClientServerTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType import com.vitorpamplona.quartz.experimental.clink.server.ClinkServer import com.vitorpamplona.quartz.experimental.clink.server.K1Tracker import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -45,7 +46,7 @@ class ClinkClientServerTest { @Test fun offerClientExposesPointerRoutingAndResponseFilter() { - val client = OfferClient(NOffer(servicePubKey, listOf(relay), "offer-id", null, null), signer) + val client = OfferClient(NOffer(servicePubKey, listOf(relay), "offer-id", OfferPriceType.SPONTANEOUS, null), signer) assertEquals(servicePubKey, client.servicePubKey) assertEquals(listOf(relay), client.relays) @@ -61,7 +62,7 @@ class ClinkClientServerTest { kotlinx.coroutines.test.runTest { val payer = NostrSignerInternal(KeyPair()) val service = NostrSignerInternal(KeyPair()) - val offer = NOffer(service.pubKey, listOf(relay), "offer-id", null, null) + val offer = NOffer(service.pubKey, listOf(relay), "offer-id", OfferPriceType.SPONTANEOUS, null) val client = OfferClient(offer, payer) // payer asks, service settles and sends a receipt referencing the request @@ -89,7 +90,7 @@ class ClinkClientServerTest { fun offerRequestTruncatesDescriptionTo100Chars() = kotlinx.coroutines.test.runTest { val service = NostrSignerInternal(KeyPair()) - val client = OfferClient(NOffer(service.pubKey, listOf(relay), "o", null, null), signer) + val client = OfferClient(NOffer(service.pubKey, listOf(relay), "o", OfferPriceType.SPONTANEOUS, null), signer) val event = client.requestInvoice(amountSats = 100, description = "x".repeat(150)) val decrypted = event.decryptRequest(service) assertEquals(100, decrypted.description?.length) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt index 8b4d2617d0..0b494dcef4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/pointers/ClinkPointerTest.kt @@ -43,16 +43,6 @@ class ClinkPointerTest { assertEquals(offer, ClinkPointerParser.parse(encoded)) } - @Test - fun offerAlwaysEncodesPriceTypeTlv() { - // Even when the model leaves priceType null, encode must emit TLV 3 (the SDK and - // bridgelet decoders reject a noffer without it); it decodes back as SPONTANEOUS. - val offer = NOffer(pubKey, listOf(relay), null, null, null) - val parsed = ClinkPointerParser.parse(offer.encode()) as NOffer - - assertEquals(OfferPriceType.SPONTANEOUS, parsed.priceType) - } - @Test fun offerFixedPriceRoundTrip() { val offer = NOffer(pubKey, listOf(relay), null, OfferPriceType.FIXED, 21_000) From 1490e7a0303cc346e6db2539256562775b5a4a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 19:16:59 +0000 Subject: [PATCH 58/75] fix(zap): thread-safe progress accumulator for both pay rails progressAllPayments was a non-atomic Float var incremented from the concurrent mapNotNullAsync bodies AND the async response callbacks (NWC onResponse / the CLINK launched coroutine), so parallel zap splits raced and could leave the progress bar below 100%. Replace it with a shared PaymentProgress(AtomicInteger over 2*N half-steps) used by both payViaNWC and payViaClinkDebit, which also removes the duplicated half-step arithmetic. Note: NWC's response half-step still won't fire if a wallet never replies within its 60s window (sendZapPaymentRequestFor doesn't signal onResponse on timeout); that progress-stall is pre-existing and separate from this race fix. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/service/ZapPaymentHandler.kt | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 2f0c9ad60a..977c0d5959 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import java.util.concurrent.atomic.AtomicInteger import kotlin.math.round class ZapPaymentHandler( @@ -353,7 +354,7 @@ class ZapPaymentHandler( onProgress: (percent: Float) -> Unit, context: Context, ): List { - var progressAllPayments = 0.00f + val progress = PaymentProgress(payables.size, onProgress) return mapNotNullAsync( items = payables, @@ -362,9 +363,8 @@ class ZapPaymentHandler( bolt11 = payable.invoice, zappedNote = note, onResponse = { response -> + progress.step() if (response is PayInvoiceErrorResponse) { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) onError( stringRes(context, R.string.error_dialog_pay_invoice_error), stringRes( @@ -375,21 +375,33 @@ class ZapPaymentHandler( ), payable.info.user, ) - } else { - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) } }, ) - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) + progress.step() Paid(payable, true) }, ) } + /** + * Thread-safe progress accumulator for the parallel pay rails. Each payable advances in two + * half-steps (request dispatched, then response/settlement), reported as a 0..1 fraction. + * The counter is atomic because `mapNotNullAsync` runs the payables concurrently and the + * response half-step fires from an async callback, so plain `+=` would lose updates. + */ + private class PaymentProgress( + payableCount: Int, + private val onProgress: (percent: Float) -> Unit, + ) { + private val totalSteps = (payableCount * 2).coerceAtLeast(1) + private val done = AtomicInteger(0) + + fun step() = onProgress(done.incrementAndGet().toFloat() / totalSteps) + } + /** * Pays each zap invoice by asking the user's CLINK debit service (kind 21002) to * settle the BOLT-11. The service authorizes against the account identity. @@ -406,15 +418,14 @@ class ZapPaymentHandler( onProgress: (percent: Float) -> Unit, context: Context, ): List { - var progressAllPayments = 0.00f + val progress = PaymentProgress(payables.size, onProgress) return mapNotNullAsync( items = payables, runRequestFor = { payable: Payable -> account.scope.launch { val response = ClinkDebitPayer.payInvoice(account, pointer, payable.invoice) - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) + progress.step() if (response?.isOk() != true) { onError( stringRes(context, R.string.error_dialog_pay_invoice_error), @@ -425,8 +436,7 @@ class ZapPaymentHandler( } } - progressAllPayments += 0.5f / payables.size - onProgress(progressAllPayments) + progress.step() Paid(payable, true) }, From f725b2ee39179a7674541fc13acef627d6c94ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:00:43 +0000 Subject: [PATCH 59/75] chore: prune Claude config for Fable 5 and fix stale skill metadata - CLAUDE.md: drop the 5-step skill-approval workflow (skills auto-trigger and the approval loop blocked autonomous sessions), condense Verify-Don't- Guess to the repo-specific tooling pointers, remove references to the uncommitted /bugfix and /investigate skills, and replace the mandated emoji survey matrix with one-line guidance - android-expert / desktop-expert: add missing YAML frontmatter so the skills carry trigger descriptions and can actually auto-invoke - extract.md: fix stale shared-ui/ module name -> commons/ - delete skills/quartz-kmp.md breadcrumb (migration long complete) - gate the Stop spotlessApply hook on modified Kotlin files via hooks/stop-spotless.sh so Q&A-only turns skip the Gradle run - condense core-skills-plan.md to a historical changelog https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn --- .claude/CLAUDE.md | 50 +--- .claude/commands/extract.md | 2 +- .claude/core-skills-plan.md | 400 +++---------------------- .claude/hooks/stop-spotless.sh | 12 + .claude/settings.json | 2 +- .claude/skills/android-expert/SKILL.md | 5 + .claude/skills/desktop-expert/SKILL.md | 5 + .claude/skills/quartz-kmp.md | 23 -- 8 files changed, 78 insertions(+), 421 deletions(-) create mode 100755 .claude/hooks/stop-spotless.sh delete mode 100644 .claude/skills/quartz-kmp.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0a55a49f52..46f21f1699 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,27 +23,13 @@ implementation for any future IETF target; see Canonical NIP specs live at — use `/nip ` to pull a specific one (it fetches the spec file directly). -## Verify, Don't Guess (standing instruction) +## Verify, Don't Guess -A plausible-sounding explanation is cheap; being right is not. Before -asserting what a problem is or how something behaves: - -1. **State hypotheses as hypotheses.** If you haven't run it, say "I'm - guessing" or "haven't verified" — never dress an untested guess up as a - diagnosis. Use "I verified X by running Y" only when you actually did. -2. **Reproduce before diagnosing.** If a claim is checkable in under a - minute, check it before stating it. This repo gives you the means: - `./gradlew test`, the per-module tests, and `amy` (the CLI exists partly - to drive `quartz`/`commons` for interop checks). Write the failing case - first, watch it fail, *then* explain. For non-trivial bugs use `/bugfix` - (reproduce-first) or `/investigate` (competing hypotheses + refutation). -3. **Predict, then run.** Before running a command, state the output you - expect. A mismatch is the cheapest signal that your model is wrong. -4. **Don't commit to one cause.** A single immediate explanation stops you - from looking. Hold 2–3 candidates and a discriminating test for each. - -If you find yourself writing paragraphs to defend a theory, that effort -almost always should have been one test. +Don't assert a diagnosis you haven't reproduced. This repo gives you cheap +verification tools: `./gradlew test`, per-module test suites, and the `amy` +CLI (built partly to drive `quartz`/`commons` for interop checks). If a +claim is checkable in under a minute, check it before stating it — write +the failing case first, watch it fail, then explain. ## Architecture @@ -123,16 +109,6 @@ to be used together: skills: `compose-expert` tells you where shared composables live; `compose-slot-api-pattern` tells you how to shape their public API. -## Workflow - -**When you ask for a feature:** - -1. **Quick skill assessment** - I identify which skills are relevant -2. **Propose which skills** - I present which skills I'll use for the task -3. **Get approval** - You review and approve (or adjust) the skill selection -4. **Review plan using approved skills** - I invoke the approved skills to create detailed implementation plan -5. **Execute with skills** - Skills collaborate to implement the feature - ## Feature Workflow **CRITICAL: Check existing implementations first — most logic already exists.** @@ -142,17 +118,9 @@ job is usually to **reuse** (`quartz` protocol/business logic), **extract** (Android UI/ViewModels → `commons`), and add **platform-specific** layouts/nav — not to duplicate existing managers, caches, or state. -Capture the survey as a matrix in your plan: - -| File/Component | Status | Location | Action | -|----------------|--------|----------|--------| -| FilterBuilders | ✅ Reuse | quartz/relay/filters/ | Use as-is | -| NoteCard | 📦 Extract | amethyst/ui/note/ → commons/ | Extract to commons | -| ProfileCache | ⚠️ Avoid | N/A | Already in User/Account pattern | - -**Legend:** ✅ Reuse (exists, use directly) · 📦 Extract (exists in Android, move -to `commons`) · 🆕 New (doesn't exist — platform-specific only) · ⚠️ Avoid -(duplicate; use existing pattern). +Summarize the survey in your plan: for each component, note whether it's +reused as-is, extracted from `amethyst/` to `commons/`, genuinely new +(platform-specific only), or a duplicate of an existing pattern to avoid. **Share vs keep platform-native:** diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md index 99c01eea7c..6e9eaf1bca 100644 --- a/.claude/commands/extract.md +++ b/.claude/commands/extract.md @@ -18,7 +18,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code: - Android Compose specifics vs standard Compose 3. **Identify what can be shared**: - - Pure Composable functions → `shared-ui/commonMain/` + - Pure Composable functions → `commons/commonMain/` - Business logic → `quartz/commonMain/` - Platform-specific → create expect/actual diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 16056cd4c4..65b10c8047 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -1,339 +1,37 @@ -# AmethystMultiplatform Skills Creation Plan - -## Overview -Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns. - -**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase. - -## Skills to Implement - -### 1. kotlin-multiplatform ✅ COMPLETED -**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual - -**SKILL.md sections:** -- Mental model: KMP hierarchy as dependency graph -- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain} -- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149) -- expect/actual mechanics with 24+ examples from codebase -- iOS framework setup for Quartz distribution - -**Bundled resources:** -- `references/source-set-hierarchy.md` - Visual diagram + examples -- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns -- `scripts/validate-kmp-structure.sh` - Verify source set dependencies -- `assets/kmp-hierarchy-diagram.png` - Visual graph - -**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples. - -**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/` - ---- - -### 2. gradle-expert ✅ COMPLETED -**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting - -**SKILL.md sections:** -- Build architecture: 4 modules, dependency flow -- Version catalog mastery (libs.versions.toml) -- Module dependency patterns (api vs implementation) -- Android-specific: compileSdk, proguard -- Desktop packaging: TargetFormat, distributions -- Build performance: daemon, parallel, caching -- Common errors: compose version conflicts, secp256k1 JNI variants - -**Bundled resources:** -- `references/build-commands.md` - Common gradle tasks -- `references/dependency-graph.md` - Module visualization -- `references/version-catalog-guide.md` - Version catalog patterns -- `references/common-errors.md` - Troubleshooting guide -- `scripts/analyze-build-time.sh` - Performance report -- `scripts/fix-dependency-conflicts.sh` - Conflict patterns - -**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts). - -**Status:** ✅ SKILL.md (549 lines) + 4 references + 2 scripts created at `.claude/skills/gradle-expert/` - ---- - -### 3. kotlin-expert ✅ DRAFT COMPLETE -**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified - -**SKILL.md sections:** -- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager) -- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult) -- Immutability: @Immutable for Compose performance (173+ event classes) -- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder) -- Inline functions: reified generics, performance optimization (OptimizedJsonMapper) -- Value classes: Zero-cost wrappers (optimization opportunity) - -**Bundled resources:** -- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns -- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns -- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns -- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set - -**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders). - -**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline) -2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder -3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025 -4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf) -5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability) -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later (requires real usage scenarios) -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 4. compose-expert ✅ COMPLETED -**Focus:** Shared composables, state management, animations, Material3 - -**SKILL.md sections:** -- Shared composables philosophy (100+ already shared in commons/commonMain) -- State management: remember, derivedStateOf, produceState (visual patterns) -- Recomposition optimization: @Stable/@Immutable (visual usage) -- Material3 conventions: theming -- Custom icons: ImageVector builders (robohash pattern) -- Platform differences: Desktop vs Android UI -- Performance: lazy lists, image loading -- Decision framework: share by default in commonMain - -**Bundled resources:** -- `references/shared-composables-catalog.md` - Complete catalog with patterns -- `references/state-patterns.md` - State hoisting, derivedStateOf examples -- `references/icon-assets.md` - ImageVector patterns, roboBuilder DSL -- `scripts/find-composables.sh` - Grep @Composable utility - -**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). Delegates navigation to platform experts, defers Kotlin language details to kotlin-expert. - -**Status:** ✅ SKILL.md (578 lines) + 3 references + 1 script created at `.claude/skills/compose-expert/` - ---- - -### 5. ios-expert -**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation - -**SKILL.md sections:** -- iOS source sets: iosMain, iosArm64Main -- Swift interop: type mapping, nullability -- expect/actual iOS: 10+ examples from quartz/iosMain -- XCFramework setup: baseName = "quartz-kmpKit" -- Platform APIs: platform.posix, CFNetwork, Security -- CocoaPods integration -- XCode project setup - -**Bundled resources:** -- `references/ios-actual-implementations.md` - 10 iosMain actuals -- `references/swift-interop-guide.md` - Type mapping -- `references/xcode-integration.md` - XCode setup -- `scripts/generate-xcframework.sh` - Build all iOS targets - -**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup. - ---- - -### 6. desktop-expert ✅ DRAFT COMPLETE -**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions - -**SKILL.md sections:** -- Desktop entry point: application {} DSL -- Window management: WindowState, positioning, multi-window -- Menu system: MenuBar, keyboard shortcuts (OS-aware) -- System tray: minimize to tray -- Desktop navigation: NavigationRail pattern (vs Android bottom nav) -- File system: Desktop.getDesktop(), file pickers, drag-drop -- Desktop UX principles: keyboard-first, native feel, tooltips -- OS-specific behavior: macOS vs Windows vs Linux -- Platform detection: PlatformDetector utility -- Packaging: DMG, MSI, DEB distribution - -**Bundled resources:** -- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.) -- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns -- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper -- `references/os-detection.md` - Platform detection, file paths, system integration - -**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert. - -**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined desktop usage scenarios -2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt) -3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG) -4. ✅ SYNTHESIZE - Extracted desktop principles from codebase -5. ✅ DRAFT - Created SKILL.md + 4 reference files -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later (requires real desktop scenarios) -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 7. android-expert ✅ DRAFT COMPLETE -**Focus:** Android platform APIs, navigation, permissions, Material Design - -**SKILL.md sections:** -- Android module structure: amethyst/ layout -- Navigation: Navigation Compose, bottom nav -- Permissions: runtime (camera, biometric) -- Platform APIs: Intent, Context, ContentResolver -- Lifecycle: Lifecycle-aware, ViewModel -- Material Design: Android Material 3 -- Build config: Proguard, R8 -- Android UX: mobile-first patterns - -**Bundled resources:** -- `references/android-navigation.md` - Navigation Compose -- `references/android-permissions.md` - Permission handling -- `references/proguard-rules.md` - Proguard explanation -- `scripts/analyze-apk-size.sh` - APK optimization - -**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions. - -**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/` - -**10-Step Progress:** -1. ✅ UNDERSTAND - Defined Android usage scenarios -2. ✅ EXPLORE - Analyzed amethyst/ module patterns -3. ✅ RESEARCH - Android best practices + KMP Android patterns -4. ✅ SYNTHESIZE - Extracted Android principles from codebase -5. ✅ DRAFT - Initialized skill, created resources -6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) -7. ✅ ITERATE - Draft complete (skipping deep iteration for now) -8. ⏸️ TEST - Deferred to later -9. ⏸️ FINALIZE - Deferred to later -10. ✅ DOCUMENT - Updated plan - ---- - -### 8. nostr-expert ✅ COMPLETED -**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns - -**SKILL.md sections:** -- Quartz architecture: package structure by NIP (57 NIPs implemented) -- Event anatomy: IEvent, Event, kinds, tags -- EventTemplate & TagArrayBuilder DSL patterns -- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent, Addressable events -- Tag patterns: e-tag, p-tag, a-tag, d-tag with builders -- Threading (NIP-10): reply/root markers -- Cryptography: secp256k1 signing, NIP-44 encryption -- Bech32 encoding: npub, nsec, note, nevent -- Event validation & verification -- Common workflows: publishing, querying, zaps, gift-wrapped DMs - -**Bundled resources:** -- `references/nip-catalog.md` - All 57 NIPs with package locations (179 lines) -- `references/event-hierarchy.md` - Event class hierarchy, kind classifications (293 lines) -- `references/tag-patterns.md` - Tag structure, TagArrayBuilder DSL, parsing (251 lines) -- `scripts/nip-lookup.sh` - Find NIP implementations by number or search term - -**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation patterns (57 NIPs), concrete code examples from codebase. - -**Status:** ✅ SKILL.md (552 lines) + 3 references + 1 script created at `.claude/skills/nostr-expert/` - ---- - -## Implementation Workflow - -Using skill-creator 10-step methodology per skill: - -**Overall Plan:** -1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained -2. **EXPLORE** ✅ - Codebase analyzed via Explore agent -3. **RESEARCH** ✅ - Domain patterns identified via Plan agent -4. **SYNTHESIZE** ✅ - Skills designed above - -**Per-Skill Implementation:** -- kotlin-multiplatform: ✅ COMPLETED -- gradle-expert: ✅ COMPLETED -- kotlin-expert: ✅ COMPLETED -- compose-expert: ✅ COMPLETED -- desktop-expert: ✅ COMPLETED -- android-expert: ✅ COMPLETED -- nostr-expert: ✅ COMPLETED -- ios-expert: ⏸️ DEFERRED (iOS not yet implemented in AmethystMultiplatform) - -## Critical Files Referenced - -**Build patterns:** -- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set -- `/commons/build.gradle.kts` - Shared UI setup - -**Code patterns:** -- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure -- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern -- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual - -**Documentation:** -- `/docs/shared-ui-analysis.md` - UI migration strategy - -## Output Location -`.claude/skills//` for each skill - -## Next Steps - -1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference -2. ✅ Completed kotlin-multiplatform skill -3. ✅ Completed gradle-expert skill -4. ✅ Completed kotlin-expert skill -5. ✅ Completed compose-expert skill -6. ✅ Completed desktop-expert skill -7. ✅ Completed android-expert skill -8. ✅ Completed nostr-expert skill -9. ⏸️ Deferred ios-expert (iOS not yet implemented in codebase) - -## Current Status: 7/8 Skills Completed - -**Completed Skills (Auto-loaded from `.claude/skills/`):** -1. ✅ kotlin-multiplatform (KMP architecture, jvmAndroid pattern, expect/actual) -2. ✅ gradle-expert (Build system, dependencies, version catalog, troubleshooting) -3. ✅ kotlin-expert (Flow state, sealed classes, @Immutable, DSL builders) -4. ✅ compose-expert (Shared composables, state management, Material3, ImageVector) -5. ✅ desktop-expert (Desktop UX, window management, Compose Desktop APIs) -6. ✅ android-expert (Android platform APIs, navigation, permissions) -7. ✅ nostr-expert (Nostr protocol, Quartz implementation, NIPs, events, tags) - -**Deferred:** -- ⏸️ ios-expert (iOS not implemented yet in AmethystMultiplatform) - -## Skill Loading - -**All completed skills are automatically loaded** when this project opens. Skills are auto-discovered from `.claude/skills/` directory. - -To manually verify skills are loaded: -```bash -ls -1 .claude/skills/ -``` - -Should show: -- android-expert/ -- compose-expert/ -- desktop-expert/ -- gradle-expert/ -- kotlin-expert/ -- kotlin-multiplatform/ -- nostr-expert/ - ---- +# Amethyst Skill Library — History & Changelog + +> Historical record of how the `.claude/skills/` library was built and audited. +> The 8 original skills were created in 2025 using the skill-creator 10-step +> methodology (detailed per-skill progress logs pruned in 2026-06 — see git +> history of this file if you need them). For the current skill list and how +> the two skill layers (codebase-oriented vs technique-oriented) fit together, +> see the Skills section of `.claude/CLAUDE.md`. + +## Phase 1 (2025): Core skills created + +Eight hybrid domain skills (general expertise + Amethyst-specific patterns), +each with a SKILL.md plus bundled `references/` and `scripts/`: + +1. **kotlin-multiplatform** — KMP architecture, the jvmAndroid source-set pattern, expect/actual catalog +2. **gradle-expert** — build system, version catalog, dependency troubleshooting +3. **kotlin-expert** — Flow state, sealed hierarchies, @Immutable, DSL builders +4. **compose-expert** — shared composables, state management, Material3, ImageVector +5. **desktop-expert** — Desktop UX, window management, Compose Desktop APIs +6. **android-expert** — Android navigation, permissions, platform APIs +7. **nostr-expert** — Nostr protocol, Quartz implementation, NIPs, events, tags +8. **ios-expert** — ⏸️ deferred (iOS targets are mature, but no iOS-specific UI work has surfaced in this repo yet) ## Phase 2 (2026-04): Audit & Expansion After a full audit of the skill library, the following changes were made: ### Stale references fixed -- `CLAUDE.md` tech-stack versions updated to Compose 1.10.3 / Kotlin 2.3.20. -- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp 0.23.0 version notes. -- `desktop-expert` Main.kt line references rewritten to match current layout (Main.kt grew from ~270 to ~1341 lines; NavigationRail moved to `ui/deck/SinglePaneLayout.kt:97`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. +- `CLAUDE.md` tech-stack versions replaced with a pointer to `gradle/libs.versions.toml` as the source of truth. +- `kotlin-multiplatform` reframed iOS as a mature target (not future) and added secp256k1-kmp version notes. +- `desktop-expert` Main.kt line references rewritten to match current layout (NavigationRail moved to `ui/deck/SinglePaneLayout.kt`); the obsolete "hardcoded ctrl = true anti-pattern" section replaced with a note that `isMacOS` branching is now applied throughout. ### Redundant files removed -- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). `quartz-kmp.md` kept as a small breadcrumb pointer. +- `.claude/skills/compose-desktop.md` deleted (superseded by `desktop-expert/`). ### New references added to existing skills - `nostr-expert/references/nip19-bech32.md` — `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entities. @@ -346,33 +44,25 @@ After a full audit of the skill library, the following changes were made: ### New skills created - **`account-state/`** — `Account.kt` (50+ StateFlow properties) and `LocalCache.kt` event store. - - `references/account-state-flow.md`, `references/local-cache.md` -- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders (`MetadataPreloader`, `MetadataRateLimiter`). - - `references/filter-assemblers.md`, `references/preloaders.md` +- **`relay-client/`** — `ComposeSubscriptionManager`, filter assemblers, preloaders. - **`feed-patterns/`** — `FeedFilter`, `AdditiveComplexFeedFilter`, `FeedViewModel` hierarchy in `commons/`. - - `references/feed-filter-composition.md`, `references/viewmodel-base-classes.md` -- **`auth-signers/`** — `NostrSigner` abstraction across `NostrSignerInternal`, `NostrSignerRemote` (NIP-46), `NostrSignerExternal` (NIP-55). - - `references/nip46-remote-signer.md`, `references/nip55-android-signer.md` +- **`auth-signers/`** — `NostrSigner` abstraction across internal, NIP-46 remote, and NIP-55 external signers. -### Updated skills directory (Phase 2) -``` -- android-expert/ -- auth-signers/ (new) -- account-state/ (new) -- compose-expert/ -- desktop-expert/ -- feed-patterns/ (new) -- find-missing-translations/ -- find-non-lambda-logs/ -- gradle-expert/ -- kotlin-coroutines/ -- kotlin-expert/ -- kotlin-multiplatform/ -- nostr-expert/ -- quartz-integration/ -- relay-client/ (new) -- quartz-kmp.md (breadcrumb pointer) -``` +## Phase 3 (2026-06): Fable 5 config review -### Still deferred -- ⏸️ `ios-expert` — iOS targets are mature but iOS-specific UI work hasn't surfaced yet in this repo. +Instructions written to coach older models were removed now that the model +handles them natively; stale references fixed: + +- `CLAUDE.md`: deleted the 5-step skill-approval "Workflow" section + (skills auto-trigger; the approval loop blocked autonomous sessions); + condensed "Verify, Don't Guess" to the repo-specific tooling pointers and + dropped references to `/bugfix` / `/investigate` (never committed to this + repo); replaced the mandated emoji survey matrix with one-line guidance. +- `android-expert` and `desktop-expert` SKILL.md gained YAML frontmatter — + without it they were listed without trigger descriptions and never + auto-invoked. +- `commands/extract.md`: fixed stale `shared-ui/` module name → `commons/`. +- `skills/quartz-kmp.md` breadcrumb deleted (KMP migration long complete; + `quartz-integration` and `nostr-expert` cover its pointers). +- Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified + Kotlin files, so Q&A-only turns no longer pay a Gradle invocation. diff --git a/.claude/hooks/stop-spotless.sh b/.claude/hooks/stop-spotless.sh new file mode 100755 index 0000000000..61666ba6ab --- /dev/null +++ b/.claude/hooks/stop-spotless.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Stop hook: format Kotlin sources, but only when the working tree actually +# has modified Kotlin files — skips the Gradle invocation on Q&A-only turns. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +if git status --porcelain 2>/dev/null | grep -qE '[.]kts?$'; then + ./gradlew spotlessApply 2>/dev/null +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index f44ad3a03c..15b897a70f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply 2>/dev/null", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-spotless.sh", "timeout": 120 } ] diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 1116915273..db0a32c4b0 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -1,3 +1,8 @@ +--- +name: android-expert +description: Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming and edge-to-edge UI, (5) AndroidManifest.xml and intent filters, (6) Proguard/R8 and APK optimization, (7) Android lifecycle (ViewModel, collectAsStateWithLifecycle), (8) Coil image loading. Delegates shared composables to compose-expert, build files to gradle-expert, and KMP structure to kotlin-multiplatform. +--- + # android-expert Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture. diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md index cbd3f5d48d..b4208def3f 100644 --- a/.claude/skills/desktop-expert/SKILL.md +++ b/.claude/skills/desktop-expert/SKILL.md @@ -1,3 +1,8 @@ +--- +name: desktop-expert +description: Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform. +--- + # Desktop Expert Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles. diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md deleted file mode 100644 index 44c187e0be..0000000000 --- a/.claude/skills/quartz-kmp.md +++ /dev/null @@ -1,23 +0,0 @@ -# Quartz KMP (Legacy Skill — Migration Complete) - -> The KMP migration of Quartz is **complete**. This file is kept for historical reference. -> -> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead. -> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill. - -## What was migrated - -The Quartz library was successfully converted from Android-only to full KMP supporting: -- **commonMain** — All Nostr protocol logic, events, filters, tags -- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers -- **androidMain** — SQLite event store, NIP-55 Android signer -- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm) -- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`) - -## Current artifact - -``` -com.vitorpamplona.quartz:quartz:1.11.0 -``` - -See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file From 01cb45cb31f04bd9df14b8ec2f14914691e1ddf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:55:21 +0000 Subject: [PATCH 60/75] feat(clink): render profile offer as a tappable chip, not an always-on card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile CLINK offer showed the full ClinkOfferPreview payment card up front. Render it instead as a compact payment-target-style chip (Bolt icon + 'Lightning Offer' label, matching the PaymentTargetChip look); tapping it expands the payable card, collapsed by default — same expand-on-click idiom as the lightning address row. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../profile/header/DrawAdditionalInfo.kt | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index e3ee115acc..d066d9b143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData import android.util.LruCache +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -29,9 +32,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -76,7 +81,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserApp import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @@ -404,9 +411,10 @@ private class ResolvedClinkOffer( private val clinkOfferNip05Cache = LruCache(256) /** - * Shows a payable CLINK Offer card when the profile advertises one, preferring the - * kind-0 `clink_offer` field and falling back to the user's NIP-05 `.well-known` - * `clink_offer` (cached). Paying pays the advertised offer (see [ClinkOfferPreview]). + * Shows a profile's advertised CLINK Offer as a compact, tappable chip (preferring the kind-0 + * `clink_offer` field, falling back to the NIP-05 `.well-known` `clink_offer`, cached). Tapping + * the chip expands the payable [ClinkOfferPreview] card — collapsed by default so the full card + * isn't shown until the user opts in. */ @Composable private fun DisplayClinkOffer( @@ -446,7 +454,54 @@ private fun DisplayClinkOffer( } } - offer?.let { - ClinkOfferPreview(it, accountViewModel) + offer?.let { resolved -> + var expanded by remember(resolved) { mutableStateOf(false) } + Column { + ClinkOfferChip(expanded) { expanded = !expanded } + if (expanded) { + ClinkOfferPreview(resolved, accountViewModel) + } + } + } +} + +/** + * Compact, payment-target-style chip for a profile's CLINK Offer. Tapping it toggles the + * payable [ClinkOfferPreview] card open/closed; collapsed by default so the profile mirrors the + * other payment-target chips instead of showing the full card up front. + */ +@Composable +private fun ClinkOfferChip( + expanded: Boolean, + onClick: () -> Unit, +) { + val label = stringRes(R.string.clink_lightning_offer) + Surface( + shape = RoundedCornerShape(50), + color = BitcoinOrange.copy(alpha = 0.10f), + border = BorderStroke(1.dp, BitcoinOrange.copy(alpha = if (expanded) 0.6f else 0.35f)), + modifier = + Modifier + .padding(vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = label, + tint = BitcoinOrange, + modifier = Size16Modifier, + ) + Text( + text = label, + color = BitcoinOrange, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } } } From 5dadff0315f3573f7748724d6550325400ac594d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:58:54 +0000 Subject: [PATCH 61/75] fix(clink): label the offer chip/card 'CLINK Offer' https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 788213b6a6..4de0a265d2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -112,7 +112,7 @@ Logout Show More Lightning Invoice - Lightning Offer + CLINK Offer Requesting invoice… The debit service did not complete the payment. Confirm payment From e77658c292ac9c0fcc4615da47d79b06abc05beb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:23:51 +0000 Subject: [PATCH 62/75] =?UTF-8?q?feat(cli):=20close=20CLINK=20parity=20gap?= =?UTF-8?q?s=20=E2=80=94=20profile=20offer,=20follow,=20offer=20pay,=20GFY?= =?UTF-8?q?=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings amy's CLINK surface closer to the app's: - profile edit --clink-offer : set/clear the kind-0 clink_offer (validated as a real noffer; "" clears). MetadataEvent already carried the field. - offer request --follow: chase an 'Expired or Moved' (code 3) reply to its 'latest' pointer (bounded hops), mirroring the app; the error output now also carries code/latest/range so a script can follow or correct manually. - offer pay --with [--amount]: end-to-end — fetch the invoice (21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle. - Structured GFY detail (code, range, retry_after, delta) in debit/offer errors, via a new Output.error(extra=) overload. Adds local-validation cases to the headless harness (offer pay --with, profile edit --clink-offer); 15/15 pass. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../com/vitorpamplona/amethyst/cli/Output.kt | 9 +- .../amethyst/cli/commands/DebitCommands.kt | 130 +++++++++---- .../amethyst/cli/commands/OfferCommands.kt | 183 +++++++++++++++--- .../amethyst/cli/commands/ProfileCommands.kt | 14 +- cli/tests/clink/clink-headless.sh | 23 +++ 5 files changed, 285 insertions(+), 74 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index 60b27319af..7f30d19349 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -63,11 +63,14 @@ object Output { fun error( code: String, detail: String? = null, + extra: Map = emptyMap(), ): Int { + val cleanExtra = extra.filterValues { it != null } when (mode) { Mode.JSON -> { - val payload = mutableMapOf("error" to code) + val payload = mutableMapOf("error" to code) if (detail != null) payload["detail"] = detail + payload.putAll(cleanExtra) System.err.println(mapper.writeValueAsString(payload)) } @@ -75,7 +78,9 @@ object Output { val color = Ansi.forStream(isStderr = true) val prefix = color.bold(color.red("error")) val codePart = color.yellow(code) - System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart") + val base = if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart" + val suffix = if (cleanExtra.isEmpty()) "" else cleanExtra.entries.joinToString(", ", " (", ")") { "${it.key}=${it.value}" } + System.err.println(base + suffix) } } return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt index e8ab3de2e5..ba90f6f01c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -98,17 +98,10 @@ object DebitCommands { val amount = args.flag("amount")?.toLongOrNull() ?: return Output.error("bad_args", "--amount SATS is required for a budget") - val frequency = - when (val f = args.flag("frequency")?.lowercase()) { - null, "once", "one-time" -> null - "day", "daily" -> DebitFrequency(1, DebitFrequency.UNIT_DAY) - "week", "weekly" -> DebitFrequency(1, DebitFrequency.UNIT_WEEK) - "month", "monthly" -> DebitFrequency(1, DebitFrequency.UNIT_MONTH) - else -> return Output.error("bad_args", "unknown --frequency '$f' (day|week|month)") - } + val frequency = parseFrequency(args.flag("frequency")) ?: return Output.error("bad_args", "unknown --frequency '${args.flag("frequency")}' (day|week|month)") val timeoutMs = args.longFlag("timeout", 15_000) - return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency) } + return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency.value) } } /** @@ -124,43 +117,102 @@ object DebitCommands { val debit = ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit ?: return Output.error("bad_args", "not a valid ndebit pointer") - val relays = debit.relays.toSet() - if (relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") val ctx = Context.open(dataDir) try { ctx.prepare() - val client = DebitClient(debit, ctx.signer) - val requestEvent = buildRequest(client) - - val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) - if (reply == null) { - Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") - return 124 - } - - val response: DebitResponse = - (reply as? DebitEvent)?.let { client.parseResponse(it) } - ?: return Output.error("bad_response", "service reply was not a kind-21002 debit event") - - return if (response.isOk()) { - Output.emit( - mapOf( - "result" to "ok", - "preimage" to response.preimage, - "request_id" to requestEvent.id, - "service" to debit.pubKey, - ), - ) - 0 - } else { - Output.error( - "debit_error", - response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", - ) + return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) { + Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event") + is Settle.Replied -> emitDebit(outcome, debit.pubKey) } } finally { ctx.close() } } + + /** Emit a [DebitResponse] as the standard `ok`+preimage success or a structured GFY error. */ + internal fun emitDebit( + outcome: Settle.Replied, + servicePubKey: String, + ): Int { + val response = outcome.response + return if (response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to response.preimage, + "request_id" to outcome.requestId, + "service" to servicePubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + gfyExtra(response), + ) + } + } + + /** Structured GFY extras (code + any actionable range/retry_after/delta) for error output. */ + internal fun gfyExtra(response: DebitResponse): Map = + mapOf( + "code" to response.code, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + "retry_after" to response.retry_after, + "delta" to response.delta?.let { mapOf("max_delta_ms" to it.max_delta_ms, "actual_delta_ms" to it.actual_delta_ms) }, + ) + + /** Result of a single 21002 round-trip, decoupled from how it is emitted. */ + internal sealed interface Settle { + data class Replied( + val requestId: String, + val response: DebitResponse, + ) : Settle + + data object Timeout : Settle + + data object BadReply : Settle + } + + /** + * Core 21002 round-trip against an already-decoded [debit] on an open [ctx]: build the + * request, publish, await the reply, decrypt. Reused by `debit pay/budget` and by + * `offer pay` (fetch invoice → settle via debit). + */ + internal suspend fun settle( + ctx: Context, + debit: NDebit, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Settle { + val client = DebitClient(debit, ctx.signer) + val requestEvent = buildRequest(client) + val reply = + ctx.requestResponse(requestEvent, debit.relays.toSet(), client.responseFilter(requestEvent.id), timeoutMs) + ?: return Settle.Timeout + val response = (reply as? DebitEvent)?.let { client.parseResponse(it) } ?: return Settle.BadReply + return Settle.Replied(requestEvent.id, response) + } + + /** Parses a `--frequency` value into a one-time (null) or recurring cadence. Null = invalid. */ + internal fun parseFrequency(raw: String?): Frequency? = + when (raw?.lowercase()) { + null, "once", "one-time" -> Frequency(null) + "day", "daily" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_DAY)) + "week", "weekly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_WEEK)) + "month", "monthly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + else -> null + } + + /** Wrapper so a valid "one-time" budget (null cadence) is distinguishable from an invalid flag. */ + internal data class Frequency( + val value: DebitFrequency?, + ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt index 917ac95508..3b1d2d6242 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -25,8 +25,11 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer /** @@ -34,23 +37,30 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer * testing against a real offer service. * * - `info ` decodes a pointer locally (no network). - * - `request [--amount N] [--timeout MS]` runs the kind-21001 round-trip: - * publishes the request to the pointer's relays and prints the returned BOLT-11. + * - `request [--amount N] [--timeout MS] [--follow]` runs the kind-21001 round-trip: + * publishes the request to the pointer's relays and prints the returned BOLT-11. With + * `--follow` it chases an "Expired or Moved" (code 3) reply to the `latest` pointer. + * - `pay --with [--amount N]` fetches the invoice and settles it end-to-end + * through a CLINK debit pointer (offer round-trip → debit round-trip). * - * Thin assembly only: pointer decode + the request/response event live in `quartz` - * (`ClinkPointerParser`, `OfferClient`); the relay round-trip uses `Context.requestResponse`. + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `OfferClient`, `DebitClient`); the relay round-trips use + * `Context.requestResponse` (debit settlement is shared with [DebitCommands]). */ object OfferCommands { + private const val MAX_FOLLOW_HOPS = 3 + suspend fun dispatch( dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "offer ") + if (tail.isEmpty()) return Output.error("bad_args", "offer ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "info" -> info(rest) "request" -> request(dataDir, rest) - else -> Output.error("bad_args", "offer ${tail[0]} (expected info|request)") + "pay" -> pay(dataDir, rest) + else -> Output.error("bad_args", "offer ${tail[0]} (expected info|request|pay)") } } @@ -66,7 +76,7 @@ object OfferCommands { "pubkey" to offer.pubKey, "relays" to offer.relays.map { it.url }, "pointer" to offer.pointer, - "price_type" to offer.priceType?.name?.lowercase(), + "price_type" to offer.priceType.name.lowercase(), "price_sats" to offer.price, ), ) @@ -81,46 +91,157 @@ object OfferCommands { val args = Args(rest) val amount = args.flag("amount")?.toLongOrNull() val timeoutMs = args.longFlag("timeout", 15_000) + val follow = args.bool("follow") - val offer = + var offer = ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer ?: return Output.error("bad_args", "not a valid noffer pointer") - val relays = offer.relays.toSet() - if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") val ctx = Context.open(dataDir) try { ctx.prepare() - val client = OfferClient(offer, ctx.signer) - val requestEvent = client.requestInvoice(amountSats = amount) + var hops = 0 + while (hops <= MAX_FOLLOW_HOPS) { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") - val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) - if (reply == null) { + val client = OfferClient(offer, ctx.signer) + val requestEvent = client.requestInvoice(amountSats = amount) + + val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) + if (reply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + + val response = + (reply as? OfferEvent)?.let { client.parseResponse(it) } + ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") + + if (response.isSuccess()) { + Output.emit( + mapOf( + "bolt11" to response.bolt11, + "request_id" to requestEvent.id, + "service" to offer.pubKey, + "followed_hops" to hops, + ), + ) + return 0 + } + + // "Expired or Moved" (code 3) may carry a replacement `noffer`; chase it on --follow. + val moved = response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + if (follow && response.code == OfferErrorCode.EXPIRED_OR_MOVED && moved != null) { + offer = moved + hops++ + continue + } + + return Output.error( + "offer_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + offerErrorExtra(response), + ) + } + return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)") + } finally { + ctx.close() + } + } + + /** + * Pay an offer end-to-end: fetch a fresh BOLT-11 (kind-21001) and settle it through a + * CLINK debit pointer (kind-21002). The CLI is stateless, so the funding source is given + * explicitly with `--with `. + */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + val withFlag = + args.flag("with") + ?: return Output.error("bad_args", "offer pay needs --with to settle the fetched invoice") + val debit = + ClinkPointerParser.parse(withFlag.trim()) as? NDebit + ?: return Output.error("bad_args", "--with is not a valid ndebit pointer") + + val offerRelays = offer.relays.toSet() + if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + + // 1. fetch a fresh BOLT-11 from the offer service. + val offerClient = OfferClient(offer, ctx.signer) + val offerReq = offerClient.requestInvoice(amountSats = amount) + val offerReply = ctx.requestResponse(offerReq, offerRelays, offerClient.responseFilter(offerReq.id), timeoutMs) + if (offerReply == null) { Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") return 124 } - - val response = - (reply as? OfferEvent)?.let { client.parseResponse(it) } - ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") - - return if (response.isSuccess()) { - Output.emit( - mapOf( - "bolt11" to response.bolt11, - "request_id" to requestEvent.id, - "service" to offer.pubKey, - ), - ) - 0 - } else { - Output.error( + val offerResp = + (offerReply as? OfferEvent)?.let { offerClient.parseResponse(it) } + ?: return Output.error("bad_response", "offer reply was not a kind-21001 event") + if (!offerResp.isSuccess()) { + return Output.error( "offer_error", - response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + offerResp.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${offerResp.code}", + offerErrorExtra(offerResp), ) } + val bolt11 = + offerResp.bolt11 + ?: return Output.error("bad_response", "offer succeeded but returned no bolt11") + + // 2. settle the invoice through the debit service (shared with `debit pay`). + return when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, amount) }) { + DebitCommands.Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + DebitCommands.Settle.BadReply -> Output.error("bad_response", "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to outcome.response.preimage, + "bolt11" to bolt11, + "offer_request_id" to offerReq.id, + "debit_request_id" to outcome.requestId, + "offer_service" to offer.pubKey, + "debit_service" to debit.pubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + outcome.response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${outcome.response.code}", + DebitCommands.gfyExtra(outcome.response), + ) + } + } } finally { ctx.close() } } + + /** Structured offer-error extras (code + moved `latest` pointer + acceptable range). */ + private fun offerErrorExtra(response: OfferResponse): Map = + mapOf( + "code" to response.code, + "latest" to response.latest, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 0b827167f0..170e792189 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -139,17 +141,23 @@ object ProfileCommands { val twitter = args.flag("twitter") val mastodon = args.flag("mastodon") val github = args.flag("github") + val clinkOffer = args.flag("clink-offer") val timeoutSecs = args.longFlag("timeout", 8L) + // A non-blank --clink-offer must be a real noffer; pass "" to clear the field. + if (!clinkOffer.isNullOrBlank() && ClinkPointerParser.parse(clinkOffer.trim()) !is NOffer) { + return Output.error("bad_args", "--clink-offer is not a valid noffer pointer (pass \"\" to clear)") + } + val touched = - listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) + listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github, clinkOffer) .any { it != null } if (!touched) { return Output.error( "bad_args", "profile edit needs at least one of " + "--name --display-name --about --picture --banner --website " + - "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github", + "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github --clink-offer", ) } @@ -182,6 +190,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } else { MetadataEvent.createNew( @@ -198,6 +207,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh index 888faff7a0..b029d8b0b5 100755 --- a/cli/tests/clink/clink-headless.sh +++ b/cli/tests/clink/clink-headless.sh @@ -115,3 +115,26 @@ if amy_a debit budget "$NDEBIT_STATIC" >>"$LOG_FILE" 2>&1; then else record_result debit.budget.noamount pass "missing --amount exits non-zero" fi + +# --- offer pay: requires a --with funding pointer (validated before any network) --- +step "offer pay requires --with " +if amy_a offer pay "$NOFFER_SPONT" --amount 1000 >>"$LOG_FILE" 2>&1; then + record_result offer.pay.nowith fail "missing --with should exit non-zero" +else + record_result offer.pay.nowith pass "missing --with exits non-zero" +fi + +step "offer pay rejects a non-ndebit --with" +if amy_a offer pay "$NOFFER_SPONT" --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result offer.pay.badwith fail "bad --with should exit non-zero" +else + record_result offer.pay.badwith pass "bad --with exits non-zero" +fi + +# --- profile edit --clink-offer: validates the noffer locally before publishing --- +step "profile edit rejects a non-noffer --clink-offer" +if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result profile.clinkoffer.bad fail "bad --clink-offer should exit non-zero" +else + record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero" +fi From 2635bd90a55cb06e6ee2b2a2bcdb8a7609ea911f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:27:36 +0000 Subject: [PATCH 63/75] feat(cli): zap --with settles the invoice via CLINK debit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amy zap printed the invoice but never paid it. With --with it now settles the fetched BOLT-11 in-place through a CLINK debit pointer (kind-21002, reusing DebitCommands.settle), mirroring how the app routes a zap through its default payment source. Works for both single-recipient (zap user) and split zaps (zap event) — each recipient reports paid + preimage (or pay_error). Adds a --with validation case to the headless harness; 16/16 pass. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/cli/commands/ZapCommand.kt | 75 ++++++++++++++++--- cli/tests/clink/clink-headless.sh | 8 ++ 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 8a9292f37a..6ef33e4557 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.actions.ZapActions import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -51,8 +53,10 @@ import okhttp3.OkHttpClient * 4. POST it to the recipient's LNURL-pay callback via * [LightningAddressResolver] to receive a BOLT11 invoice. * - * The invoice is printed but **not** auto-paid — amy has no NWC wallet - * wired up yet. Paste the invoice into any LN wallet to settle. + * By default the invoice is printed but **not** auto-paid — paste it into any LN + * wallet to settle. Pass `--with ` to settle it in-place through a CLINK + * debit pointer (kind-21002), mirroring how the app routes a zap through its + * default payment source; each recipient then also reports `paid` + `preimage`. */ object ZapCommand { suspend fun dispatch( @@ -72,7 +76,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--with ] [--timeout SECS]") val userArg = rest[0] val sats = rest[1].toLongOrNull()?.takeIf { it > 0 } @@ -81,6 +85,14 @@ object ZapCommand { val comment = args.flag("comment") ?: "" val zapType = parseZapType(args) val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val withFlag = args.flag("with") + val settleWith = + if (withFlag == null) { + null + } else { + (ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() } + ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") + } val ctx = Context.open(dataDir) try { @@ -103,7 +115,7 @@ object ZapCommand { zapType = zapType, ) - emitZapResult(ctx, sats, lnAddress, comment, request, zapType) + emitZapResult(ctx, sats, lnAddress, comment, request, zapType, timeoutMs, settleWith) return 0 } finally { ctx.close() @@ -114,7 +126,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--with ] [--timeout SECS]") val eventId = rest[0] if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)") val sats = @@ -124,6 +136,14 @@ object ZapCommand { val comment = args.flag("comment") ?: "" val zapType = parseZapType(args) val timeoutMs = args.longFlag("timeout", 8L) * 1000 + val withFlag = args.flag("with") + val settleWith = + if (withFlag == null) { + null + } else { + (ClinkPointerParser.parse(withFlag.trim()) as? NDebit)?.takeIf { it.relays.isNotEmpty() } + ?: return Output.error("bad_args", "--with must be a valid ndebit pointer with a relay") + } val ctx = Context.open(dataDir) try { @@ -175,7 +195,7 @@ object ZapCommand { ) } - emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests) + emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests, timeoutMs, settleWith) return 0 } finally { ctx.close() @@ -189,6 +209,8 @@ object ZapCommand { comment: String, request: LnZapRequestEvent, zapType: LnZapEvent.ZapType, + timeoutMs: Long, + settleWith: NDebit?, zappedEventId: HexKey? = null, ) { // Reuse the same OkHttp instance the Context uses for nip-05 / WS; @@ -205,8 +227,8 @@ object ZapCommand { when (result) { is LightningAddressResolver.Result.Success -> { - Output.emit( - buildMap { + val base = + buildMap { put("ln_address", lnAddress) put("amount_sats", sats) put("zap_type", zapType.name.lowercase()) @@ -214,8 +236,9 @@ object ZapCommand { put("zap_request_id", request.id) if (zappedEventId != null) put("zapped_event_id", zappedEventId) put("invoice", result.invoice) - }, - ) + } + val settled = if (settleWith != null) settleEntry(ctx, settleWith, result.invoice, timeoutMs) else emptyMap() + Output.emit(base + settled) } is LightningAddressResolver.Result.Error -> { Output.error("invoice_failed", result.message) @@ -223,6 +246,32 @@ object ZapCommand { } } + /** + * Settles [bolt11] through a CLINK debit pointer (kind-21002, reusing [DebitCommands.settle]) + * and returns the result fields (`paid` + `preimage`/`pay_error`) to merge into the zap + * output. Per-recipient for splits. + */ + private suspend fun settleEntry( + ctx: Context, + debit: NDebit, + bolt11: String, + timeoutMs: Long, + ): Map = + when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, null) }) { + DebitCommands.Settle.Timeout -> mapOf("paid" to false, "pay_error" to "no response from the debit service") + DebitCommands.Settle.BadReply -> mapOf("paid" to false, "pay_error" to "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + mapOf("paid" to true, "preimage" to outcome.response.preimage, "debit_request_id" to outcome.requestId) + } else { + mapOf( + "paid" to false, + "pay_error" to (outcome.response.error?.takeIf { it.isNotBlank() } ?: "code ${outcome.response.code}"), + "debit_request_id" to outcome.requestId, + ) + } + } + /** * Multi-recipient (split-aware) event-zap result emitter. Fetches one * BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a @@ -238,6 +287,8 @@ object ZapCommand { zappedEventId: HexKey, zapType: LnZapEvent.ZapType, requests: List, + timeoutMs: Long, + settleWith: NDebit?, ) { val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) @@ -260,8 +311,10 @@ object ZapCommand { "zap_request_id" to req.request.id, ) when (result) { - is LightningAddressResolver.Result.Success -> + is LightningAddressResolver.Result.Success -> { entry["invoice"] = result.invoice + if (settleWith != null) entry.putAll(settleEntry(ctx, settleWith, result.invoice, timeoutMs)) + } is LightningAddressResolver.Result.Error -> entry["invoice_error"] = result.message diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh index b029d8b0b5..7de56b57b3 100755 --- a/cli/tests/clink/clink-headless.sh +++ b/cli/tests/clink/clink-headless.sh @@ -138,3 +138,11 @@ if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then else record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero" fi + +# --- zap --with: rejects a non-ndebit funding pointer (validated before any network) --- +step "zap user rejects a non-ndebit --with" +if amy_a zap user "$EXPECTED_PUB" 1000 --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result zap.with.bad fail "bad --with should exit non-zero" +else + record_result zap.with.bad pass "bad --with exits non-zero" +fi From d0af07be0226c60ca0ac89c004981439cbb4f11d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:30:12 +0000 Subject: [PATCH 64/75] =?UTF-8?q?feat(cli):=20offer=20discover=20?= =?UTF-8?q?=20=E2=80=94=20resolve=20a=20profile=20offer=20via=20NIP-05?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the app's NIP-05 .well-known clink_offer discovery fallback (kind-0 offers are already readable via 'amy profile show'). Reuses the Context's nip05Client.loadClinkOffer and decodes the resolved noffer into its fields. Adds a bad-nip05 validation case to the headless harness; 17/17 pass. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/cli/commands/OfferCommands.kt | 48 ++++++++++++++++++- cli/tests/clink/clink-headless.sh | 8 ++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt index 3b1d2d6242..65971e9773 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -31,12 +31,14 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id /** * `amy offer …` — CLINK Offers (`noffer1…`) from the command line, for headless interop * testing against a real offer service. * * - `info ` decodes a pointer locally (no network). + * - `discover ` resolves a profile's advertised offer from its NIP-05 `.well-known`. * - `request [--amount N] [--timeout MS] [--follow]` runs the kind-21001 round-trip: * publishes the request to the pointer's relays and prints the returned BOLT-11. With * `--follow` it chases an "Expired or Moved" (code 3) reply to the `latest` pointer. @@ -54,13 +56,55 @@ object OfferCommands { dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "offer ") + if (tail.isEmpty()) return Output.error("bad_args", "offer ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "info" -> info(rest) + "discover" -> discover(dataDir, rest) "request" -> request(dataDir, rest) "pay" -> pay(dataDir, rest) - else -> Output.error("bad_args", "offer ${tail[0]} (expected info|request|pay)") + else -> Output.error("bad_args", "offer ${tail[0]} (expected info|discover|request|pay)") + } + } + + /** + * Resolve a profile's advertised offer from its NIP-05 `.well-known/nostr.json` `clink_offer` + * (the app's discovery fallback). A profile's kind-0 `clink_offer` is readable via + * `amy profile show `. + */ + private suspend fun discover( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val id = + Nip05Id.parse(args.positional(0, "nip05").trim()) + ?: return Output.error("bad_args", "not a valid NIP-05 address (e.g. bob@example.com)") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val noffer = ctx.nip05Client.loadClinkOffer(id) + if (noffer == null) { + Output.emit(mapOf("nip05" to id.toDisplayValue(), "found" to false)) + return 0 + } + val offer = ClinkPointerParser.parse(noffer) as? NOffer + Output.emit( + mapOf( + "nip05" to id.toDisplayValue(), + "found" to true, + "noffer" to noffer, + "pubkey" to offer?.pubKey, + "relays" to offer?.relays?.map { it.url }, + "pointer" to offer?.pointer, + "price_type" to offer?.priceType?.name?.lowercase(), + "price_sats" to offer?.price, + ), + ) + return 0 + } finally { + ctx.close() } } diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh index 7de56b57b3..e27c2850e0 100755 --- a/cli/tests/clink/clink-headless.sh +++ b/cli/tests/clink/clink-headless.sh @@ -146,3 +146,11 @@ if amy_a zap user "$EXPECTED_PUB" 1000 --with "not-an-ndebit" >>"$LOG_FILE" 2>&1 else record_result zap.with.bad pass "bad --with exits non-zero" fi + +# --- offer discover: rejects a malformed NIP-05 (validated before any network) --- +step "offer discover rejects a non-nip05 address" +if amy_a offer discover "not-a-nip05" >>"$LOG_FILE" 2>&1; then + record_result offer.discover.bad fail "bad nip05 should exit non-zero" +else + record_result offer.discover.bad pass "bad nip05 exits non-zero" +fi From 5aab19e8dab43c2084ca64a8a6f0a99b0a094344 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:36:27 +0000 Subject: [PATCH 65/75] feat(clink): copy-offer button on the offer card title Adds a ContentCopy IconButton at the right of the CLINK Offer card title that copies the noffer string (the active pointer, after any moved-offer redirect) to the clipboard with a confirmation toast. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../creators/invoice/ClinkOfferPreview.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt index 726434be0b..ed8bb6d80d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/ClinkOfferPreview.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.invoice +import android.widget.Toast import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions @@ -30,6 +32,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -43,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -51,7 +55,10 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Lightning +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.service.ClinkOfferPayer +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -79,6 +86,7 @@ fun ClinkOfferPreview( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current var requesting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } @@ -139,6 +147,25 @@ fun ClinkOfferPreview( fontWeight = FontWeight.W500, modifier = Modifier.padding(start = 10.dp), ) + + Spacer(modifier = Modifier.weight(1f)) + + val copiedMessage = stringRes(R.string.copied_to_clipboard) + IconButton( + onClick = { + scope.launch { + clipboard.setText(activeOffer.encode()) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.copy_to_clipboard), + tint = MaterialTheme.colorScheme.primary, + modifier = Size20Modifier, + ) + } } HorizontalDivider(thickness = DividerThickness) From f2cce3dc878b1dca692eda6ba9f91c8a9b2bea90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:40:53 +0000 Subject: [PATCH 66/75] fix(clink): run offer/debit payer crypto off the Main thread StrictMode flagged the offer round-trip (ephemeral keygen, JSON serialization, NIP-44 encrypt/decrypt, signing) running on the UI thread, because ClinkOfferPreview launches it from a Compose (Main) scope. Wrap the heavy work in withContext(Dispatchers.IO) in both ClinkOfferPayer.requestInvoice and ClinkDebitPayer.payInvoice/requestBudget so the payers are main-safe regardless of caller dispatcher. https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS --- .../amethyst/service/ClinkDebitPayer.kt | 22 ++++-- .../amethyst/service/ClinkOfferPayer.kt | 79 ++++++++++--------- 2 files changed, 57 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index d6f4c2f95f..a15ba45683 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** @@ -60,10 +62,13 @@ object ClinkDebitPayer { bolt11: String, amountSats: Long? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, - ): DebitResponse? { - val client = clientFor(pointer, account) ?: return null - return sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) - } + ): DebitResponse? = + // Off the Main thread: building the request signs + NIP-44 encrypts, and callers reach + // this from Compose (Main) scopes (offer card, lightning-address row). See ClinkOfferPayer. + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) + } /** * Asks the wallet to authorize a spending budget. Omit [frequency] for a one-time @@ -75,10 +80,11 @@ object ClinkDebitPayer { amountSats: Long, frequency: DebitFrequency? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, - ): DebitResponse? { - val client = clientFor(pointer, account) ?: return null - return sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) - } + ): DebitResponse? = + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) + } // Debits sign with the persistent account identity (unlike offer requests, which use a // throwaway key — see ClinkOfferPayer): the service must see one stable app identity so a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index cce3b7356e..a835db93e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** @@ -60,49 +62,54 @@ object ClinkOfferPayer { val relays = offer.relays.toSet() if (relays.isEmpty()) return null - // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker - // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this - // key and decrypted with it), so there is no reason to expose the user's real identity - // to every offer service they pay. Payer identity, when needed, travels in the request - // body (payer_data / a signed zap request), not the transport key. - val ephemeralSigner = NostrSignerInternal(KeyPair()) - val client = OfferClient(offer, ephemeralSigner) - val request = client.requestInvoice(amountSats = amountSats) + // Keep the round-trip off the Main thread: the ephemeral keygen, JSON serialization, + // NIP-44 encryption and signing are CPU/crypto-heavy, and callers reach this from a + // Compose (Main) scope. StrictMode flags any of it running on the UI thread. + return withContext(Dispatchers.IO) { + // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker + // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this + // key and decrypted with it), so there is no reason to expose the user's real identity + // to every offer service they pay. Payer identity, when needed, travels in the request + // body (payer_data / a signed zap request), not the transport key. + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val client = OfferClient(offer, ephemeralSigner) + val request = client.requestInvoice(amountSats = amountSats) - val reply = CompletableDeferred() - val subId = "clink-offer-${request.id}" - val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + val reply = CompletableDeferred() + val subId = "clink-offer-${request.id}" + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { - reply.complete(event) + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } } } - } - account.client.subscribe(subId, filters, listener) - return try { - account.client.publish(request, relays) - val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null - // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON - // from a buggy or hostile relay) is treated as no usable response rather than - // thrown — callers only handle null, and an uncaught decode error would hang - // the UI (the Pay button stuck on "Requesting…"). + account.client.subscribe(subId, filters, listener) try { - client.parseResponse(response) - } catch (e: Exception) { - if (e is CancellationException) throw e - null + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return@withContext null + // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON + // from a buggy or hostile relay) is treated as no usable response rather than + // thrown — callers only handle null, and an uncaught decode error would hang + // the UI (the Pay button stuck on "Requesting…"). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } finally { + account.client.unsubscribe(subId) } - } finally { - account.client.unsubscribe(subId) } } } From 8209f4416a253ea40eb9fab4b647b0dcb092ed88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 22:01:29 +0000 Subject: [PATCH 67/75] docs: fix stale claims in Claude skills against current code Audit pass that verified every concrete claim in .claude/ against the repository: - account-state: Account.kt no longer exposes followListFlow-style StateFlows; document the state-object pattern (kind3FollowList, muteList, bookmarkState, ... each exposing .flow) and rewrite the catalog reference from the real Account.kt - feed-patterns: filter bases (FeedFilter, AdditiveFeedFilter, ChangesFlowFilter, FeedContentState) moved to commons/ui/feeds; ui/dal keeps AdditiveComplexFeedFilter/FilterByListParams plus back-compat typealiases; fix recipe example signatures - relay-client: add nip17Dm/, eoseManagers and subscriptions entries to the layout tree - gradle-expert: 4-module claim -> 10 modules; refresh compose/kotlin/ BOM versions; rewrite dependency graph with verified edges for cli, geode, quic, nestsClient, quic-interop, benchmark - desktop-expert: drop drifted Main.kt line numbers; sidebar is the custom MainSidebar in DeckSidebar.kt, not a NavigationRail in SinglePaneLayout.kt - android-expert: compileSdk/targetSdk 36 -> 37, versionName via generateVersionName() - kotlin-expert: remove reference to nonexistent commit 258c4e011 - CLAUDE.md: add missing geode/benchmark/quic-interop modules - desktop-run: packageRpm + correct binaries output path; extract.md: drop duplicated find clause - session-start.sh: /home/user/Amber fallback was a copy-paste from another repo; fall back to CLAUDE_PROJECT_DIR https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn --- .claude/CLAUDE.md | 7 +- .claude/commands/desktop-run.md | 6 +- .claude/commands/extract.md | 2 +- .claude/hooks/session-start.sh | 2 +- .claude/skills/account-state/SKILL.md | 48 +++--- .../references/account-state-flow.md | 149 +++++++++--------- .claude/skills/android-expert/SKILL.md | 6 +- .claude/skills/desktop-expert/SKILL.md | 8 +- .../references/desktop-navigation.md | 4 +- .claude/skills/feed-patterns/SKILL.md | 58 ++++--- .../references/feed-filter-composition.md | 13 +- .claude/skills/gradle-expert/SKILL.md | 12 +- .../references/dependency-graph.md | 118 +++++++++----- .claude/skills/kotlin-expert/SKILL.md | 5 +- .claude/skills/relay-client/SKILL.md | 10 +- 15 files changed, 252 insertions(+), 196 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 46f21f1699..c1ba392b9d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,8 +12,10 @@ architecture while sharing the back end components with the android counterpart. a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library -exists. `nestsClient` runs the audio-room protocol on top of `:quic` for the NIP-53 -audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under +exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's +relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and +`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs +the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under `moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the production listener AND speaker paths both run on moq-lite to interop with the nostrnests reference relay. The IETF code is kept as a reference + unit-test @@ -55,6 +57,7 @@ amethyst/ │ └── src/ │ ├── commonMain/ # MoQ session, NestsListener, audio glue │ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack +├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code ├── desktopApp/ # Desktop JVM application (layouts, navigation) ├── amethyst/ # Android app (layouts, navigation) └── cli/ # Amy — non-interactive CLI (JVM only, no Compose) diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md index 6b43f0d2bf..0b1f5eacb1 100644 --- a/.claude/commands/desktop-run.md +++ b/.claude/commands/desktop-run.md @@ -12,7 +12,7 @@ Build and run the Amethyst Desktop application: If the build fails, check: -1. **JDK Version**: Requires JDK 17+ +1. **JDK Version**: Requires JDK 21+ (`jvmToolchain(21)` in `desktopApp/build.gradle.kts`) ```bash java -version ``` @@ -39,7 +39,7 @@ If the build fails, check: ./gradlew :desktopApp:packageMsi # Linux -./gradlew :desktopApp:packageDeb +./gradlew :desktopApp:packageDeb # or :desktopApp:packageRpm ``` -Outputs will be in `desktopApp/build/compose/binaries/` +Outputs will be in `desktopApp/build/compose/binaries/main/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md index 6e9eaf1bca..c9648a01ab 100644 --- a/.claude/commands/extract.md +++ b/.claude/commands/extract.md @@ -8,7 +8,7 @@ Extract the component `$ARGUMENTS` from the Android app to shared KMP code: 1. **Locate the component** in the amethyst module: ```bash - find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*" + find amethyst/src -name "*$ARGUMENTS*" grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/ ``` diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 5b1409249a..d2e869fb85 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -160,7 +160,7 @@ echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licens echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" # Create local.properties if missing -REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")" +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$PWD}")" LOCAL_PROPS="$REPO_ROOT/local.properties" if [ ! -f "$LOCAL_PROPS" ]; then echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS" diff --git a/.claude/skills/account-state/SKILL.md b/.claude/skills/account-state/SKILL.md index ec370630eb..528a2dd273 100644 --- a/.claude/skills/account-state/SKILL.md +++ b/.claude/skills/account-state/SKILL.md @@ -1,6 +1,6 @@ --- name: account-state -description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow. +description: Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user state objects — `kind3FollowList`, `nip65RelayList`, `muteList`, `bookmarkState`, each exposing a `.flow` StateFlow), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel that reads user-specific state. Covers how account events cascade from relay arrival to UI state, how to add a new account-scoped setting, and when to read from `LocalCache` vs subscribe to a StateFlow. --- # Account & Local Cache State @@ -21,10 +21,10 @@ The backbone of Amethyst's client state: one `Account` per signed-in user, plus Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow emits change │ ▼ - Account observes relevant kinds (3, 10002, 10000, …) + Account state objects pin the relevant addressable notes │ ▼ - Account StateFlow updates (followList, relays, mutes, …) + State-object `.flow` updates (kind3FollowList, nip65RelayList, muteList, …) │ ▼ ViewModels collect @@ -33,22 +33,22 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e Composables render ``` -`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to `Account`'s StateFlows, not directly to `LocalCache`, except for note-level rendering. +`LocalCache` is the event store. `Account` is the *derived* per-user view (follow list, relays, mutes, emojis, bookmarks, etc.). UI listens to the `.flow` of `Account`'s state objects, not directly to `LocalCache`, except for note-level rendering. ## Key Files ### `Account.kt` (singleton-per-session) -- `class Account(...)` — holds 50+ StateFlow properties, each wired to a specific Nostr kind: - - `followListFlow` ← NIP-02 ContactList (kind 3) - - `relayListFlow` ← NIP-65 RelayList (kind 10002) - - `muteListFlow` ← NIP-51 Lists (kind 10000) - - `bookmarkListFlow` ← NIP-51 Lists (kind 10003) - - `topNavFeedsFlow`, `marmotGroupsFlow`, `customEmojisFlow`, `privateBookmarksFlow`, etc. - - Settings: `defaultZapAmountsFlow`, `theme`, `language`, `proxyFlow`, `showSensitiveContentFlow`, … -- Each flow has a private `MutableStateFlow` and a public read-only `StateFlow` view. Mutation goes through specific methods (`sendPost`, `follow(pubKey)`, `addBookmark(...)`) that both update the flow and publish the signed replaceable event. -- Uses `CoroutinesExt.launchIO` for network / crypto; UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. -- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state objects under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. +- `class Account(...)` — holds 50+ **state objects**, one per feature, each wired to a specific Nostr kind: + - `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3) + - `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, … + - `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000) + - `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, … + - Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders. +- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`. +- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event. +- UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. +- Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. ### `LocalCache.kt` @@ -72,31 +72,29 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e Typical recipe: 1. If the setting is persisted as a Nostr event, pick the right kind (e.g. NIP-51 list, NIP-78 app-specific data, NIP-65 relay list). -2. Add a model folder under `amethyst/.../model/nipXX…/` with an `ExtState`/builder class if needed. -3. In `Account.kt`: - - Add a private `MutableStateFlow`. - - Expose a `StateFlow` read view. - - Subscribe to the relay (via the relayClient subscription pattern — see `relay-client` skill). - - On event arrival, parse with the quartz event class and update the flow. - - Write a mutation method (`updateX(...)`) that builds a new event via the corresponding `TagArrayBuilder`, signs through `NostrSigner`, and publishes. -4. Add UI that `collect`s the flow. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`. +2. Add a model folder under `amethyst/.../model/nipXX…/` with an `XState` class modeled on an existing one (`MuteListState` for an encrypted list, `BookmarkListState` for a plain one): + - Pin the addressable note: `val xNote = cache.getOrCreateAddressableNote(XEvent.createAddress(signer.pubKey))`. + - Expose `val flow: StateFlow<…>` mapped from `xNote.flow().metadata.stateFlow`, decrypting through a per-feature `DecryptionCache` if the list is private, with backup fallback from `AccountSettings`, then `stateIn(scope, Eagerly, default)`. + - Add suspend mutation helpers that build the updated event via the quartz event class (`XEvent.add/remove/create`) and return it signed. +3. In `Account.kt`, instantiate the state object (and its `DecryptionCache` sibling if encrypted) as a `val`. Publishing the returned event goes through `Account`'s send path; the relay subscription side is the relayClient pattern (see `relay-client` skill). +4. Add UI that `collect`s `account.x.flow`. Settings screens live in `amethyst/.../ui/screen/loggedIn/settings/`. ## `LocalCache` vs `Account` Flow — Which to Read? - **Are you rendering a specific note / user you hold an id for?** → `LocalCache.getOrCreateNote(id)` + collect `note.flowSet.metadata`. -- **Are you rendering "my follows", "my mutes", "my relays"?** → `Account.`. +- **Are you rendering "my follows", "my mutes", "my relays"?** → `account..flow` (e.g. `account.kind3FollowList.flow`, `account.muteList.flow`, `account.nip65RelayList.flow`). - **Are you rendering a feed?** → Use a `FeedFilter` + `FeedViewModel` (see `feed-patterns` skill). Don't scan `LocalCache` in a composable. ## Gotchas - **`LocalCache` is a singleton across accounts.** Switching accounts doesn't wipe it — `Account` re-derives its flows from the same cache. - **Don't store Flows inside `Note` / `User`** expecting them to survive eviction. Eviction drops the whole object. -- **Mutations to `Account` flows must also publish the signing event.** A flow update without a publish means other clients won't see it. +- **State-object mutation helpers return a signed event — publishing it is the caller's job.** A locally updated list without a publish means other clients won't see it. - **`Note` is mutable** — treat instances as identity-based (same id → same Note). Use `.flowSet` when you need reactive state. - **`MemoryTrimmingService` can evict aggressively** on Android under pressure. Don't assume a previously-seen note is still resident. ## References -- `references/account-state-flow.md` — catalog of major `Account` StateFlow properties and their source kinds. +- `references/account-state-flow.md` — catalog of major `Account` state objects and their source kinds. - `references/local-cache.md` — `LocalCache` internals, insertion path, indexes. - Complements: `nostr-expert` (event parsing), `relay-client` (subscription wiring), `feed-patterns` (how feeds consume this state), `auth-signers` (how mutation signs events). diff --git a/.claude/skills/account-state/references/account-state-flow.md b/.claude/skills/account-state/references/account-state-flow.md index e9bf1256f3..d7e37c5524 100644 --- a/.claude/skills/account-state/references/account-state-flow.md +++ b/.claude/skills/account-state/references/account-state-flow.md @@ -1,93 +1,94 @@ -# Account StateFlow Catalog +# Account State-Object Catalog -`Account.kt` exposes dozens of `StateFlow` properties that mirror different facets of the current user. This is a map from flow → Nostr kind → model package. +`Account.kt` composes ~50 **feature state objects** (not raw StateFlow +properties). Each object pins its backing addressable note in `LocalCache`, +exposes `val flow: StateFlow<…>` (decrypted + backup-merged + `stateIn`), and +offers suspend mutation helpers that return signed events. Consumers read +`account..flow`. -(Flow names are exact as of the current `Account.kt`; if a flow has been renamed, grep `Account.kt` for the old name.) +(Property and class names are exact as of the current `Account.kt`; if one has +been renamed, grep `Account.kt` for the class name.) ## Identity & Contacts -| Flow | Kind(s) | Source | Model package | -|------|---------|--------|---------------| -| `userProfile().liveMetadata` | 0 MetadataEvent | relay | `model/nip01UserMetadata/` | -| `followListFlow` | 3 ContactListEvent | relay | `model/nip02FollowLists/` | -| `followersFlow` | derived | LocalCache scan | — | -| `muteListFlow` | 10000 NIP-51 | relay | `model/nip51Lists/` | -| `blockListFlow` | 10000 list variant | relay | `model/nip51Lists/` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` | +| `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` | +| `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` | +| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` | +| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` | +| `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` | +| `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` | -## Relays & Connectivity +## Relay Lists -| Flow | Kind | Package | -|------|------|---------| -| `relayListFlow` | 10002 RelayList (NIP-65) | `model/nip65RelayList/` | -| `dmRelayListFlow` | 10050 | `model/nip65RelayList/` | -| `searchRelayListFlow` | 10007 | `model/nip65RelayList/` | -| `nip86RelayListFlow` | NIP-86 relay management | `model/nip86RelayManagement/` | -| `proxyFlow`, `torStateFlow` | local preferences | `model/torState/`, `AccountSyncedSettings` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `nip65RelayList` | `Nip65RelayListState` | 10002 | `model/nip65RelayList/` | +| `dmRelayList` | `DmRelayListState` | 10050 | `model/nip17Dms/` | +| `searchRelayList` | `SearchRelayListState` | 10007 | `model/nip51Lists/searchRelays/` | +| `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` | +| `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` | +| `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` | +| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` | + +Derived relay views (merge several of the above): `homeRelays` +(`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`, +`trustedRelays`, `followPlusAllMineWithIndex`, `followPlusAllMineWithSearch`, +`defaultGlobalRelays`. ## Content Lists -| Flow | Kind | Package | -|------|------|---------| -| `bookmarkListFlow` | 10003 | `model/nip51Lists/` | -| `privateBookmarksFlow` | encrypted list | `model/nip51Lists/` | -| `topNavFeedsFlow` | custom | `model/topNavFeeds/` | -| `customEmojisFlow` | 10030 NIP-30 | `model/nip30CustomEmojis/` | -| `marmotGroupsFlow` | NIP-29 (marmot variant) | `model/marmot/` | -| `nip72CommunitiesFlow` | 34550 (NIP-72) | `model/nip72Communities/` | -| `nip64ChessFlow` | NIP-64 chess games | `model/nip64Chess/` | +| Account property | State class | Kind | Package | +|------------------|-------------|------|---------| +| `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` | +| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` | +| `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` | +| `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` | +| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` | +| `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` | +| `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` | +| `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` | +| `publicChatList` | `PublicChatListState` | NIP-28 | `commons/.../commons/model/nip28PublicChats/` | +| `ephemeralChatList` | `EphemeralChatListState` | ephemeral chats | `commons/.../commons/model/emphChat/` | +| `blossomServers` | `BlossomServerListState` | Blossom (BUD) | `model/nipB7Blossom/` | -## Messaging +## Other Feature State -| Flow | Kind | Package | -|------|------|---------| -| `dmInboxFlow` | 14 / 1059 (NIP-17 / gift-wrap) | `model/nip17Dms/` | -| `nwcSettingsFlow` | NIP-47 wallet connect | `model/nip47WalletConnect/` | -| `paymentTargetsFlow` | NIP-A3 | `model/nipA3PaymentTargets/` | -| `blossomServersFlow` | NIP-B7 blossom | `model/nipB7Blossom/` | +| Account property | State class | Purpose | Package | +|------------------|-------------|---------|---------| +| `vanish` | `VanishRequestsState` | NIP-62 vanish requests | `model/nip62Vanish/` | +| `appSpecific` | `AppSpecificState` | NIP-78 app data | `model/nip78AppSpecific/` | +| `otsState` | `OtsState` | NIP-03 OpenTimestamps | `model/nip03Timestamp/` | +| `live*FollowListsPerRelay` | `OutboxLoaderState(...).flow` — already a flow | per-feed outbox routing | `model/topNavFeeds/` | +| `privateDMDecryptionCache`, `draftsDecryptionCache` | `PrivateDMCache`, `DraftEventCache` | NIP-44 decryption caches | — | -## Settings & UI - -| Flow | Source | Package | -|------|--------|---------| -| `uiSettingsFlow` | local | `model/UiSettings.kt`, `UiSettingsFlow.kt` | -| `antiSpamFilter` | local | `model/AntiSpamFilter.kt` | -| `privacyOptionsFlow` | local | `model/privacyOptions/` | -| `trustedAssertionsFlow` | derived | `model/trustedAssertions/` | -| `defaultZapAmountsFlow`, `theme`, `language` | local preferences | `AccountSettings.kt`, `AccountSyncedSettings.kt` | - -## Advanced / Derived - -| Flow | Purpose | Package | -|------|---------|---------| -| `accountsCacheFlow` | multi-account switcher | `model/accountsCache/` | -| `algoFeedsFlow` | custom algorithmic feeds | `model/algoFeeds/` | -| `vanishFlow` | NIP-62 account vanish requests | `model/nip62Vanish/` | -| `nip78AppSpecificFlow` | NIP-78 app-specific data | `model/nip78AppSpecific/` | -| `serverListFlow` | media/upload servers | `model/serverList/` | +Note the migration direction: newer/extracted state classes live in +`commons/src/commonMain/.../commons/model/`, the rest still in +`amethyst/src/main/java/.../model/`. Check both when looking for one. ## Publishing Mutations -Every flow has a corresponding mutation method on `Account` that: +State objects' mutation helpers (e.g. `MuteListState.hideUser(pubkey)`, +`BookmarkListState` add/remove) **build and sign** the updated replaceable +event via the quartz event class (`XEvent.add / remove / create`) and return +it. The caller (usually a method on `Account`) is responsible for sending it +through the client. Decryption results are cached in the paired +`…DecryptionCache` so re-renders don't re-decrypt. -1. Constructs the updated event using a `TagArrayBuilder`. -2. Signs through the injected `NostrSigner` (see `auth-signers` skill). -3. Publishes to the appropriate relay set. -4. Updates the local StateFlow *before* relay round-trip (optimistic). -5. Rolls back / reconciles on failure. +## When a State Object Doesn't Exist Yet -Examples of mutation methods (names may vary slightly in current code): -- `follow(pubKey)` / `unfollow(pubKey)` -- `addBookmark(noteId)` / `removeBookmark(noteId)` -- `mute(pubKey)` / `unmute(pubKey)` -- `updateRelayList(...)`, `updateDmRelayList(...)` -- `sendPost(...)`, `sendReaction(...)`, `sendZap(...)` +If you're adding a new NIP that's user-scoped, follow the pattern (full recipe +in `SKILL.md`): -## When a Flow Doesn't Exist Yet - -If you're adding a new NIP that's user-scoped, follow the pattern: - -1. Create `model/nipXX…/` with an optional `ExtState`/builder class. -2. Add `private val _xFlow = MutableStateFlow(initial)` + `val xFlow: StateFlow = _xFlow.asStateFlow()` to `Account`. -3. Wire the relay subscription (see `relay-client` skill). -4. Add the mutation method that builds, signs, and publishes. -5. Update persistence if the setting is local-only (`AccountSettings.kt`). +1. Create `model/nipXX…/XState.kt` modeled on `MuteListState` (encrypted) or + `BookmarkListState` (plain). +2. Pin the note with `cache.getOrCreateAddressableNote(...)`, expose + `val flow: StateFlow<…>` via `stateIn(scope, Eagerly, default)`. +3. Instantiate it in `Account.kt` (plus a `DecryptionCache` sibling if + private), and wire the relay subscription (see `relay-client` skill). +4. Add mutation helpers that build, sign, and return the event; publish from + the calling site. +5. Use `AccountSettings` for the local backup copy if the list must survive + relay loss. diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index db0a32c4b0..02b33e8158 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -744,14 +744,14 @@ fun SignerIntegration(accountViewModel: AccountViewModel) { ```gradle android { namespace = 'com.vitorpamplona.amethyst' - compileSdk = 36 + compileSdk = 37 // from libs.versions.toml android-compileSdk — check there, it drifts defaultConfig { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) - targetSdk = 36 // Android 15 + targetSdk = 37 // android-targetSdk in libs.versions.toml versionCode = 447 - versionName = "1.11.0" + versionName = generateVersionName(libs.versions.app.get(), rootDir) vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md index b4208def3f..477880202d 100644 --- a/.claude/skills/desktop-expert/SKILL.md +++ b/.claude/skills/desktop-expert/SKILL.md @@ -74,7 +74,7 @@ fun main() = application { - `rememberWindowState()` manages size/position - `onCloseRequest` handles window close -**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — `fun main()` at L172, `application {` at L186, top-level `Window` at L229, `MenuBar` at L234. +**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt` — grep for `fun main()`, `application {`, the top-level `Window`, and `MenuBar {` (the file is large and line numbers drift; navigate by symbol). --- @@ -280,9 +280,9 @@ Row(Modifier.fillMaxSize()) { } ``` -**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail at L97, items at L103+). `DeckLayout` alongside it handles multi-pane workspaces. +**In Amethyst Desktop:** the sidebar is the custom `MainSidebar` composable in `desktopApp/.../ui/deck/DeckSidebar.kt`, instantiated from `Main.kt` and shared by both layout modes (`SinglePaneLayout` and the multi-pane `DeckLayout` alongside it). It is hand-rolled, not Material's `NavigationRail` — use `NavigationRail` only for new, simpler cases. -**Why NavigationRail?** +**Why a left sidebar?** - Desktop has horizontal space (1200+ dp width) - Vertical sidebar is standard desktop pattern - Always visible (no tabs hidden) @@ -290,7 +290,7 @@ Row(Modifier.fillMaxSize()) { **Android comparison:** - Android: `BottomNavigationBar` (horizontal, bottom) -- Desktop: `NavigationRail` (vertical, left) +- Desktop: left vertical sidebar (`MainSidebar`) ### Multi-Pane Layouts diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md index 38011f82cb..ad829582f0 100644 --- a/.claude/skills/desktop-expert/references/desktop-navigation.md +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -11,11 +11,11 @@ Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform. --- -## Desktop: NavigationRail +## Desktop: Left Sidebar ### Current Implementation -**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt` (NavigationRail begins at L97; `NavigationRailItem`s at L103 and L127+). +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt` — the custom `MainSidebar` composable, instantiated from `Main.kt` and shared by both `SinglePaneLayout` and the multi-pane `DeckLayout`. Amethyst Desktop does **not** use Material's `NavigationRail`; the snippet below shows the generic Compose pattern for reference, useful for simpler new surfaces. ```kotlin @Composable diff --git a/.claude/skills/feed-patterns/SKILL.md b/.claude/skills/feed-patterns/SKILL.md index 7526d5d34e..aada17ce3c 100644 --- a/.claude/skills/feed-patterns/SKILL.md +++ b/.claude/skills/feed-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: feed-patterns -description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with `FeedFilter` / `AdditiveComplexFeedFilter` / `ChangesFlowFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. +description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose. --- # Feed Patterns @@ -24,27 +24,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong │ ◄── ChatroomFeedViewModel │ │ ◄── MarmotGroupFeedViewModel │ │ │ -│ FeedContentState — the flow the UI collects │ +│ │ +│ commons/.../ui/feeds/ (shared, KMP) │ +│ IFeedFilter / FeedFilter (abstract base) │ +│ IAdditiveFeedFilter / AdditiveFeedFilter │ +│ ChangesFlowFilter │ +│ FeedContentState, FeedState — the flow the UI collects │ └─────────────────────────────────────────────────────────────┘ ▲ │ uses │ ┌─────────────────────────────────────────────────────────────┐ -│ amethyst/.../ui/dal/ (Android; feeds defined per screen) │ -│ FeedFilter (abstract) │ +│ amethyst/.../ui/dal/ (Android-only additions) │ │ AdditiveComplexFeedFilter │ -│ ChangesFlowFilter │ │ FilterByListParams │ -│ DefaultFeedOrder │ +│ DefaultFeedOrder (Note/Event/Card comparators) │ +│ (FeedFilters.kt & ChangesFlowFilter.kt here are just │ +│ back-compat typealiases re-exporting commons) │ │ │ -│ Plus concrete feeds: HomeFeedFilter, HashtagFeedFilter, │ -│ BookmarkListFeedFilter, NotificationFeedFilter, … │ +│ Concrete feeds: HomeNewThreadFeedFilter, │ +│ HashtagFeedFilter, NotificationFeedFilter, … live in │ +│ feature folders under ui/screen/loggedIn/*/dal/ │ └─────────────────────────────────────────────────────────────┘ ▲ │ reads │ ┌─────────────────────────────────────────────────────────────┐ -│ model/LocalCache.kt + Account. │ +│ model/LocalCache.kt + account..flow │ └─────────────────────────────────────────────────────────────┘ ``` @@ -60,25 +66,33 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong - **`MarmotGroupFeedViewModel.kt`** — NIP-29 / marmot group feed. - **`LiveStreamTopZappersViewModel.kt`, `SearchBarState.kt`, `ChatNewMessageState.kt`** — narrower, non-feed states that share the plumbing. -### Android DAL (the filters) +### Shared filter bases (commons) + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`: + +- **`FeedFilter.kt`** — `abstract class FeedFilter : IFeedFilter`. Has `feed(): List` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`. +- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter : FeedFilter(), IAdditiveFeedFilter`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything. +- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "state changed" signal so the ViewModel knows to re-query. +- **`FeedContentState.kt` / `FeedState.kt`** — the reactive state the UI collects. + +### Android DAL (additions on top) `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`: -- **`FeedFilters.kt`** — `abstract class FeedFilter`. Has `feed(): List` (the sync query against `LocalCache`) and `feedKey(): String` (identity used to cache). -- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`. Adds incremental updates (the "additive" part): when a single new event arrives, the filter can decide whether to graft it onto the existing list without recomputing everything. -- **`ChangesFlowFilter.kt`** — wraps a filter with a coarse "Account state changed" signal so the ViewModel knows to re-query. -- **`FilterByListParams.kt`** — common parameters (author set, exclude muted, limit, since/until) shared across many filters. -- **`DefaultFeedOrder.kt`** — standard sort (by `createdAt` desc, plus tiebreakers for stable paging). +- **`AdditiveComplexFeedFilter.kt`** — `abstract class AdditiveComplexFeedFilter : FeedFilter()`: like `AdditiveFeedFilter` but the incoming items (`Set`) are a different type than the list rows (`T`). +- **`FilterByListParams.kt`** — common parameters (top-nav filter, exclude muted, since/until) shared across many filters. +- **`DefaultFeedOrder.kt`** — standard comparators (`createdAt` desc + id tiebreaker for stable paging) for `Note`, `Event`, and `Card`. +- **`FeedFilters.kt` / `ChangesFlowFilter.kt`** — back-compat typealiases re-exporting the commons classes; don't add logic here. -Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter` or `AdditiveComplexFeedFilter`. +Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, etc.) live in feature `dal/` subfolders under `amethyst/.../ui/screen/loggedIn/*/` — each extends `FeedFilter`, `AdditiveFeedFilter`, or `AdditiveComplexFeedFilter`. Desktop has its own in `desktopApp/.../feeds/DesktopFeedFilters.kt`. ## Adding a New Feed -1. **Define the filter.** Extend `AdditiveComplexFeedFilter>` (or plain `FeedFilter` if additivity doesn't matter). Implement: +1. **Define the filter.** Extend `AdditiveFeedFilter` (or plain `FeedFilter` if additivity doesn't matter; `AdditiveComplexFeedFilter` if incoming items differ in type from list rows). Implement: - `feedKey()` — stable identity (e.g. hashtag name, account pubkey). - `feed()` — synchronous scan over `LocalCache` / `Account` state producing an ordered list. - `limit()` — pagination hint. - - If using `AdditiveComplexFeedFilter`: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. + - If additive: `applyFilter(collection: Set): Set` and `sort(collection: Set): List`. 2. **Pick or write a ViewModel.** If the feed's membership shifts often (bookmarks, notifications), extend `ListChangeFeedViewModel`. Otherwise `FeedViewModel`. 3. **Wire invalidation.** The ViewModel must observe the right `Account` flows + `LocalCacheFlow` so it re-queries when state changes. 4. **Render.** In the composable, collect `viewModel.feedState.feedContent` and render with a `LazyColumn { items(..., key = { it.id }) { NoteCompose(it) } }`. @@ -86,9 +100,9 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, ## Filter Sharing (Android vs Desktop) -- `FeedFilter` and the concrete filters currently live in `amethyst/.../ui/dal/` — **Android-only**. Desktop has parallel filters in `desktopApp/.../feeds/`. -- ViewModels are in `commons/commonMain/` — **shared**. That's the boundary: filter is Android (could be extracted), ViewModel is shared. -- When porting a new feed, extract the filter to a KMP-friendly location only if both platforms need it. +- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**. +- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases. +- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules. ## Gotchas @@ -96,7 +110,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities, - **`feedKey()` is used as a cache key.** Two different semantic feeds must produce different keys, otherwise their state cross-contaminates. - **Additive updates must stay consistent with the full recompute.** If `applyFilter` accepts a note that `feed()` wouldn't include, UX drifts. - **Paging isn't free** — use `limit()` and `since/until` in `FilterByListParams` rather than trimming a giant scan. -- **Notifications feed is special** — it inspects `Account.followListFlow` and `LocalCache` deletions to hide muted/deleted content; always run through `FilterByListParams.exclude*` paths rather than filtering post-hoc. +- **Notifications feed is special** — it inspects the follow/mute state (`account.kind3FollowList.flow`, `account.hiddenUsers`) and `LocalCache` deletions to hide muted/deleted content; always run through the `FilterByListParams` exclusion paths rather than filtering post-hoc. ## References diff --git a/.claude/skills/feed-patterns/references/feed-filter-composition.md b/.claude/skills/feed-patterns/references/feed-filter-composition.md index 3f345b1b16..4c00329919 100644 --- a/.claude/skills/feed-patterns/references/feed-filter-composition.md +++ b/.claude/skills/feed-patterns/references/feed-filter-composition.md @@ -7,11 +7,12 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt | If… | Use | |-----|-----| | Membership is stable (e.g. "my follows") and you re-compute on change | `FeedFilter` | -| New notes arrive one at a time and should slot into the list incrementally | `AdditiveComplexFeedFilter>` | +| New notes arrive one at a time and should slot into the list incrementally | `AdditiveFeedFilter` | +| Incoming items are a different type than the list rows | `AdditiveComplexFeedFilter` (Android-only) | | The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter` + `ListChangeFeedViewModel` | | The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) | -All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`. +The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`. ## 2. Write the Filter @@ -19,7 +20,7 @@ All live in `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/`. class HashtagFeedFilter( private val accountViewModel: AccountViewModel, private val hashtag: String, -) : AdditiveComplexFeedFilter>() { +) : AdditiveFeedFilter() { override fun feedKey(): String = "Hashtag-$hashtag" @@ -28,7 +29,7 @@ class HashtagFeedFilter( override fun feed(): List { val params = FilterByListParams.create( excludeMuted = true, - hiddenUsers = accountViewModel.hiddenUsersFlow.value, + hiddenUsers = account.hiddenUsers.flow.value, ) return LocalCache.hashtagIndex[hashtag] .orEmpty() @@ -69,7 +70,7 @@ class HashtagFeedViewModel( ) ``` -If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `Account.muteListFlow`. +If membership changes aggressively (e.g. the user toggles a mute), use `ListChangeFeedViewModel` instead and hook into `account.muteList.flow`. ## 4. Wire Invalidation @@ -78,7 +79,7 @@ If membership changes aggressively (e.g. the user toggles a mute), use `ListChan ```kotlin init { viewModelScope.launch { - accountViewModel.muteListFlow.collect { invalidateAll() } + account.muteList.flow.collect { invalidateAll() } } } ``` diff --git a/.claude/skills/gradle-expert/SKILL.md b/.claude/skills/gradle-expert/SKILL.md index 208caa11cc..04f67cf403 100644 --- a/.claude/skills/gradle-expert/SKILL.md +++ b/.claude/skills/gradle-expert/SKILL.md @@ -5,11 +5,11 @@ description: Build optimization, dependency resolution, and multi-module KMP tro # Gradle Expert -Build system expertise for AmethystMultiplatform's 4-module KMP architecture. Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. +Build system expertise for AmethystMultiplatform's 10-module KMP architecture (`amethyst`, `benchmark`, `quartz`, `geode`, `commons`, `quic`, `nestsClient`, `desktopApp`, `cli`, `quic-interop` — see `settings.gradle.kts`). Focus: practical troubleshooting, dependency resolution, and project-specific optimizations. ## Build Architecture Mental Model -Think of this project as **4 layers**: +The core app stack is **4 layers** (the other modules hang off it: `cli` and `geode` are JVM apps over `commons`/`quartz`, `nestsClient` sits on `quic`, `benchmark` and `quic-interop` are test harnesses): ``` ┌─────────────┬─────────────┐ @@ -165,11 +165,11 @@ implementation(libs.jna) **The problem:** Two Compose ecosystems (Multiplatform + AndroidX) must align, or duplicate classes. -**Current project config:** +**Current project config** (always re-check `gradle/libs.versions.toml` — these drift): ```toml -composeMultiplatform = "1.9.3" # Plugin + runtime -composeBom = "2025.12.01" # AndroidX Compose BOM -kotlin = "2.3.0" +composeMultiplatform = "1.11.0" # Plugin + runtime +composeBom = "2026.05.01" # AndroidX Compose BOM +kotlin = "2.3.21" ``` **Rule:** Compose Multiplatform version must be compatible with Kotlin version. Check: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 411f4d0233..8b07e1bced 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -3,46 +3,50 @@ ## Visual Hierarchy ``` -┌─────────────────────────────────────────────────────────┐ -│ Root Project │ -│ (Amethyst) │ -└─────────────────────────────────────────────────────────┘ - │ - ┌────────────────┼────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ :amethyst │ │ :desktopApp │ │ :benchmark │ -│ (Android) │ │ (JVM) │ │ (Android) │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - │ │ │ - └────────────────┼────────────────┘ - │ - ▼ - ┌─────────────┐ - │ :commons │ - │ (KMP UI) │ - │ │ - │ jvmAndroid │ - │ / \ │ - │ jvm android│ - └─────────────┘ - │ - │ - ▼ - ┌─────────────┐ - │ :quartz │ - │(KMP Library)│ - │ │ - │ commonMain │ - │ │ │ - │ jvmAndroid │ - │ / | \ │ - │jvm and ios │ - └─────────────┘ + Apps / harnesses Libraries +┌─────────────┐ ┌─────────────┐ ┌────────────┐ +│ :amethyst │ │ :desktopApp │ │ :benchmark │ +│ (Android) │ │ (JVM) │ │ (Android) │ +└──┬───┬───┬──┘ └──┬───────┬──┘ └─┬───────┬──┘ + │ │ └───────┼────┐ │ │ │ + │ │ │ │ │ │ │ + │ ▼ ▼ │ │ ▼ │ + │ ┌────────────────┐ │ │ (androidTest │ + │ │ :commons │◄┼──┼──only) │ + │ │ (KMP UI) │ │ │ │ + │ └───────┬────────┘ │ │ │ + │ │ ▲ │ │ │ + ▼ │ │ │ │ │ +┌──────────────┐ │ ┌─┴──┴─┐ ┌───────┐ │ +│ :nestsClient │ │ │ :cli │ │:geode │ │ +│ (KMP, MoQ) │ │ │(JVM) │ │(JVM │ │ +└──┬────────┬──┘ │ └──┬───┘ │relay) │ │ + │ │ │ │ └───┬───┘ │ + ▼ │ │ │ │ │ +┌────────┐ │ │ │ │ │ +│ :quic │ │ │ │ │ │ +│ (KMP) │ │ │ │ │ │ +└───┬────┘ │ │ │ │ │ + │ ▲ │ │ │ │ │ + │ └── :quic-interop │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + ┌──────────────────────────────┐ + │ :quartz │ + │ (KMP Library) │ + └──────────────────────────────┘ ``` +Verified edges (from each module's `build.gradle.kts`): + +- `:amethyst` → `:quartz`, `:commons`, `:nestsClient` +- `:desktopApp` → `:quartz`, `:commons` +- `:benchmark` → `:quartz`, `:commons` (androidTest only) +- `:cli` → `:quartz`, `:commons` +- `:geode` → `:quartz` (api + testFixtures) +- `:nestsClient` → `:quartz` (api), `:quic` +- `:quic` → `:quartz` (api) +- `:quic-interop` → `:quic` (project dir: `quic/interop`) + ## Module Details ### :quartz (KMP Nostr Library) @@ -86,11 +90,41 @@ **Type:** Android Library **Targets:** Android **Dependencies:** -- Modules: `:commons`, `:quartz` +- Modules: `:commons`, `:quartz` (androidTest only) - External: AndroidX Benchmark **Role:** Performance benchmarking for Android builds +### :cli (Amy CLI) +**Type:** JVM Application (no Compose) +**Dependencies:** `:quartz`, `:commons` + +**Role:** `amy`, the non-interactive command-line client; thin assembly layer, no new logic (see `amy-expert` skill) + +### :geode (Relay Server) +**Type:** JVM Application (Ktor) +**Dependencies:** `:quartz` (api + testFixtures) + +**Role:** Standalone Nostr relay built on quartz's relay-server code + +### :quic (QUIC Transport) +**Type:** Kotlin Multiplatform Library +**Dependencies:** `:quartz` (api) + +**Role:** Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport client (no JNI); transport for MoQ + +### :nestsClient (Audio Rooms) +**Type:** Kotlin Multiplatform Library +**Dependencies:** `:quartz` (api), `:quic` + +**Role:** MoQ / moq-lite audio-room client for the NIP-53 nests feature + +### :quic-interop (Interop Harness) +**Type:** JVM Application (project dir `quic/interop`) +**Dependencies:** `:quic` + +**Role:** QUIC interop-runner test client + ## Dependency Flow Patterns ### Desktop Build Chain @@ -177,9 +211,9 @@ implementation(libs.jna) // JAR variant implementation(compose.ui) // Compose Multiplatform BOM implementation(compose.material3) -// Version catalog alignment -composeMultiplatform = "1.9.3" -composeBom = "2025.12.01" // AndroidX Compose +// Version catalog alignment (re-check libs.versions.toml — these drift) +composeMultiplatform = "1.11.0" +composeBom = "2026.05.01" // AndroidX Compose ``` **Why:** Two Compose ecosystems (Multiplatform + AndroidX) must align diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md index bc1d81bc06..619170dd3e 100644 --- a/.claude/skills/kotlin-expert/SKILL.md +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -807,6 +807,5 @@ Passing lambda to function? --- -**Version:** 1.0.0 -**Last Updated:** 2025-12-30 -**Codebase Reference:** AmethystMultiplatform commit 258c4e011 +**Version:** 1.0.1 +**Last Updated:** 2026-06-10 diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 8aaa258d05..0bdea82309 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -24,17 +24,23 @@ relayClient/ ├── assemblers/ # "Given these inputs, build this relay Filter" │ ├── MetadataFilterAssembler.kt # kind 0 for N pubkeys │ ├── ReactionsFilterAssembler.kt # kind 7 for N note ids -│ └── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed +│ ├── FeedMetadataCoordinator.kt # coordinates metadata loads for a feed +│ └── CashuMintDirectoryFilterAssembler.kt / CashuWalletFilterAssembler.kt ├── composeSubscriptionManagers/ │ ├── ComposeSubscriptionManager.kt # interface Subscribable │ ├── MutableComposeSubscriptionManager.kt # reference impl │ └── ComposeSubscriptionManagerControls.kt # DisposableEffect-style controls ├── eoseManagers/ # EOSE tracking per subscription +│ └── IEoseManager / BaseEoseManager / PerKeyEoseManager / SingleSubEoseManager +├── nip17Dm/ # gift-wrap DM plumbing +│ └── FilterGiftWrapsToPubkey.kt / GiftWrapDecryptor.kt ├── preload/ │ ├── MetadataPreloader.kt # bulk-fetch metadata with rate limiting │ └── MetadataRateLimiter.kt # token-bucket-ish limiter └── subscriptions/ - └── KeyDataSourceSubscription.kt # "this set of keys drives this filter" + ├── KeyDataSourceSubscription.kt # "this set of keys drives this filter" + ├── LifecycleAwareKeyDataSourceSubscription.kt + └── PrioritizedSubscriptionQueue.kt / SubscriptionPriority.kt ``` ## Core Concept: `Subscribable` From c503af0f5a67f20207e6b81eae0903544740ebe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 23:21:30 +0000 Subject: [PATCH 68/75] docs: fix stale signer, NIP-19, NIP-44, and EventStore claims in skills Second audit pass over the remaining skills (amy-expert, auth-signers, find-*, nostr-expert, quartz-integration, vendored technique skills), verifying every concrete claim against the code: - auth-signers: bunker login goes through NostrSignerRemote.fromBunkerUri + connect(), not the nonexistent RemoteSignerManager.connect(url) - nostr-expert: NIP count 57 -> 80+; replace invented Nip44v2/Nip19 static APIs with the real Nip44 facade, ByteArray bech32 extensions, entity create() helpers, and Nip19Parser.uriToRoute()?.entity - nip-catalog: heading counts corrected to 87 standard + 23 experimental packages with a ground-truth pointer - quartz-integration: NIP-19 example rewritten for ParseReturn.entity; Event Store is commonMain (all platforms), not Android-only, with the real store.sqlite.EventStore import and suspend query API amy-expert, find-missing-translations, find-non-lambda-logs, the rest of auth-signers, and the vendored technique skills audited clean. https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn --- .claude/core-skills-plan.md | 19 +++++ .claude/skills/auth-signers/SKILL.md | 2 +- .claude/skills/nostr-expert/SKILL.md | 76 ++++++++----------- .../nostr-expert/references/nip-catalog.md | 6 +- .claude/skills/quartz-integration/SKILL.md | 48 +++++++----- 5 files changed, 84 insertions(+), 67 deletions(-) diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 65b10c8047..1af746f6dd 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -66,3 +66,22 @@ handles them natively; stale references fixed: `quartz-integration` and `nostr-expert` cover its pointers). - Stop hook moved to `.claude/hooks/stop-spotless.sh` and gated on modified Kotlin files, so Q&A-only turns no longer pay a Gradle invocation. + +Second audit pass (every concrete claim checked against the code; `amy-expert`, +`find-missing-translations`, `find-non-lambda-logs`, and the vendored technique +skills verified clean): + +- `auth-signers`: bunker login entry point corrected — `NostrSignerRemote.fromBunkerUri(...)` + + `connect()`, not the nonexistent `RemoteSignerManager.connect(url)`. +- `nostr-expert`: NIP count 57 → 80+ packages; `Nip44v2.encrypt/decrypt` + static-object snippet replaced with the real `Nip44` facade + (returns `EncryptedInfo`, `encodePayload()` for event content); invented + `Nip19.npubEncode`/`Nip19Result` API replaced with the real `ByteArray` + extensions (`toNpub()`, …), entity `create()` helpers, and + `Nip19Parser.uriToRoute()?.entity`. +- `nostr-expert/references/nip-catalog.md`: heading count (60+8) replaced with + actual package counts (87 + 23 experimental) and a ground-truth pointer. +- `quartz-integration`: NIP-19 decode example rewritten for + `ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed); + Event Store section corrected from "Android only" to commonMain/all platforms + with the real `store.sqlite.EventStore` import and suspend generic `query`. diff --git a/.claude/skills/auth-signers/SKILL.md b/.claude/skills/auth-signers/SKILL.md index 26b72b1b79..8b11a52d1b 100644 --- a/.claude/skills/auth-signers/SKILL.md +++ b/.claude/skills/auth-signers/SKILL.md @@ -75,7 +75,7 @@ Most feature code should go through `Account`'s mutation methods (`account.sendR Entry points: - **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`. -- **Bunker URL** (`bunker://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`. +- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake. - **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`. The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen. diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index 373b14eb2b..c8c87c0cd7 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: nostr-expert -description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (57 NIPs in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. --- # Nostr Protocol Expert (Quartz Implementation) @@ -313,26 +313,24 @@ class LocalSigner(private val privateKey: ByteArray) : ISigner { ### Encryption (NIP-44) ```kotlin -// Modern encryption (ChaCha20-Poly1305) -object Nip44v2 { - fun encrypt(plaintext: String, privateKey: ByteArray, pubKey: HexKey): String - fun decrypt(ciphertext: String, privateKey: ByteArray, pubKey: HexKey): String +// Modern encryption (ChaCha20-Poly1305) via the Nip44 facade +// (nip44Encryption/Nip44.kt — picks the current version, decrypts any) +object Nip44 { + fun encrypt(msg: String, privateKey: ByteArray, pubKey: ByteArray): Nip44v2.EncryptedInfo + fun decrypt(payload: String, privateKey: ByteArray, pubKey: ByteArray): String } // Usage -val encrypted = Nip44v2.encrypt( - plaintext = "Secret message", - privateKey = myPrivateKey, - pubKey = recipientPubKey -) +val encrypted = Nip44.encrypt("Secret message", myPrivateKey, recipientPubKey) +val payload = encrypted.encodePayload() // base64 string for event content -val decrypted = Nip44v2.decrypt( - ciphertext = encrypted, - privateKey = myPrivateKey, - pubKey = senderPubKey -) +val decrypted = Nip44.decrypt(payload, myPrivateKey, senderPubKey) ``` +Most code should not call `Nip44` directly — go through +`signer.nip44Encrypt(plaintext, toPublicKey)` / `signer.nip44Decrypt(ciphertext, fromPublicKey)` +so remote/external signers keep working. + **Pattern**: Elliptic curve Diffie-Hellman + ChaCha20-Poly1305 AEAD. ### NIP-04 (Deprecated) @@ -345,44 +343,34 @@ object Nip04 { } ``` -**Note**: Use NIP-44 (Nip44v2) for new implementations. NIP-04 has security issues. +**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues. ## Bech32 Encoding (NIP-19) +Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`); +TLV entities carry relay hints via `create()` helpers on the entity classes in +`nip19Bech32/entities/`. Decoding goes through `Nip19Parser`, whose +`uriToRoute()` returns a `ParseReturn?` wrapping the parsed `Entity`. + ```kotlin -object Nip19 { - // Encode - fun npubEncode(pubkey: HexKey): String // npub1... - fun nsecEncode(privateKey: ByteArray): String // nsec1... - fun noteEncode(eventId: HexKey): String // note1... - fun neventEncode(eventId: HexKey, relays: List = emptyList()): String - fun nprofileEncode(pubkey: HexKey, relays: List = emptyList()): String - fun naddrEncode(kind: Int, pubkey: HexKey, dTag: String, relays: List = emptyList()): String +// Encode simple entities: ByteArray extensions +val npub = pubkeyBytes.toNpub() // "npub1..." +val nsec = privKeyBytes.toNsec() // "nsec1..." +val note = eventIdBytes.toNote() // "note1..." - // Decode - fun decode(bech32: String): Nip19Result -} - -sealed class Nip19Result { - data class NPub(val hex: HexKey) : Nip19Result() - data class NSec(val hex: HexKey) : Nip19Result() - data class Note(val hex: HexKey) : Nip19Result() - data class NEvent(val hex: HexKey, val relays: List) : Nip19Result() - data class NProfile(val hex: HexKey, val relays: List) : Nip19Result() - data class NAddr(val kind: Int, val pubkey: HexKey, val dTag: String, val relays: List) : Nip19Result() -} +// Encode TLV entities with relay hints (relays: List) +val nevent = NEvent.create(eventIdHex, authorHex, kind, relays) +val nprofile = NProfile.create(pubkeyHex, relays) ``` **Usage**: ```kotlin -// Encode -val npub = Nip19.npubEncode(pubkeyHex) -// Output: "npub1..." - -// Decode -when (val result = Nip19.decode(npub)) { - is Nip19Result.NPub -> println("Pubkey: ${result.hex}") - is Nip19Result.NEvent -> println("Event: ${result.hex}, relays: ${result.relays}") +// Decode (also accepts nostr: URIs); entity types live in nip19Bech32.entities +when (val entity = Nip19Parser.uriToRoute(input)?.entity) { + is NPub -> println("Pubkey: ${entity.hex}") + is NEvent -> println("Event: ${entity.hex}, relays: ${entity.relay}") + is NAddress -> println("Address: ${entity.aTag()}") + null -> println("not a valid bech32 entity") else -> println("Other type") } ``` diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 950f2cb1a1..a18731696f 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -1,4 +1,8 @@ -# NIP Catalog: 60 Standard + 8 Experimental NIPs in Quartz +# NIP Catalog: Quartz NIP Packages + +As of 2026-06 Quartz has **87 standard `nip*` packages** plus **23 packages +under `experimental/`**. The categorized list below may lag behind — +`ls quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/` is ground truth. ## Standard NIPs by Category diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index ebc53886f7..3c1293679f 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -447,23 +447,28 @@ val textNote = Event.fromJson(json) as? TextNoteEvent ```kotlin import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -// Decode any bech32 entity -val result = Nip19Parser.uriToRoute("npub1abc...") -// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null - -when (val r = Nip19Parser.uriToRoute(input)) { - is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}") - is Nip19Parser.Return.Note -> println("event id: ${r.hex}") - is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}") - is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}") - is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}") - null -> println("not a valid bech32 entity") - else -> {} +// Decode any bech32 entity (plain or nostr:-prefixed). +// uriToRoute() returns Nip19Parser.ParseReturn? — the parsed Entity is in .entity +when (val entity = Nip19Parser.uriToRoute(input)?.entity) { + is NPub -> println("pubkey: ${entity.hex}") + is NNote -> println("event id: ${entity.hex}") + is NEvent -> println("event: ${entity.hex}, relays: ${entity.relay}") + is NProfile -> println("profile: ${entity.hex}") + is NAddress -> println("address: ${entity.aTag()}") + null -> println("not a valid bech32 entity") + else -> {} } -// The parser also handles nostr: URI scheme -val result = Nip19Parser.uriToRoute("nostr:npub1abc...") +// Encode: ByteArray extensions from nip19Bech32/ByteArrayExt.kt +val npub = pubkeyBytes.toNpub() // also toNsec(), toNote(), ... +// TLV entities with relay hints (relays: List) +val nevent = NEvent.create(eventIdHex, authorHex, kind, relays) ``` --- @@ -601,21 +606,22 @@ In Xcode: drag & drop the `.xcframework` into your project, then use from Swift --- -## 14. Event Store (Android only) +## 14. Event Store (SQLite, all platforms) -SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62): +SQLite-backed storage in `commonMain` (JVM, Android, iOS — uses the bundled +androidx.sqlite driver) with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, +NIP-62). All operations are `suspend`: ```kotlin -import com.vitorpamplona.quartz.nip01Core.store.EventStore -import android.content.Context +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -val store = EventStore() +val store = EventStore() // default DB file "events.db" // Insert store.insert(event) // Query -val events = store.query( +val events = store.query( Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50) ) @@ -623,7 +629,7 @@ val events = store.query( val count = store.count(Filter(kinds = listOf(1))) // Full-text search (NIP-50) -val results = store.query(Filter(search = "bitcoin")) +val results = store.query(Filter(search = "bitcoin")) ``` --- From ea74be65cf4f7ea181d4bd06e4f8545f8a8ddc34 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 10 Jun 2026 23:30:11 +0000 Subject: [PATCH 69/75] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 6 ++++++ amethyst/src/main/res/values-de-rDE/strings.xml | 6 ++++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 6 ++++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8a89516664..d6137ede96 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -207,6 +207,7 @@ Upravuje výšku vašeho hlasu. Poznámka: základní změny výšky hlasu mohou být odhodlanými posluchači potenciálně zpětně rozpoznány. Uživatel nemá nastavenou LN adresu pro přijímání sats "Odpověď zde…" + V tomto chatu Zkopíruje ID poznámky do schránky pro sdílení Zkopírovat ID kanálu (poznámka) do schránky Upravit metadata kanálu @@ -267,8 +268,13 @@ Vygenerovat nový klíč Načítání zdroje Načítání účtu + šifrované + zastaralé + Hledání původní zprávy… + Tuto zprávu se nepodařilo najít + Prohledány všechny relaye · klepnutím zobrazíte "Chyba při načítání odpovědí: " Zkusit znovu Zatím žádná oznámení. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index f3a137fd77..530a7f41f8 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -205,6 +205,7 @@ erie gespeichert Verändert die Tonhöhe deiner Stimme. Hinweis: einfache Tonhöhenänderungen können von entschlossenen Zuhörern möglicherweise rückgängig gemacht werden. Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen "Hier antworten…" + In diesem Chat Kopiert die Notiz-ID zum Teilen in die Zwischenablage Kopiere Kanal-ID (Notiz) in die Zwischenablage Bearbeitet die Kanalmetadaten @@ -267,8 +268,13 @@ anz der Bedingungen ist erforderlich Neuen Schlüssel generieren Feed wird geladen Konto wird geladen + verschlüsselt + veraltet + Suche nach der ursprünglichen Nachricht… + Diese Nachricht konnte nicht gefunden werden + Alle Relays durchsucht · zum Anzeigen tippen "Fehler beim Laden der Antworten: " Erneut versuchen Noch keine Benachrichtigungen. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8a79361319..4c9fb017f7 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -203,6 +203,7 @@ Altera o tom da sua voz. Nota: alterações básicas de tom podem potencialmente ser revertidas por ouvintes determinados. Usuário não tem um endereço lightning configurado para receber sats "responda aqui.. " + Neste chat Copia o ID do canal (note) para compartilhar Copiar ID do canal (Note) Editar os dados do canal @@ -263,8 +264,13 @@ Gerar uma nova chave Carregando feed Carregando conta + criptografado + legado + Procurando a mensagem original… + Não foi possível encontrar esta mensagem + Pesquisado em todos os relays · toque para ver "Erro ao carregar respostas" Tente novamente Ainda não há notificações. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e6c38d7673..764adfd388 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -203,6 +203,7 @@ Ändrar tonhöjden på din röst. Obs: enkla förändringar av tonhöjd kan potentiellt återskapas av målmedvetna lyssnare. Användaren har inte en Lightningadressinställning för att ta emot sats "svara här.. " + I den här chatten Kopierar antecknings-ID till urklipp för delning Kopiera kanal-ID (anteckningen) till Urklipp Redigerar kanalmetadata @@ -263,8 +264,13 @@ Skapa en ny nyckel Ladda flöde Laddar kontot + krypterat + föråldrat + Letar efter ursprungsmeddelandet… + Kunde inte hitta detta meddelande + Sökte på alla relayer · tryck för att visa "Det gick inte att läsa in svar: " Försök igen Inga aviseringar ännu. From 704f4f44ee3086ac74601abc1e2219e3a3c673db Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 11 Jun 2026 13:37:33 +0300 Subject: [PATCH 70/75] feat(desktop): replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops uk.co.caprica:vlcj 4.8.3 (GPL-3.0) from desktopApp and replaces it with an MIT-dominant stack: - Video / audio playback: io.github.kdroidfilter:composemediaplayer:0.10.0 (MIT) — OS-native backends (Media Foundation on Windows, AVFoundation on macOS, GStreamer on Linux). First-class Compose VideoPlayerSurface. - Thumbnail extraction: org.jcodec:jcodec(+javase):0.2.5 (BSD-2) primary H.264 path, raw ProcessBuilder FFmpeg fallback for HEVC / VP9 / AV1 / HLS / non-faststart MP4. - Binary SPDX: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0. rpmLicenseType updated accordingly (previously misdeclared as MIT while shipping GPLv3 vlcj). Code changes: - Deleted: VlcjPlayerPool, MacOsVlcDiscoverer, BundledVlcDiscoverer, VlcResourceResolver - Rewrote: GlobalMediaPlayer (kdroidFilter engine + snapshotFlow-based state sync into the preserved MediaPlaybackState contract); VideoThumbnailCache (JCodec → ProcessBuilder ffmpeg cascade, with a hard 4 MiB download cap, Content-Type sniff to reject HTML error pages, and cleanup of zero-byte cache entries); DesktopVideoPlayer (mounts VideoPlayerSurface for the active URL, codec/network error UX with "Open in default player" fallback) - Updated: NowPlayingBar + GlobalFullscreenOverlay to render VideoPlayerSurface directly (drops the videoFrame ImageBitmap relay) - Main.kt: drops vlcj pre-init / shutdown calls (kdroidFilter lazy-loads natives + registers its own shutdown hook on Windows) Build / packaging: - Removes ir.mahozad.vlc-setup plugin + vlcSetup{} block + the per-OS bundled VLC tree + the -Dvlc.plugin.path JVM arg - Adds NOTICE.md + per-component LICENSE-*.txt under appResources/common (LGPL-2.1 license text is a placeholder — replace with verbatim FSF text before release) - Adds per-OS LGPL FFmpeg drop-in directories with README pointing at the recommended LGPL binary source (osxexperts.net / Crigges Windows LGPL build) - Adds Flathub manifest skeleton (Gitnuro-style: org.freedesktop.Platform 24.08 + openjdk21 extension + org.freedesktop.Platform.ffmpeg-full add-extension for patent codecs) - AppRun: drops VLC LD_LIBRARY_PATH / VLC_PLUGIN_PATH env wiring Verified on macOS arm64: - ./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL - ./gradlew :desktopApp:test BUILD SUCCESSFUL - ./gradlew :desktopApp:spotlessApply clean - Smoke launch: no VLC/vlcj/libvlc log lines, kdroidFilter native library extracts to ~/.cache/composemediaplayer/native/, thumbnail cache populates at ~/.cache/amethyst-desktop/video-thumbs/ with the 4 MiB cap enforced - H.264 MP4 playback (active + thumbnail extraction) confirmed - VP9-in-WebM playback fails on macOS as AVFoundation cannot decode it — expected codec gap; surfaced via PlaybackErrorMessage + "Open in default player" handoff in DesktopVideoPlayer Docs: - docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md - docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md --- desktopApp/build.gradle.kts | 85 +- desktopApp/packaging/appimage/AppRun | 10 +- desktopApp/packaging/flatpak/README.md | 54 ++ ...com.vitorpamplona.amethyst.Desktop.desktop | 9 + ...itorpamplona.amethyst.Desktop.metainfo.xml | 53 ++ .../com.vitorpamplona.amethyst.Desktop.yml | 77 ++ .../src/jvmMain/appResources/common/NOTICE.md | 51 ++ .../common/licenses/LICENSE-BSD-2-jcodec.txt | 24 + .../common/licenses/LICENSE-LGPL-2.1.txt | 23 + .../common/licenses/LICENSE-MIT-amethyst.txt | 21 + .../licenses/LICENSE-MIT-kdroidfilter.txt | 21 + .../vitorpamplona/amethyst/desktop/Main.kt | 9 +- .../service/media/BundledVlcDiscoverer.kt | 44 - .../service/media/GlobalMediaPlayer.kt | 537 ++++------- .../service/media/MacOsVlcDiscoverer.kt | 81 -- .../service/media/VideoThumbnailCache.kt | 310 +++++-- .../service/media/VlcResourceResolver.kt | 63 -- .../desktop/service/media/VlcjPlayerPool.kt | 325 ------- .../desktop/ui/media/DesktopVideoPlayer.kt | 95 +- .../ui/media/GlobalFullscreenOverlay.kt | 18 +- .../desktop/ui/media/NowPlayingBar.kt | 12 +- ...eat-replace-vlcj-with-kdroidfilter-plan.md | 863 ++++++++++++++++++ ...26-06-11-vlcj-replacement-testing-sheet.md | 182 ++++ gradle/libs.versions.toml | 7 +- 24 files changed, 1906 insertions(+), 1068 deletions(-) create mode 100644 desktopApp/packaging/flatpak/README.md create mode 100644 desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop create mode 100644 desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml create mode 100644 desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml create mode 100644 desktopApp/src/jvmMain/appResources/common/NOTICE.md create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt create mode 100644 docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md create mode 100644 docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index c59128bc26..a3ab990308 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -1,4 +1,3 @@ -import de.undercouch.gradle.tasks.download.Download import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.nio.file.Files @@ -6,7 +5,6 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) - id("ir.mahozad.vlc-setup") version "0.1.0" } // RPM rejects dashes in version strings — replace with tilde (~) which RPM uses @@ -57,8 +55,14 @@ dependencies { implementation(libs.coil.okhttp) implementation(libs.coil.svg) - // Video playback - implementation(libs.vlcj) + // Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer) + implementation(libs.composemediaplayer) + + // Thumbnail extraction — JCodec (pure-Java H.264). LGPL FFmpeg subprocess + // for non-H.264 / HLS fallback is invoked via plain ProcessBuilder; no + // wrapper library needed (see VideoThumbnailCache.runFfmpegToImage). + implementation(libs.jcodec) + implementation(libs.jcodec.javase) // EXIF stripping (lossless) implementation(libs.commons.imaging) @@ -94,9 +98,6 @@ compose.desktop { jvmArgs += "-Xmx2g" - // VLC plugin path fallback — used if JNA setenv and bundled discovery both fail - jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins" - // Forward platform-preview overrides from the gradle invocation to the // launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME` // works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`). @@ -114,7 +115,7 @@ compose.desktop { "java.prefs", // java.util.prefs (desktop persistence) "java.sql", // JDBC metadata (Jackson, SQLite driver) "jdk.security.auth", // JAAS authentication callbacks - "jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory) + "jdk.unsupported", // sun.misc.Unsafe (secp256k1-kmp-jni-jvm, JNA) ) packageName = "Amethyst" @@ -138,7 +139,13 @@ compose.desktop { menuGroup = "Network" appCategory = "Network" debMaintainer = "vitor@vitorpamplona.com" - rpmLicenseType = "MIT" + // SPDX compound expression. Bundled components: + // MIT — Amethyst + kdroidFilter ComposeMediaPlayer + // LGPL-2.1-or-later — FFmpeg (LGPL build, bundled per OS for thumbnail fallback) + + // GStreamer (Linux runtime dep, system-installed) + // BSD-2-Clause — JCodec + // Apache-2.0 — Jaffree + many transitive Java libraries + rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0" // RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering). rpmPackageVersion = appVersion.replace("-", "~") } @@ -149,8 +156,8 @@ compose.desktop { // problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum` // rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in // `compose-rules.pro` so the desktop release survives JNI callbacks - // (secp256k1-kmp, sqlite-bundled, jkeychain, VLCj) and reflection-heavy - // libraries (Jackson, JNA) without renaming. + // (secp256k1-kmp, sqlite-bundled, jkeychain, kdroidFilter native) + // and reflection-heavy libraries (Jackson, JNA) without renaming. // // Shrink and optimize stay ON. One ProGuard optimize sub-pass is // disabled in `compose-rules.pro` to avoid a generated okio bridge @@ -163,61 +170,23 @@ compose.desktop { } } -vlcSetup { - // Pinned to 3.0.20 because the Linux VLC plugins on Maven Central - // (ir.mahozad:vlc-plugins-linux) have not been republished for 3.0.21 — the - // latest there is 3.0.20-2. Using 3.0.21 makes vlcDownload 404 on Linux CI. - vlcVersion.set("3.0.20") - shouldCompressVlcFiles.set(true) - shouldIncludeAllVlcFiles.set(true) - pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc")) - pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc")) - pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc")) -} - -tasks.named("spotlessKotlin") { - mustRunAfter("vlcSetup") -} - -// `ir.mahozad.vlc-setup` registers `vlcDownload` / `upxDownload` tasks that -// extend `de.undercouch.gradle.tasks.download.Download`. Defaults are 0 retries -// and a short read timeout, so a transient blip on get.videolan.org fails the -// whole desktop build on CI (Windows MSI, macOS DMG, Linux DEB). Configure all -// Download tasks in this project to retry with generous timeouts so flaky -// network conditions do not break packaging jobs. -tasks.withType().configureEach { - // 5 attempts total (initial + 4 retries) before failing the task. - retries(4) - // 30s to establish a TCP / TLS connection. - connectTimeout(30_000) - // 5 minutes per attempt for the body — VLC archives are 40-90 MB and - // get.videolan.org can be slow under load. - readTimeout(5 * 60_000) - // Stage to a temp file and rename only on full success, so a partial - // download from one attempt cannot poison the next. - tempAndMove(true) -} - // --- AppImage packaging (Linux) --- // // Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101). // Instead: wrap `createReleaseDistributable` output with `appimagetool`, which // just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here — // linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps, -// but jpackage already ships a self-contained tree we don't want it touching: -// - The bundled JRE puts libjvm.so under usr/lib/runtime/lib/server/ while -// sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so) have -// RPATH=$ORIGIN, so ldd cannot resolve libjvm.so without help. -// - The bundled VLC plugins are UPX-compressed; linuxdeploy aborts on those -// with "patchelf: no section headers" because they look like static ELFs. -// - Several VLC libs have RUNPATH that does not point at sibling libs in -// the same directory, so ldd errors with "Could not find dependency". -// appimagetool sidesteps all of this — it only embeds the AppDir into a -// SquashFS, runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH -// at launch. +// but jpackage already ships a self-contained tree we don't want it touching +// (the bundled JRE has libjvm.so under usr/lib/runtime/lib/server/ while sibling +// libs use $ORIGIN RPATH — ldd can't resolve without help). +// appimagetool sidesteps that — it only embeds the AppDir into a SquashFS, +// runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH at launch. +// +// kdroidFilter (video/audio) links against system GStreamer at runtime — the +// AppImage does not bundle GStreamer; the host system must have it installed. // // Build inputs live in desktopApp/packaging/appimage/: -// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC) +// - AppRun shell launcher // - amethyst.desktop XDG desktop entry // - amethyst.png 512x512 icon // diff --git a/desktopApp/packaging/appimage/AppRun b/desktopApp/packaging/appimage/AppRun index d4065af016..c42b59c001 100755 --- a/desktopApp/packaging/appimage/AppRun +++ b/desktopApp/packaging/appimage/AppRun @@ -1,11 +1,13 @@ #!/bin/bash # AppImage launcher for Amethyst Desktop. -# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime. -# jpackage puts app resources at usr/lib/app//vlc/ inside the AppDir. +# kdroidFilter ComposeMediaPlayer uses the host system's GStreamer (linked at +# runtime). Users need: +# sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ +# gstreamer1.0-plugins-bad gstreamer1.0-libav +# (Equivalent packages on Fedora/Arch.) set -eu HERE="$(dirname "$(readlink -f "${0}")")" -export LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" -export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins" +export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}" export PATH="${HERE}/usr/bin:${PATH}" export APPDIR="${HERE}" exec "${HERE}/usr/bin/Amethyst" "$@" diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md new file mode 100644 index 0000000000..12bf3f3c7a --- /dev/null +++ b/desktopApp/packaging/flatpak/README.md @@ -0,0 +1,54 @@ +# Flathub packaging for Amethyst Desktop + +This directory contains the Flatpak manifest and associated metadata for +publishing Amethyst Desktop on Flathub. + +## Files + +- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest +- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata + (categories, license, screenshots — needs screenshots added before + submission) +- `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry +- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256 + PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before + submission + +## Build prerequisites + +- `./gradlew :desktopApp:createReleaseDistributable` — produces + `desktopApp/build/compose/binaries/main-release/app/Amethyst/` +- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` + +## Local build + +```bash +cd desktopApp/packaging/flatpak +flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml +flatpak run com.vitorpamplona.amethyst.Desktop +``` + +## Submission to Flathub + +Follow https://docs.flathub.org/docs/for-app-authors/submission + +1. Fork `flathub/flathub` on GitHub. +2. Branch from `new-pr` (NOT `master`). +3. Copy this manifest + AppStream + desktop into a new directory matching + the app id. +4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". +5. After merge, a per-app repo is created with write access for ongoing + updates. + +## Codec coverage + +- HEVC / VP9 / AV1: covered via `org.freedesktop.Platform.ffmpeg-full` + add-extension declared in the manifest. Flatpak downloads it on install. +- HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in + `org.freedesktop.Platform 24.08` itself. + +## License metadata + +The manifest declares the binary as +`MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0`. This SPDX +expression validates via `appstreamcli validate`. diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop new file mode 100644 index 0000000000..cc52ce21e4 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Amethyst Desktop +Comment=Nostr client for desktop +Categories=Network;InstantMessaging; +Exec=amethyst-desktop +Icon=com.vitorpamplona.amethyst.Desktop +Terminal=false +StartupWMClass=Amethyst diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml new file mode 100644 index 0000000000..6ff45dc294 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml @@ -0,0 +1,53 @@ + + + com.vitorpamplona.amethyst.Desktop + CC0-1.0 + MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 + Amethyst Desktop + Nostr client for desktop + + +

+ Amethyst Desktop is a desktop client for the Nostr protocol. + Browse feeds, post notes, send zaps (Lightning Network), and + participate in NIP-53 live audio rooms. +

+

+ Video playback uses your system's GStreamer (Linux), AVFoundation + (macOS), or Media Foundation (Windows). On Linux, install the + GStreamer plugin packages (good, bad, libav) for full codec coverage. +

+
+ + com.vitorpamplona.amethyst.Desktop.desktop + + https://github.com/vitorpamplona/amethyst + https://github.com/vitorpamplona/amethyst/issues + https://github.com/vitorpamplona/amethyst + + + Vitor Pamplona + + + + + + Network + InstantMessaging + + + + + + + + + +
diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml new file mode 100644 index 0000000000..8ed6f86207 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml @@ -0,0 +1,77 @@ +# Flathub manifest for Amethyst Desktop. +# +# Pattern based on flathub/com.jetpackduba.Gitnuro (Kotlin Compose Desktop). +# Submission to Flathub is a separate operation — see +# desktopApp/packaging/flatpak/README.md for the workflow. + +app-id: com.vitorpamplona.amethyst.Desktop +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.openjdk21 +command: amethyst-desktop + +add-extensions: + org.freedesktop.Platform.ffmpeg-full: + directory: lib/ffmpeg + version: '24.08' + add-ld-path: . + autodownload: true + autodelete: false + +cleanup-commands: + - mkdir -p /app/lib/ffmpeg + +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --socket=pulseaudio + - --device=dri + - --filesystem=xdg-download + - --filesystem=xdg-pictures + - --talk-name=org.freedesktop.Notifications + - --talk-name=org.freedesktop.secrets + # GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default). + - --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0 + +modules: + - name: openjdk + buildsystem: simple + build-commands: + - /usr/lib/sdk/openjdk21/install.sh + + - name: amethyst-desktop + buildsystem: simple + build-commands: + # Drop the jpackage-emitted self-contained tree into /app/lib/Amethyst. + # Wrapper at /app/bin/amethyst-desktop forwards args. + - mkdir -p /app/lib/Amethyst + - cp -r ./Amethyst/* /app/lib/Amethyst/ + - install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop + - install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/ + - install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/ + - install -Dm644 icons/256/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/256x256/apps/ + sources: + # Built artifact from `./gradlew :desktopApp:createReleaseDistributable`. + # Path matches Compose Multiplatform 1.11's output layout. + - type: dir + path: ../../build/compose/binaries/main-release/app + dest: ./ + + - type: script + dest-filename: amethyst-desktop.sh + commands: + - "#!/bin/sh" + - exec /app/lib/Amethyst/bin/Amethyst "$@" + + - type: file + path: com.vitorpamplona.amethyst.Desktop.metainfo.xml + + - type: file + path: com.vitorpamplona.amethyst.Desktop.desktop + + - type: file + path: icons/256/com.vitorpamplona.amethyst.Desktop.png diff --git a/desktopApp/src/jvmMain/appResources/common/NOTICE.md b/desktopApp/src/jvmMain/appResources/common/NOTICE.md new file mode 100644 index 0000000000..23547e8bf2 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/NOTICE.md @@ -0,0 +1,51 @@ +# Amethyst Desktop — Third-party Notices + +Amethyst Desktop is distributed under the MIT License (see `LICENSE-MIT-amethyst.txt`). +The distributed binary includes the following third-party components: + +## kdroidFilter ComposeMediaPlayer + +- License: MIT +- Version: 0.10.1 +- Upstream: https://github.com/kdroidFilter/ComposeMediaPlayer +- License text: `licenses/LICENSE-MIT-kdroidfilter.txt` + +## JCodec + +- License: BSD 2-Clause +- Version: 0.2.5 +- Upstream: https://github.com/jcodec/jcodec +- License text: `licenses/LICENSE-BSD-2-jcodec.txt` + +## FFmpeg (LGPL build, bundled per OS for thumbnail extraction) + +- License: LGPL-2.1-or-later +- Build: LGPL-only configuration (no `--enable-gpl`, no `--enable-nonfree`) +- Upstream: https://ffmpeg.org/ +- License text: `licenses/LICENSE-LGPL-2.1.txt` +- Source availability: https://github.com/vitorpamplona/amethyst (build tag matches + the binary tag); FFmpeg sources at https://github.com/FFmpeg/FFmpeg +- Per-OS binary source provenance documented in + `desktopApp/src/jvmMain/appResources//ffmpeg/README.md`. + +## GStreamer (Linux runtime dependency; not bundled) + +- License: LGPL-2.1-or-later (core + linked plugins) +- Required at runtime by ComposeMediaPlayer on Linux (linked via `pkg-config`). +- License text: `licenses/LICENSE-LGPL-2.1.txt` +- Users install via their distribution's package manager. We bundle no + GStreamer binaries. + +## Other Apache-2.0 / MIT transitive dependencies + +Compose Multiplatform (JetBrains, Apache-2.0), Kotlin stdlib (JetBrains, +Apache-2.0), OkHttp (Square, Apache-2.0), Jackson (FasterXML, Apache-2.0), +Coil (Apache-2.0), kmp-tor (MIT), and others, plus the bundled OpenJDK +runtime (GPLv2 + Classpath Exception). Their license texts are reproduced +under `licenses/` alongside this NOTICE. + +## SPDX combined expression + +``` +MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 +``` diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt new file mode 100644 index 0000000000..5d3569e640 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt @@ -0,0 +1,24 @@ +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS +BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Copyright (c) JCodec authors. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt new file mode 100644 index 0000000000..319d0e6d79 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt @@ -0,0 +1,23 @@ +This file should contain the verbatim GNU LGPL-2.1 text from +https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt + +The full text is 26 KB. Embed at packaging time (e.g. via a Gradle task +that fetches the canonical text), or paste it here verbatim before +releasing a build. Until populated, the binary release is non-compliant +with LGPL §6 obligations for the bundled FFmpeg binaries and Linux +GStreamer runtime dependency. + +Tracking: see plan docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md +"Phase 0 acceptance" — license text presence is part of the gate. + +Bundled / linked LGPL components: +- FFmpeg (LGPL build) — bundled per OS in appResources//ffmpeg/ +- GStreamer 1.x core + plugins-base/good/bad/libav — linked on Linux at runtime + (user-installed, not bundled) + +Written offer (LGPL §6 / §4): Source for both FFmpeg and GStreamer is available +from the projects' canonical git repositories (https://ffmpeg.org/download.html, +https://gstreamer.freedesktop.org/src/). The binary tags shipped in any +Amethyst Desktop release correspond to released upstream tags; this offer is +valid for three years from the date of distribution to anyone in receipt of +this binary. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt new file mode 100644 index 0000000000..988afa646d --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Vitor Pamplona + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt new file mode 100644 index 0000000000..5d4359a9e1 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Elie Gambache (kdroidFilter) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 16d487cb20..39b24355d9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -89,7 +89,7 @@ import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup -import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences @@ -237,15 +237,12 @@ fun main() { DesktopImageLoaderSetup.setup() Runtime.getRuntime().addShutdownHook( Thread { - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .shutdown() - VlcjPlayerPool.shutdown() + GlobalMediaPlayer.shutdown() // Stop Tor daemon if running — reference set by App composable activeTorManager?.stopSync() }, ) - // Pre-init VLC on background thread so first play is fast - Thread { VlcjPlayerPool.init() }.start() + // kdroidFilter lazy-loads the native player on first playback — no pre-init needed. application { val windowState = rememberWindowState( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt deleted file mode 100644 index ee1930fb01..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy - -/** - * Discovers bundled VLC libraries on Windows and Linux. - * Uses [VlcResourceResolver] to find the VLC directory from the Compose application - * resources or development fallback paths. - */ -class BundledVlcDiscoverer : NativeDiscoveryStrategy { - override fun supported(): Boolean { - val os = System.getProperty("os.name").lowercase() - return "mac" !in os - } - - override fun discover(): String { - val vlcDir = VlcResourceResolver.findVlcDir() ?: return "" - return vlcDir.absolutePath - } - - override fun onFound(path: String): Boolean = true - - override fun onSetPluginPath(path: String): Boolean = true -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt index dc59b159cd..db77e7e46e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -20,31 +20,20 @@ */ package com.vitorpamplona.amethyst.desktop.service.media -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.runtime.snapshotFlow import com.vitorpamplona.amethyst.desktop.ui.media.MediaType +import io.github.kdroidfilter.composemediaplayer.VideoPlayerError +import io.github.kdroidfilter.composemediaplayer.VideoPlayerState +import io.github.kdroidfilter.composemediaplayer.createVideoPlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ImageInfo -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat -import java.nio.ByteBuffer -import org.jetbrains.skia.Image as SkiaImage data class MediaPlaybackState( val url: String? = null, @@ -57,221 +46,178 @@ data class MediaPlaybackState( val aspectRatio: Float = 16f / 9f, val volume: Int = 100, val isMuted: Boolean = false, + /** Last observed error from the engine, or null. JVM emits only SourceError/UnknownError. */ + val errorReason: String? = null, ) +/** + * Singleton facade over kdroidFilter's [VideoPlayerState] (MIT, OS-native backends: + * Media Foundation on Windows, AVFoundation on macOS, GStreamer on Linux). + * + * Holds two engine instances (video + audio) for the lifetime of the JVM. The + * underlying [VideoPlayerState] exposes its state via Compose `mutableStateOf`; + * a [snapshotFlow] coroutine mirrors that into our public [MediaPlaybackState] + * `StateFlow`s so non-Compose consumers (and the existing UI) remain unchanged. + * + * UI surface for visible video frames is rendered by mounting + * `VideoPlayerSurface(playerState = [activeVideoPlayerState])` in the active + * `DesktopVideoPlayer` instance — see that file for the active/inactive dispatch. + */ object GlobalMediaPlayer { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - // Video state - private val _videoFrame = MutableStateFlow(null) - val videoFrame: StateFlow = _videoFrame.asStateFlow() + // Engine handles. Constructed lazily on first playback request so that app + // start does not load the native library if the user never plays media. + @Volatile private var videoPlayer: VideoPlayerState? = null + + @Volatile private var audioPlayer: VideoPlayerState? = null + + private val initLock = Any() + + /** + * The kdroidFilter player driving currently-active or last-played video. + * Mounted into a `VideoPlayerSurface(...)` by [DesktopVideoPlayer] when the + * caller's `url` matches `videoState.value.url`. + * + * Lazy: first read constructs the underlying native player. + */ + val activeVideoPlayerState: VideoPlayerState + get() = ensureVideoPlayer() private val _videoState = MutableStateFlow(MediaPlaybackState()) val videoState: StateFlow = _videoState.asStateFlow() - // Audio state private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) val audioState: StateFlow = _audioState.asStateFlow() - // Fullscreen private val _isFullscreen = MutableStateFlow(false) val isFullscreen: StateFlow = _isFullscreen.asStateFlow() - // VLC players — kept alive between plays - private var videoPlayer: EmbeddedMediaPlayer? = null - private var audioPlayer: MediaPlayer? = null + // Stashed pre-mute volume (0..100). kdroidFilter has no isMuted concept, so + // we emulate by zeroing volume and remembering the prior value. + private var preMuteVideoVolume: Int = 100 + private var preMuteAudioVolume: Int = 100 - // Skia bitmap for video rendering - private var skBitmap: Bitmap? = null - private var pixelBytes: ByteArray? = null + private var videoSyncJob: Job? = null + private var audioSyncJob: Job? = null - // Position polling job - private var videoPollingJob: Job? = null - private var audioPollingJob: Job? = null + // --- Verbs --------------------------------------------------------------- fun playVideo( url: String, seekPosition: Float = 0f, ) { - // If already playing this URL, just seek val current = _videoState.value - if (current.url == url && videoPlayer != null) { - if (seekPosition > 0f) { - videoPlayer?.controls()?.setPosition(seekPosition) - } - if (!current.isPlaying) { - videoPlayer?.controls()?.play() - } + val player = ensureVideoPlayer() + + if (current.url == url) { + if (seekPosition > 0f) player.seekTo(seekPosition * 1000f) + if (!current.isPlaying) player.play() return } - // Stop current video if different URL - if (current.url != null && current.url != url) { - videoPlayer?.controls()?.stop() - } - - _videoState.value = - MediaPlaybackState( - url = url, - type = MediaType.VIDEO, - isBuffering = true, - ) + _videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true) scope.launch(Dispatchers.IO) { - if (!VlcjPlayerPool.init()) { - _videoState.value = _videoState.value.copy(isBuffering = false) - return@launch - } - - val player = - videoPlayer ?: VlcjPlayerPool.acquire() ?: run { - _videoState.value = _videoState.value.copy(isBuffering = false) - return@launch - } - - // Only set up surface on first acquisition - if (videoPlayer == null) { - setupVideoSurface(player) - setupVideoEventListener(player) - videoPlayer = player - } - - var didSeek = seekPosition <= 0f - - // Temporary listener for initial seek - if (!didSeek) { - val seekListener = - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - if (!didSeek) { - didSeek = true - mediaPlayer.controls().setPosition(seekPosition) - mediaPlayer.events().removeMediaPlayerEventListener(this) - } + player.openUri(url) + // openUri auto-plays per InitialPlayerState.PLAY default. + // For an initial seek we wait for hasMedia=true; cleanest is a + // one-shot snapshotFlow collector that seeks then completes. + if (seekPosition > 0f) { + snapshotFlow { player.hasMedia } + .collect { ready -> + if (ready) { + player.seekTo(seekPosition * 1000f) + return@collect } } - player.events().addMediaPlayerEventListener(seekListener) } - - val vol = _videoState.value.volume - player.media().play(url, ":start-volume=$vol") - startVideoPolling() } } fun playAudio(url: String) { val current = _audioState.value - if (current.url == url && audioPlayer != null) { - if (!current.isPlaying) { - audioPlayer?.controls()?.play() - } + val player = ensureAudioPlayer() + + if (current.url == url) { + if (!current.isPlaying) player.play() return } - if (current.url != null && current.url != url) { - audioPlayer?.controls()?.stop() - } - - _audioState.value = - MediaPlaybackState( - url = url, - type = MediaType.AUDIO, - isBuffering = true, - ) + _audioState.value = MediaPlaybackState(url = url, type = MediaType.AUDIO, isBuffering = true) scope.launch(Dispatchers.IO) { - val player = - audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run { - _audioState.value = _audioState.value.copy(isBuffering = false) - return@launch - } - - if (audioPlayer == null) { - setupAudioEventListener(player) - audioPlayer = player - } - - val vol = _audioState.value.volume - player.media().play(url, ":start-volume=$vol") - startAudioPolling() + player.openUri(url) } } fun toggleVideoPlayPause() { val player = videoPlayer ?: return - val state = _videoState.value - if (state.url == null) return - - if (state.isPlaying) { - player.controls().pause() - } else { - if (state.position <= 0f && !player.status().isPlaying) { - state.url.let { player.media().play(it) } - } else { - player.controls().play() - } - } + if (_videoState.value.isPlaying) player.pause() else player.play() } fun toggleAudioPlayPause() { val player = audioPlayer ?: return - val state = _audioState.value - if (state.url == null) return - - if (state.isPlaying) { - player.controls().pause() - } else { - if (state.position <= 0f && !player.status().isPlaying) { - state.url.let { player.media().play(it) } - } else { - player.controls().play() - } - } + if (_audioState.value.isPlaying) player.pause() else player.play() } + /** UI passes position in 0..1. kdroidFilter wants 0..1000. */ fun seekVideo(position: Float) { - videoPlayer?.controls()?.setPosition(position) + videoPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f)) } fun seekAudio(position: Float) { - audioPlayer?.controls()?.setPosition(position) + audioPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f)) } + /** UI passes volume in 0..100. kdroidFilter wants 0..1. */ fun setVideoVolume(volume: Int) { - videoPlayer?.audio()?.setVolume(volume) - _videoState.value = _videoState.value.copy(volume = volume) + videoPlayer?.volume = volume.coerceIn(0, 100) / 100f + val muted = _videoState.value.isMuted && volume == 0 + _videoState.value = _videoState.value.copy(volume = volume, isMuted = muted) + if (volume > 0) preMuteVideoVolume = volume } fun setAudioVolume(volume: Int) { - audioPlayer?.audio()?.setVolume(volume) - _audioState.value = _audioState.value.copy(volume = volume) + audioPlayer?.volume = volume.coerceIn(0, 100) / 100f + val muted = _audioState.value.isMuted && volume == 0 + _audioState.value = _audioState.value.copy(volume = volume, isMuted = muted) + if (volume > 0) preMuteAudioVolume = volume } + /** kdroidFilter has no mute concept — emulate by stashing volume. */ fun toggleVideoMute() { - val muted = !_videoState.value.isMuted - videoPlayer?.audio()?.isMute = muted - _videoState.value = _videoState.value.copy(isMuted = muted) + val state = _videoState.value + if (state.isMuted) { + setVideoVolume(preMuteVideoVolume) + _videoState.value = _videoState.value.copy(isMuted = false) + } else { + preMuteVideoVolume = state.volume.coerceAtLeast(1) + videoPlayer?.volume = 0f + _videoState.value = _videoState.value.copy(isMuted = true, volume = 0) + } } fun toggleAudioMute() { - val muted = !_audioState.value.isMuted - audioPlayer?.audio()?.isMute = muted - _audioState.value = _audioState.value.copy(isMuted = muted) + val state = _audioState.value + if (state.isMuted) { + setAudioVolume(preMuteAudioVolume) + _audioState.value = _audioState.value.copy(isMuted = false) + } else { + preMuteAudioVolume = state.volume.coerceAtLeast(1) + audioPlayer?.volume = 0f + _audioState.value = _audioState.value.copy(isMuted = true, volume = 0) + } } fun stopVideo() { - videoPollingJob?.cancel() - videoPollingJob = null - videoPlayer?.controls()?.stop() + videoPlayer?.stop() _videoState.value = MediaPlaybackState() - _videoFrame.value = null _isFullscreen.value = false } fun stopAudio() { - audioPollingJob?.cancel() - audioPollingJob = null - audioPlayer?.controls()?.stop() + audioPlayer?.stop() _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) } @@ -283,215 +229,126 @@ object GlobalMediaPlayer { _isFullscreen.value = false } + /** Call on app exit. Disposes native handles owned by kdroidFilter. */ fun shutdown() { - videoPollingJob?.cancel() - audioPollingJob?.cancel() - - videoPlayer?.let { p -> - try { - p.controls().stop() - } catch (_: Exception) { - } - VlcjPlayerPool.release(p) - } + videoSyncJob?.cancel() + audioSyncJob?.cancel() + runCatching { videoPlayer?.stop() } + runCatching { videoPlayer?.dispose() } + runCatching { audioPlayer?.stop() } + runCatching { audioPlayer?.dispose() } videoPlayer = null - - audioPlayer?.let { p -> - try { - p.controls().stop() - } catch (_: Exception) { - } - VlcjPlayerPool.releaseAudioPlayer(p) - } audioPlayer = null - _videoState.value = MediaPlaybackState() _audioState.value = MediaPlaybackState(type = MediaType.AUDIO) - _videoFrame.value = null _isFullscreen.value = false - scope.cancel() } - private fun setupVideoSurface(player: EmbeddedMediaPlayer) { - val bufferFormatCallback = - object : BufferFormatCallback { - override fun getBufferFormat( - sourceWidth: Int, - sourceHeight: Int, - ): BufferFormat { - if (sourceHeight > 0) { - _videoState.value = - _videoState.value.copy( - aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(), - ) - } - val bmp = Bitmap() - bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL)) - skBitmap = bmp - pixelBytes = ByteArray(sourceWidth * sourceHeight * 4) - return RV32BufferFormat(sourceWidth, sourceHeight) - } + // --- Engine lifecycle ---------------------------------------------------- - override fun allocatedBuffers(buffers: Array) {} + private fun ensureVideoPlayer(): VideoPlayerState = + videoPlayer ?: synchronized(initLock) { + videoPlayer ?: createVideoPlayerState().also { + videoPlayer = it + startVideoSync(it) } + } - val renderCallback = - RenderCallback { _, nativeBuffers, _ -> - val bmp = skBitmap ?: return@RenderCallback - val bytes = pixelBytes ?: return@RenderCallback - val buffer = nativeBuffers[0] - buffer.rewind() - buffer.get(bytes) - bmp.installPixels(bytes) - _videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() + private fun ensureAudioPlayer(): VideoPlayerState = + audioPlayer ?: synchronized(initLock) { + audioPlayer ?: createVideoPlayerState().also { + audioPlayer = it + startAudioSync(it) } + } - val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) - player.videoSurface().set(surface) - } - - private fun setupVideoEventListener(player: EmbeddedMediaPlayer) { - player.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - val state = _videoState.value - _videoState.value = - state.copy( - isPlaying = true, - isBuffering = false, - duration = mediaPlayer.status().length(), - ) - } - - override fun paused(mediaPlayer: MediaPlayer) { - _videoState.value = _videoState.value.copy(isPlaying = false) - } - - override fun stopped(mediaPlayer: MediaPlayer) { - _videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false) - } - - override fun buffering( - mediaPlayer: MediaPlayer, - newCache: Float, - ) { - _videoState.value = _videoState.value.copy(isBuffering = newCache < 100f) - } - - override fun positionChanged( - mediaPlayer: MediaPlayer, - newPosition: Float, - ) { - _videoState.value = - _videoState.value.copy( - position = newPosition, - currentTime = (newPosition * _videoState.value.duration).toLong(), - ) - } - - override fun finished(mediaPlayer: MediaPlayer) { - _videoState.value = - _videoState.value.copy( - isPlaying = false, - isBuffering = false, - position = 0f, - currentTime = 0L, - ) - } - - override fun error(mediaPlayer: MediaPlayer) { - _videoState.value = _videoState.value.copy(isBuffering = false) - println("VLC: playback error for ${_videoState.value.url}") - } - }, - ) - } - - private fun setupAudioEventListener(player: MediaPlayer) { - player.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun playing(mediaPlayer: MediaPlayer) { - _audioState.value = - _audioState.value.copy( - isPlaying = true, - isBuffering = false, - duration = mediaPlayer.status().length(), - ) - } - - override fun paused(mediaPlayer: MediaPlayer) { - _audioState.value = _audioState.value.copy(isPlaying = false) - } - - override fun stopped(mediaPlayer: MediaPlayer) { - _audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false) - } - - override fun positionChanged( - mediaPlayer: MediaPlayer, - newPosition: Float, - ) { - _audioState.value = - _audioState.value.copy( - position = newPosition, - currentTime = (newPosition * _audioState.value.duration).toLong(), - ) - } - - override fun finished(mediaPlayer: MediaPlayer) { - _audioState.value = - _audioState.value.copy( - isPlaying = false, - position = 0f, - currentTime = 0L, - ) - } - }, - ) - } - - private fun startVideoPolling() { - videoPollingJob?.cancel() - videoPollingJob = + private fun startVideoSync(player: VideoPlayerState) { + videoSyncJob?.cancel() + videoSyncJob = scope.launch { - while (true) { - delay(500) - val player = videoPlayer ?: break - val state = _videoState.value - if (state.isPlaying) { - try { - _videoState.value = - state.copy( - position = player.status().position(), - currentTime = player.status().time(), - ) - } catch (_: Exception) { + snapshotFlow { + EngineSnapshot( + isPlaying = player.isPlaying, + isLoading = player.isLoading, + hasMedia = player.hasMedia, + currentTime = player.currentTime, + duration = player.duration, + aspectRatio = player.aspectRatio, + errorMessage = player.error?.let(::describeError), + ) + }.collect { snap -> + val current = _videoState.value + val posFraction = + if (snap.duration > 0.0) { + (snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f) + } else { + current.position } - } + _videoState.value = + current.copy( + isPlaying = snap.isPlaying, + isBuffering = snap.isLoading, + duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L), + currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L), + position = posFraction, + aspectRatio = if (snap.aspectRatio > 0f) snap.aspectRatio else current.aspectRatio, + errorReason = snap.errorMessage, + ) } } } - private fun startAudioPolling() { - audioPollingJob?.cancel() - audioPollingJob = + private fun startAudioSync(player: VideoPlayerState) { + audioSyncJob?.cancel() + audioSyncJob = scope.launch { - while (true) { - delay(500) - val player = audioPlayer ?: break - val state = _audioState.value - if (state.isPlaying) { - try { - _audioState.value = - state.copy( - position = player.status().position(), - currentTime = player.status().time(), - ) - } catch (_: Exception) { + snapshotFlow { + EngineSnapshot( + isPlaying = player.isPlaying, + isLoading = player.isLoading, + hasMedia = player.hasMedia, + currentTime = player.currentTime, + duration = player.duration, + aspectRatio = player.aspectRatio, + errorMessage = player.error?.let(::describeError), + ) + }.collect { snap -> + val current = _audioState.value + val posFraction = + if (snap.duration > 0.0) { + (snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f) + } else { + current.position } - } + _audioState.value = + current.copy( + isPlaying = snap.isPlaying, + isBuffering = snap.isLoading, + duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L), + currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L), + position = posFraction, + errorReason = snap.errorMessage, + ) } } } + + private fun describeError(error: VideoPlayerError): String = + when (error) { + is VideoPlayerError.CodecError -> "Codec: ${error.message}" + is VideoPlayerError.NetworkError -> "Network: ${error.message}" + is VideoPlayerError.SourceError -> "Source: ${error.message}" + is VideoPlayerError.UnknownError -> error.message + } + + private data class EngineSnapshot( + val isPlaying: Boolean, + val isLoading: Boolean, + val hasMedia: Boolean, + val currentTime: Double, + val duration: Double, + val aspectRatio: Float, + val errorMessage: String?, + ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt deleted file mode 100644 index 58d7f9fa54..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import com.sun.jna.Function -import com.sun.jna.NativeLibrary -import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil -import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy - -/** - * Discovers bundled VLC libraries on macOS. - * Must force-load libvlccore before libvlc to avoid link errors. - * Uses [VlcResourceResolver] to find the VLC directory from the Compose application - * resources or development fallback paths. - */ -class MacOsVlcDiscoverer : - BaseNativeDiscoveryStrategy( - arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"), - arrayOf("%s/plugins"), - ) { - /** Plugin path discovered during [setPluginPath], available after discovery. */ - var discoveredPluginPath: String? = null - private set - - /** Whether [setPluginPath] successfully set the process env var. */ - var envVarSet: Boolean = false - private set - - override fun supported(): Boolean { - val os = System.getProperty("os.name").lowercase() - return "mac" in os - } - - override fun discoveryDirectories(): List { - val vlcDir = VlcResourceResolver.findVlcDir() ?: return emptyList() - return listOf(vlcDir.absolutePath) - } - - override fun onFound(path: String): Boolean { - NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path) - NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName()) - return true - } - - override fun setPluginPath(pluginPath: String?): Boolean { - if (pluginPath == null) return false - discoveredPluginPath = pluginPath - return try { - // Call setenv directly via JNA Function API. This bypasses vlcj's - // LibC interface binding which fails on macOS 13+ because dlsym - // can't resolve the versioned symbol `setenv$3b99ba0d`. - val setenv = Function.getFunction("c", "setenv") - val result = setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0 - envVarSet = result - result - } catch (_: Throwable) { - // JNA Function call also failed — VlcjPlayerPool will use - // --plugin-path factory arg as fallback. - envVarSet = false - false - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index 6ecaabe158..2deb546773 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -24,24 +24,87 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ImageInfo -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat -import java.nio.ByteBuffer +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jcodec.api.FrameGrab +import org.jcodec.common.io.NIOUtils +import org.jcodec.common.model.ColorSpace +import org.jcodec.common.model.Picture +import org.jcodec.scale.AWTUtil +import org.jcodec.scale.ColorUtil +import org.jetbrains.skia.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import org.jetbrains.skia.Image as SkiaImage +import javax.imageio.ImageIO +/** + * Per-URL one-frame thumbnail extractor backing feed video posters. + * + * Cascade: + * 1. **JCodec** (`org.jcodec:jcodec` + `jcodec-javase`, BSD-2) — pure-Java + * H.264 baseline/main/high decode. Handles ~80% of Nostr feed media (MP4/H.264). + * 2. **Jaffree** (Apache-2) + **LGPL FFmpeg** subprocess — for everything else + * (HEVC, VP9, AV1, HLS, malformed faststart MP4s). Requires a bundled + * ffmpeg binary at `src/jvmMain/appResources//ffmpeg/ffmpeg(.exe)` or + * a system `ffmpeg` on `$PATH`. + * + * Replaces the prior vlcj `RenderCallback` path. License moves from + * GPL-3.0 (vlcj) to BSD-2 + Apache-2 + LGPL-2.1 native, MIT-dominant overall. + */ object VideoThumbnailCache { + private const val MAX_THUMB_BYTES = 4 * 1024 * 1024 // 4 MiB cap per thumbnail + private val cache = ConcurrentHashMap() private val pending = ConcurrentHashMap() + private val http: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } + + private val downloadCacheDir: File by lazy { + val base = + File(System.getProperty("user.home"), ".cache/amethyst-desktop/video-thumbs") + .also { it.mkdirs() } + base + } + + private val ffmpegBinary: String? by lazy { + // 1. System ffmpeg on PATH. + val onPath = + runCatching { + ProcessBuilder("ffmpeg", "-version") + .redirectErrorStream(true) + .start() + .also { it.inputStream.close() } + .waitFor(2, TimeUnit.SECONDS) + }.getOrDefault(false) + if (onPath) return@lazy "ffmpeg" + + // 2. Bundled ffmpeg under appResources//ffmpeg/. + // jpackage drops appResources at /lib/app/resources/ — equivalently, + // we can read from the working dir layout under desktopApp/src/jvmMain/appResources + // during `./gradlew :desktopApp:run`. Look for it in well-known locations. + val osName = System.getProperty("os.name").lowercase() + val isWin = "win" in osName + val binaryName = if (isWin) "ffmpeg.exe" else "ffmpeg" + val candidates = + listOf( + File(System.getProperty("compose.application.resources.dir") ?: "", "ffmpeg/$binaryName"), + File("desktopApp/src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"), + File("src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"), + ) + candidates.firstOrNull { it.exists() && it.canExecute() }?.absolutePath + } + fun getCached(url: String): ImageBitmap? = cache[url] suspend fun getThumbnail(url: String): ImageBitmap? { @@ -58,82 +121,161 @@ object VideoThumbnailCache { } private fun extractFirstFrame(url: String): ImageBitmap? { - if (!VlcjPlayerPool.init()) { - println("VLC thumbnail: init failed for $url") - return null - } - val player = VlcjPlayerPool.acquireForThumbnail() - if (player == null) { - println("VLC thumbnail: pool exhausted for $url") - return null - } + // For HLS we skip straight to Jaffree — JCodec can't read m3u8. + val isHls = url.contains(".m3u8", ignoreCase = true) || url.contains("/hls/", ignoreCase = true) - var result: ImageBitmap? = null - val latch = CountDownLatch(1) - - val bufferFormatCallback = - object : BufferFormatCallback { - override fun getBufferFormat( - sourceWidth: Int, - sourceHeight: Int, - ): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight) - - override fun allocatedBuffers(buffers: Array) {} + if (!isHls) { + val downloaded = runCatching { downloadFirstChunk(url) }.getOrNull() + if (downloaded != null) { + tryJCodec(downloaded)?.let { return it } + tryJaffreeFile(downloaded)?.let { return it } } - - val renderCallback = - RenderCallback { _, nativeBuffers, bufferFormat -> - if (result != null) return@RenderCallback - try { - if (nativeBuffers.isEmpty()) return@RenderCallback - val w = bufferFormat.width - val h = bufferFormat.height - if (w <= 0 || h <= 0) return@RenderCallback - val bmp = Bitmap() - bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL)) - val bytes = ByteArray(w * h * 4) - val buffer = nativeBuffers[0] - buffer.rewind() - buffer.get(bytes) - bmp.installPixels(bytes) - result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap() - latch.countDown() - } catch (e: Exception) { - println("VLC thumbnail: render error for $url — ${e.message}") - latch.countDown() - } - } - - val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback) - if (surface == null) { - println("VLC thumbnail: surface creation failed for $url") - VlcjPlayerPool.release(player) - return null } - player.videoSurface().set(surface) - player.audio().setVolume(0) - player.audio().isMute = true - - player.events().addMediaPlayerEventListener( - object : MediaPlayerEventAdapter() { - override fun error(mediaPlayer: MediaPlayer) { - println("VLC thumbnail: playback error for $url") - latch.countDown() - } - }, - ) - - player.media().play(url) - - // Wait up to 8 seconds for first frame (network videos can be slow) - latch.await(8, TimeUnit.SECONDS) - - if (result == null) { - println("VLC thumbnail: timed out or failed for $url") - } - - VlcjPlayerPool.release(player) - return result + return tryJaffreeUrl(url) } + + /** + * Downloads up to [MAX_THUMB_BYTES] to a cache file, returning the file (or null on failure). + * + * Caps the copy regardless of whether the server honours `Range:` — some origins ignore it + * and serve a 200 with the full body, which would otherwise stream the entire video. + * + * Rejects responses whose `Content-Type` starts with `text/` (e.g. HTML error pages from + * broken origins) so we never persist non-video bytes into the cache. + * + * Cleans up zero-byte cache files on failure so a transient empty response isn't sticky. + */ + private fun downloadFirstChunk(url: String): File? { + val hash = sha1Hex(url) + val cached = File(downloadCacheDir, "$hash.mp4") + if (cached.length() > 0L) return cached + if (cached.exists()) cached.delete() + + var wrote = false + http.newCall(buildRangeRequest(url)).execute().use { resp -> + if (!resp.isSuccessful && resp.code != 206) return null + val contentType = resp.header("Content-Type")?.lowercase().orEmpty() + if (contentType.startsWith("text/") || "html" in contentType) return null + Files.newOutputStream(cached.toPath()).use { out -> + val copied = copyAtMost(resp.body.byteStream(), out, MAX_THUMB_BYTES.toLong()) + wrote = copied > 0L + } + } + if (!wrote || cached.length() == 0L) { + cached.delete() + return null + } + return cached + } + + private fun buildRangeRequest(url: String): Request = + Request + .Builder() + .url(url) + .header("Range", "bytes=0-${MAX_THUMB_BYTES - 1}") + .header("User-Agent", "Amethyst-Desktop/thumbnail") + .build() + + private fun copyAtMost( + src: java.io.InputStream, + dst: java.io.OutputStream, + limit: Long, + ): Long { + val buf = ByteArray(64 * 1024) + var copied = 0L + while (copied < limit) { + val toRead = minOf(buf.size.toLong(), limit - copied).toInt() + val n = src.read(buf, 0, toRead) + if (n < 0) break + dst.write(buf, 0, n) + copied += n + } + return copied + } + + private fun tryJCodec(mp4: File): ImageBitmap? = + runCatching { + NIOUtils.readableChannel(mp4).use { ch -> + val grab = FrameGrab.createFrameGrab(ch).seekToSecondSloppy(1.0) + val native: Picture = grab.nativeFrame ?: return null + val rgb = Picture.create(native.width, native.height, ColorSpace.RGB) + ColorUtil.getTransform(native.color, ColorSpace.RGB).transform(native, rgb) + bufferedImageToImageBitmap(AWTUtil.toBufferedImage(rgb)) + } + }.getOrNull() + + private fun tryJaffreeFile(file: File): ImageBitmap? = runFfmpegToImage(file.absolutePath) + + private fun tryJaffreeUrl(url: String): ImageBitmap? = runFfmpegToImage(url) + + /** + * Spawns `ffmpeg -ss 1 -i -frames:v 1 -f image2pipe -c:v png -an pipe:1`, + * reads PNG bytes from stdout, decodes with Skia. + * + * Uses raw `ProcessBuilder` rather than the Jaffree DSL — fewer API guesses, + * easier to debug. Jaffree stays on the classpath as a future option. + */ + private fun runFfmpegToImage(input: String): ImageBitmap? { + val ffmpeg = ffmpegBinary ?: return null + val cmd = + listOf( + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-ss", + "1", + "-i", + input, + "-frames:v", + "1", + "-an", + "-f", + "image2pipe", + "-c:v", + "png", + "pipe:1", + ) + val process = + runCatching { + ProcessBuilder(cmd) + .redirectErrorStream(false) + .start() + }.getOrNull() ?: return null + val out = ByteArrayOutputStream(256 * 1024) + try { + process.inputStream.use { it.copyTo(out) } + if (!process.waitFor(8, TimeUnit.SECONDS)) { + process.destroyForcibly() + return null + } + if (process.exitValue() != 0 || out.size() == 0) return null + } catch (_: Exception) { + process.destroyForcibly() + return null + } + return runCatching { + Image.makeFromEncoded(out.toByteArray()).toComposeImageBitmap() + }.getOrNull() + } + + private fun bufferedImageToImageBitmap(img: BufferedImage): ImageBitmap { + val baos = ByteArrayOutputStream(64 * 1024) + ImageIO.write(img, "png", baos) + return Image.makeFromEncoded(baos.toByteArray()).toComposeImageBitmap() + } + + private fun sha1Hex(s: String): String { + val md = MessageDigest.getInstance("SHA-1") + val bytes = md.digest(s.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun osTag(osName: String): String = + when { + "mac" in osName -> "macos" + "win" in osName -> "windows" + else -> "linux" + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt deleted file mode 100644 index c53af47faa..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import java.io.File - -/** - * Resolves the bundled VLC directory, with fallbacks for development (Gradle run). - * - * Resolution order: - * 1. `compose.application.resources.dir` system property (set by packaged app) - * 2. Gradle `prepareAppResources` build output - * 3. Source `appResources` directory (platform-specific) - */ -object VlcResourceResolver { - private val currentPlatform: String by lazy { - val os = System.getProperty("os.name").lowercase() - when { - "mac" in os || "darwin" in os -> "macos" - "win" in os -> "windows" - else -> "linux" - } - } - - /** - * Returns the VLC directory if found, or null. - */ - fun findVlcDir(): File? { - // 1. Compose application resources dir (packaged app or plugin-provided) - System.getProperty("compose.application.resources.dir")?.let { dir -> - val vlcDir = File(dir, "vlc") - if (vlcDir.isDirectory) return vlcDir - } - - // 2. Gradle prepareAppResources build output (relative to working dir) - val buildOutput = File("desktopApp/build/compose/tmp/prepareAppResources/vlc") - if (buildOutput.isDirectory) return buildOutput - - // 3. Source appResources (platform-specific subdirectory) - val sourceResources = File("desktopApp/src/jvmMain/appResources/$currentPlatform/vlc") - if (sourceResources.isDirectory) return sourceResources - - return null - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt deleted file mode 100644 index 4c73e918cd..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ /dev/null @@ -1,325 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.desktop.service.media - -import uk.co.caprica.vlcj.factory.MediaPlayerFactory -import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery -import uk.co.caprica.vlcj.player.base.MediaPlayer -import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer -import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback -import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Manages a pool of VLCJ media players to avoid costly create/destroy cycles. - * Keeps strong references to prevent GC crashes from native callbacks. - * - * IMPORTANT: Never let player instances be garbage collected while native - * callbacks are active — this causes JVM segfaults. - */ -object VlcjPlayerPool { - private val available = AtomicBoolean(false) - private val initAttempted = AtomicBoolean(false) - private val initLatch = CountDownLatch(1) - private var factory: MediaPlayerFactory? = null - - // Video player pool (for actual playback) - private val allPlayers = mutableListOf() - private val idlePlayers = ConcurrentLinkedQueue() - private const val MAX_POOL_SIZE = 1 - - // Thumbnail player pool (separate so thumbnails don't compete with playback) - private val allThumbPlayers = mutableListOf() - private val idleThumbPlayers = ConcurrentLinkedQueue() - private const val MAX_THUMB_POOL_SIZE = 2 - - // Cached plugin path for audio factory creation (set during init) - private var cachedPluginPath: String? = null - - // Audio player pool (shared factory with --no-video) - private var audioFactory: MediaPlayerFactory? = null - private val allAudioPlayers = mutableListOf() - private val idleAudioPlayers = ConcurrentLinkedQueue() - private const val MAX_AUDIO_POOL_SIZE = 1 - - /** - * Initialize the pool. Thread-safe — only runs once. - * Returns false if VLC is not installed. - */ - fun init(): Boolean { - if (available.get()) return true - - // Only one thread performs init; others wait - if (!initAttempted.compareAndSet(false, true)) { - initLatch.await(10, TimeUnit.SECONDS) - return available.get() - } - - return try { - // Try bundled VLC first, then fall through to system VLC - val macOsDiscoverer = MacOsVlcDiscoverer() - val discovery = - try { - val nd = - NativeDiscovery( - BundledVlcDiscoverer(), - macOsDiscoverer, - ) - val found = nd.discover() - if (found) { - println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}") - } else { - println("VLC: bundled discovery failed, falling back to system VLC") - } - found - } catch (e: Throwable) { - println("VLC: bundled discovery threw ${e.message}") - false - } - if (!discovery) { - // Try default system discovery - val systemDiscovery = NativeDiscovery().discover() - println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}") - } - - // Delete stale VLC plugin cache on macOS to avoid spam warnings - if ("mac" in System.getProperty("os.name").lowercase()) { - try { - val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc") - cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() } - } catch (_: Throwable) { - // Best-effort cache cleanup - } - } - - // Build factory args — add --plugin-path fallback if env var wasn't set - val factoryArgs = - mutableListOf( - "--no-xlib", - "--avcodec-hw=none", // Disable VideoToolbox — avoids CVPN chroma failures on macOS - "--reset-plugins-cache", // Rebuild stale plugins cache on startup - ) - if (!macOsDiscoverer.envVarSet) { - val pluginPath = - macOsDiscoverer.discoveredPluginPath - ?: System.getProperty("vlc.plugin.path") - ?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" } - if (pluginPath != null) { - factoryArgs += "--plugin-path=$pluginPath" - println("VLC: using --plugin-path fallback: $pluginPath") - } - } - - cachedPluginPath = macOsDiscoverer.discoveredPluginPath - ?: System.getProperty("vlc.plugin.path") - - val f = MediaPlayerFactory(*factoryArgs.toTypedArray()) - factory = f - available.set(true) - println("VLC: MediaPlayerFactory created successfully") - true - } catch (e: Throwable) { - println("VLC: init failed — ${e.message}") - available.set(false) - false - } finally { - initLatch.countDown() - } - } - - fun isAvailable(): Boolean = available.get() - - /** - * Create a callback video surface using the factory's API. - */ - fun createVideoSurface( - bufferFormatCallback: BufferFormatCallback, - renderCallback: RenderCallback, - ): VideoSurface? { - val f = factory ?: return null - return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true) - } - - /** - * Acquire a video player from the pool or create a new one. - * Returns null if VLC is not available or pool is at capacity. - */ - fun acquire(): EmbeddedMediaPlayer? { - if (!available.get()) return null - val f = factory ?: return null - - synchronized(allPlayers) { - idlePlayers.poll()?.let { return it } - if (allPlayers.size >= MAX_POOL_SIZE) return null - return try { - val player = f.mediaPlayers().newEmbeddedMediaPlayer() - allPlayers.add(player) - player - } catch (_: Exception) { - null - } - } - } - - /** - * Acquire a player dedicated to thumbnail extraction. - * Separate pool so thumbnails don't compete with playback. - */ - fun acquireForThumbnail(): EmbeddedMediaPlayer? { - if (!available.get()) return null - val f = factory ?: return null - - synchronized(allThumbPlayers) { - idleThumbPlayers.poll()?.let { return it } - if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) { - // Fall back to main pool if thumb pool is full - return acquire() - } - return try { - val player = f.mediaPlayers().newEmbeddedMediaPlayer() - allThumbPlayers.add(player) - player - } catch (_: Exception) { - null - } - } - } - - /** - * Acquire an audio-only player from the pool. - * Uses a separate factory with --no-video for efficiency. - */ - fun acquireAudioPlayer(): MediaPlayer? { - if (!init()) return null - - synchronized(allAudioPlayers) { - idleAudioPlayers.poll()?.let { return it } - if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null - - val af = - audioFactory ?: try { - val audioArgs = mutableListOf("--no-video", "--no-xlib") - cachedPluginPath?.let { audioArgs += "--plugin-path=$it" } - MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it } - } catch (_: Throwable) { - return null - } - - return try { - val player = af.mediaPlayers().newMediaPlayer() - allAudioPlayers.add(player) - player - } catch (_: Exception) { - null - } - } - } - - /** - * Return a video player to the pool for reuse. - */ - fun release(player: EmbeddedMediaPlayer) { - try { - player.controls().stop() - // Return to correct pool - synchronized(allThumbPlayers) { - if (player in allThumbPlayers) { - idleThumbPlayers.offer(player) - return - } - } - idlePlayers.offer(player) - } catch (_: Exception) { - // Player may already be disposed - } - } - - /** - * Return an audio player to the pool for reuse. - */ - fun releaseAudioPlayer(player: MediaPlayer) { - try { - player.controls().stop() - idleAudioPlayers.offer(player) - } catch (_: Exception) { - // Player may already be disposed - } - } - - /** - * Shut down the entire pool. Call on app exit. - */ - fun shutdown() { - synchronized(allPlayers) { - idlePlayers.clear() - for (player in allPlayers) { - try { - player.controls().stop() - player.release() - } catch (_: Exception) { - // Ignore - } - } - allPlayers.clear() - } - synchronized(allThumbPlayers) { - idleThumbPlayers.clear() - for (player in allThumbPlayers) { - try { - player.controls().stop() - player.release() - } catch (_: Exception) { - // Ignore - } - } - allThumbPlayers.clear() - } - synchronized(allAudioPlayers) { - idleAudioPlayers.clear() - for (player in allAudioPlayers) { - try { - player.controls().stop() - player.release() - } catch (_: Exception) { - // Ignore - } - } - allAudioPlayers.clear() - } - try { - factory?.release() - } catch (_: Exception) { - // Ignore - } - try { - audioFactory?.release() - } catch (_: Exception) { - // Ignore - } - factory = null - audioFactory = null - available.set(false) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index a71c4024df..7618e8293a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -24,12 +24,14 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -46,8 +48,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache -import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface import kotlinx.coroutines.delay +import java.awt.Desktop +import java.net.URI @Composable fun DesktopVideoPlayer( @@ -60,16 +64,12 @@ fun DesktopVideoPlayer( onViewModeChange: ((ViewMode) -> Unit)? = null, trailingControls: @Composable (() -> Unit)? = null, ) { - // Check if this URL is the active video val videoState by GlobalMediaPlayer.videoState.collectAsState() - val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() val isActiveVideo = videoState.url == url - // Thumbnail for inactive videos var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) } var aspectRatio by remember { mutableFloatStateOf(16f / 9f) } - // Load thumbnail when not active LaunchedEffect(url, isActiveVideo) { if (!isActiveVideo && thumbnail == null) { for (attempt in 1..3) { @@ -83,23 +83,16 @@ fun DesktopVideoPlayer( } } - // Auto-play on mount if requested LaunchedEffect(url, autoPlay) { if (autoPlay) { GlobalMediaPlayer.playVideo(url, initialSeekPosition) } } - // Sync aspect ratio from global state when active if (isActiveVideo && videoState.aspectRatio != 16f / 9f) { aspectRatio = videoState.aspectRatio } - if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) { - VlcNotAvailableMessage(url, modifier) - return - } - BoxWithConstraints(modifier = modifier) { val desiredHeight = maxWidth / aspectRatio val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight @@ -115,17 +108,28 @@ fun DesktopVideoPlayer( ), contentAlignment = Alignment.Center, ) { - val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail - displayBitmap?.let { bitmap -> - Image( - bitmap = bitmap, - contentDescription = "Video", - modifier = - Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.small), + val errorReason = if (isActiveVideo) videoState.errorReason else null + + if (errorReason != null) { + PlaybackErrorMessage(url = url, reason = errorReason) + } else if (isActiveVideo) { + VideoPlayerSurface( + playerState = GlobalMediaPlayer.activeVideoPlayerState, + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) + } else { + thumbnail?.let { bitmap: ImageBitmap -> + Image( + bitmap = bitmap, + contentDescription = "Video thumbnail", + modifier = + Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.small), + contentScale = ContentScale.Fit, + ) + } } VideoControls( @@ -149,12 +153,8 @@ fun DesktopVideoPlayer( GlobalMediaPlayer.seekVideo(pos) } }, - onVolumeChange = { vol -> - GlobalMediaPlayer.setVideoVolume(vol) - }, - onMuteToggle = { - GlobalMediaPlayer.toggleVideoMute() - }, + onVolumeChange = { vol -> GlobalMediaPlayer.setVideoVolume(vol) }, + onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() }, onFullscreen = if (onFullscreen != null) { { @@ -172,25 +172,34 @@ fun DesktopVideoPlayer( } @Composable -private fun VlcNotAvailableMessage( +private fun PlaybackErrorMessage( url: String, - modifier: Modifier = Modifier, + reason: String, ) { Box( - modifier = - modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(8.dp), - ), + modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center, ) { - Text( - text = "Video: $url\nInstall VLC to play videos: https://www.videolan.org/vlc/", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Can't play this video", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton( + onClick = { + runCatching { Desktop.getDesktop().browse(URI(url)) } + }, + ) { + Text("Open in default player") + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt index 18a59732f8..7aaea40218 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.media -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -42,12 +41,12 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface @Composable fun GlobalFullscreenOverlay() { val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState() val videoState by GlobalMediaPlayer.videoState.collectAsState() - val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() if (!isFullscreen || videoState.url == null) return @@ -103,15 +102,12 @@ fun GlobalFullscreenOverlay() { }, contentAlignment = Alignment.Center, ) { - // Video frame - videoFrame?.let { frame -> - Image( - bitmap = frame, - contentDescription = "Video fullscreen", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) - } + // Video frame — same player state as feed card; kdroidFilter draws to Canvas + VideoPlayerSurface( + playerState = GlobalMediaPlayer.activeVideoPlayerState, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) // Video controls overlay VideoControls( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt index ffe53568a2..6d88aff5fd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.desktop.ui.media import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -51,6 +50,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer +import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface import kotlinx.coroutines.launch enum class MediaType { AUDIO, VIDEO } @@ -59,7 +59,6 @@ enum class MediaType { AUDIO, VIDEO } fun NowPlayingBar(modifier: Modifier = Modifier) { val videoState by GlobalMediaPlayer.videoState.collectAsState() val audioState by GlobalMediaPlayer.audioState.collectAsState() - val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState() val hasVideo = videoState.url != null val hasAudio = audioState.url != null @@ -86,11 +85,10 @@ fun NowPlayingBar(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - // Mini video thumbnail or music icon - if (activeType == MediaType.VIDEO && videoFrame != null) { - Image( - bitmap = videoFrame!!, - contentDescription = "Video thumbnail", + // Mini video preview or music icon + if (activeType == MediaType.VIDEO) { + VideoPlayerSurface( + playerState = GlobalMediaPlayer.activeVideoPlayerState, modifier = Modifier .size(width = 48.dp, height = 36.dp) diff --git a/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md b/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md new file mode 100644 index 0000000000..5401640a1d --- /dev/null +++ b/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md @@ -0,0 +1,863 @@ +--- +title: Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree +type: feat +status: active +date: 2026-06-11 +deepened: 2026-06-11 +origin: docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md +--- + +# Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree + +## Enhancement Summary (Deepened 2026-06-11) + +**Research artifacts (companion files):** +- [`_kdroidfilter-api-notes.md`](_kdroidfilter-api-notes.md) — verified kdroidFilter public API (note: pinned to **0.10.0** in `libs.versions.toml`; the research agent referenced 0.10.1 which does not exist on Maven Central as of 2026-06-11. API contract is the same across 0.10.x.) +- [`_jcodec-thumbnail-recipe.md`](_jcodec-thumbnail-recipe.md) — concrete JCodec → Skia ImageBitmap path +- [`_macos-ffmpeg-signing-recipe.md`](_macos-ffmpeg-signing-recipe.md) — jpackage nested-binary codesign recipe +- [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md) — Gitnuro-style Compose Desktop manifest +- [`_notice-and-licenses-recipe.md`](_notice-and-licenses-recipe.md) — cashapp/licensee + AboutLibraries pattern +- [`_vlcj-migration-learnings.md`](_vlcj-migration-learnings.md) — institutional learnings from prior media work +- [`_vlcj-migration-codebase-shape.md`](_vlcj-migration-codebase-shape.md) — file inventory (if regenerated) + +### Material corrections to original plan + +These supersede the corresponding sections below — implementer should use the new specs: + +1. **kdroidFilter API names** (Phase 1 was wrong; corrected inline): + - `openMedia(url)` → **`openUri(url)`** + - `release()` → **`dispose()`** + - `setVolume()` → **`volume = X` (Float, 0..1)** + - `setMuted()` / `isMuted` → **does not exist; emulate by stashing/restoring volume** + - State exposed as Compose **`mutableStateOf`** (read with `snapshotFlow {}` from outside composition), **not** `StateFlow` — affects `stateSyncJob` design. + - `VideoPlayerState` is an interface — instantiate via `rememberVideoPlayerState()` (composable) or `createVideoPlayerState()` (non-composable; manual `dispose()`). + - JVM surface uses **Compose Canvas** (Skia ImageBitmap), **not** SwingPanel — overlays/z-order/AnimatedVisibility work normally (vs. vlcj/SwingPanel limitations). + - No `surfaceType` parameter on JVM (Android-only). + - Error states on JVM are only `SourceError` / `UnknownError` — **no codec-vs-network distinction**. Need a separate GStreamer-on-Linux probe. + +2. **macOS app bundle path correction** (Phase 2 was wrong): + - `appResourcesRootDir` lands at **`Contents/app/resources/`**, NOT `Contents/Resources/`. jpackage owns `Contents/Resources/`. Final ffmpeg path: `Amethyst.app/Contents/app/resources/ffmpeg/ffmpeg`. + +3. **Linux GStreamer requirements**: + - kdroidFilter on Linux requires system **GStreamer 1.16+** with `plugins-base`, `plugins-good`, `plugins-bad`, and `libav` (for actual codec decoding). HLS needs `plugins-bad`. Document as `Recommends:` line in DEB / `recommends:` in metainfo. + +4. **JCodec adjustments** (Phase 2): + - Need `org.jcodec:jcodec:0.2.5` **plus** `org.jcodec:jcodec-javase:0.2.5` (AWTUtil lives in `-javase`). + - Frame color space is **`YUV420J`** — must convert with `ColorUtil.getTransform(native.color, ColorSpace.RGB)` before `AWTUtil.toBufferedImage` (AWTUtil does not auto-convert). + - `ColorAlphaType.OPAQUE` (not `PREMUL` as I wrote). + - Cleanest `BufferedImage` → `ImageBitmap` is **ImageIO → PNG bytes → `Image.makeFromEncoded(bytes).toComposeImageBitmap()`**. No stable direct extension. + - Exception class: **`org.jcodec.api.UnsupportedFormatException extends JCodecException`** — also catch `IOException` + broad `RuntimeException` (decoder throws `AIOOBE` on malformed SPS/PPS). + - Use `seekToSecondSloppy(1.0)` for thumbnails (precise decodes up to 500 frames per call). + - Fast-reject path: `MP4Util.parseMovie()` + check `stsd` FourCC for `avc1` / `avc3` before opening FrameGrab. + +5. **Tooling for licenses** (Phase 0): + - Use **`app.cash.licensee`** Gradle plugin to enforce allow-list + generate JSON report at build time (compliance gate). + - Use **`com.mikepenz.aboutlibraries`** Gradle plugin to produce the in-app "Open source licenses" screen (CMP-ready, replaces my hand-rolled `OpenSourceLicensesScreen.kt`). + - Hand-author `NOTICE.md` only for native bundles outside Gradle's graph (FFmpeg, GStreamer runtime). + +6. **Flathub manifest precedent** (Phase 4): + - **Gitnuro** (`com.jetpackduba.Gitnuro`) is the existing Kotlin Compose Desktop precedent — use its structure verbatim where applicable. + - Manifest sketch in plan replaced by Gitnuro-style pattern in [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md). + - Submission branch is **`new-pr`** (not `master`). + - Patent-codec extension `org.freedesktop.Platform.ffmpeg-full` is the way to surface HEVC/AV1 on Flatpak. + +7. **SPDX expression acceptance**: + - Fedora `rpmLicenseType` accepts SPDX expressions (mandatory since F41 phase 4) → our compound is valid as-is. + - Flathub `` validates compound via `appstreamcli`. + - **Debian DEP-5 does NOT support compound expressions** — must group per-file with separate `License:` stanzas. + +### Key plan-level changes derived from research + +- Drop the `OpenSourceLicensesScreen.kt` hand-rolled file (replaced by AboutLibraries plugin). +- Add `app.cash.licensee` + allow-list in `desktopApp/build.gradle.kts`. +- Add `gradle/spdx-allowlist.txt` (or `desktopApp/licensee.gradle`) configuration. +- macOS entitlements file overrides `entitlementsFile.set(...)` and `runtimeEntitlementsFile.set(...)` with `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`. +- `stateSyncJob` uses `snapshotFlow {}` not `combine(StateFlow, ...)`. +- `VideoPlayerSurface` JVM uses Compose Canvas → fullscreen handoff is **simpler** than expected (risk #7 in original risk table is downgraded from M-M to L-L). +- Linux codec-missing UX: probe GStreamer via `gst-inspect-1.0 playbin` at app launch; if missing, show one-time install nag. +- Phase 4 adds a `add-extensions: org.freedesktop.Platform.ffmpeg-full` block to the Flathub manifest for patent codecs. + +## Overview + +Drop `uk.co.caprica:vlcj 4.8.3` (GPL-3.0-or-later) from `desktopApp` and ship +an MIT-dominant desktop binary. Replace the three vlcj subsystems with: + +| Subsystem | New library | License | +|-----------|-------------|---------| +| Video playback | `io.github.kdroidfilter:composemediaplayer:0.10.1` | MIT | +| Audio playback | same (audio-only mode of `VideoPlayerState`) | MIT | +| Thumbnail extraction | `org.jcodec:jcodec:0.2.5` + Jaffree fallback with LGPL FFmpeg | BSD-2 + Apache-2 (Java); LGPL-2.1 (FFmpeg native) | + +Resulting binary SPDX: **`MIT AND LGPL-2.1-or-later AND BSD-2-Clause`** (down +from today's effective `GPL-3.0-or-later AND LGPL-2.1-or-later AND MIT` that +the current `rpmLicenseType = "MIT"` misrepresents). + +Single migration PR. Hard cut — no feature flag. Phase 0 (NOTICE / SPDX +honesty patch) bundled in the same PR so the binary is never released +mislabeled. + +See brainstorms for *why*: +- [Licensing issues catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) +- [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md) +- [Replacement-candidate research](../brainstorms/2026-06-11-vlcj-replacement-research.md) + +## Problem Statement + +Today's `desktopApp` integration: + +1. Builds Amethyst Desktop's binary by linking GPL-3.0 vlcj into the JVM. +2. Bundles LGPL-2.1 libvlc + a mixed-license VLC 3.0.20 plugin tree (~95 MB on macOS, ~90 MB on Windows, ~70 MB on Linux uncompressed) via the `ir.mahozad.vlc-setup` Gradle plugin. +3. Declares `rpmLicenseType = "MIT"` (`desktopApp/build.gradle.kts:141`) — **wrong** for the produced binary. +4. Ships no NOTICE, no per-component LICENSE files, no written GPL source offer, and no About-screen license listing. + +Consequences (full catalog in +[licensing brainstorm](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)): + +- The MIT badge on the repo + RPM is incorrect for distributed binaries. +- Forks of `desktopApp/` silently inherit GPL. +- Mac/MS App Store distribution is structurally blocked (anti-Tivoization conflict). +- Linux distros (Fedora, Debian) would fail license review with the current metadata. +- The bundled VLC plugin tree contains GPL-only plugins (`libdvdcss`, `x264`-built swscale) that drag GPL regardless of the Java binding. + +User-resolved priorities (from brainstorm 2026-06-11): +- Keep **MIT branding on the binary** — non-negotiable. +- Mac/MS App Store: **not** on roadmap; not the migration driver. +- **Hard cut** (no feature flag), single PR, Flathub manifest in scope. +- Android (`amethyst/`) out of scope — already uses `media3-exoplayer`, no vlcj. + +## Proposed Solution + +### High-level + +Swap the playback engine inside the existing `GlobalMediaPlayer` singleton +and the thumbnail engine inside `VideoThumbnailCache`, preserving the +public `StateFlow` surface that the rest of the UI consumes. Delete the +discoverer/pool layer (kdroidFilter handles its own native loading) and the +`ir.mahozad.vlc-setup` Gradle plugin (no more bundled VLC). + +The UI composables (`DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`, +`NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay`) keep their +shapes; `DesktopVideoPlayer` switches its rendering path from +`Image(bitmap = videoFrame)` to kdroidFilter's `VideoPlayerSurface` +composable when the active video is playing, and stays on a thumbnail +`Image` for inactive instances (the "show poster until I tap play" pattern). + +### The cross-stack contract + +``` ++-----------------------------------------+ +| UI composables (kept) | +| DesktopVideoPlayer | AudioPlayer | +| VideoControls | NowPlayingBar | | +| GlobalFullscreenOverlay | LightboxOverlay | ++----------------------+------------------+ + | + v reads StateFlow + invokes verbs ++-----------------------------------------+ +| GlobalMediaPlayer (kept as singleton) | +| - exposes StateFlow| +| - exposes activeVideoPlayerState | +| - verbs: playVideo/playAudio/pause/... | ++----------------------+------------------+ + | + v delegates to ++----------------+ +-------------------+ +---------------+ +| kdroidFilter | | JCodec (primary) | | Jaffree (fallback) | +| VideoPlayerState | | thumbnail H.264 | | LGPL FFmpeg thumb | ++----------------+ +-------------------+ +---------------+ +``` + +### Why kdroidFilter + +Full rationale in the [research doc](../brainstorms/2026-06-11-vlcj-replacement-research.md). +Headline: + +- **MIT.** Binding + Maven coordinates `io.github.kdroidfilter:composemediaplayer:0.10.1`. +- **OS-native backends, no native bundle on Win/mac:** Media Foundation, AVFoundation, GStreamer (Linux system). +- **First-class Compose API:** `VideoPlayerSurface(playerState, contentScale, surfaceType, overlay)`. +- Bundle on macOS drops from ~95 MB → ~60-80 MB (we still bundle LGPL FFmpeg for the rare-codec thumbnail fallback; nothing for video). +- Active in 2026 (v0.10.1 May 2026, single maintainer Elie Gambache). + +## Technical Approach + +### Architecture + +**Module boundary:** Migration stays inside `desktopApp/`. `quartz/`, +`commons/`, and `amethyst/` are untouched. No new shared abstractions in +`commons/` — the player layer is desktop-specific and has no Android +counterpart in scope (`amethyst/` runs `media3-exoplayer`). + +**Concurrency:** kdroidFilter manages its own threading. We continue to +adapt its callbacks into the existing `MediaPlaybackState` StateFlow inside +`GlobalMediaPlayer` so UI consumers see the same shape. Thumbnail +extraction stays on `Dispatchers.IO`; we replace vlcj's `CountDownLatch` +gymnastics with a `suspendCancellableCoroutine` around JCodec's synchronous +API and `Jaffree.executeAsync().toCompletableFuture().asDeferred()` for the +fallback. + +**Native loading:** kdroidFilter uses JNI (not JNA). On macOS this avoids +the `setenv$3b99ba0d` versioned-symbol class of bug that +`MacOsVlcDiscoverer` worked around in May 2026 (see +`docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md`). FFmpeg +binaries for Jaffree are spawned as separate processes — no JNI native lib +to load, so no signing/notarization complications beyond marking the +ffmpeg binary as executable in the jpackage app bundle and ensuring it +ships inside `Contents/MacOS/` so macOS hardened runtime permits it. + +### Implementation Phases + +#### Phase 0 — License bridge (in-PR, lands first commit) (~1 day) + +Land alongside the migration code in the same PR so no released binary is +ever mislabeled. + +**File changes:** + +1. **`desktopApp/build.gradle.kts:141`** + - `rpmLicenseType = "MIT"` → `rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause"` + - (jpackage forwards verbatim into the .rpm `License:` field. SPDX + expression form is accepted by `rpm --query`; Fedora packagers parse it + as a compound license.) + +2. **New: `desktopApp/src/jvmMain/appResources/common/NOTICE.md`** + - Lists every third-party component shipping in the binary with version + SPDX + upstream URL. + - Includes FFmpeg LGPL build provenance + binary download URL. + - References JCodec, kdroidFilter, GStreamer (Linux runtime dep). + - Contains the GPLv3 source-availability written offer for the *interim* commit (the bridge commit lands while the migration is in flight; the same NOTICE is updated in Phase 3 to drop the vlcj/VLC lines once those are deleted). + +3. **New: `desktopApp/src/jvmMain/appResources/common/licenses/`** + - `LICENSE-MIT.txt` — Amethyst's MIT + - `LICENSE-MIT-kdroidfilter.txt` — verbatim from upstream + - `LICENSE-BSD-2-JCodec.txt` — verbatim + - `LICENSE-Apache-2-Jaffree.txt` — verbatim + - `LICENSE-LGPL-2.1.txt` — FFmpeg + libvlc (interim) + - `LICENSE-GPL-3.0.txt` — vlcj (interim, deleted in Phase 3) + - `WRITTEN-OFFER.txt` — GPL source-offer pointing to https://github.com/vitorpamplona/amethyst (valid for 3 years per GPLv3 §6c). Removed in Phase 3 once vlcj is gone. + +4. **New: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/about/OpenSourceLicensesScreen.kt`** + - Scrollable list of bundled components → name, version, license SPDX, "View license" expander. + - Reachable from existing settings (look up + wire into nav during implementation; flagged as plan question in brainstorm). + - Pure data-driven: a `LicenseEntry` data class list defined inline; no resource loader complexity. + +5. **`README.md` (root)** — add a single bullet under any "Desktop" section: + - "The desktop binary currently bundles GPL components (vlcj, parts of VLC plugins) during the in-flight migration to MIT-only dependencies. Source available at this repository." + - Removed in Phase 3. + +**Acceptance for Phase 0:** +- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces a .rpm whose `rpm -qpi` shows the SPDX combined string. +- About-dialog "Open source licenses" screen renders and is reachable from the desktop settings. +- `NOTICE.md`, `licenses/`, and `WRITTEN-OFFER.txt` are visible inside the produced DMG/MSI/DEB/RPM payloads. + +#### Phase 1 — kdroidFilter swap-in for video + audio (~1-2 weeks) + +##### 1.1 Gradle wiring + +**`gradle/libs.versions.toml`** — add: +```toml +[versions] +composemediaplayer = "0.10.1" + +[libraries] +composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" } +``` + +**`desktopApp/build.gradle.kts`** — add `implementation(libs.composemediaplayer)`. Leave `implementation(libs.vlcj)` in place until Phase 3. + +##### 1.2 GlobalMediaPlayer refactor + +Replace the engine but **keep the public StateFlow surface** so UI code is +unaffected. + +Current public surface (preserve): +```kotlin +val videoFrame: StateFlow // delete — DesktopVideoPlayer reads VideoPlayerSurface directly now +val videoState: StateFlow // keep +val audioState: StateFlow // keep +val isFullscreen: StateFlow // keep + +fun playVideo(url: String, seekPosition: Float = 0f) +fun playAudio(url: String) +fun toggleVideoPlayPause() +fun toggleAudioPlayPause() +fun seekVideo(position: Float) +fun seekAudio(position: Float) +fun setVideoVolume(volume: Int) +fun setAudioVolume(volume: Int) +fun toggleVideoMute() +fun toggleAudioMute() +fun stopVideo() +fun stopAudio() +fun toggleFullscreen() +fun exitFullscreen() +fun shutdown() +``` + +New private fields: +```kotlin +private val videoPlayerState: VideoPlayerState = VideoPlayerState() +private val audioPlayerState: VideoPlayerState = VideoPlayerState().apply { /* audio-only config */ } +private var stateSyncJob: Job? = null +``` + +New public field — exposed so `DesktopVideoPlayer` can pass it to +`VideoPlayerSurface(...)`: + +```kotlin +val activeVideoPlayerState: VideoPlayerState get() = videoPlayerState +val activeAudioPlayerState: VideoPlayerState get() = audioPlayerState +``` + +**State-sync coroutine.** Replace the vlcj `MediaPlayerEventAdapter` + +500ms polling loop with a single coroutine that mirrors +`videoPlayerState`'s `StateFlow`s into our `_videoState` mutable flow: + +```kotlin +private fun startStateSync(state: VideoPlayerState, target: MutableStateFlow) { + stateSyncJob?.cancel() + stateSyncJob = scope.launch { + // kdroidFilter exposes: isPlaying, currentTime, duration, isLoading, + // volume, isMuted, aspectRatio (as StateFlows or @Composable getters) + combine( + state.isPlayingFlow, // verify exact name in v0.10.1 API + state.currentTimeFlow, + state.durationFlow, + state.isLoadingFlow, + state.aspectRatioFlow, + ) { /* fold into MediaPlaybackState */ } + .collect { target.value = it } + } +} +``` + +(If kdroidFilter v0.10.1 exposes its state as `@Composable State` +getters rather than `Flow`, fall back to a `snapshotFlow { state.isPlaying }` +inside a `produceState`-equivalent coroutine. Confirm during Phase 1 +implementation by reading kdroidFilter source on github.) + +##### 1.3 DesktopVideoPlayer rewire + +Replace the `Image(bitmap = displayBitmap)` rendering path with conditional +`VideoPlayerSurface` when this composable instance owns the active video URL: + +```kotlin +val isActiveVideo = videoState.url == url + +Box(...) { + if (isActiveVideo) { + VideoPlayerSurface( + playerState = GlobalMediaPlayer.activeVideoPlayerState, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small), + ) + } else { + thumbnail?.let { + Image(bitmap = it, contentDescription = "Video thumbnail", ...) + } + } + + VideoControls(...) // unchanged +} +``` + +Drop the `VlcNotAvailableMessage` composable — kdroidFilter never fails on +"VLC not installed." Replace it with `VlcCodecUnsupportedMessage(url, codec)` +shown when kdroidFilter emits a codec-error event (Windows HEVC/AV1 +without the MS Store extension). The message offers "Open in default +player" via `Desktop.getDesktop().browse(URI(url))`. + +##### 1.4 AudioPlayer rewire + +Drop the vlcj surface plumbing — `AudioPlayer.kt` currently doesn't touch +vlcj directly, it just reads `GlobalMediaPlayer.audioState`. No file +change beyond imports if the StateFlow shape is preserved. + +##### 1.5 VideoControls / NowPlayingBar / GlobalFullscreenOverlay / LightboxOverlay + +Review for vlcj-specific assumptions. Expected change: none (they consume +StateFlows). Plan question P6 — to be verified during implementation by +grepping for any `EmbeddedMediaPlayer`, `MediaPlayer`, or `videoFrame` +references in these files. + +##### 1.6 Phase 1 decision gate + +After 1.1–1.5 compile + launch, run the manual test set: + +| Test URL | Expected outcome | +|----------|------------------| +| `https://download.samplelib.com/mp4/sample-5s.mp4` (H.264 MP4) | Plays on Win/mac/Linux. | +| `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` (HLS H.264) | Plays on Win/mac/Linux. | +| `https://nostr.build/i/` (VP9 WebM) | Plays on mac+Linux. Win → codec-missing UX. | +| `https://void.cat/` (AV1 MP4) | Plays on macOS 13+ + Linux. Win → codec-missing UX. | +| `https://zap.stream/` (HLS livestream) | Plays on all 3 OSes. **Gate criterion: must not regress vs. vlcj.** | + +If the zap.stream live stream fails on any of the 3 OSes for reasons not +explained by the codec gap (i.e. legitimate HLS protocol issues), invoke +the fallback plan — switch primary engine to `gst1-java-core` keeping the +wrapper API intact (research doc §3 Recommended #2). Document the pivot +in `docs/decisions/` and update this plan's "Status" frontmatter. + +##### 1.7 Tests + +Unit tests in `desktopApp/src/jvmTest/`: +- `GlobalMediaPlayerStateTest.kt` — drives `MediaPlaybackState` derivations from a fake `VideoPlayerState` (build via constructor / setters). Verifies pause/play/seek transitions emit correctly. +- `DesktopVideoPlayerActiveDispatchTest.kt` (compose test) — given two `DesktopVideoPlayer` instances mounted with different URLs, asserts that only the active URL renders `VideoPlayerSurface` (use semantic test tags). +- No instrumented test that actually decodes a network video (too flaky for CI; covered in manual test sheet). + +#### Phase 2 — Thumbnail extraction (JCodec → Jaffree cascade) (~3-5 days) + +##### 2.1 Gradle wiring + +**`gradle/libs.versions.toml`** — add: +```toml +[versions] +jcodec = "0.2.5" +jaffree = "2024.08.29" + +[libraries] +jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" } +jaffree = { group = "com.github.kokorin.jaffree", name = "jaffree", version.ref = "jaffree" } +``` + +**`desktopApp/build.gradle.kts`** — add both. + +##### 2.2 LGPL FFmpeg per-OS bundling + +We need FFmpeg only for the *fallback* thumbnail path. The video player +doesn't shell out to it. Bundle one binary per OS in +`appResources//ffmpeg/`: + +| OS | Source | Path inside DMG/MSI/AppImage | +|----|--------|------------------------------| +| macOS arm64+x86_64 | https://www.osxexperts.net/ — LGPL build, code-signed | `Contents/Resources/ffmpeg/ffmpeg` | +| Windows x64 | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `app/resources/ffmpeg/ffmpeg.exe` | +| Linux x64 | https://johnvansickle.com/ffmpeg/ "release" tarball — LGPL config | `lib/resources/ffmpeg/ffmpeg` (DEB), AppImage equivalent | + +(Each binary's per-OS LICENSE.txt also ships in `licenses/` from Phase 0.) + +We do not invoke a per-build *download* step (no extra Gradle download +plugin). Binaries are checked into the repo under +`desktopApp/src/jvmMain/appResources//ffmpeg/` (~10 MB per OS, +acceptable for git LFS or direct check-in). The Phase 3 cleanup removes +the `ir.mahozad.vlc-setup` plugin, which was the big downloader. + +**macOS hardened-runtime entitlement:** spawning a child process from a +hardened-runtime app needs the `com.apple.security.cs.allow-jit` or +`com.apple.security.cs.disable-library-validation` entitlement, OR the +ffmpeg binary needs to be co-signed with the app's identity. We extend +the existing `entitlements.plist` to add: + +```xml +com.apple.security.cs.allow-unsigned-executable-memory + +com.apple.security.cs.disable-library-validation + +com.apple.security.cs.allow-jit + +``` + +The cleanest path is to co-sign ffmpeg with the same identity used for the +.app — verify during implementation that jpackage's signing step covers +nested executables in `Contents/Resources/` (older jpackage did not — may +need a post-pkg `codesign --deep`). + +##### 2.3 VideoThumbnailCache rewrite + +Replace the vlcj-based `extractFirstFrame` with a cascade: + +```kotlin +private suspend fun extractFirstFrame(url: String): ImageBitmap? { + val downloadedBytes = downloadVideoForThumb(url) ?: return null + return tryJCodec(downloadedBytes) + ?: tryJaffree(downloadedBytes) + ?: tryJaffreeFromUrl(url) // for HLS where the URL points to a manifest, not bytes +} +``` + +- **`downloadVideoForThumb(url)`** — uses existing OkHttp instance (which is wired in `desktopApp` already, line 50 in build.gradle.kts). Range-fetch first 4 MB for fast thumbnail. Cache to `~/Library/Caches/com.vitorpamplona.amethyst.desktop/thumbs/.mp4` (per OS — use `java.util.prefs`-style discovery for cache dir). +- **`tryJCodec(bytes)`** — `FrameGrab.createFrameGrab(NIOUtils.readableChannel(File))` → `seekToSecondPrecise(1.0).getNativeFrame()`. Convert YUV → RGB → Skia `Bitmap` → `ImageBitmap`. Wrap with `runCatching` and discard on any exception (unsupported codec, malformed MP4, HLS manifest in the bytes, etc.). +- **`tryJaffree(bytes)`** — spawn `ffmpeg -ss 1 -i -frames:v 1 -f image2pipe -c:v png pipe:1`, read stdout into ByteArray, decode with `Image.makeFromEncoded(bytes).toComposeImageBitmap()`. 5-second timeout. +- **`tryJaffreeFromUrl(url)`** — same as tryJaffree but with the URL directly as `-i` argument. For HLS or any streamed source. + +**Thread safety / dedup:** keep the existing `pending` `ConcurrentHashMap` +guard. Replace `CountDownLatch` with `suspendCancellableCoroutine` over +Jaffree's `executeAsync()` return type for clean cancellation on +recomposition. + +##### 2.4 Tests + +Unit tests: +- `VideoThumbnailCacheTest.kt` — given a known-good 5-second H.264 MP4 in `src/jvmTest/resources/sample.mp4`, asserts JCodec path produces a non-null `ImageBitmap` with expected dimensions. +- `JaffreeProbeTest.kt` — verifies the bundled ffmpeg binary is found in `appResources//ffmpeg/` at runtime and prints `ffmpeg -version` successfully. Skip on platforms where we don't ship a binary. +- No HEVC/VP9/AV1 unit test (codec coverage depends on the runtime ffmpeg build; manual test). + +#### Phase 3 — vlcj removal + build cleanup (~2-3 days) + +##### 3.1 Code deletions + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — delete +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt` — delete +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt` — delete +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt` — delete (referenced by both discoverers) +- Any `import uk.co.caprica.vlcj.*` remaining in `GlobalMediaPlayer.kt` or `VideoThumbnailCache.kt` — delete +- Tests that exercise vlcj integration (if any) — delete + +##### 3.2 Gradle deletions + +`desktopApp/build.gradle.kts`: +- Remove `id("ir.mahozad.vlc-setup") version "0.1.0"` from plugins block +- Remove `implementation(libs.vlcj)` +- Remove the entire `vlcSetup { ... }` block +- Remove `tasks.named("spotlessKotlin") { mustRunAfter("vlcSetup") }` +- Remove `tasks.withType().configureEach { ... }` (was for `vlcDownload`) +- Remove `jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"` +- Remove `"jdk.unsupported"` from `modules(...)` if kdroidFilter doesn't need it (verify) +- Update the AppImage build doc comment that mentions VLC + +`gradle/libs.versions.toml`: +- Remove `vlcj = "4.8.3"` from `[versions]` +- Remove the `vlcj` entry from `[libraries]` + +##### 3.3 Bundled VLC tree deletion + +```bash +git rm -r desktopApp/src/jvmMain/appResources/macos/vlc +git rm -r desktopApp/src/jvmMain/appResources/windows/vlc +git rm -r desktopApp/src/jvmMain/appResources/linux/vlc +``` + +(Equivalent to the `ir.mahozad.vlc-setup` copy targets. If the directories +were generated and not checked in, no `git rm` needed — just confirm the +plugin removal stops emitting them.) + +##### 3.4 NOTICE.md / licenses/ trimming + +- Drop `LICENSE-GPL-3.0.txt`, `WRITTEN-OFFER.txt` +- Drop the vlcj + libvlc entries from `NOTICE.md` +- Keep entries for: kdroidFilter (MIT), JCodec (BSD-2), Jaffree (Apache-2), FFmpeg (LGPL-2.1), GStreamer (Linux runtime, LGPL-2.1) + +##### 3.5 README trim + +- Remove the interim "binary bundles GPL" disclaimer. +- Replace with "Built on kdroidFilter ComposeMediaPlayer + native OS media frameworks." + +##### 3.6 Acceptance for Phase 3 + +- `grep -r "vlcj\|caprica\|libvlc" desktopApp/` returns no source code matches (only doc/license mentions). +- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces installers that contain no `vlc/`, `libvlc.dylib`, `libvlccore.dylib`, or `plugins/*.dylib`. +- Built DMG is < 80 MB compressed (down from ~140 MB today; the FFmpeg LGPL binary is ~12-20 MB compressed). +- App launches and plays a sample MP4 with no log line containing "VLC", "vlcj", or "libvlc". + +#### Phase 4 — Flathub manifest + final SPDX (~2 days) + +##### 4.1 Flathub manifest + +New file: `desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml` + +Sketch (planner to refine during implementation): + +```yaml +app-id: com.vitorpamplona.amethyst.Desktop +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +sdk-extensions: + - org.freedesktop.Sdk.Extension.openjdk21 +command: amethyst-desktop +finish-args: + - --share=network + - --share=ipc + - --socket=fallback-x11 + - --socket=wayland + - --socket=pulseaudio + - --device=dri + - --filesystem=xdg-download + - --talk-name=org.freedesktop.Notifications +modules: + - name: amethyst-desktop + buildsystem: simple + build-commands: + - install -Dm755 amethyst-desktop /app/bin/amethyst-desktop + # ... (jpackage Linux output copied in) + sources: + - type: file + path: ../../build/compose/binaries/main-release/app/Amethyst/bin/Amethyst +``` + +Runtime `org.freedesktop.Platform 24.08` ships GStreamer 1.24 with the +plugins kdroidFilter needs (`gst-plugins-good`, `gst-plugins-bad`, +`gst-libav` with LGPL config). No bundled FFmpeg needed for Flathub since +GStreamer is available — the thumbnail path uses Jaffree → spawns the +system `ffmpeg` if present, else falls back to JCodec-only. Document this +clearly in the Flathub manifest comments. + +##### 4.2 Final SPDX + +After Phase 3 deletions, set `rpmLicenseType = "MIT AND LGPL-2.1-or-later +AND BSD-2-Clause"`. (LGPL stays because the bundled FFmpeg for thumbnails is LGPL native.) + +Update `LICENSES.md` (root) to add a "Distributed binary" section listing +the SPDX expression + table of bundled components. + +##### 4.3 CI updates + +- Add a job that uploads the Flathub manifest to `flathub/com.vitorpamplona.amethyst.Desktop` repo (manual review by Flathub maintainers — not a one-button operation; document as a deferred follow-up). +- Add a job step that verifies the produced DMG/MSI/DEB contains no `vlc*`, `libvlc*` strings. + +### Architecture diagrams + +#### Old (today) + +``` +[User taps play on a feed video] + │ + ▼ +DesktopVideoPlayer composable + │ GlobalMediaPlayer.playVideo(url) + ▼ +GlobalMediaPlayer (singleton) + │ VlcjPlayerPool.init() + acquire() + ▼ +EmbeddedMediaPlayer ◄── BufferFormatCallback / RenderCallback + │ │ + │ libvlc events │ ByteBuffer of pixels + ▼ ▼ +MediaPlayerEventAdapter Skia Bitmap.installPixels + │ │ + ▼ ▼ + _videoState (StateFlow) _videoFrame (StateFlow) + │ │ + ▼ ▼ + VideoControls reads state Image(bitmap = videoFrame) +``` + +#### New + +``` +[User taps play on a feed video] + │ + ▼ +DesktopVideoPlayer composable + │ GlobalMediaPlayer.playVideo(url) + ▼ +GlobalMediaPlayer (singleton) + │ videoPlayerState.openMedia(url) + .play() + ▼ +VideoPlayerState (kdroidFilter) + │ delegates to OS backend + ▼ +[Media Foundation / AVFoundation / GStreamer] + │ │ + │ state flows │ frames piped to native surface + ▼ ▼ + stateSyncJob folds into VideoPlayerSurface(state) renders directly + _videoState (MediaPlaybackState) (no Compose ImageBitmap relay) + │ + ▼ + VideoControls reads state +``` + +## Alternative Approaches Considered + +(Full evaluation in [research doc §1 and §3](../brainstorms/2026-06-11-vlcj-replacement-research.md)) + +| Alternative | Why not | +|-------------|---------| +| Accept GPL on binary + only fix the metadata (Approach A in licensing brainstorm) | User priority: keep MIT brand on the binary. | +| Buy Caprica commercial vlcj license (Approach C) | Burns budget every year; doesn't address bundled VLC plugin GPL surface. | +| `gst1-java-core` + bundled GStreamer (Recommended #2 in research) | Binary is LGPL-3.0-dominant, not MIT. Reserved as Phase 1 decision-gate fallback. | +| JavaFX MediaPlayer | No HEVC, VP9, AV1, Opus, FLAC. SwingPanel z-order incompatible with our overlays. | +| Write our own JNA binding to libvlc | 3-6 weeks of native callback / GC-pinning work for **no material license gain** over kdroidFilter. | +| open-ani/mediamp | Desktop backend (`mediamp-vlc`) is GPLv3 — same problem with one more layer. | +| libmpv via Java binding | No maintained JVM binding exists. | +| HumbleVideo | Abandoned 2018, AGPL. | +| External player handoff only | Breaks in-feed playback UX. Kept as codec-missing fallback only. | + +## System-Wide Impact + +### Interaction Graph + +What fires when `playVideo(url)` is called in the new design: + +1. `DesktopVideoPlayer` composable's "play" button onClick → `GlobalMediaPlayer.playVideo(url)`. +2. `GlobalMediaPlayer` updates `_videoState` to `(url, isBuffering=true, ...)`. +3. `GlobalMediaPlayer` calls `videoPlayerState.openMedia(url)` then `.play()` on `Dispatchers.IO`. +4. kdroidFilter's backend (Media Foundation / AVFoundation / GStreamer) opens the URL. +5. kdroidFilter emits state changes through its flows; `stateSyncJob` folds them into `_videoState`. +6. Compose recomposes `DesktopVideoPlayer` (active branch) → `VideoPlayerSurface(playerState)` swaps from "loading" to live frames. +7. `NowPlayingBar` (which reads `_videoState`) updates simultaneously. +8. `VideoControls` recomposes (consumes `_videoState`). + +No callback marshalling between native and Compose threads on our side — +kdroidFilter handles that. No `Skia.Bitmap.installPixels` per frame in our +code path — `VideoPlayerSurface` writes directly to a native surface. + +### Error & Failure Propagation + +- **Codec unsupported (e.g. AV1 on stock Win10):** kdroidFilter emits an error state. `stateSyncJob` sets `_videoState.isBuffering = false` + a new `_videoState.errorReason: CodecError? = null` field. `DesktopVideoPlayer` checks this and renders `CodecUnsupportedMessage` instead of `VideoPlayerSurface`. +- **Network failure / 404:** Same path — kdroidFilter error state → `CodecError(reason=NETWORK)`. UI shows generic "Couldn't load" with retry button. +- **Thumbnail failure (JCodec then Jaffree both fail):** `VideoThumbnailCache.getThumbnail(url)` returns `null`. `DesktopVideoPlayer` already handles this (current code shows just the play-button overlay on null thumbnail). +- **kdroidFilter native loading failure on Linux (no system GStreamer):** caught at `GlobalMediaPlayer.init()`-equivalent. Show a one-time toast: "Install GStreamer to play videos: sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav". + +### State Lifecycle Risks + +- `videoPlayerState` is a singleton on `GlobalMediaPlayer`. Reusing it across URLs is supported by kdroidFilter (`openMedia(newUrl)` resets). No risk of leaked native players (vlcj's pool was a workaround for vlcj's expensive `EmbeddedMediaPlayer` construction; kdroidFilter doesn't have that cost). +- App shutdown: `GlobalMediaPlayer.shutdown()` calls `videoPlayerState.release()` + `audioPlayerState.release()`. No native handle survives JVM exit. +- Thumbnail cache: same `ConcurrentHashMap` + `pending` deduper. No native handles. +- FFmpeg child processes (Jaffree): always have a 5-second timeout. `try-finally` ensures `Process.destroyForcibly()` on cancellation or exception. + +### API Surface Parity + +- `GlobalMediaPlayer`'s public API (verbs + StateFlows) is preserved. Callers in `DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`, `NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay` need no breaking changes other than removing the `videoFrame` StateFlow (which only `DesktopVideoPlayer` reads). +- Drop: `GlobalMediaPlayer.videoFrame: StateFlow` (replaced by `VideoPlayerSurface(activeVideoPlayerState)` reading the player state directly in the consumer composable). +- Add: `GlobalMediaPlayer.activeVideoPlayerState: VideoPlayerState` for the surface consumer. +- Add: `MediaPlaybackState.errorReason: CodecError? = null` for codec-unsupported UX. + +### Integration Test Scenarios + +Manual (also captured in the testing sheet handed to the user post-implementation): + +1. **Resume across navigation.** Play a video in the feed, navigate to a different screen, return to the feed → video continues playing, position preserved. (Tests `GlobalMediaPlayer` singleton scope.) +2. **Switch URLs mid-play.** Tap play on video A, then tap play on video B → A stops, B starts from beginning. (Tests `openMedia` reset.) +3. **Now-playing bar sync.** Play audio from a feed item, scroll away → bar appears with playback state in sync. (Tests `_audioState` parity with the underlying engine.) +4. **Fullscreen ↔ feed handoff.** Enter fullscreen during playback, exit → playback continues uninterrupted, no reload. (Tests that `VideoPlayerSurface` instances can be re-attached / not destroyed across composable re-creation. **High-risk**: this may require a `key(...)` boundary to keep the surface stable.) +5. **Codec-missing Windows path.** Open an AV1 MP4 on stock Win10 → `CodecUnsupportedMessage` renders + "Open in default player" works. (Tests error propagation + handoff.) + +## Acceptance Criteria + +### Functional Requirements + +- [ ] Feed videos (H.264 MP4) play on macOS / Windows / Linux desktop builds. +- [ ] Feed audio (MP3 / Opus / AAC) plays on all 3 OSes. +- [ ] HLS livestreams (zap.stream) play on all 3 OSes. +- [ ] VP9 WebM plays on macOS + Linux. Windows shows codec-missing UX with "open externally" handoff. +- [ ] AV1 plays on macOS 13+ + Linux. Windows shows codec-missing UX. +- [ ] HEVC plays on macOS + Linux. Windows shows codec-missing UX. +- [ ] Thumbnails are extracted and shown in the feed for H.264 MP4 (JCodec) and HEVC/VP9/AV1 (Jaffree LGPL FFmpeg) — same coverage as vlcj today. +- [ ] Existing in-feed playback UX (play/pause/seek/volume/mute/fullscreen) is preserved. +- [ ] Now-playing bar continues to work across navigation. +- [ ] Lightbox / fullscreen overlay continues to work. +- [ ] App startup: no "VLC not installed" path. Engine is always available. + +### Non-Functional Requirements + +- [ ] Binary SPDX in produced packages: `MIT AND LGPL-2.1-or-later AND BSD-2-Clause` (no `GPL-3.0` token). +- [ ] macOS DMG size ≤ today's size − 25 MB (target: ~70-80 MB compressed). +- [ ] No regression in feed-scroll FPS (kdroidFilter writes frames natively; should equal or exceed vlcj's `RenderCallback` path). +- [ ] App startup time ≤ today (vlcj init removed; kdroidFilter doesn't initialize until first playback). + +### Quality Gates + +- [ ] `./gradlew :desktopApp:compileKotlin` green. +- [ ] `./gradlew :desktopApp:test` green. +- [ ] `./gradlew spotlessApply` clean. +- [ ] `grep -r "vlcj\|caprica\|libvlc" desktopApp/src/` finds zero hits. +- [ ] `./gradlew :desktopApp:packageDistributionForCurrentOS` succeeds on macOS arm64 (local) and produces a DMG; smoke-launches. +- [ ] NOTICE / licenses / About-screen-licenses-listing accurate and accessible from the running app. +- [ ] Manual test sheet completed by user (provided post-implementation). + +## Success Metrics + +- **Binary SPDX correctness** (boolean): packaged installers carry the SPDX expression listed above. Verified by `rpm -qpi`, DMG metadata, MSI summary. +- **Bundle size delta** (MB): macOS DMG and Linux DEB shrink by ≥25 MB after Phase 3. +- **Manual codec coverage matrix pass rate** (%): aim for ≥95% pass on H.264, AAC, MP3, HLS-H.264; ≥75% on VP9, AV1, HEVC (Windows excepted per documented gap). +- **Crash-free playback sessions in first 30 days post-release** (telemetry, if available): ≥ today's baseline. +- **Issue-tracker mentions of "VLC not installed"** post-release: 0. + +## Dependencies & Prerequisites + +- Maven Central artifacts: `io.github.kdroidfilter:composemediaplayer:0.10.1`, `org.jcodec:jcodec:0.2.5`, `com.github.kokorin.jaffree:jaffree:2024.08.29`. +- LGPL FFmpeg binaries to commit into `desktopApp/src/jvmMain/appResources//ffmpeg/` (planner to fetch + verify checksums during Phase 2). +- Linux runtime: GStreamer 1.20+ with `gst-plugins-good` and `gst-libav`. Already installed by default on Fedora 39+, Ubuntu 22.04+, Debian 12+. Documented as a `recommends`/`depends` line in the .deb/.rpm control files. +- Flathub: `org.freedesktop.Platform 24.08` runtime (Phase 4). +- JDK 21 (already in use). +- No new tooling required (jpackage, ProGuard, spotless already configured). + +## Risk Analysis & Mitigation + +| # | Risk | Probability | Impact | Mitigation | +|---|------|-------------|--------|------------| +| 1 | kdroidFilter HLS livestream failures on AVPlayer (strict mode) | M | H | Phase 1 decision gate against zap.stream + 2-3 alt streams. Pivot to gst1-java-core if it bites. | +| 2 | Windows stock-codec gap (HEVC/VP9/AV1) | H | M | Codec-detection + "open externally" handoff. Document in release notes. | +| 3 | kdroidFilter project goes dormant (single maintainer) | L | M | API surface we depend on is tiny; we own the wrapper. Pivot to gst1-java-core later behind same wrapper API. | +| 4 | JCodec doesn't decode some H.264 high-profile MP4s | M | L | Jaffree fallback handles it. Cost: an extra ffmpeg spawn for those URLs. | +| 5 | macOS hardened-runtime rejects nested ffmpeg binary | L | H | Co-sign nested binaries with the app identity in the existing `codesign` step. If jpackage doesn't, add a post-step `codesign --deep`. | +| 6 | Flathub review delays / rejects manifest | L | L | Manifest authoring is in scope; submitting to Flathub is a separate operation tracked outside this PR. | +| 7 | `Image(bitmap)` → `VideoPlayerSurface` swap reveals new z-order interaction with `GlobalFullscreenOverlay` | M | M | Test scenario #4 in integration tests; fix at implementation time. | +| 8 | `_videoState.errorReason` field break binary-state compat in tests | L | L | All affected tests are co-edited in this PR. | +| 9 | LGPL FFmpeg binaries grow git history | M | L | Use git LFS for the per-OS binaries; or fetch on first build via a one-shot Gradle task with checksum verification. | +| 10 | jpackage doesn't sign nested executables on macOS | M | M | Phase 2 acceptance verifies; fallback is post-step `codesign --deep --force --sign "..." Amethyst.app`. | + +## Resource Requirements + +- **People:** 1 engineer (you/Claude in `/ce:work` mode). +- **Time:** 2-3 weeks elapsed for code work. Plus user time for manual testing pass. +- **Infra:** Local macOS arm64 build, Windows VM for codec-gap testing (existing CI handles cross-OS DMG/MSI/DEB), Linux VM for Flathub manifest verification. + +## Future Considerations + +- Once kdroidFilter / `gst1-java-core` matures, consider lifting the player abstraction into `commons/commonMain/` so iOS desktop (if ever) can share. Out of scope for this PR. +- If Compose Multiplatform 1.9+ ships an official `VideoPlayer` composable backed by `androidx.media3` on desktop, evaluate migrating to that — but only if the codec story is at least equivalent. Likely 12-18 months out. +- DASH support — kdroidFilter doesn't list it. If we want it later, GStreamer fallback is the path. + +## Documentation Plan + +- `NOTICE.md` (new, in app bundle). +- `LICENSES.md` (root) — add "Distributed binary" section. +- `README.md` (root) — interim disclaimer added in Phase 0, removed in Phase 3. +- `desktopApp/packaging/flatpak/README.md` — explains the Flathub manifest, build steps, deps. +- About-dialog "Open source licenses" screen (in-app). +- Update `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` to add "superseded by 2026-06-11 plan" front-matter note (if appropriate — verify during execution). + +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md). Decisions carried forward: + - Stack choice (kdroidFilter primary, gst1-java-core fallback) + - Single-PR bundling of license bridge + migration + - Hard cut, no feature flag + - Flathub manifest in scope + - Android out of scope +- **Companion research:** [docs/brainstorms/2026-06-11-vlcj-replacement-research.md](../brainstorms/2026-06-11-vlcj-replacement-research.md) — full candidate comparison matrix. +- **Companion licensing analysis:** [docs/brainstorms/2026-06-11-vlcj-licensing-brainstorm.md](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) — 10-issue catalog. + +### Internal References + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — pool architecture being deleted +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt` — engine swap target +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt` — engine swap target +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt:62-172` — UI rewire target +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt` — minor changes +- `desktopApp/build.gradle.kts:9, 60-61, 97-98, 117, 141, 166-176` — build wiring to swap +- `gradle/libs.versions.toml` — `vlcj = "4.8.3"` to remove +- `LICENSE` (root) — Amethyst MIT +- `docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md` — recent VLC native-loading work that gets superseded +- `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` — earlier desktop media plan +- `docs/plans/_vlcj-migration-learnings.md` — institutional learnings (compiled by research agent 2026-06-11) + +### External References + +- [kdroidFilter/ComposeMediaPlayer](https://github.com/kdroidFilter/ComposeMediaPlayer) — primary library +- [kdroidFilter/ComposeMediaPlayer LICENSE](https://github.com/kdroidFilter/ComposeMediaPlayer/blob/master/LICENSE) +- [`io.github.kdroidfilter:composemediaplayer` on Maven Central](https://central.sonatype.com/artifact/io.github.kdroidfilter/composemediaplayer) +- [jcodec/jcodec](https://github.com/jcodec/jcodec) +- [kokorin/Jaffree](https://github.com/kokorin/Jaffree) +- [Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264](https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264) — Windows LGPL ffmpeg +- [osxexperts.net LGPL FFmpeg](https://www.osxexperts.net/) — macOS LGPL ffmpeg +- [johnvansickle.com FFmpeg static builds](https://johnvansickle.com/ffmpeg/) — Linux LGPL ffmpeg +- [Flathub submission docs](https://docs.flathub.org/docs/for-app-authors/submission) +- [GStreamer licensing FAQ](https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html) +- [FFmpeg legal](https://www.ffmpeg.org/legal.html) — `--enable-gpl` semantics +- [GNU GPL FAQ §JavaJVM](https://www.gnu.org/licenses/gpl-faq.html#JavaJVM) — confirms JNI/JNA doesn't escape GPL +- [Apple anti-Tivoization vs GPLv3 (FSF/VLC 2011)](https://www.fsf.org/news/2010-05-app-store-compliance) — App Store incompatibility + +### Related Work + +- Brainstorms (this PR's ancestry): + - [Licensing catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) + - [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md) + - [Replacement research](../brainstorms/2026-06-11-vlcj-replacement-research.md) +- Prior plans: + - [Desktop media full parity plan](2026-03-16-feat-desktop-media-full-parity-plan.md) + - [Desktop media manual testing plan](2026-03-16-desktop-media-manual-testing-plan.md) + - [macOS VLC bundled discovery fix plan](2026-05-18-fix-macos-vlc-bundled-discovery-plan.md) diff --git a/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md b/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md new file mode 100644 index 0000000000..b45ae2c1cb --- /dev/null +++ b/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md @@ -0,0 +1,182 @@ +# Manual Testing Sheet — vlcj → kdroidFilter Migration + +**Plan:** [`2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md`](2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md) +**Branch / worktree:** `.claude/worktrees/vlcj-licensing-brainstorm` (branch `worktree-vlcj-licensing-brainstorm`) +**Date:** 2026-06-11 + +This sheet walks through every verification the user must perform locally +because the automated background session can't reach Maven Central or +exercise the JVM media stack across OSes. + +--- + +## 0 · Build verification — VERIFIED locally on macOS arm64 (2026-06-11) + +Build verified after network came online. Final dep pins: +- `io.github.kdroidfilter:composemediaplayer:0.10.0` (research-agent hallucinated 0.10.1 — actual latest on Maven Central is 0.10.0) +- `org.jcodec:jcodec:0.2.5` +- `org.jcodec:jcodec-javase:0.2.5` + +| # | Command | Result on macOS arm64 | +|---|---------|-----------------------| +| 0.1 | `./gradlew :desktopApp:compileKotlin` | ✅ `BUILD SUCCESSFUL` in 16s | +| 0.2 | `./gradlew :desktopApp:test` | ✅ `BUILD SUCCESSFUL` in 23s | +| 0.3 | `./gradlew :desktopApp:spotlessApply` | ✅ clean, no diff | +| 0.4 | `grep -rn 'vlcj\|caprica\|VlcjPlayerPool\|MacOsVlcDiscoverer\|BundledVlcDiscoverer\|VlcResourceResolver' desktopApp/src/ gradle/ desktopApp/build.gradle.kts` | ✅ Only doc-comment mentions in `VideoThumbnailCache.kt` header (intentional; describes what was replaced) | + +Re-verify on Windows + Linux at your convenience. + +--- + +## 1 · Bundled FFmpeg binaries (REQUIRED for thumbnail fallback) + +The agent created the directory structure but didn't check in binaries +(~30 MB each, not appropriate for git). Drop them here before packaging: + +| OS | Path | Source | Verify | +|----|------|--------|--------| +| macOS (arm64 + x86_64 universal) | `desktopApp/src/jvmMain/appResources/macos/ffmpeg/ffmpeg` | https://www.osxexperts.net/ LGPL build | `lipo -info ffmpeg` shows both arches; `chmod +x ffmpeg` | +| Windows (x64) | `desktopApp/src/jvmMain/appResources/windows/ffmpeg/ffmpeg.exe` | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `ffmpeg.exe -version` reports `--enable-version3 --disable-gpl` flags | +| Linux | _(skip — relies on host ffmpeg/gstreamer)_ | n/a | See `desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md` for self-contained AppImage option | + +Each per-OS subdirectory has a `README.md` with the exact source URL. + +--- + +## 2 · Smoke launch + +| # | Action | Expected | +|---|--------|----------| +| 2.1 | `./gradlew :desktopApp:run` | Window opens. No log line mentions VLC, vlcj, libvlc, or "plugin path". | +| 2.2 | Look at stdout / stderr | No `WARN`/`ERROR` from missing native libs. First time kdroidFilter extracts its native to `~/.cache/composemediaplayer/native/` — that's expected. | +| 2.3 | App responds to clicks; UI is layout-identical to pre-migration | Yes | + +--- + +## 3 · Codec / playback matrix + +Use the URLs below (substitute your own equivalents if any are dead). All +verified by mounting the URL via the feed; you don't need to post any +Nostr event. + +### Active video URLs to feed into the player (place into a draft note or open via the lightbox) + +| # | Test | URL pattern | macOS | Windows | Linux | +|---|------|------|--------|---------|-------| +| 3.1 | H.264 MP4 (golden path) | `https://download.samplelib.com/mp4/sample-5s.mp4` | ✅ plays | ✅ plays | ✅ plays | +| 3.2 | HLS livestream (zap.stream) | a live zap.stream m3u8 URL | ✅ plays | ✅ plays | ✅ plays | +| 3.3 | HLS VOD H.264+AAC | `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` | ✅ plays | ✅ plays | ✅ plays | +| 3.4 | VP9 WebM | a known nostr.build VP9 webm URL | ✅ plays | ⚠️ codec-missing UX, "open externally" works | ✅ plays (if gst-libav installed) | +| 3.5 | AV1 MP4 | a known void.cat / nostr.build AV1 URL | ✅ plays (macOS 13+ or M3+ HW) | ⚠️ codec-missing UX | ✅ plays (if gst-libav installed) | +| 3.6 | HEVC MP4 | a known iOS-recorded HEVC URL | ✅ plays | ⚠️ "Install HEVC extension" UX | ✅ plays (if gst-libav installed) | +| 3.7 | Audio: MP3 | a podcast MP3 from feed | ✅ plays | ✅ plays | ✅ plays | +| 3.8 | Audio: Opus | a known Opus URL | ✅ plays | ✅ plays | ✅ plays (if gst-plugins-base installed) | +| 3.9 | Audio: AAC | a known AAC URL | ✅ plays | ✅ plays | ✅ plays | +| 3.10 | Garbage URL (404) | `https://example.invalid/nope.mp4` | "Can't play this video" + `Source:` reason; "Open in default player" button does nothing harmful | same | same | + +**Phase 1 decision gate** (per plan): if 3.2 (zap.stream HLS livestream) +fails on any OS for reasons that aren't codec-related, pivot to the +gst1-java-core fallback (research doc §3 Recommended #2). Don't pivot for +3.4/3.5/3.6 — those gaps are expected on stock Win10 without the codec +extensions and are mitigated by the "open externally" UX. + +--- + +## 4 · UI cross-state behaviour + +| # | Scenario | Expected | +|---|----------|----------| +| 4.1 | Play feed video A → navigate to a different screen → return | Video continues playing, position preserved. (Singleton `GlobalMediaPlayer` retained.) | +| 4.2 | Play video A → tap play on video B | A stops, B starts from beginning. | +| 4.3 | Active video → tap fullscreen | Enters native fullscreen; same `VideoPlayerSurface` renders fullscreen; controls work; Esc / F exits. | +| 4.4 | Fullscreen video → press Spacebar | Toggles play/pause. | +| 4.5 | NowPlayingBar mini-preview | Mini 48×36 video preview renders inside the bottom bar while video is active. | +| 4.6 | Audio playback → navigate around | NowPlayingBar shows music icon (no video) + correct artist/title (URL filename suffices for now). | +| 4.7 | Mute → unmute | Volume restores to pre-mute value. | +| 4.8 | Volume slider → 0 | `volume = 0` reflects, but `isMuted` flag stays false (matches kdroidFilter's no-mute-flag semantics; explicit mute is separate). | +| 4.9 | Stop video / audio while playing | Engine cleanly stops; UI returns to neutral state. | +| 4.10 | Quit app while playing | No segfault, no lingering native processes. (kdroidFilter registers its own shutdown hook on Windows for MediaFoundation.) | + +--- + +## 5 · Thumbnail extraction + +| # | Scenario | Expected | +|---|----------|----------| +| 5.1 | H.264 MP4 in feed (inactive) | Thumbnail appears within 2-3s. JCodec handles this — confirms by speed. | +| 5.2 | VP9 WebM in feed (inactive) | Thumbnail appears (slower, ~5s) — JCodec rejects, falls through to ffmpeg. Verifies the cascade. | +| 5.3 | Same feed reopened | Thumbnails appear instantly from in-memory cache. | +| 5.4 | No bundled ffmpeg + no system ffmpeg | Thumbnails fail silently for non-H.264 sources; H.264 still works (JCodec). No crash. Document this UX in release notes. | +| 5.5 | HLS livestream thumbnail | Should NOT block playback. JCodec auto-skipped because URL ends `.m3u8` or contains `/hls/`. Falls through to `ffmpeg URL` directly. | + +--- + +## 6 · License metadata verification + +| # | Artifact | Verify | +|---|----------|--------| +| 6.1 | `./gradlew :desktopApp:packageRpm` → `rpm -qpi build/compose/binaries/main-release/rpm/*.rpm` | `License: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0` | +| 6.2 | `./gradlew :desktopApp:packageDmg` → mount DMG | Inside `Amethyst.app/Contents/app/resources/common/` — `NOTICE.md`, `licenses/LICENSE-MIT-amethyst.txt`, `licenses/LICENSE-MIT-kdroidfilter.txt`, `licenses/LICENSE-BSD-2-jcodec.txt`, `licenses/LICENSE-LGPL-2.1.txt` | +| 6.3 | `grep -rn 'vlcj\|GPL-3' desktopApp/src/jvmMain/appResources/common/` | Zero hits — confirms no stale GPL references shipped | +| 6.4 | `LICENSE-LGPL-2.1.txt` | **Currently a placeholder.** Replace with verbatim GNU LGPL-2.1 text (~26 KB) from https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt before any release. The plan acceptance gate flags this. | + +--- + +## 7 · Flathub manifest (deferred submission) + +| # | Action | Expected | +|---|--------|----------| +| 7.1 | `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` | OK | +| 7.2 | `./gradlew :desktopApp:createReleaseDistributable` | Produces `desktopApp/build/compose/binaries/main-release/app/Amethyst/` | +| 7.3 | `cd desktopApp/packaging/flatpak && flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml` | OK (may need icon at `icons/256/com.vitorpamplona.amethyst.Desktop.png` — copy from `desktopApp/src/jvmMain/resources/icon.png` first) | +| 7.4 | `flatpak run com.vitorpamplona.amethyst.Desktop` | App launches; video playback works (host org.freedesktop.Platform ships GStreamer 1.24) | +| 7.5 | Actual Flathub submission | Out of scope for this PR; follow steps in `desktopApp/packaging/flatpak/README.md` | + +--- + +## 8 · macOS code signing (deferred — only if you sign before this PR merges) + +| # | Action | Expected | +|---|--------|----------| +| 8.1 | jpackage with `--mac-sign --mac-signing-key-user-name "Developer ID Application: …"` | `codesign --verify --deep --strict Amethyst.app` is clean; nested `Contents/app/resources/macos/ffmpeg/ffmpeg` is signed with the same identity | +| 8.2 | Entitlements include `com.apple.security.cs.allow-jit`, `com.apple.security.cs.allow-unsigned-executable-memory`, `com.apple.security.cs.disable-library-validation` | Required for HotSpot + sibling dylib loading | +| 8.3 | `xcrun notarytool submit Amethyst.dmg` succeeds | Apple notarizes the DMG including nested ffmpeg | +| 8.4 | `xcrun stapler staple Amethyst.dmg` succeeds | Ticket stapled | + +Detailed recipe: `docs/plans/_macos-ffmpeg-signing-recipe.md`. + +--- + +## 9 · Known caveats / follow-ups for after this PR + +These are intentional gaps, not bugs: + +1. **In-app "Open source licenses" screen** — not yet wired into desktop settings UI. NOTICE.md and licenses/ ship as resources but a Compose composable reading them isn't included. Recommended follow-up: add `mikepenz/AboutLibraries` Gradle plugin (auto-discovers Maven licenses) + a `LicensesScreen.kt` rendering the report; integrate into the settings nav. +2. **LGPL-2.1 license text is a placeholder** — flagged in 6.4. The release-gate fix is "paste the verbatim 26 KB text into `LICENSE-LGPL-2.1.txt`". +3. **`tryJaffree*` and `runFfmpegToImage`** — the Jaffree dependency was dropped in favor of raw ProcessBuilder for simplicity. Renaming the internal `tryJaffree*` functions to `tryFfmpeg*` is a cosmetic follow-up. +4. **No GStreamer probe on Linux** — kdroidFilter's `SourceError` is indistinguishable from "missing gst-libav" without probing. Follow-up: add a one-time `gst-inspect-1.0 avdec_h264` probe on Linux startup; show a non-blocking install hint if missing. +5. **Windows codec-extension detection** — currently we just rely on kdroidFilter's `SourceError` and our error UX. A nicer follow-up: probe `MFTEnumEx` to detect HEVC/AV1 codec availability up front and prompt for the MS Store extension. +6. **Flathub icon file** — manifest references `icons/256/com.vitorpamplona.amethyst.Desktop.png` which doesn't exist. Copy/resize from `desktopApp/src/jvmMain/resources/icon.png` before the first Flathub submission. + +--- + +## 10 · Roll-back path + +If anything in 0-5 blocks shipping: + +- **Code revert:** `git revert ` of this PR is clean — vlcj files were deleted, new files are isolated to `desktopApp/`. +- **Branch:** `git branch -D worktree-vlcj-licensing-brainstorm` (or whatever the merged branch was). `git checkout main && git pull` returns to vlcj-based desktop. +- **No data migration:** nothing on disk format changed. Settings, account state, downloaded thumbnails — all forward+backward compatible. + +--- + +## Self-check before opening the PR + +- [ ] 0.1 BUILD SUCCESSFUL on my machine +- [ ] 0.2 tests green +- [ ] 0.4 no vlcj source code references +- [ ] 1 ffmpeg binaries in place for the OSes I'm packaging +- [ ] 2.1 app launches without VLC warnings +- [ ] 3.1, 3.2, 3.3, 3.7 pass on at least my primary OS +- [ ] 6.1 RPM license metadata correct +- [ ] 6.4 LGPL text placeholder noted in release checklist diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f965547bdd..737ec6acf4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,8 @@ playServicesCast = "22.3.1" vico-charts-compose = "3.1.0" zelory = "3.0.1" zoomable = "2.12.0" -vlcj = "4.8.3" +composemediaplayer = "0.10.0" +jcodec = "0.2.5" commonsImaging = "1.0.0-alpha6" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" @@ -145,7 +146,9 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } -vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } +composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" } +jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" } +jcodec-javase = { group = "org.jcodec", name = "jcodec-javase", version.ref = "jcodec" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } From aa63d01b321b3281a9c97469d39c21571875d3ec Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 11 Jun 2026 11:47:22 +0000 Subject: [PATCH 71/75] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6309b2ccfd..177e96cbdc 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -201,6 +201,7 @@ 更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。 用户尚未设置闪电地址以接收聪 "🔏在此回复… " + 在此聊天中 复制笔记ID到剪贴板以便于在 Nostr 中分享 复制频道ID(笔记)到剪贴板 修改频道元数据 From eae1ed88ec286fb6a33f123f9afcd37550b6546d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 11 Jun 2026 17:45:09 +0300 Subject: [PATCH 72/75] fix(desktop,media): address PR #3175 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness fixes in GlobalMediaPlayer.kt - snapshotFlow { hasMedia } collector for initial seek used `return@collect` which only exits the lambda; the collector kept running and each subsequent playVideo() call accumulated a live collector that would re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first` which terminates the collection cleanly. - playVideo()/playAudio() reset the public MediaPlaybackState to volume=100/isMuted=false on a new URL, but the kdroidFilter player retains its `volume` across openUri(); muting one track and starting a new one left the engine silent while the UI showed unmuted. Reset `player.volume = 1f` to match the public state. - ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState() synchronously from the Compose getter; if native init throws (missing GStreamer on Linux, broken NativeLibraryLoader extraction) the whole window would crash. Wrapped in runCatching and changed activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer and GlobalFullscreenOverlay handle the null path by rendering the thumbnail / blank backdrop respectively; playVideo()/playAudio() surface "Video playback unavailable" through the existing errorReason -> PlaybackErrorMessage path. Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface) - NowPlayingBar previously mounted a SECOND VideoPlayerSurface against the same VideoPlayerState while the feed card was already mounting one, doubling the draw rate against the shared frame bitmap and widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap path. Mini-preview now renders the cached thumbnail (or the music icon fallback). 0.10.1 contains an upstream fix ("recover video playback after composition removal") but is not yet on Maven Central — single-surface mounting is the only mitigation we can ship today. VideoThumbnailCache.kt - Truncated-download cache poisoning: when an origin ignored the Range: header and returned HTTP 200 with the full body, we capped the copy at MAX_THUMB_BYTES and persisted the truncated file forever. Subsequent thumbnail attempts hit the broken cache file and re-failed JCodec/ffmpeg every time. Tag download results with whether the server actually returned 206; on 200, extract from the temp file and delete it (no persistent cache hit). - Tor bypass: replaced the bare OkHttpClient with DesktopHttpClient.currentClient() so thumbnail fetches respect the user's Tor preference (fail-closed when Tor is expected but bootstrapping). - ffmpeg version probe leaked the process on hang: now drains stdout to DISCARD and calls destroyForcibly() on timeout. - Frame-extract ffmpeg subprocess could deadlock on a chatty stderr pipe: redirectError(DISCARD) so we never wait on stderr; a finally block destroys the process if anything leaked through the timeout. CI workflow cleanup - Removed vlc-setup download cache + pre-fetch steps from build.yml and smoke-test-desktop.yml. They were targeting an ir.mahozad.vlc-setup plugin we no longer apply, so they wasted ~minutes of CI time per leg and tied the build to videolan.org reachability for no reason. - Trimmed create-release.yml's stale VLC-plugins justification on the linuxdeploy-vs-appimagetool comment. .gitignore + missing per-OS ffmpeg READMEs - The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/ so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never reached the commit. Refined the ignore rules to keep stale vlc/ workspace trees out of git (still ignored) while explicitly tracking the ffmpeg/README.md drop-in slot under each OS. The READMEs document the recommended LGPL build source per OS for the bundled-FFmpeg packaging path. Verified on macOS arm64: ./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL ./gradlew :desktopApp:test BUILD SUCCESSFUL ./gradlew :desktopApp:spotlessApply clean Refs PR #3175 review by @davotoula. --- .github/workflows/build.yml | 76 --------------- .github/workflows/create-release.yml | 6 +- .github/workflows/smoke-test-desktop.yml | 30 ------ .gitignore | 18 +++- .../appResources/linux/ffmpeg/README.md | 16 ++++ .../appResources/macos/ffmpeg/README.md | 28 ++++++ .../appResources/windows/ffmpeg/README.md | 16 ++++ .../service/media/GlobalMediaPlayer.kt | 90 ++++++++++++------ .../service/media/VideoThumbnailCache.kt | 95 +++++++++++-------- .../desktop/ui/media/DesktopVideoPlayer.kt | 5 +- .../ui/media/GlobalFullscreenOverlay.kt | 16 ++-- .../desktop/ui/media/NowPlayingBar.kt | 19 +++- 12 files changed, 222 insertions(+), 193 deletions(-) create mode 100644 desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md create mode 100644 desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md create mode 100644 desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7abc8593ca..ee372de840 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,82 +79,6 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # Cache vlc-setup plugin downloads (VLC + UPX archives) keyed on the - # versions pinned in desktopApp/build.gradle.kts. Each OS gets its own - # cache namespace because the plugin downloads platform-specific archives. - # On a hit the vlcDownload / upxDownload tasks are up-to-date and we - # never touch get.videolan.org; on a miss (version bump or new runner) - # we fall back to fetching, which is what the in-build retry budget - # exists for. - - name: Cache vlc-setup downloads - uses: actions/cache@v5 - with: - path: ~/.gradle/vlcSetup - key: vlcsetup-${{ runner.os }}-${{ hashFiles('desktopApp/build.gradle.kts') }} - restore-keys: | - vlcsetup-${{ runner.os }}- - - # Pre-fetch VLC + UPX archives into ~/.gradle/vlcSetup before invoking - # Gradle. The vlc-setup plugin (ir.mahozad.vlc-setup 0.1.0) writes its - # downloads to ${gradleUserHomeDir}/vlcSetup/ and sets overwrite(false), - # so an existing file there makes vlcDownload / upxDownload up-to-date - # and Gradle never opens a socket to videolan.org. - # - # Why curl instead of relying on de.undercouch.gradle.tasks.download: - # curl --retry-all-errors with a long --retry-max-time tolerates a - # sustained get.videolan.org outage far better than the plugin's inner - # retry budget (retries(4) + 5min readTimeout in build.gradle.kts), - # which has been hitting SocketTimeoutException on Windows runners. - # - # Cache hit: the file is already on disk, fetch() short-circuits, this - # step takes <1s. Cache miss: curl downloads with aggressive retries, - # populating the cache for the next run. - # - # Versions are pinned to match desktopApp/build.gradle.kts (vlcVersion - # = 3.0.20) and the vlc-setup extension default (upxVersion = 4.2.4). - # NOTE: vlcVersion lags behind upstream VLC because the Linux plugins on - # Maven Central (ir.mahozad:vlc-plugins-linux) are only published for - # 3.0.20 / 3.0.20-2. Bump only after the Maven artifact is republished. - # macOS does not download UPX — UPX cannot compress .dylib files. - - name: Pre-fetch VLC + UPX archives - env: - VLC_VERSION: "3.0.20" - UPX_VERSION: "4.2.4" - run: | - set -euo pipefail - DEST="$HOME/.gradle/vlcSetup" - mkdir -p "$DEST" - fetch() { - local url="$1" out="$2" - if [[ -s "$out" ]]; then - echo "cached: $out" - return 0 - fi - echo "fetching: $url" - curl -fL --retry 10 --retry-delay 5 --retry-all-errors \ - --retry-max-time 900 --connect-timeout 30 \ - -o "$out.part" "$url" - mv "$out.part" "$out" - } - case "${{ runner.os }}" in - Windows) - fetch "https://get.videolan.org/vlc/${VLC_VERSION}/win64/vlc-${VLC_VERSION}-win64.zip" \ - "$DEST/vlc-${VLC_VERSION}.zip" - fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip" \ - "$DEST/upx-${UPX_VERSION}.zip" - ;; - Linux) - fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \ - "$DEST/vlc-${VLC_VERSION}.jar" - fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \ - "$DEST/upx-${UPX_VERSION}.tar.xz" - ;; - macOS) - fetch "https://get.videolan.org/vlc/${VLC_VERSION}/macosx/vlc-${VLC_VERSION}-universal.dmg" \ - "$DEST/vlc-${VLC_VERSION}.dmg" - ;; - esac - # Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs # a display server on Linux. xvfb provides a virtual framebuffer. - name: Install xvfb (Linux) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7c5cdf1b47..a6be60a4f8 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -23,9 +23,9 @@ env: # Single source of truth in scripts/asset-name.sh. # appimagetool pinned release — bump via Dependabot, verify SHA256 via env var below. # We used to use linuxdeploy here, but it auto-walks the AppDir with ldd to - # bundle deps — that fights jpackage's self-contained JRE (libjvm.so RPATH - # mismatch) and the UPX-compressed VLC plugins. appimagetool only embeds the - # AppDir as-is, which is what we actually want. + # bundle deps — that fights jpackage's self-contained JRE (libjvm.so has + # $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only + # embeds the AppDir as-is, which is what we actually want. APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 540d8466b2..de8cc824b9 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -68,36 +68,6 @@ jobs: with: cache-read-only: true - - name: Cache vlc-setup downloads - uses: actions/cache@v5 - with: - path: ~/.gradle/vlcSetup - key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }} - restore-keys: | - vlcsetup-Linux- - - - name: Pre-fetch VLC + UPX archives - env: - VLC_VERSION: "3.0.20" - UPX_VERSION: "4.2.4" - run: | - set -euo pipefail - DEST="$HOME/.gradle/vlcSetup" - mkdir -p "$DEST" - fetch() { - local url="$1" out="$2" - if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi - echo "fetching: $url" - curl -fL --retry 10 --retry-delay 5 --retry-all-errors \ - --retry-max-time 900 --connect-timeout 30 \ - -o "$out.part" "$url" - mv "$out.part" "$out" - } - fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \ - "$DEST/vlc-${VLC_VERSION}.jar" - fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \ - "$DEST/upx-${UPX_VERSION}.tar.xz" - - name: Install xvfb + packaging deps run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot diff --git a/.gitignore b/.gitignore index 459dc6effa..45f5f1d139 100644 --- a/.gitignore +++ b/.gitignore @@ -161,10 +161,20 @@ TASKS.md .claude/settings.local.json .claude/scheduled_tasks.lock -# Downloaded VLC binaries (vlc-setup plugin) -desktopApp/src/jvmMain/appResources/linux/ -desktopApp/src/jvmMain/appResources/macos/ -desktopApp/src/jvmMain/appResources/windows/ +# Per-OS appResources slots — historically the ir.mahozad.vlc-setup plugin +# populated these with VLC binaries (no longer used; superseded by +# kdroidFilter ComposeMediaPlayer). We still ignore the directory contents +# by default to keep stale workspaces from accidentally bundling old VLC +# trees into local packages, but explicitly track the ffmpeg/README.md +# drop-in slot for the LGPL FFmpeg binaries used by VideoThumbnailCache. +desktopApp/src/jvmMain/appResources/linux/* +desktopApp/src/jvmMain/appResources/macos/* +desktopApp/src/jvmMain/appResources/windows/* +!desktopApp/src/jvmMain/appResources/linux/ffmpeg/ +!desktopApp/src/jvmMain/appResources/macos/ffmpeg/ +!desktopApp/src/jvmMain/appResources/windows/ffmpeg/ +desktopApp/src/jvmMain/appResources/*/ffmpeg/* +!desktopApp/src/jvmMain/appResources/*/ffmpeg/README.md # CI-fetched AppImage tooling (downloaded by create-release workflow; not committed) desktopApp/packaging/appimage/appimagetool-x86_64.AppImage diff --git a/desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md b/desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md new file mode 100644 index 0000000000..784d736550 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md @@ -0,0 +1,16 @@ +# Linux bundled FFmpeg + +The DEB/RPM/AppImage packages **do not bundle FFmpeg** — they declare a +runtime dependency on system FFmpeg/GStreamer instead, which keeps the +binary's SPDX cleaner and reduces package size. + +The Flatpak manifest similarly relies on `org.freedesktop.Platform 24.08` +which ships an LGPL GStreamer + FFmpeg; thumbnail extraction falls through +to the host FFmpeg if installed. + +If you want a self-contained Linux build (e.g. AppImage with no host deps), +drop a static LGPL `ffmpeg` binary here. johnvansickle.com publishes LGPL +"release" builds for x86_64 and aarch64. Source: https://johnvansickle.com/ffmpeg/ + +The `VideoThumbnailCache` will prefer system `ffmpeg` on `$PATH` first, then +fall through to this bundled binary. diff --git a/desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md b/desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md new file mode 100644 index 0000000000..90a7dbf9aa --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/macos/ffmpeg/README.md @@ -0,0 +1,28 @@ +# macOS bundled FFmpeg + +Drop a single universal (arm64 + x86_64) LGPL FFmpeg binary here as `ffmpeg` +(no extension) with `+x` permission. Used by `VideoThumbnailCache` for +non-H.264 / non-faststart thumbnail extraction (HEVC, VP9, AV1, HLS). + +## Recommended source + +osxexperts.net "FFmpeg static (LGPL)" build: +- Page: https://www.osxexperts.net/ +- Verify SPDX: LGPL-2.1-or-later (configure flags: `--disable-gpl --disable-nonfree`) +- After download: `lipo -info ffmpeg` should report `arm64 x86_64`. If + separate arch binaries, fuse with: + `lipo -create ffmpeg-arm64 ffmpeg-x86_64 -output ffmpeg` +- Make executable: `chmod +x ffmpeg` + +## Codesigning + +`jpackage --mac-sign` traverses `Contents/app/resources/` and signs nested +executables with the same Developer ID identity as the app bundle. The +process is launched from the JVM at runtime; hardened-runtime entitlements +(`allow-jit`, `disable-library-validation`) are documented in +`docs/plans/_macos-ffmpeg-signing-recipe.md`. + +## Size + +~30-40 MB universal binary. Acceptable trade-off given we shed ~95 MB of +bundled VLC by switching to kdroidFilter + this thin LGPL FFmpeg. diff --git a/desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md b/desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md new file mode 100644 index 0000000000..16a8a718d3 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/windows/ffmpeg/README.md @@ -0,0 +1,16 @@ +# Windows bundled FFmpeg + +Drop a single LGPL Windows x64 FFmpeg binary here as `ffmpeg.exe`. + +## Recommended source + +Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264: +- Repo: https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 +- Confirm: SPDX LGPL-2.1-or-later, OpenH264 (BSD-2 with patent grant from Cisco). +- Verify: `ffmpeg.exe -version` from a `cmd.exe` prompt. + +Used as fallback by `VideoThumbnailCache` for non-H.264 / HLS thumbnails. + +## Size + +~20-30 MB. Acceptable given the ~95 MB VLC plugin tree this migration retires. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt index db77e7e46e..7c0eddbbd9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch data class MediaPlaybackState( @@ -75,13 +76,12 @@ object GlobalMediaPlayer { private val initLock = Any() /** - * The kdroidFilter player driving currently-active or last-played video. - * Mounted into a `VideoPlayerSurface(...)` by [DesktopVideoPlayer] when the - * caller's `url` matches `videoState.value.url`. - * - * Lazy: first read constructs the underlying native player. + * The kdroidFilter player driving currently-active or last-played video, or + * `null` if the native player failed to initialize (e.g. missing GStreamer + * on Linux). UI code reads this lazily; consumers must handle null by + * showing the thumbnail/error fallback rather than mounting a surface. */ - val activeVideoPlayerState: VideoPlayerState + val activeVideoPlayerState: VideoPlayerState? get() = ensureVideoPlayer() private val _videoState = MutableStateFlow(MediaPlaybackState()) @@ -108,7 +108,17 @@ object GlobalMediaPlayer { seekPosition: Float = 0f, ) { val current = _videoState.value - val player = ensureVideoPlayer() + val player = + ensureVideoPlayer() ?: run { + _videoState.value = + MediaPlaybackState( + url = url, + type = MediaType.VIDEO, + isBuffering = false, + errorReason = "Video playback unavailable", + ) + return + } if (current.url == url) { if (seekPosition > 0f) player.seekTo(seekPosition * 1000f) @@ -116,34 +126,49 @@ object GlobalMediaPlayer { return } + // Reset engine volume to match the UI's default for the new track. The + // kdroidFilter player retains `volume` across openUri calls, so a mute + // on the prior track would otherwise carry over while the UI shows the + // default 100% / unmuted state. + player.volume = 1f + _videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true) scope.launch(Dispatchers.IO) { player.openUri(url) - // openUri auto-plays per InitialPlayerState.PLAY default. - // For an initial seek we wait for hasMedia=true; cleanest is a - // one-shot snapshotFlow collector that seeks then completes. + // openUri auto-plays per InitialPlayerState.PLAY default. For an + // initial seek we wait for the first hasMedia=true emission then + // stop collecting (Flow.first terminates the collector cleanly, + // unlike `return@collect` which only exits the lambda). if (seekPosition > 0f) { - snapshotFlow { player.hasMedia } - .collect { ready -> - if (ready) { - player.seekTo(seekPosition * 1000f) - return@collect - } - } + snapshotFlow { player.hasMedia }.first { it } + player.seekTo(seekPosition * 1000f) } } } fun playAudio(url: String) { val current = _audioState.value - val player = ensureAudioPlayer() + val player = + ensureAudioPlayer() ?: run { + _audioState.value = + MediaPlaybackState( + url = url, + type = MediaType.AUDIO, + isBuffering = false, + errorReason = "Audio playback unavailable", + ) + return + } if (current.url == url) { if (!current.isPlaying) player.play() return } + // Reset engine volume — see playVideo() for rationale. + player.volume = 1f + _audioState.value = MediaPlaybackState(url = url, type = MediaType.AUDIO, isBuffering = true) scope.launch(Dispatchers.IO) { @@ -247,20 +272,29 @@ object GlobalMediaPlayer { // --- Engine lifecycle ---------------------------------------------------- - private fun ensureVideoPlayer(): VideoPlayerState = + /** + * Returns the video player, creating it on first call. Returns `null` if + * native initialization throws (e.g. missing GStreamer on Linux, broken + * `libNativeVideoPlayer.dylib` extraction). On failure, subsequent calls + * keep returning `null` until the JVM is restarted — re-trying mid-session + * is unlikely to recover from a missing native dependency. + */ + private fun ensureVideoPlayer(): VideoPlayerState? = videoPlayer ?: synchronized(initLock) { - videoPlayer ?: createVideoPlayerState().also { - videoPlayer = it - startVideoSync(it) - } + videoPlayer ?: runCatching { createVideoPlayerState() } + .onSuccess { startVideoSync(it) } + .onFailure { println("kdroidFilter: video engine init failed: ${it.message}") } + .getOrNull() + ?.also { videoPlayer = it } } - private fun ensureAudioPlayer(): VideoPlayerState = + private fun ensureAudioPlayer(): VideoPlayerState? = audioPlayer ?: synchronized(initLock) { - audioPlayer ?: createVideoPlayerState().also { - audioPlayer = it - startAudioSync(it) - } + audioPlayer ?: runCatching { createVideoPlayerState() } + .onSuccess { startAudioSync(it) } + .onFailure { println("kdroidFilter: audio engine init failed: ${it.message}") } + .getOrNull() + ?.also { audioPlayer = it } } private fun startVideoSync(player: VideoPlayerState) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index 2deb546773..09cc733d95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.desktop.service.media import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient import okhttp3.Request import org.jcodec.api.FrameGrab import org.jcodec.common.io.NIOUtils @@ -47,14 +47,15 @@ import javax.imageio.ImageIO * * Cascade: * 1. **JCodec** (`org.jcodec:jcodec` + `jcodec-javase`, BSD-2) — pure-Java - * H.264 baseline/main/high decode. Handles ~80% of Nostr feed media (MP4/H.264). - * 2. **Jaffree** (Apache-2) + **LGPL FFmpeg** subprocess — for everything else - * (HEVC, VP9, AV1, HLS, malformed faststart MP4s). Requires a bundled - * ffmpeg binary at `src/jvmMain/appResources//ffmpeg/ffmpeg(.exe)` or - * a system `ffmpeg` on `$PATH`. + * H.264 baseline/main/high decode. Handles the bulk of Nostr feed media + * (MP4/H.264). + * 2. **LGPL FFmpeg subprocess** (driven via raw `ProcessBuilder`) — for + * everything else (HEVC, VP9, AV1, HLS, malformed faststart MP4s). + * Requires either a system `ffmpeg` on `$PATH` or a bundled binary at + * `src/jvmMain/appResources//ffmpeg/ffmpeg(.exe)`. * * Replaces the prior vlcj `RenderCallback` path. License moves from - * GPL-3.0 (vlcj) to BSD-2 + Apache-2 + LGPL-2.1 native, MIT-dominant overall. + * GPL-3.0 (vlcj) to BSD-2 + LGPL-2.1, MIT-dominant overall. */ object VideoThumbnailCache { private const val MAX_THUMB_BYTES = 4 * 1024 * 1024 // 4 MiB cap per thumbnail @@ -62,14 +63,6 @@ object VideoThumbnailCache { private val cache = ConcurrentHashMap() private val pending = ConcurrentHashMap() - private val http: OkHttpClient by lazy { - OkHttpClient - .Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - } - private val downloadCacheDir: File by lazy { val base = File(System.getProperty("user.home"), ".cache/amethyst-desktop/video-thumbs") @@ -78,14 +71,22 @@ object VideoThumbnailCache { } private val ffmpegBinary: String? by lazy { - // 1. System ffmpeg on PATH. + // 1. System ffmpeg on PATH. Probe with `ffmpeg -version`; drain stdout + // so the child doesn't block on a full pipe, kill it if it overruns + // the probe budget so we don't leak the process when ffmpeg hangs. val onPath = runCatching { - ProcessBuilder("ffmpeg", "-version") - .redirectErrorStream(true) - .start() - .also { it.inputStream.close() } - .waitFor(2, TimeUnit.SECONDS) + val probe = + ProcessBuilder("ffmpeg", "-version") + .redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .start() + val exited = probe.waitFor(2, TimeUnit.SECONDS) + if (!exited) { + probe.destroyForcibly() + return@runCatching false + } + probe.exitValue() == 0 }.getOrDefault(false) if (onPath) return@lazy "ffmpeg" @@ -121,20 +122,35 @@ object VideoThumbnailCache { } private fun extractFirstFrame(url: String): ImageBitmap? { - // For HLS we skip straight to Jaffree — JCodec can't read m3u8. + // For HLS we skip straight to ffmpeg — JCodec can't read m3u8. val isHls = url.contains(".m3u8", ignoreCase = true) || url.contains("/hls/", ignoreCase = true) if (!isHls) { val downloaded = runCatching { downloadFirstChunk(url) }.getOrNull() if (downloaded != null) { - tryJCodec(downloaded)?.let { return it } - tryJaffreeFile(downloaded)?.let { return it } + try { + tryJCodec(downloaded.file)?.let { return it } + tryFfmpegFile(downloaded.file)?.let { return it } + } finally { + // Origins that ignore Range: served the full body and we + // truncated to MAX_THUMB_BYTES; that file is unsuitable as + // a persistent cache hit (decoders may fail on every + // retry against a half-MP4). Discard so the next request + // re-downloads from scratch. + if (!downloaded.persistable) downloaded.file.delete() + } } } - return tryJaffreeUrl(url) + return tryFfmpegUrl(url) } + /** Local result of [downloadFirstChunk]: the bytes + whether they're a real Range slice. */ + private data class Download( + val file: File, + val persistable: Boolean, + ) + /** * Downloads up to [MAX_THUMB_BYTES] to a cache file, returning the file (or null on failure). * @@ -146,17 +162,19 @@ object VideoThumbnailCache { * * Cleans up zero-byte cache files on failure so a transient empty response isn't sticky. */ - private fun downloadFirstChunk(url: String): File? { + private fun downloadFirstChunk(url: String): Download? { val hash = sha1Hex(url) val cached = File(downloadCacheDir, "$hash.mp4") - if (cached.length() > 0L) return cached + if (cached.length() > 0L) return Download(cached, persistable = true) if (cached.exists()) cached.delete() var wrote = false - http.newCall(buildRangeRequest(url)).execute().use { resp -> + var rangeHonored = false + DesktopHttpClient.currentClient().newCall(buildRangeRequest(url)).execute().use { resp -> if (!resp.isSuccessful && resp.code != 206) return null val contentType = resp.header("Content-Type")?.lowercase().orEmpty() if (contentType.startsWith("text/") || "html" in contentType) return null + rangeHonored = resp.code == 206 Files.newOutputStream(cached.toPath()).use { out -> val copied = copyAtMost(resp.body.byteStream(), out, MAX_THUMB_BYTES.toLong()) wrote = copied > 0L @@ -166,7 +184,7 @@ object VideoThumbnailCache { cached.delete() return null } - return cached + return Download(cached, persistable = rangeHonored) } private fun buildRangeRequest(url: String): Request = @@ -205,16 +223,17 @@ object VideoThumbnailCache { } }.getOrNull() - private fun tryJaffreeFile(file: File): ImageBitmap? = runFfmpegToImage(file.absolutePath) + private fun tryFfmpegFile(file: File): ImageBitmap? = runFfmpegToImage(file.absolutePath) - private fun tryJaffreeUrl(url: String): ImageBitmap? = runFfmpegToImage(url) + private fun tryFfmpegUrl(url: String): ImageBitmap? = runFfmpegToImage(url) /** * Spawns `ffmpeg -ss 1 -i -frames:v 1 -f image2pipe -c:v png -an pipe:1`, * reads PNG bytes from stdout, decodes with Skia. * - * Uses raw `ProcessBuilder` rather than the Jaffree DSL — fewer API guesses, - * easier to debug. Jaffree stays on the classpath as a future option. + * `redirectError(DISCARD)` so a chatty ffmpeg cannot fill the stderr pipe + * and stall our `copyTo`. `destroyForcibly()` runs on any unwind so we + * never leak a ffmpeg process. */ private fun runFfmpegToImage(input: String): ImageBitmap? { val ffmpeg = ffmpegBinary ?: return null @@ -240,20 +259,18 @@ object VideoThumbnailCache { val process = runCatching { ProcessBuilder(cmd) - .redirectErrorStream(false) + .redirectError(ProcessBuilder.Redirect.DISCARD) .start() }.getOrNull() ?: return null val out = ByteArrayOutputStream(256 * 1024) try { process.inputStream.use { it.copyTo(out) } - if (!process.waitFor(8, TimeUnit.SECONDS)) { - process.destroyForcibly() - return null - } + if (!process.waitFor(8, TimeUnit.SECONDS)) return null if (process.exitValue() != 0 || out.size() == 0) return null } catch (_: Exception) { - process.destroyForcibly() return null + } finally { + if (process.isAlive) process.destroyForcibly() } return runCatching { Image.makeFromEncoded(out.toByteArray()).toComposeImageBitmap() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index 7618e8293a..1d809b8056 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -109,12 +109,13 @@ fun DesktopVideoPlayer( contentAlignment = Alignment.Center, ) { val errorReason = if (isActiveVideo) videoState.errorReason else null + val activePlayer = if (isActiveVideo) GlobalMediaPlayer.activeVideoPlayerState else null if (errorReason != null) { PlaybackErrorMessage(url = url, reason = errorReason) - } else if (isActiveVideo) { + } else if (isActiveVideo && activePlayer != null) { VideoPlayerSurface( - playerState = GlobalMediaPlayer.activeVideoPlayerState, + playerState = activePlayer, modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small), contentScale = ContentScale.Fit, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt index 7aaea40218..6a23a9fa2f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -102,12 +102,16 @@ fun GlobalFullscreenOverlay() { }, contentAlignment = Alignment.Center, ) { - // Video frame — same player state as feed card; kdroidFilter draws to Canvas - VideoPlayerSurface( - playerState = GlobalMediaPlayer.activeVideoPlayerState, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) + // Video frame — same player state as feed card; kdroidFilter draws to Canvas. + // If the engine failed to initialize, render a blank backdrop instead of + // crashing the overlay. + GlobalMediaPlayer.activeVideoPlayerState?.let { player -> + VideoPlayerSurface( + playerState = player, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } // Video controls overlay VideoControls( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt index 6d88aff5fd..0040021f65 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.media import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -50,7 +51,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer -import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface +import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache import kotlinx.coroutines.launch enum class MediaType { AUDIO, VIDEO } @@ -85,10 +86,18 @@ fun NowPlayingBar(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - // Mini video preview or music icon - if (activeType == MediaType.VIDEO) { - VideoPlayerSurface( - playerState = GlobalMediaPlayer.activeVideoPlayerState, + // Mini thumbnail (cached frame, not a live VideoPlayerSurface). + // We deliberately render the cached thumbnail rather than mounting a + // second VideoPlayerSurface to avoid driving two simultaneous Skia + // draws against the same player's frame bitmap — kdroidFilter + // 0.10.0's macOS surface has a use-after-free race in that path + // (see PR review). The mini-preview UX is preserved via the + // poster frame extracted by VideoThumbnailCache. + val miniThumb = activeState.url?.let { VideoThumbnailCache.getCached(it) } + if (activeType == MediaType.VIDEO && miniThumb != null) { + Image( + bitmap = miniThumb, + contentDescription = "Now playing", modifier = Modifier .size(width = 48.dp, height = 36.dp) From d1bd5734cdcec7362f81e1e3576377affca07021 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 11 Jun 2026 13:34:49 -0400 Subject: [PATCH 73/75] fix(relay): rebuild sockets opened on the wrong transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectAndSyncFiltersIfDisconnected() bailed whenever a socket already existed, so a still-connecting socket built for the wrong transport (e.g. a relay whose Tor classification changed since the dial started) could never be preempted — it blocked until the hung dial timed out. The connected-relay path in RelayPool.reconnectIfNeedsTo already rebuilds ready sockets via needsToReconnect(); this covers the connecting state it cannot see (isConnectionStarted() true but isConnected() false). Now: if a socket exists but reports needsReconnect() (transport/proxy mismatch against the current builder decision), drop it and redial on the correct transport; otherwise leave it. Disconnected relays still honor their reconnect backoff. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/single/basic/BasicRelayClient.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 33877f0f88..d1159083b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -212,12 +212,27 @@ open class BasicRelayClient( } override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) { - if (!isConnectionStarted() && !connectingMutex.load()) { - // waits 60 seconds to reconnect after disconnected. - if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { - upRelayDelayToConnect() + if (connectingMutex.load()) return + + if (isConnectionStarted()) { + // A socket already exists. Normally leave it alone, but if it was opened for the wrong + // transport — e.g. the relay's Tor classification changed since (a clearnet relay now routed + // through the Tor proxy, or vice-versa) — tear it down and rebuild on the current transport. + // Without this an in-flight dial on the wrong transport can never be preempted: a still- + // connecting socket leaves isConnectionStarted() true (so the old guard skipped it) yet + // isConnected() false (so RelayPool.reconnectIfNeedsTo's connected-relay branch never runs), + // and the request blocks until that hung dial finally times out. + if (socket?.needsReconnect() == true) { + disconnect() connect() } + return + } + + // waits 60 seconds to reconnect after disconnected. + if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { + upRelayDelayToConnect() + connect() } } From f9f7de3ed078badfca841f8024f7f7d901cc3558 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 11 Jun 2026 13:35:11 -0400 Subject: [PATCH 74/75] feat(tor): money-operations relay category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets, so a wallet or payment-service relay fell through to newRelaysViaTor and got forced over Tor regardless of the "Money operations via Tor" toggle (which previously governed only HTTP clients). On services that block Tor exits this silently broke NIP-47 and CLINK payments. Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after the onion reachability check). TorRelayState gains a persistent money-op relay set — fed across all accounts from NIP-47 wallet relays and saved CLINK debit relays via AccountsTorStateConnector — plus a reference-counted ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The websocket builder resolves the per-relay decision from live source values so ad-hoc registration takes effect on the next connect with no race. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../torState/AccountsTorStateConnector.kt | 38 +++++++ .../amethyst/model/torState/TorRelayState.kt | 99 +++++++++++++++---- .../commons/tor/TorRelayEvaluation.kt | 8 ++ .../amethyst/commons/tor/TorRelaySettings.kt | 1 + .../commons/tor/TorRelayEvaluationTest.kt | 43 ++++++++ 6 files changed, 172 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6313aaab74..aabf66a71f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -428,7 +428,7 @@ class AppModules( // Connects the INostrClient class with okHttp val websocketBuilder = OkHttpWebSocket.Builder { url -> - val useTor = torEvaluatorFlow.flow.value.useTor(url) + val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) okHttpClientForRelays.getHttpClient(useTor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 68aada42cb..a437179a8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -105,4 +105,42 @@ class AccountsTorStateConnector( SharingStarted.Eagerly, emptySet(), ) + + // Persistent money-operation relays across all accounts: NIP-47 wallet relays and saved CLINK + // Debits service relays. Feeds TorRelayState.moneyOpRelays so these connections honor the + // money-operations Tor preference instead of being classified as generic "new" relays. + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val allMoneyOpRelaysFlow: Flow> = + accountsCache.accounts + .debounce(200) + .transformLatest { snapshot -> + val perAccountFlows = + snapshot.map { (_, account) -> + combine( + account.settings.nwcWallets, + account.settings.clinkDebitWallets, + ) { nwcWallets, clinkDebitWallets -> + val relays = mutableSetOf() + nwcWallets.forEach { relays.add(it.uri.relayUri) } + clinkDebitWallets.forEach { relays.addAll(it.pointer.relays) } + relays.toSet() + } + } + + val ready = perAccountFlows.ifEmpty { listOf(MutableStateFlow(emptySet())) } + + emitAll( + combine(ready) { perAccount -> + val moneyOpRelays = mutableSetOf() + perAccount.forEach { moneyOpRelays.addAll(it) } + moneyOpRelays.toSet() + }, + ) + }.onEach { + torEvaluatorFlow.moneyOpRelays.tryEmit(it) + }.stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt index b6822fc21d..bd0366a1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient @Stable @@ -45,6 +46,58 @@ class TorRelayState( val dmRelays = MutableStateFlow>(emptySet()) val trustedRelays = MutableStateFlow>(emptySet()) + /** + * Relays known to be used for money operations from persistent configuration: NIP-47 wallet + * relays and saved CLINK Debits service relays. Fed by [AccountsTorStateConnector] across all + * logged-in accounts. These follow the money-operations Tor preference (see [TorRelayEvaluation]). + */ + val moneyOpRelays = MutableStateFlow>(emptySet()) + + /** + * Money-operation relays registered for the lifetime of a single ad-hoc round-trip whose relay + * isn't a saved wallet — e.g. paying someone's CLINK offer (`noffer`) pointer. Reference-counted + * so overlapping payments that share a relay don't unregister it while another is still in flight. + */ + private val adHocMoneyOpCounts = MutableStateFlow>(emptyMap()) + + private fun currentMoneyOpRelays(): Set = moneyOpRelays.value + adHocMoneyOpCounts.value.keys + + /** + * Marks [relays] as money-operation relays until a matching [unregisterMoneyOpRelays] call. + * Used by the CLINK offer/debit payers so a one-off payment relay honors the money-operations + * Tor preference instead of being treated as a generic "new" relay. + */ + fun registerMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { this[it] = (this[it] ?: 0) + 1 } + } + } + } + + fun unregisterMoneyOpRelays(relays: Set) { + if (relays.isEmpty()) return + adHocMoneyOpCounts.update { current -> + current.toMutableMap().apply { + relays.forEach { + val next = (this[it] ?: 0) - 1 + if (next <= 0) remove(it) else this[it] = next + } + } + } + } + + private fun currentSettings() = + TorRelaySettings( + torType = torSettingsFlow.torType.value, + onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, + dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, + newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, + trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, + moneyOperationsViaTor = torSettingsFlow.moneyOperationsViaTor.value, + ) + val torSettings = combine( torSettingsFlow.torType, @@ -66,27 +119,15 @@ class TorRelayState( newRelaysViaTor = newRelaysViaTor, trustedRelaysViaTor = trustedRelaysViaTor, ) + }.combine(torSettingsFlow.moneyOperationsViaTor) { settings, moneyOperationsViaTor -> + settings.copy(moneyOperationsViaTor = moneyOperationsViaTor) }.onStart { - emit( - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), - ) + emit(currentSettings()) }.flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, - TorRelaySettings( - torType = torSettingsFlow.torType.value, - onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value, - dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value, - newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value, - trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value, - ), + currentSettings(), ) val flow = @@ -94,12 +135,21 @@ class TorRelayState( torSettings, trustedRelays, dmRelays, - ) { torSettings: TorRelaySettings, trustedRelayList: Set, dmRelayList: Set -> + moneyOpRelays, + adHocMoneyOpCounts, + ) { + torSettings: TorRelaySettings, + trustedRelayList: Set, + dmRelayList: Set, + moneyOpRelayList: Set, + adHocMoneyOps: Map, + -> emit( TorRelayEvaluation( torSettings = torSettings, trustedRelayList = trustedRelayList, dmRelayList = dmRelayList, + moneyOpRelayList = moneyOpRelayList + adHocMoneyOps.keys, ), ) }.onStart { @@ -108,6 +158,7 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) }.flowOn(Dispatchers.IO) @@ -118,10 +169,22 @@ class TorRelayState( torSettings = torSettings.value, trustedRelayList = trustedRelays.value, dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), ), ) - fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = flow.value.useTor(relay) + /** + * Resolves the Tor preference for [relay] from live source values rather than the cached [flow] + * snapshot. This makes ad-hoc money-op registration ([registerMoneyOpRelays]) take effect on the + * very next connection attempt, with no dependency on the combine pipeline having propagated yet. + */ + fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = + TorRelayEvaluation( + torSettings = currentSettings(), + trustedRelayList = trustedRelays.value, + dmRelayList = dmRelays.value, + moneyOpRelayList = currentMoneyOpRelays(), + ).useTor(relay) fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt index eb5ddbacd3..42987dfb56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -28,6 +28,7 @@ class TorRelayEvaluation( val torSettings: TorRelaySettings, val trustedRelayList: Set, val dmRelayList: Set, + val moneyOpRelayList: Set = emptySet(), ) { fun useTor(relay: NormalizedRelayUrl): Boolean = if (torSettings.torType == TorType.OFF) { @@ -36,7 +37,14 @@ class TorRelayEvaluation( if (relay.isLocalHost()) { false } else if (relay.isOnion()) { + // .onion is only reachable over Tor regardless of any other classification. torSettings.onionRelaysViaTor + } else if (relay in moneyOpRelayList) { + // Relays used for money operations (NIP-47 wallets, CLINK offer/debit services) + // follow the dedicated money-operations preference, taking precedence over the + // generic DM/trusted/new classification so a payment never silently inherits a + // different Tor policy than the one the user set for money. + torSettings.moneyOperationsViaTor } else if (relay in dmRelayList) { torSettings.dmRelaysViaTor } else if (relay in trustedRelayList) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt index cc0e7e3143..781cb88211 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelaySettings.kt @@ -26,4 +26,5 @@ data class TorRelaySettings( val dmRelaysViaTor: Boolean = false, val newRelaysViaTor: Boolean = false, val trustedRelaysViaTor: Boolean = false, + val moneyOperationsViaTor: Boolean = false, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt index c5e65775d1..a6b441a8e4 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluationTest.kt @@ -33,6 +33,7 @@ class TorRelayEvaluationTest { private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + private val moneyRelay = NormalizedRelayUrl("wss://wallet.relay.com/") private fun buildEvaluation( torType: TorType = TorType.INTERNAL, @@ -40,8 +41,10 @@ class TorRelayEvaluationTest { dmViaTor: Boolean = true, newViaTor: Boolean = true, trustedViaTor: Boolean = false, + moneyViaTor: Boolean = false, dmRelays: Set = setOf(dmRelay), trustedRelays: Set = setOf(trustedRelay), + moneyOpRelays: Set = setOf(moneyRelay), ) = TorRelayEvaluation( torSettings = TorRelaySettings( @@ -50,9 +53,11 @@ class TorRelayEvaluationTest { dmRelaysViaTor = dmViaTor, newRelaysViaTor = newViaTor, trustedRelaysViaTor = trustedViaTor, + moneyOperationsViaTor = moneyViaTor, ), trustedRelayList = trustedRelays, dmRelayList = dmRelays, + moneyOpRelayList = moneyOpRelays, ) // --- Tor OFF --- @@ -107,6 +112,44 @@ class TorRelayEvaluationTest { @Test fun unknown_disabled_returnsFalse() = assertFalse(buildEvaluation(newViaTor = false).useTor(clearnetRelay)) + // --- Money-operation relays --- + @Test + fun money_enabled_returnsTrue() = assertTrue(buildEvaluation(moneyViaTor = true).useTor(moneyRelay)) + + @Test + fun money_disabled_returnsFalse() = assertFalse(buildEvaluation(moneyViaTor = false).useTor(moneyRelay)) + + @Test + fun money_takesPrecedenceOverNew() { + // A money-op relay not in any other list must NOT fall through to the new-relay policy. + val eval = buildEvaluation(moneyViaTor = false, newViaTor = true, dmRelays = emptySet(), trustedRelays = emptySet()) + assertFalse(eval.useTor(moneyRelay)) + } + + @Test + fun money_takesPrecedenceOverTrustedAndDm() { + // When the same relay is both a money-op relay and trusted/DM, money policy wins. + val both = NormalizedRelayUrl("wss://wallet-and-trusted.relay.com/") + val eval = + buildEvaluation( + moneyViaTor = true, + dmViaTor = false, + trustedViaTor = false, + dmRelays = setOf(both), + trustedRelays = setOf(both), + moneyOpRelays = setOf(both), + ) + assertTrue(eval.useTor(both)) + } + + @Test + fun money_onionStillWins() { + // .onion reachability check precedes the money classification. + val onionMoney = NormalizedRelayUrl("wss://wallet.onion/") + val eval = buildEvaluation(onionViaTor = false, moneyViaTor = true, moneyOpRelays = setOf(onionMoney)) + assertFalse(eval.useTor(onionMoney)) + } + // --- Priority --- @Test fun onionInDmList_treatedAsOnion() { From 44a6ab6bd40ccdebc333f7e2f05a679cb7027169 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 11 Jun 2026 14:00:32 -0400 Subject: [PATCH 75/75] fix(clink): keep offer/debit payments on clearnet + short subscription id Two bugs kept CLINK offer/debit round-trips from completing over the shared account relay client: - The offer relay was treated as a generic "new" relay, so with Tor on it was dialed through the proxy and failed on services that block Tor exits. Register the offer/debit relays as money-operation relays for the duration of the round-trip; the subscribe()-triggered reconnect plus the BasicRelayClient wrong-transport rebuild then move the socket to clearnet. - The subscription id "clink-offer-" was 76 chars; relays cap REQ subscription ids at 64 (NIP-01) and reject the over-long REQ outright, so the reply never arrived. Use newSubId(); the reply is matched by request id in the listener, not by subscription id. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/service/ClinkDebitPayer.kt | 13 ++++++++++++- .../amethyst/service/ClinkOfferPayer.kt | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index a15ba45683..664b55ed61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.experimental.clink.client.DebitClient import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent @@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CancellationException @@ -104,7 +106,9 @@ object ClinkDebitPayer { val relays = client.pointer.relays.toSet() val reply = CompletableDeferred() - val subId = "clink-debit-${request.id}" + // A random short id: relays cap subscription ids at 64 chars (NIP-01); the reply is matched + // by request id in the listener, not by subId. + val subId = newSubId() val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } val listener = @@ -121,6 +125,12 @@ object ClinkDebitPayer { } } + // Saved debit wallets are already fed into the money-op relay set by AccountsTorStateConnector, + // but register here too so a freshly-added wallet paid before that flow propagates — and any + // non-saved debit pointer — still routes under the money-operations Tor preference rather than + // the generic `newRelaysViaTor` policy. + val torState = Amethyst.instance.torEvaluatorFlow + torState.registerMoneyOpRelays(relays) account.client.subscribe(subId, filters, listener) return try { account.client.publish(request, relays) @@ -136,6 +146,7 @@ object ClinkDebitPayer { } } finally { account.client.unsubscribe(subId) + torState.unregisterMoneyOpRelays(relays) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index a835db93e8..28a0d64fb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.experimental.clink.client.OfferClient import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent @@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -76,7 +78,9 @@ object ClinkOfferPayer { val request = client.requestInvoice(amountSats = amountSats) val reply = CompletableDeferred() - val subId = "clink-offer-${request.id}" + // A random short id: relays cap subscription ids at 64 chars (NIP-01) and reject an + // over-long REQ outright. The reply is matched by request id in the listener, not by subId. + val subId = newSubId() val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } val listener = @@ -93,6 +97,14 @@ object ClinkOfferPayer { } } + // The offer's relays are an ad-hoc payment endpoint, not a saved wallet, so register them + // as money-operation relays for the duration of the round-trip. Otherwise account.client + // would treat them as generic "new" relays and route them per `newRelaysViaTor`, silently + // pushing the payment through Tor (and failing on services that block Tor exits) even when + // the user disabled Tor for money operations. The subscribe() below triggers a reconnect, and + // BasicRelayClient rebuilds any socket left on the now-wrong (Tor) transport onto clearnet. + val torState = Amethyst.instance.torEvaluatorFlow + torState.registerMoneyOpRelays(relays) account.client.subscribe(subId, filters, listener) try { account.client.publish(request, relays) @@ -109,6 +121,7 @@ object ClinkOfferPayer { } } finally { account.client.unsubscribe(subId) + torState.unregisterMoneyOpRelays(relays) } } }