From 2635bd90a55cb06e6ee2b2a2bcdb8a7609ea911f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:27:36 +0000 Subject: [PATCH] feat(cli): zap --with settles the invoice via CLINK debit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amy zap printed the invoice but never paid it. With --with 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 --- .../amethyst/cli/commands/ZapCommand.kt | 75 ++++++++++++++++--- cli/tests/clink/clink-headless.sh | 8 ++ 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 8a9292f37a..6ef33e4557 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -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 ` 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, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap user [--comment X] [--anon] [--with ] [--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, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--with ] [--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 { 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 = + 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, + 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 diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh index b029d8b0b5..7de56b57b3 100755 --- a/cli/tests/clink/clink-headless.sh +++ b/cli/tests/clink/clink-headless.sh @@ -138,3 +138,11 @@ if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then else record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero" fi + +# --- zap --with: rejects a non-ndebit funding pointer (validated before any network) --- +step "zap user rejects a non-ndebit --with" +if amy_a zap user "$EXPECTED_PUB" 1000 --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result zap.with.bad fail "bad --with should exit non-zero" +else + record_result zap.with.bad pass "bad --with exits non-zero" +fi