From 5f41907149e826506592119863e686561cc6ef8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 19:44:21 +0000 Subject: [PATCH 01/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] =?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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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 5e465410b27169fdcbc5d87ab1ae6f8089866f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:26:03 +0000 Subject: [PATCH 37/55] 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 eb7b3bad07ef7f7b66910a9c96e3a51c71483bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:46:51 +0000 Subject: [PATCH 38/55] 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 39/55] 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 f4e0bcf73ddb864d6a24b84e5cd5fdf07d5e5d34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:44:00 +0000 Subject: [PATCH 40/55] 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 61387ba12ce45e28e424afc81290817d4b3b79d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:40:29 +0000 Subject: [PATCH 41/55] 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 15b12e7f13d57be8c546add81a461623ab1eaea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:54:09 +0000 Subject: [PATCH 42/55] 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 33f1d20a21bb639fc0d2d0861c01322bcc3689ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:29:46 +0000 Subject: [PATCH 43/55] 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 7e7898bf77d4964d89b1d5c46e7c5c4ae2972c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:55:57 +0000 Subject: [PATCH 44/55] =?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 45/55] 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 01cb45cb31f04bd9df14b8ec2f14914691e1ddf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:55:21 +0000 Subject: [PATCH 46/55] 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 47/55] 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 48/55] =?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 49/55] 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 50/55] =?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 51/55] 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 52/55] 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 d1bd5734cdcec7362f81e1e3576377affca07021 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 11 Jun 2026 13:34:49 -0400 Subject: [PATCH 53/55] 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 54/55] 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 55/55] 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) } } }