feat(cli): zap --with <ndebit> settles the invoice via CLINK debit

amy zap printed the invoice but never paid it. With --with <ndebit> 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
This commit is contained in:
Claude
2026-06-10 21:27:36 +00:00
parent e77658c292
commit 2635bd90a5
2 changed files with 72 additions and 11 deletions
@@ -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 <ndebit>` 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<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--timeout SECS]")
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--with <ndebit>] [--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<String>,
): Int {
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--timeout SECS]")
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--private] [--with <ndebit>] [--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<String, Any?> {
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<String, Any?> =
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<ZapActions.ZapRequestForSplit>,
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