diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/CanonicalInvocation.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/CanonicalInvocation.kt new file mode 100644 index 0000000000..75e168d17a --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/CanonicalInvocation.kt @@ -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 { + 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" +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentMessages.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentMessages.kt new file mode 100644 index 0000000000..e81fffec3e --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentMessages.kt @@ -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? { + 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 diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentSession.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentSession.kt new file mode 100644 index 0000000000..ad08dfacd7 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentSession.kt @@ -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 = 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 = + 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) { + 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) { + 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? = options.firstOrNull { it.pmi in supportedPmis } + + /** PMIs both sides support, preserving the server's ordering. */ + fun intersectPmis(serverPmis: List): List = serverPmis.filter { it in supportedPmis } +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentTags.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentTags.kt new file mode 100644 index 0000000000..85e36101dc --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/payment/PaymentTags.kt @@ -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", "", "", ""]` — 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): List = + tags + .filter { it.size >= 2 && it[0] == CvmTags.PAYMENT_METHOD } + .mapNotNull { Pmi.parseOrNull(it[1]) } + + fun parseCaps(tags: Array): List = 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): 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): List> = + 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): Pair? = + tags + .firstOrNull { it.size >= 3 && it[0] == CvmTags.CHANGE } + ?.let { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } } +} diff --git a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/payment/PaymentTest.kt b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/payment/PaymentTest.kt new file mode 100644 index 0000000000..0ea5a433d7 --- /dev/null +++ b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/payment/PaymentTest.kt @@ -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 { 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 = 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( + 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")))) + } +}