mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user