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.
This commit is contained in:
Claude
2026-06-09 21:52:05 +00:00
parent a7066784d4
commit f0276f1e04
5 changed files with 342 additions and 0 deletions
@@ -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<DebitEvent>()
val subId = "clink-debit-${request.id}"
val filters: Map<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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)
}
}
}
@@ -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())
}
@@ -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
}
}
@@ -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<NwcWalletEntryNorm>,
debitWallets: List<ClinkDebitWalletEntryNorm>,
): List<PaymentSource> = nwcWallets.map { PaymentSource.Nwc(it) } + debitWallets.map { PaymentSource.ClinkDebit(it) }
fun resolveDefault(
nwcWallets: List<NwcWalletEntryNorm>,
debitWallets: List<ClinkDebitWalletEntryNorm>,
defaultId: String?,
): PaymentSource? = resolveDefault(all(nwcWallets, debitWallets), defaultId)
fun resolveDefault(
sources: List<PaymentSource>,
defaultId: String?,
): PaymentSource? = defaultId?.let { id -> sources.firstOrNull { it.id == id } } ?: sources.firstOrNull()
}
@@ -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)
}
}