mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(contextvm): CEP-8 pricing and payment, CEP-21 PMI conventions
Build item 13, client side. Tag family, both lifecycles, the canonical
invocation identity, and the negotiation state machine.
The canonical identity is the part with teeth. A payment authorizes a future
execution and the client is told to retry "the same request", so the identity
is the client pubkey plus sha256(JCS({method, params})) with params._meta
removed. The exclusion is load-bearing rather than tidy: MCP regenerates
progressToken on every callTool, so without it two semantically identical
invocations hash differently and a paid authorization could never be matched.
It applies to identity derivation only -- semanticParams() returns a copy, so
the full original params still reach the handler at execution time.
The other rule with consequences is that neither side may silently fall back.
PaymentSession makes a failed explicit_gating negotiation visible instead of
degrading, and mayAutoPay() is the gate a handler must pass: a client that
required visible payments will not settle a transparent payment_required it
receives anyway. A handler that simply pays what it is asked violates the
client half of CEP-8, so the refusal lives in the type rather than in a
comment.
Also covers the cap tag with fixed and inclusive-range prices, PMI validation
against the W3C format with the -direct bearer suffix from CEP-21, the
transparent notifications, and the -32042/-32043 error payloads.
31 CVM-8-* and CVM-21-* tests. Verified by mutation: including _meta in the
identity, and dropping the visible-payments check, each kill exactly their
guarding test. Module suite at 120.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.contextvm.payment
|
||||
|
||||
import com.vitorpamplona.contextvm.json.toPlainJson
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest
|
||||
import com.vitorpamplona.contextvm.mcp.McpParams
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.jcs.JsonCanonicalization
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
/**
|
||||
* CEP-8's canonical invocation identity for the `explicit_gating` lifecycle.
|
||||
*
|
||||
* A successful payment authorizes a *future* execution, and the client is told
|
||||
* to retry "the same request". That has to be defined on something stable, so
|
||||
* the identity is the client pubkey plus
|
||||
* `sha256(JCS({method, params}))` — with `params._meta` removed.
|
||||
*
|
||||
* The `_meta` exclusion is the load-bearing part. MCP regenerates
|
||||
* `progressToken` on every `callTool`, so without it two semantically identical
|
||||
* invocations hash differently and a paid authorization could never be matched.
|
||||
* The JSON-RPC id, the outer event id, timestamps, signatures and tags are all
|
||||
* excluded for the same reason: a retry may legitimately change any of them.
|
||||
*
|
||||
* The exclusion applies **only** to identity derivation. When an authorization
|
||||
* is consumed, the full original `params` — `_meta` included — must still reach
|
||||
* the handler, so progress and streaming keep working at execution time.
|
||||
*/
|
||||
object CanonicalInvocation {
|
||||
/** Derives the invocation identity hash from an MCP request. */
|
||||
fun identityOf(request: JsonRpcRequest): HexKey = identityOf(request.method, request.params)
|
||||
|
||||
fun identityOf(
|
||||
method: String,
|
||||
params: JsonObject?,
|
||||
): HexKey {
|
||||
val payload =
|
||||
buildMap<String, Any?> {
|
||||
put(METHOD, method)
|
||||
params?.let { put(PARAMS, semanticParams(it).toPlainJson()) }
|
||||
}
|
||||
|
||||
return sha256(JsonCanonicalization.canonicalize(payload).encodeToByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* The full authorization key: the requesting client plus the invocation.
|
||||
*
|
||||
* A payment authorizes one client's future execution, not anyone's, so the
|
||||
* pubkey is part of the identity rather than context around it.
|
||||
*/
|
||||
fun authorizationKey(
|
||||
clientPubKey: HexKey,
|
||||
request: JsonRpcRequest,
|
||||
) = clientPubKey.lowercase() + ":" + identityOf(request)
|
||||
|
||||
/** [params] with `_meta` removed; everything else untouched. */
|
||||
fun semanticParams(params: JsonObject): JsonObject =
|
||||
if (!params.containsKey(McpParams.META)) {
|
||||
params
|
||||
} else {
|
||||
buildJsonObject {
|
||||
params.forEach { (key, value) -> if (key != McpParams.META) put(key, value) }
|
||||
}
|
||||
}
|
||||
|
||||
private const val METHOD = "method"
|
||||
private const val PARAMS = "params"
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.contextvm.payment
|
||||
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcError
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcNotification
|
||||
import com.vitorpamplona.contextvm.mcp.McpMethods
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/**
|
||||
* A payment the server is asking for.
|
||||
*
|
||||
* Carried by `notifications/payment_required` in the transparent lifecycle and
|
||||
* as one entry of `error.data.payment_options` under explicit gating — the
|
||||
* fields are identical, which is why one type covers both.
|
||||
*/
|
||||
data class PaymentRequest(
|
||||
val amount: Double,
|
||||
val pmi: Pmi,
|
||||
/**
|
||||
* The settlement payload, opaque here.
|
||||
*
|
||||
* Its format is PMI-defined, so a handler must match [pmi] before trying to
|
||||
* interpret it. Treating it as a BOLT11 invoice because it starts with "ln"
|
||||
* is exactly the guess the PMI exists to prevent.
|
||||
*/
|
||||
val payRequest: String,
|
||||
val description: String? = null,
|
||||
/** Seconds. Absent means the PMI or the implementation defines expiry. */
|
||||
val ttl: Long? = null,
|
||||
val meta: JsonObject? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val AMOUNT = "amount"
|
||||
const val PAY_REQ = "pay_req"
|
||||
const val PMI = "pmi"
|
||||
const val DESCRIPTION = "description"
|
||||
const val TTL = "ttl"
|
||||
const val META = "_meta"
|
||||
|
||||
fun parseOrNull(params: JsonObject): PaymentRequest? {
|
||||
val amount = (params[AMOUNT] as? JsonPrimitive)?.doubleOrNull ?: return null
|
||||
val pmi = (params[PMI] as? JsonPrimitive)?.contentOrNullIfNotString()?.let { Pmi.parseOrNull(it) } ?: return null
|
||||
val payRequest = (params[PAY_REQ] as? JsonPrimitive)?.contentOrNullIfNotString() ?: return null
|
||||
|
||||
return PaymentRequest(
|
||||
amount = amount,
|
||||
pmi = pmi,
|
||||
payRequest = payRequest,
|
||||
description = (params[DESCRIPTION] as? JsonPrimitive)?.contentOrNullIfNotString(),
|
||||
ttl = (params[TTL] as? JsonPrimitive)?.longOrNull,
|
||||
meta = params[META] as? JsonObject,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Server confirmation that payment was accepted (transparent lifecycle). */
|
||||
data class PaymentAccepted(
|
||||
val amount: Double,
|
||||
val pmi: Pmi,
|
||||
val meta: JsonObject? = null,
|
||||
)
|
||||
|
||||
/** Server rejection of an attempted payment (transparent lifecycle). */
|
||||
data class PaymentRejected(
|
||||
val pmi: Pmi,
|
||||
/** For a bearer asset, the amount actually required. */
|
||||
val amount: Double? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/** Parsers for the CEP-8 notifications and errors. */
|
||||
object PaymentMessages {
|
||||
/** Reads a `notifications/payment_required`, or null if this is another notification. */
|
||||
fun paymentRequired(notification: JsonRpcNotification): PaymentRequest? {
|
||||
if (notification.method != McpMethods.PAYMENT_REQUIRED) return null
|
||||
return notification.params?.let { PaymentRequest.parseOrNull(it) }
|
||||
}
|
||||
|
||||
fun paymentAccepted(notification: JsonRpcNotification): PaymentAccepted? {
|
||||
if (notification.method != McpMethods.PAYMENT_ACCEPTED) return null
|
||||
val params = notification.params ?: return null
|
||||
val amount = (params[PaymentRequest.AMOUNT] as? JsonPrimitive)?.doubleOrNull ?: return null
|
||||
val pmi =
|
||||
(params[PaymentRequest.PMI] as? JsonPrimitive)
|
||||
?.contentOrNullIfNotString()
|
||||
?.let { Pmi.parseOrNull(it) } ?: return null
|
||||
return PaymentAccepted(amount, pmi, params[PaymentRequest.META] as? JsonObject)
|
||||
}
|
||||
|
||||
fun paymentRejected(notification: JsonRpcNotification): PaymentRejected? {
|
||||
if (notification.method != McpMethods.PAYMENT_REJECTED) return null
|
||||
val params = notification.params ?: return null
|
||||
val pmi =
|
||||
(params[PaymentRequest.PMI] as? JsonPrimitive)
|
||||
?.contentOrNullIfNotString()
|
||||
?.let { Pmi.parseOrNull(it) } ?: return null
|
||||
return PaymentRejected(
|
||||
pmi = pmi,
|
||||
amount = (params[PaymentRequest.AMOUNT] as? JsonPrimitive)?.doubleOrNull,
|
||||
message = (params["message"] as? JsonPrimitive)?.contentOrNullIfNotString(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment options carried by a `-32042 Payment Required` error.
|
||||
*
|
||||
* Returns null when [error] is a different failure, and an empty list when
|
||||
* the error claims payment is required but offers no way to make it — which
|
||||
* the caller should treat as malformed rather than as "nothing to pay".
|
||||
*/
|
||||
fun paymentOptions(error: JsonRpcError): List<PaymentRequest>? {
|
||||
if (error.code != JsonRpcError.PAYMENT_REQUIRED) return null
|
||||
val data = error.data as? JsonObject ?: return emptyList()
|
||||
val options = data[PAYMENT_OPTIONS] as? JsonArray ?: return emptyList()
|
||||
return options.mapNotNull { (it as? JsonObject)?.let(PaymentRequest::parseOrNull) }
|
||||
}
|
||||
|
||||
/** Seconds the server suggests waiting before retrying a `-32043 Payment Pending`. */
|
||||
fun retryAfter(error: JsonRpcError): Long? {
|
||||
if (error.code != JsonRpcError.PAYMENT_PENDING) return null
|
||||
val data = error.data as? JsonObject ?: return null
|
||||
return (data[RETRY_AFTER] as? JsonPrimitive)?.longOrNull
|
||||
}
|
||||
|
||||
/** Human-readable guidance a payment error carries. */
|
||||
fun instructions(error: JsonRpcError): String? =
|
||||
(error.data as? JsonObject)
|
||||
?.get(INSTRUCTIONS)
|
||||
?.let { (it as? JsonPrimitive)?.contentOrNullIfNotString() }
|
||||
|
||||
const val PAYMENT_OPTIONS = "payment_options"
|
||||
const val RETRY_AFTER = "retry_after"
|
||||
const val INSTRUCTIONS = "instructions"
|
||||
}
|
||||
|
||||
private fun JsonPrimitive.contentOrNullIfNotString() = if (isString) content else null
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.contextvm.payment
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
|
||||
/**
|
||||
* Client-side CEP-8 negotiation state for one session.
|
||||
*
|
||||
* The rule this exists to enforce: a server that will not honour
|
||||
* `explicit_gating` MUST NOT silently fall back to the transparent lifecycle,
|
||||
* and a client that *required* explicit gating MUST NOT auto-satisfy transparent
|
||||
* `payment_required` notifications it receives anyway. A payment handler that
|
||||
* simply pays whatever it is asked to pay violates the client half of that, so
|
||||
* [mayAutoPay] is the gate every handler has to pass through.
|
||||
*/
|
||||
class PaymentSession(
|
||||
/** The lifecycle this client wants. */
|
||||
private val requested: PaymentInteraction = PaymentInteraction.DEFAULT,
|
||||
/**
|
||||
* True when the application needs payment decisions to be visible — an agent
|
||||
* or a user must approve them.
|
||||
*
|
||||
* With this set, failing to negotiate [PaymentInteraction.EXPLICIT_GATING]
|
||||
* disables automatic payment entirely rather than degrading to silent
|
||||
* spending.
|
||||
*/
|
||||
private val requiresVisiblePayments: Boolean = false,
|
||||
/** PMIs this client can actually settle. */
|
||||
private val supportedPmis: List<Pmi> = emptyList(),
|
||||
) {
|
||||
private var negotiated = false
|
||||
private var effective = PaymentInteraction.DEFAULT
|
||||
|
||||
/** The lifecycle in force. Before the first response this is the default. */
|
||||
val effectiveMode get() = effective
|
||||
|
||||
/** True once the server has disclosed (or implied) the effective mode. */
|
||||
val isNegotiated get() = negotiated
|
||||
|
||||
/**
|
||||
* True when negotiation did not deliver the mode this client asked for.
|
||||
*
|
||||
* Only meaningful after [observeServerTags].
|
||||
*/
|
||||
val negotiationFailed get() = negotiated && effective != requested
|
||||
|
||||
/** Tags to attach to the first direct message of the session. */
|
||||
fun negotiationTags(): List<Tag> =
|
||||
buildList {
|
||||
// transparent is the default, so advertising it is noise; only a
|
||||
// non-default request needs to be stated.
|
||||
if (requested != PaymentInteraction.DEFAULT) add(PaymentTags.paymentInteraction(requested))
|
||||
supportedPmis.forEach { add(PaymentTags.pmi(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the server's first direct response.
|
||||
*
|
||||
* Absence of the tag means transparent, per CEP-8's first-message semantics.
|
||||
*/
|
||||
fun observeServerTags(tags: Array<Tag>) {
|
||||
effective = PaymentTags.parsePaymentInteraction(tags) ?: PaymentInteraction.DEFAULT
|
||||
negotiated = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a mid-session upsert.
|
||||
*
|
||||
* CEP-8 treats a repeated `payment_interaction` as an upsert rather than a
|
||||
* one-shot handshake, because ContextVM messaging is connectionless: a
|
||||
* server cannot observe a client reconnecting, so first-message-only
|
||||
* negotiation could never be renegotiated after a transport reset. An absent
|
||||
* tag on a later message inherits the current mode.
|
||||
*/
|
||||
fun observeLaterServerTags(tags: Array<Tag>) {
|
||||
PaymentTags.parsePaymentInteraction(tags)?.let {
|
||||
effective = it
|
||||
negotiated = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a handler may settle [request] without asking anyone.
|
||||
*
|
||||
* False when the client required visible payments but did not get explicit
|
||||
* gating, and false for a PMI this client cannot settle anyway.
|
||||
*/
|
||||
fun mayAutoPay(request: PaymentRequest): Boolean {
|
||||
if (requiresVisiblePayments && effective != PaymentInteraction.EXPLICIT_GATING) return false
|
||||
return request.pmi in supportedPmis
|
||||
}
|
||||
|
||||
/**
|
||||
* The first offered option this client can settle, or null.
|
||||
*
|
||||
* Order is the server's preference; a client picks the first it supports
|
||||
* rather than the cheapest, since price comparison across payment rails is
|
||||
* not something this layer can do.
|
||||
*/
|
||||
fun selectPayable(options: List<PaymentRequest>): PaymentRequest? = options.firstOrNull { it.pmi in supportedPmis }
|
||||
|
||||
/** PMIs both sides support, preserving the server's ordering. */
|
||||
fun intersectPmis(serverPmis: List<Pmi>): List<Pmi> = serverPmis.filter { it in supportedPmis }
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.contextvm.payment
|
||||
|
||||
import com.vitorpamplona.contextvm.core.CvmTags
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
|
||||
/**
|
||||
* How payment is surfaced for a session (CEP-8).
|
||||
*
|
||||
* The distinction is not cosmetic: under [TRANSPARENT] payment is handled by
|
||||
* transport middleware and the application may never see it, while under
|
||||
* [EXPLICIT_GATING] payment becomes the invocation's own error result so an
|
||||
* agent or user can decide. A client that needs the decision visible must not
|
||||
* silently accept the other mode — see [PaymentSession].
|
||||
*/
|
||||
enum class PaymentInteraction(
|
||||
val wire: String,
|
||||
) {
|
||||
TRANSPARENT("transparent"),
|
||||
EXPLICIT_GATING("explicit_gating"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** The compatibility baseline when no tag is present. */
|
||||
val DEFAULT = TRANSPARENT
|
||||
|
||||
fun fromWire(value: String?) = entries.firstOrNull { it.wire == value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Payment Method Identifier.
|
||||
*
|
||||
* Follows the W3C format (`[a-z0-9-]+`). A PMI is not just a discovery label: it
|
||||
* is the type tag for the opaque `pay_req` string, so a handler that does not
|
||||
* recognise the PMI cannot interpret the payment request at all.
|
||||
*/
|
||||
data class Pmi(
|
||||
val value: String,
|
||||
) {
|
||||
init {
|
||||
require(PATTERN.matches(value)) { "PMI must match [a-z0-9-]+ but was '$value'" }
|
||||
}
|
||||
|
||||
/**
|
||||
* True for bearer-asset methods that allow settlement directly on the
|
||||
* request via a `direct_payment` tag (CEP-21's `-direct` suffix convention).
|
||||
*/
|
||||
val supportsDirectPayment get() = value.endsWith(DIRECT_SUFFIX)
|
||||
|
||||
override fun toString() = value
|
||||
|
||||
companion object {
|
||||
private val PATTERN = Regex("[a-z0-9-]+")
|
||||
|
||||
const val DIRECT_SUFFIX = "-direct"
|
||||
|
||||
/** The one PMI CEP-21 currently recommends: `pay_req` is a BOLT11 invoice. */
|
||||
val LIGHTNING_BOLT11 = Pmi("bitcoin-lightning-bolt11")
|
||||
|
||||
fun parseOrNull(value: String) = if (PATTERN.matches(value)) Pmi(value) else null
|
||||
}
|
||||
}
|
||||
|
||||
/** A capability's advertised reference price. */
|
||||
sealed interface Price {
|
||||
data class Fixed(
|
||||
val amount: Long,
|
||||
) : Price
|
||||
|
||||
/**
|
||||
* An inclusive range. The server may request any amount within it, so this
|
||||
* is a discovery hint rather than a commitment.
|
||||
*/
|
||||
data class Range(
|
||||
val min: Long,
|
||||
val max: Long,
|
||||
) : Price
|
||||
|
||||
fun includes(amount: Long) =
|
||||
when (this) {
|
||||
is Fixed -> amount == this.amount
|
||||
is Range -> amount in min..max
|
||||
}
|
||||
}
|
||||
|
||||
/** What a [CapTag] prices. */
|
||||
enum class CapabilityKind(
|
||||
val prefix: String,
|
||||
) {
|
||||
TOOL("tool:"),
|
||||
PROMPT("prompt:"),
|
||||
RESOURCE("resource:"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun of(identifier: String) = entries.firstOrNull { identifier.startsWith(it.prefix) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `["cap", "<tool:name>", "<price>", "<unit>"]` — a reference price for discovery and UX. */
|
||||
data class CapTag(
|
||||
val kind: CapabilityKind,
|
||||
val name: String,
|
||||
val price: Price,
|
||||
val unit: String,
|
||||
) {
|
||||
fun toTag(): Tag = arrayOf(CvmTags.CAPABILITY_PRICE, kind.prefix + name, priceWire(), unit)
|
||||
|
||||
private fun priceWire() =
|
||||
when (price) {
|
||||
is Price.Fixed -> price.amount.toString()
|
||||
is Price.Range -> "${price.min}-${price.max}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(tag: Tag): CapTag? {
|
||||
if (tag.size < 4 || tag[0] != CvmTags.CAPABILITY_PRICE) return null
|
||||
val kind = CapabilityKind.of(tag[1]) ?: return null
|
||||
val price = parsePrice(tag[2]) ?: return null
|
||||
return CapTag(kind, tag[1].removePrefix(kind.prefix), price, tag[3])
|
||||
}
|
||||
|
||||
private fun parsePrice(raw: String): Price? {
|
||||
val separator = raw.indexOf('-')
|
||||
if (separator <= 0) return raw.toLongOrNull()?.let { Price.Fixed(it) }
|
||||
|
||||
val min = raw.substring(0, separator).toLongOrNull() ?: return null
|
||||
val max = raw.substring(separator + 1).toLongOrNull() ?: return null
|
||||
return if (min <= max) Price.Range(min, max) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Assembling and reading the CEP-8 tag family. */
|
||||
object PaymentTags {
|
||||
fun pmi(pmi: Pmi): Tag = arrayOf(CvmTags.PAYMENT_METHOD, pmi.value)
|
||||
|
||||
fun paymentInteraction(mode: PaymentInteraction): Tag = arrayOf(CvmTags.PAYMENT_INTERACTION, mode.wire)
|
||||
|
||||
fun directPayment(
|
||||
pmi: Pmi,
|
||||
payload: String,
|
||||
): Tag = arrayOf(CvmTags.DIRECT_PAYMENT, pmi.value, payload)
|
||||
|
||||
fun change(
|
||||
pmi: Pmi,
|
||||
payload: String,
|
||||
): Tag = arrayOf(CvmTags.CHANGE, pmi.value, payload)
|
||||
|
||||
fun parsePmis(tags: Array<Tag>): List<Pmi> =
|
||||
tags
|
||||
.filter { it.size >= 2 && it[0] == CvmTags.PAYMENT_METHOD }
|
||||
.mapNotNull { Pmi.parseOrNull(it[1]) }
|
||||
|
||||
fun parseCaps(tags: Array<Tag>): List<CapTag> = tags.mapNotNull(CapTag::parse)
|
||||
|
||||
/**
|
||||
* The interaction mode a peer is asking for, or null when the tag is absent.
|
||||
*
|
||||
* Absence is meaningful: on a session's first direct message it means
|
||||
* [PaymentInteraction.TRANSPARENT], while on a later message it means the
|
||||
* current effective mode is inherited unchanged.
|
||||
*/
|
||||
fun parsePaymentInteraction(tags: Array<Tag>): PaymentInteraction? =
|
||||
tags
|
||||
.firstOrNull { it.size >= 2 && it[0] == CvmTags.PAYMENT_INTERACTION }
|
||||
?.let { PaymentInteraction.fromWire(it[1]) }
|
||||
|
||||
/**
|
||||
* Direct-payment offers in request order.
|
||||
*
|
||||
* A client SHOULD send at most one, but a server evaluating several takes
|
||||
* the first whose PMI it supports, so order is preserved here.
|
||||
*/
|
||||
fun parseDirectPayments(tags: Array<Tag>): List<Pair<Pmi, String>> =
|
||||
tags
|
||||
.filter { it.size >= 3 && it[0] == CvmTags.DIRECT_PAYMENT }
|
||||
.mapNotNull { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } }
|
||||
|
||||
fun parseChange(tags: Array<Tag>): Pair<Pmi, String>? =
|
||||
tags
|
||||
.firstOrNull { it.size >= 3 && it[0] == CvmTags.CHANGE }
|
||||
?.let { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } }
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.contextvm.payment
|
||||
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcError
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcNotification
|
||||
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** `CVM-8-*` and `CVM-21-*`: pricing, payment lifecycles and canonical identity. */
|
||||
class PaymentTest {
|
||||
private val lightning = Pmi.LIGHTNING_BOLT11
|
||||
private val cashuDirect = Pmi("bitcoin-cashu-v4-direct")
|
||||
|
||||
private fun params(json: String): JsonObject = (JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"m","params":$json}""") as JsonRpcRequest).params!!
|
||||
|
||||
private fun request(
|
||||
method: String,
|
||||
json: String,
|
||||
) = JsonRpcRequest(JsonRpcId.Num(1), method, params(json))
|
||||
|
||||
// --- CEP-21 PMI ---
|
||||
|
||||
@Test
|
||||
fun `CVM-21-01 accepts the W3C PMI format and rejects anything else`() {
|
||||
assertEquals("bitcoin-lightning-bolt11", lightning.value)
|
||||
assertNull(Pmi.parseOrNull("Bitcoin-Lightning"))
|
||||
assertNull(Pmi.parseOrNull("bitcoin_lightning"))
|
||||
assertFailsWith<IllegalArgumentException> { Pmi("UPPER") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-21-02 detects the -direct bearer settlement suffix`() {
|
||||
assertTrue(cashuDirect.supportsDirectPayment)
|
||||
assertFalse(lightning.supportsDirectPayment)
|
||||
}
|
||||
|
||||
// --- cap tag ---
|
||||
|
||||
@Test
|
||||
fun `CVM-8-01 parses a fixed price`() {
|
||||
val tag = CapTag.parse(arrayOf("cap", "tool:get_weather", "100", "sats"))!!
|
||||
assertEquals(CapabilityKind.TOOL, tag.kind)
|
||||
assertEquals("get_weather", tag.name)
|
||||
assertEquals(Price.Fixed(100), tag.price)
|
||||
assertEquals("sats", tag.unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-02 parses an inclusive range price`() {
|
||||
val tag = CapTag.parse(arrayOf("cap", "prompt:summarize", "100-1000", "sats"))!!
|
||||
assertEquals(CapabilityKind.PROMPT, tag.kind)
|
||||
assertEquals(Price.Range(100, 1000), tag.price)
|
||||
assertTrue(tag.price.includes(500))
|
||||
assertFalse(tag.price.includes(1001))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-03 round-trips a cap tag`() {
|
||||
val tag = CapTag(CapabilityKind.RESOURCE, "file://x", Price.Range(1, 2), "usd")
|
||||
assertEquals(tag, CapTag.parse(tag.toTag()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-04 rejects a cap tag without a typed capability prefix`() {
|
||||
assertNull(CapTag.parse(arrayOf("cap", "get_weather", "100", "sats")))
|
||||
}
|
||||
|
||||
// --- canonical invocation identity ---
|
||||
|
||||
@Test
|
||||
fun `CVM-8-10 excludes _meta so a regenerated progressToken still matches`() {
|
||||
// MCP regenerates progressToken on every callTool. Without the exclusion
|
||||
// a retry could never match a paid authorization.
|
||||
val first =
|
||||
request(
|
||||
"tools/call",
|
||||
"""{"name":"get_weather","arguments":{"location":"NY"},"_meta":{"progressToken":"a"}}""",
|
||||
)
|
||||
val retry =
|
||||
request(
|
||||
"tools/call",
|
||||
"""{"name":"get_weather","arguments":{"location":"NY"},"_meta":{"progressToken":"b"}}""",
|
||||
)
|
||||
assertEquals(CanonicalInvocation.identityOf(first), CanonicalInvocation.identityOf(retry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-11 is unaffected by the JSON-RPC id`() {
|
||||
val a = JsonRpcRequest(JsonRpcId.Num(1), "tools/call", params("""{"name":"x"}"""))
|
||||
val b = JsonRpcRequest(JsonRpcId.Text("other"), "tools/call", params("""{"name":"x"}"""))
|
||||
assertEquals(CanonicalInvocation.identityOf(a), CanonicalInvocation.identityOf(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-12 is unaffected by params member order`() {
|
||||
val a = request("tools/call", """{"name":"x","arguments":{"a":1,"b":2}}""")
|
||||
val b = request("tools/call", """{"arguments":{"b":2,"a":1},"name":"x"}""")
|
||||
assertEquals(CanonicalInvocation.identityOf(a), CanonicalInvocation.identityOf(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-13 changes when the semantic arguments change`() {
|
||||
val ny = request("tools/call", """{"name":"get_weather","arguments":{"location":"NY"}}""")
|
||||
val sf = request("tools/call", """{"name":"get_weather","arguments":{"location":"SF"}}""")
|
||||
assertNotEquals(CanonicalInvocation.identityOf(ny), CanonicalInvocation.identityOf(sf))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-14 changes when the method changes`() {
|
||||
assertNotEquals(
|
||||
CanonicalInvocation.identityOf(request("tools/call", """{"name":"x"}""")),
|
||||
CanonicalInvocation.identityOf(request("prompts/get", """{"name":"x"}""")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-15 binds the authorization to the requesting client`() {
|
||||
val call = request("tools/call", """{"name":"x"}""")
|
||||
assertNotEquals(
|
||||
CanonicalInvocation.authorizationKey("aa".repeat(32), call),
|
||||
CanonicalInvocation.authorizationKey("bb".repeat(32), call),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-16 semanticParams strips only _meta and leaves the rest intact`() {
|
||||
// The exclusion is for identity only: the handler still needs the full
|
||||
// params at execution time, so this must not mutate the original.
|
||||
val original = params("""{"name":"x","arguments":{"a":1},"_meta":{"progressToken":"t"}}""")
|
||||
val semantic = CanonicalInvocation.semanticParams(original)
|
||||
|
||||
assertFalse(semantic.containsKey("_meta"))
|
||||
assertTrue(semantic.containsKey("name"))
|
||||
assertTrue(semantic.containsKey("arguments"))
|
||||
assertTrue(original.containsKey("_meta"), "the source params must not be mutated")
|
||||
}
|
||||
|
||||
// --- lifecycle negotiation ---
|
||||
|
||||
@Test
|
||||
fun `CVM-8-20 an absent payment_interaction tag means transparent`() {
|
||||
val session = PaymentSession()
|
||||
session.observeServerTags(emptyArray())
|
||||
assertEquals(PaymentInteraction.TRANSPARENT, session.effectiveMode)
|
||||
assertFalse(session.negotiationFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-21 explicit gating is in force once the server echoes it`() {
|
||||
val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING)
|
||||
session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING)))
|
||||
assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode)
|
||||
assertFalse(session.negotiationFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-22 a server that ignores the request is a failed negotiation, not a downgrade`() {
|
||||
val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING)
|
||||
session.observeServerTags(emptyArray())
|
||||
assertTrue(session.negotiationFailed, "silent fallback must be visible to the caller")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-23 a client needing visible payments will not auto-pay after a failed negotiation`() {
|
||||
// The client half of the no-silent-fallback rule: a handler that simply
|
||||
// pays whatever it is asked would violate it.
|
||||
val session =
|
||||
PaymentSession(
|
||||
requested = PaymentInteraction.EXPLICIT_GATING,
|
||||
requiresVisiblePayments = true,
|
||||
supportedPmis = listOf(lightning),
|
||||
)
|
||||
session.observeServerTags(emptyArray())
|
||||
|
||||
val demand = PaymentRequest(amount = 100.0, pmi = lightning, payRequest = "lnbc...")
|
||||
assertFalse(session.mayAutoPay(demand))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-24 the same client does auto-pay once explicit gating is accepted`() {
|
||||
val session =
|
||||
PaymentSession(
|
||||
requested = PaymentInteraction.EXPLICIT_GATING,
|
||||
requiresVisiblePayments = true,
|
||||
supportedPmis = listOf(lightning),
|
||||
)
|
||||
session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING)))
|
||||
assertTrue(session.mayAutoPay(PaymentRequest(100.0, lightning, "lnbc...")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-25 never auto-pays a PMI it cannot settle`() {
|
||||
val session = PaymentSession(supportedPmis = listOf(lightning))
|
||||
session.observeServerTags(emptyArray())
|
||||
assertFalse(session.mayAutoPay(PaymentRequest(100.0, cashuDirect, "cashuB...")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-26 a later payment_interaction tag upserts the session mode`() {
|
||||
val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING)
|
||||
session.observeServerTags(emptyArray())
|
||||
assertEquals(PaymentInteraction.TRANSPARENT, session.effectiveMode)
|
||||
|
||||
session.observeLaterServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING)))
|
||||
assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-27 an absent tag on a later message inherits the current mode`() {
|
||||
val session = PaymentSession()
|
||||
session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING)))
|
||||
session.observeLaterServerTags(emptyArray())
|
||||
assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-28 advertises the requested mode and its PMIs on the first message`() {
|
||||
val session =
|
||||
PaymentSession(
|
||||
requested = PaymentInteraction.EXPLICIT_GATING,
|
||||
supportedPmis = listOf(lightning, cashuDirect),
|
||||
)
|
||||
val tags: List<Tag> = session.negotiationTags()
|
||||
assertContentEquals(arrayOf("payment_interaction", "explicit_gating"), tags[0])
|
||||
assertContentEquals(arrayOf("pmi", lightning.value), tags[1])
|
||||
assertContentEquals(arrayOf("pmi", cashuDirect.value), tags[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-29 omits the tag when requesting the default mode`() {
|
||||
assertTrue(PaymentSession().negotiationTags().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-30 intersects PMIs preserving the server's preference order`() {
|
||||
val session = PaymentSession(supportedPmis = listOf(cashuDirect, lightning))
|
||||
assertEquals(listOf(lightning, cashuDirect), session.intersectPmis(listOf(lightning, cashuDirect)))
|
||||
assertEquals(listOf(lightning), session.intersectPmis(listOf(Pmi("unknown-rail"), lightning)))
|
||||
}
|
||||
|
||||
// --- messages ---
|
||||
|
||||
@Test
|
||||
fun `CVM-8-40 parses a payment_required notification`() {
|
||||
val notification =
|
||||
JsonRpcNotification(
|
||||
"notifications/payment_required",
|
||||
params(
|
||||
"""{"amount":100,"pay_req":"lnbc...","pmi":"bitcoin-lightning-bolt11",
|
||||
"description":"tool run","ttl":600,"_meta":{"note":"x"}}""",
|
||||
),
|
||||
)
|
||||
val parsed = PaymentMessages.paymentRequired(notification)!!
|
||||
assertEquals(100.0, parsed.amount)
|
||||
assertEquals(lightning, parsed.pmi)
|
||||
assertEquals("lnbc...", parsed.payRequest)
|
||||
assertEquals(600L, parsed.ttl)
|
||||
assertEquals("tool run", parsed.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-41 parses accepted and rejected notifications`() {
|
||||
val accepted =
|
||||
PaymentMessages.paymentAccepted(
|
||||
JsonRpcNotification(
|
||||
"notifications/payment_accepted",
|
||||
params("""{"amount":100,"pmi":"bitcoin-lightning-bolt11"}"""),
|
||||
),
|
||||
)!!
|
||||
assertEquals(100.0, accepted.amount)
|
||||
|
||||
val rejected =
|
||||
PaymentMessages.paymentRejected(
|
||||
JsonRpcNotification(
|
||||
"notifications/payment_rejected",
|
||||
params("""{"pmi":"bitcoin-cashu-v4-direct","message":"Insufficient","amount":150}"""),
|
||||
),
|
||||
)!!
|
||||
assertEquals(cashuDirect, rejected.pmi)
|
||||
assertEquals(150.0, rejected.amount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-42 reads payment options off a -32042 error`() {
|
||||
val error =
|
||||
JsonRpcError(
|
||||
JsonRpcError.PAYMENT_REQUIRED,
|
||||
"Payment Required",
|
||||
params(
|
||||
"""{"instructions":"pay then retry","payment_options":[
|
||||
{"amount":100,"pmi":"bitcoin-lightning-bolt11","pay_req":"lnbc..."}]}""",
|
||||
),
|
||||
)
|
||||
val options = PaymentMessages.paymentOptions(error)!!
|
||||
assertEquals(1, options.size)
|
||||
assertEquals(lightning, options[0].pmi)
|
||||
assertEquals("pay then retry", PaymentMessages.instructions(error))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-43 reads retry_after off a -32043 error`() {
|
||||
val error =
|
||||
JsonRpcError(
|
||||
JsonRpcError.PAYMENT_PENDING,
|
||||
"Payment Pending",
|
||||
params("""{"instructions":"retry later","retry_after":5}"""),
|
||||
)
|
||||
assertEquals(5L, PaymentMessages.retryAfter(error))
|
||||
assertNull(PaymentMessages.paymentOptions(error), "a pending error carries no options")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-44 ignores a non-payment error`() {
|
||||
val error = JsonRpcError(JsonRpcError.INVALID_PARAMS, "Unsupported payment_interaction")
|
||||
assertNull(PaymentMessages.paymentOptions(error))
|
||||
assertNull(PaymentMessages.retryAfter(error))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-45 selects the first offered option it can settle`() {
|
||||
val session = PaymentSession(supportedPmis = listOf(lightning))
|
||||
val options =
|
||||
listOf(
|
||||
PaymentRequest(100.0, cashuDirect, "cashuB..."),
|
||||
PaymentRequest(100.0, lightning, "lnbc..."),
|
||||
)
|
||||
assertEquals(lightning, session.selectPayable(options)?.pmi)
|
||||
assertNull(session.selectPayable(listOf(PaymentRequest(1.0, cashuDirect, "x"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CVM-8-46 parses direct_payment offers in request order and the change tag`() {
|
||||
val tags =
|
||||
arrayOf<Tag>(
|
||||
PaymentTags.directPayment(cashuDirect, "token-a"),
|
||||
PaymentTags.directPayment(lightning, "token-b"),
|
||||
)
|
||||
assertEquals(listOf(cashuDirect to "token-a", lightning to "token-b"), PaymentTags.parseDirectPayments(tags))
|
||||
assertEquals(cashuDirect to "rest", PaymentTags.parseChange(arrayOf(PaymentTags.change(cashuDirect, "rest"))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user