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-<event id>" 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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-11 14:00:32 -04:00
co-authored by Claude Opus 4.8
parent f9f7de3ed0
commit 44a6ab6bd4
2 changed files with 26 additions and 2 deletions
@@ -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<DebitEvent>()
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<NormalizedRelayUrl, List<Filter>> = 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)
}
}
}
@@ -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<OfferEvent>()
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<NormalizedRelayUrl, List<Filter>> = 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)
}
}
}