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:
Claude
2026-09-18 01:49:30 +00:00
parent ef766b83b6
commit ce717751b0
5 changed files with 949 additions and 0 deletions
@@ -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"
}
@@ -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
@@ -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"))))
}
}