From f0276f1e045d5359a817888ecb38f32c1f5339e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:52:05 +0000 Subject: [PATCH] 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) + } +}