diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index 60b27319af..7f30d19349 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -63,11 +63,14 @@ object Output { fun error( code: String, detail: String? = null, + extra: Map = emptyMap(), ): Int { + val cleanExtra = extra.filterValues { it != null } when (mode) { Mode.JSON -> { - val payload = mutableMapOf("error" to code) + val payload = mutableMapOf("error" to code) if (detail != null) payload["detail"] = detail + payload.putAll(cleanExtra) System.err.println(mapper.writeValueAsString(payload)) } @@ -75,7 +78,9 @@ object Output { val color = Ansi.forStream(isStderr = true) val prefix = color.bold(color.red("error")) val codePart = color.yellow(code) - System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart") + val base = if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart" + val suffix = if (cleanExtra.isEmpty()) "" else cleanExtra.entries.joinToString(", ", " (", ")") { "${it.key}=${it.value}" } + System.err.println(base + suffix) } } return 1 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt index e8ab3de2e5..ba90f6f01c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -98,17 +98,10 @@ object DebitCommands { val amount = args.flag("amount")?.toLongOrNull() ?: return Output.error("bad_args", "--amount SATS is required for a budget") - val frequency = - when (val f = args.flag("frequency")?.lowercase()) { - null, "once", "one-time" -> null - "day", "daily" -> DebitFrequency(1, DebitFrequency.UNIT_DAY) - "week", "weekly" -> DebitFrequency(1, DebitFrequency.UNIT_WEEK) - "month", "monthly" -> DebitFrequency(1, DebitFrequency.UNIT_MONTH) - else -> return Output.error("bad_args", "unknown --frequency '$f' (day|week|month)") - } + val frequency = parseFrequency(args.flag("frequency")) ?: return Output.error("bad_args", "unknown --frequency '${args.flag("frequency")}' (day|week|month)") val timeoutMs = args.longFlag("timeout", 15_000) - return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency) } + return roundTrip(dataDir, args, timeoutMs) { client -> client.requestBudget(amount, frequency.value) } } /** @@ -124,43 +117,102 @@ object DebitCommands { val debit = ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit ?: return Output.error("bad_args", "not a valid ndebit pointer") - val relays = debit.relays.toSet() - if (relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") val ctx = Context.open(dataDir) try { ctx.prepare() - val client = DebitClient(debit, ctx.signer) - val requestEvent = buildRequest(client) - - val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) - if (reply == null) { - Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") - return 124 - } - - val response: DebitResponse = - (reply as? DebitEvent)?.let { client.parseResponse(it) } - ?: return Output.error("bad_response", "service reply was not a kind-21002 debit event") - - return if (response.isOk()) { - Output.emit( - mapOf( - "result" to "ok", - "preimage" to response.preimage, - "request_id" to requestEvent.id, - "service" to debit.pubKey, - ), - ) - 0 - } else { - Output.error( - "debit_error", - response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", - ) + return when (val outcome = settle(ctx, debit, timeoutMs, buildRequest)) { + Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + Settle.BadReply -> Output.error("bad_response", "service reply was not a kind-21002 debit event") + is Settle.Replied -> emitDebit(outcome, debit.pubKey) } } finally { ctx.close() } } + + /** Emit a [DebitResponse] as the standard `ok`+preimage success or a structured GFY error. */ + internal fun emitDebit( + outcome: Settle.Replied, + servicePubKey: String, + ): Int { + val response = outcome.response + return if (response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to response.preimage, + "request_id" to outcome.requestId, + "service" to servicePubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + gfyExtra(response), + ) + } + } + + /** Structured GFY extras (code + any actionable range/retry_after/delta) for error output. */ + internal fun gfyExtra(response: DebitResponse): Map = + mapOf( + "code" to response.code, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + "retry_after" to response.retry_after, + "delta" to response.delta?.let { mapOf("max_delta_ms" to it.max_delta_ms, "actual_delta_ms" to it.actual_delta_ms) }, + ) + + /** Result of a single 21002 round-trip, decoupled from how it is emitted. */ + internal sealed interface Settle { + data class Replied( + val requestId: String, + val response: DebitResponse, + ) : Settle + + data object Timeout : Settle + + data object BadReply : Settle + } + + /** + * Core 21002 round-trip against an already-decoded [debit] on an open [ctx]: build the + * request, publish, await the reply, decrypt. Reused by `debit pay/budget` and by + * `offer pay` (fetch invoice → settle via debit). + */ + internal suspend fun settle( + ctx: Context, + debit: NDebit, + timeoutMs: Long, + buildRequest: suspend (DebitClient) -> DebitEvent, + ): Settle { + val client = DebitClient(debit, ctx.signer) + val requestEvent = buildRequest(client) + val reply = + ctx.requestResponse(requestEvent, debit.relays.toSet(), client.responseFilter(requestEvent.id), timeoutMs) + ?: return Settle.Timeout + val response = (reply as? DebitEvent)?.let { client.parseResponse(it) } ?: return Settle.BadReply + return Settle.Replied(requestEvent.id, response) + } + + /** Parses a `--frequency` value into a one-time (null) or recurring cadence. Null = invalid. */ + internal fun parseFrequency(raw: String?): Frequency? = + when (raw?.lowercase()) { + null, "once", "one-time" -> Frequency(null) + "day", "daily" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_DAY)) + "week", "weekly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_WEEK)) + "month", "monthly" -> Frequency(DebitFrequency(1, DebitFrequency.UNIT_MONTH)) + else -> null + } + + /** Wrapper so a valid "one-time" budget (null cadence) is distinguishable from an invalid flag. */ + internal data class Frequency( + val value: DebitFrequency?, + ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt index 917ac95508..3b1d2d6242 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -25,8 +25,11 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.experimental.clink.client.OfferClient +import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent +import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer /** @@ -34,23 +37,30 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer * testing against a real offer service. * * - `info ` decodes a pointer locally (no network). - * - `request [--amount N] [--timeout MS]` runs the kind-21001 round-trip: - * publishes the request to the pointer's relays and prints the returned BOLT-11. + * - `request [--amount N] [--timeout MS] [--follow]` runs the kind-21001 round-trip: + * publishes the request to the pointer's relays and prints the returned BOLT-11. With + * `--follow` it chases an "Expired or Moved" (code 3) reply to the `latest` pointer. + * - `pay --with [--amount N]` fetches the invoice and settles it end-to-end + * through a CLINK debit pointer (offer round-trip → debit round-trip). * - * Thin assembly only: pointer decode + the request/response event live in `quartz` - * (`ClinkPointerParser`, `OfferClient`); the relay round-trip uses `Context.requestResponse`. + * Thin assembly only: pointer decode + the request/response events live in `quartz` + * (`ClinkPointerParser`, `OfferClient`, `DebitClient`); the relay round-trips use + * `Context.requestResponse` (debit settlement is shared with [DebitCommands]). */ object OfferCommands { + private const val MAX_FOLLOW_HOPS = 3 + suspend fun dispatch( dataDir: DataDir, tail: Array, ): Int { - if (tail.isEmpty()) return Output.error("bad_args", "offer ") + if (tail.isEmpty()) return Output.error("bad_args", "offer ") val rest = tail.drop(1).toTypedArray() return when (tail[0]) { "info" -> info(rest) "request" -> request(dataDir, rest) - else -> Output.error("bad_args", "offer ${tail[0]} (expected info|request)") + "pay" -> pay(dataDir, rest) + else -> Output.error("bad_args", "offer ${tail[0]} (expected info|request|pay)") } } @@ -66,7 +76,7 @@ object OfferCommands { "pubkey" to offer.pubKey, "relays" to offer.relays.map { it.url }, "pointer" to offer.pointer, - "price_type" to offer.priceType?.name?.lowercase(), + "price_type" to offer.priceType.name.lowercase(), "price_sats" to offer.price, ), ) @@ -81,46 +91,157 @@ object OfferCommands { val args = Args(rest) val amount = args.flag("amount")?.toLongOrNull() val timeoutMs = args.longFlag("timeout", 15_000) + val follow = args.bool("follow") - val offer = + var offer = ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer ?: return Output.error("bad_args", "not a valid noffer pointer") - val relays = offer.relays.toSet() - if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") val ctx = Context.open(dataDir) try { ctx.prepare() - val client = OfferClient(offer, ctx.signer) - val requestEvent = client.requestInvoice(amountSats = amount) + var hops = 0 + while (hops <= MAX_FOLLOW_HOPS) { + val relays = offer.relays.toSet() + if (relays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") - val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) - if (reply == null) { + val client = OfferClient(offer, ctx.signer) + val requestEvent = client.requestInvoice(amountSats = amount) + + val reply = ctx.requestResponse(requestEvent, relays, client.responseFilter(requestEvent.id), timeoutMs) + if (reply == null) { + Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") + return 124 + } + + val response = + (reply as? OfferEvent)?.let { client.parseResponse(it) } + ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") + + if (response.isSuccess()) { + Output.emit( + mapOf( + "bolt11" to response.bolt11, + "request_id" to requestEvent.id, + "service" to offer.pubKey, + "followed_hops" to hops, + ), + ) + return 0 + } + + // "Expired or Moved" (code 3) may carry a replacement `noffer`; chase it on --follow. + val moved = response.latest?.let { ClinkPointerParser.parse(it) as? NOffer } + if (follow && response.code == OfferErrorCode.EXPIRED_OR_MOVED && moved != null) { + offer = moved + hops++ + continue + } + + return Output.error( + "offer_error", + response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + offerErrorExtra(response), + ) + } + return Output.error("offer_error", "too many redirects following moved offers (>$MAX_FOLLOW_HOPS)") + } finally { + ctx.close() + } + } + + /** + * Pay an offer end-to-end: fetch a fresh BOLT-11 (kind-21001) and settle it through a + * CLINK debit pointer (kind-21002). The CLI is stateless, so the funding source is given + * explicitly with `--with `. + */ + private suspend fun pay( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val amount = args.flag("amount")?.toLongOrNull() + val timeoutMs = args.longFlag("timeout", 15_000) + + val offer = + ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer + ?: return Output.error("bad_args", "not a valid noffer pointer") + val withFlag = + args.flag("with") + ?: return Output.error("bad_args", "offer pay needs --with to settle the fetched invoice") + val debit = + ClinkPointerParser.parse(withFlag.trim()) as? NDebit + ?: return Output.error("bad_args", "--with is not a valid ndebit pointer") + + val offerRelays = offer.relays.toSet() + if (offerRelays.isEmpty()) return Output.error("bad_pointer", "noffer carries no relay to reach") + if (debit.relays.isEmpty()) return Output.error("bad_pointer", "ndebit carries no relay to reach") + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + + // 1. fetch a fresh BOLT-11 from the offer service. + val offerClient = OfferClient(offer, ctx.signer) + val offerReq = offerClient.requestInvoice(amountSats = amount) + val offerReply = ctx.requestResponse(offerReq, offerRelays, offerClient.responseFilter(offerReq.id), timeoutMs) + if (offerReply == null) { Output.error("timeout", "no response from the offer service within ${timeoutMs}ms") return 124 } - - val response = - (reply as? OfferEvent)?.let { client.parseResponse(it) } - ?: return Output.error("bad_response", "service reply was not a kind-21001 offer event") - - return if (response.isSuccess()) { - Output.emit( - mapOf( - "bolt11" to response.bolt11, - "request_id" to requestEvent.id, - "service" to offer.pubKey, - ), - ) - 0 - } else { - Output.error( + val offerResp = + (offerReply as? OfferEvent)?.let { offerClient.parseResponse(it) } + ?: return Output.error("bad_response", "offer reply was not a kind-21001 event") + if (!offerResp.isSuccess()) { + return Output.error( "offer_error", - response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${response.code}", + offerResp.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${offerResp.code}", + offerErrorExtra(offerResp), ) } + val bolt11 = + offerResp.bolt11 + ?: return Output.error("bad_response", "offer succeeded but returned no bolt11") + + // 2. settle the invoice through the debit service (shared with `debit pay`). + return when (val outcome = DebitCommands.settle(ctx, debit, timeoutMs) { it.payInvoice(bolt11, amount) }) { + DebitCommands.Settle.Timeout -> { + Output.error("timeout", "no response from the debit service within ${timeoutMs}ms") + 124 + } + DebitCommands.Settle.BadReply -> Output.error("bad_response", "debit reply was not a kind-21002 event") + is DebitCommands.Settle.Replied -> + if (outcome.response.isOk()) { + Output.emit( + mapOf( + "result" to "ok", + "preimage" to outcome.response.preimage, + "bolt11" to bolt11, + "offer_request_id" to offerReq.id, + "debit_request_id" to outcome.requestId, + "offer_service" to offer.pubKey, + "debit_service" to debit.pubKey, + ), + ) + 0 + } else { + Output.error( + "debit_error", + outcome.response.error?.takeIf { it.isNotBlank() } ?: "service returned error code ${outcome.response.code}", + DebitCommands.gfyExtra(outcome.response), + ) + } + } } finally { ctx.close() } } + + /** Structured offer-error extras (code + moved `latest` pointer + acceptable range). */ + private fun offerErrorExtra(response: OfferResponse): Map = + mapOf( + "code" to response.code, + "latest" to response.latest, + "range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) }, + ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index 0b827167f0..170e792189 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -139,17 +141,23 @@ object ProfileCommands { val twitter = args.flag("twitter") val mastodon = args.flag("mastodon") val github = args.flag("github") + val clinkOffer = args.flag("clink-offer") val timeoutSecs = args.longFlag("timeout", 8L) + // A non-blank --clink-offer must be a real noffer; pass "" to clear the field. + if (!clinkOffer.isNullOrBlank() && ClinkPointerParser.parse(clinkOffer.trim()) !is NOffer) { + return Output.error("bad_args", "--clink-offer is not a valid noffer pointer (pass \"\" to clear)") + } + val touched = - listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github) + listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github, clinkOffer) .any { it != null } if (!touched) { return Output.error( "bad_args", "profile edit needs at least one of " + "--name --display-name --about --picture --banner --website " + - "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github", + "--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github --clink-offer", ) } @@ -182,6 +190,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } else { MetadataEvent.createNew( @@ -198,6 +207,7 @@ object ProfileCommands { twitter = twitter, mastodon = mastodon, github = github, + clinkOffer = clinkOffer, ) } diff --git a/cli/tests/clink/clink-headless.sh b/cli/tests/clink/clink-headless.sh index 888faff7a0..b029d8b0b5 100755 --- a/cli/tests/clink/clink-headless.sh +++ b/cli/tests/clink/clink-headless.sh @@ -115,3 +115,26 @@ if amy_a debit budget "$NDEBIT_STATIC" >>"$LOG_FILE" 2>&1; then else record_result debit.budget.noamount pass "missing --amount exits non-zero" fi + +# --- offer pay: requires a --with funding pointer (validated before any network) --- +step "offer pay requires --with " +if amy_a offer pay "$NOFFER_SPONT" --amount 1000 >>"$LOG_FILE" 2>&1; then + record_result offer.pay.nowith fail "missing --with should exit non-zero" +else + record_result offer.pay.nowith pass "missing --with exits non-zero" +fi + +step "offer pay rejects a non-ndebit --with" +if amy_a offer pay "$NOFFER_SPONT" --with "not-an-ndebit" >>"$LOG_FILE" 2>&1; then + record_result offer.pay.badwith fail "bad --with should exit non-zero" +else + record_result offer.pay.badwith pass "bad --with exits non-zero" +fi + +# --- profile edit --clink-offer: validates the noffer locally before publishing --- +step "profile edit rejects a non-noffer --clink-offer" +if amy_a profile edit --clink-offer "not-a-noffer" >>"$LOG_FILE" 2>&1; then + record_result profile.clinkoffer.bad fail "bad --clink-offer should exit non-zero" +else + record_result profile.clinkoffer.bad pass "bad --clink-offer exits non-zero" +fi