feat(cli): close CLINK parity gaps — profile offer, follow, offer pay, GFY detail

Brings amy's CLINK surface closer to the app's:

- profile edit --clink-offer <noffer|"">: set/clear the kind-0 clink_offer
  (validated as a real noffer; "" clears). MetadataEvent already carried the field.
- offer request --follow: chase an 'Expired or Moved' (code 3) reply to its
  'latest' pointer (bounded hops), mirroring the app; the error output now also
  carries code/latest/range so a script can follow or correct manually.
- offer pay <noffer> --with <ndebit> [--amount]: end-to-end — fetch the invoice
  (21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle.
- Structured GFY detail (code, range, retry_after, delta) in debit/offer errors,
  via a new Output.error(extra=) overload.

Adds local-validation cases to the headless harness (offer pay --with, profile
edit --clink-offer); 15/15 pass.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
This commit is contained in:
Claude
2026-06-10 21:23:51 +00:00
parent 5dadff0315
commit e77658c292
5 changed files with 285 additions and 74 deletions
@@ -63,11 +63,14 @@ object Output {
fun error(
code: String,
detail: String? = null,
extra: Map<String, Any?> = emptyMap(),
): Int {
val cleanExtra = extra.filterValues { it != null }
when (mode) {
Mode.JSON -> {
val payload = mutableMapOf<String, Any>("error" to code)
val payload = mutableMapOf<String, Any?>("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
@@ -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<String, Any?> =
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?,
)
}
@@ -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 <noffer>` decodes a pointer locally (no network).
* - `request <noffer> [--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 <noffer> [--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 <noffer> --with <ndebit> [--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<String>,
): Int {
if (tail.isEmpty()) return Output.error("bad_args", "offer <info|request>")
if (tail.isEmpty()) return Output.error("bad_args", "offer <info|request|pay>")
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 <ndebit>`.
*/
private suspend fun pay(
dataDir: DataDir,
rest: Array<String>,
): 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 <ndebit> 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<String, Any?> =
mapOf(
"code" to response.code,
"latest" to response.latest,
"range" to response.range?.let { mapOf("min" to it.min, "max" to it.max) },
)
}
@@ -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,
)
}
+23
View File
@@ -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 <ndebit>"
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