mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user