mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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())
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+51
@@ -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()
|
||||
}
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user