đLemonade Legendsđ
- What youâll build
- CEP-8 in one breath (what the SDK implements)
- Prerequisites (for running this repo)
- Step 1: define a priced capability (advertise what costs money)
- Step 2: dynamic price resolution (compute the final quote)
- Step 3: configure a payment processor (how settlement happens)
- Step 4: wrap the transport (turn a server into a paid server)
- Step 5: implement the tool (mint the badge after payment)
- End-to-end flow (request â quote â pay â mint)
- The dual API: humans via UI, agents via MCP
- Key takeaways you can reuse
- Build on ContextVM
This is a small, fun, and simple teaching project: a ContextVM server with one paid tool to mint and sell Nostr badges (NIP-58), and one free tool to get stats, designed to illustrate the new CEP-8 payments flow without distractions.
Youâll see how CEP-8 turns âpay before executeâ into a predictable middleware pattern: advertise a priced capability, compute a per-request quote, request payment, verify settlement, and handle everything about the payment flow.
For this project we put together a ContextVM server that sells a NIP-58 badge using CEP-8 payments, and a Svelte web app that uses the server and creates a UI for payments. The cool part: the server can be used either via a UI for humans or by an LLM agent via an MCP clientâbut the focus of this post is to provide an educational example of what CEP-8 brings.
To keep the example concrete, and demonstrate dynamic price resolution, the price follows a tiny time-based rule: 21 sats on day 0, then +21 sats/day.
Both the server and the web app are open source (MIT) and available for you to study, fork, or reuse. We built this project using the CVM skills we released recently, which made it straightforward to create the server, set up the payment middleware, and wire up the Svelte UI.
In this post youâll see how to:
Mark a tool as priced.
Compute a final per-request quote with resolvePrice.
Gate execution using payments middleware withServerPayments()).
Add an accountability layer with a tiny SQLite ledger (anti-double-mint + stats) badge_awards).
What youâll build
Lemonade Legends exposes two tools:
A paid tool, mint_badge, that issues a NIP-58 badge award event to the caller.
A free tool, stats, that shows issuance stats and current pricing.
The âfun experimentâ twist is time-based pricing:
Opening price: 21 sats
Daily increment: +21 sats/day
That gives you a dynamic quote thatâs deterministic and easy to explain.
In code, the pricing model is quite simple: getCurrentPrice() depends on getDaysElapsed(), and those are anchored to a server opening timestamp.
The more interesting part is where that quote gets applied: inside CEP-8âs payment gate via resolvePrice.
CEP-8 in one breath (what the SDK implements)
CEP-8 defines a minimal contract:
The server declares certain capabilities as priced.
When a client calls one, the server emits notifications/payment_required instead of executing it.
The client pays the opaque pay_req using a handler for the negotiated PMI.
The server verifies and emits notifications/payment_accepted.
Only then does the server forward the original request to the underlying tool.
In the TypeScript SDK, payments are middleware wrappersâso you donât rewrite transports; you wrap them.
Server wrapper: withServerPayments()
Client wrapper: withClientPayments()
Prerequisites (for running this repo)
Bun
A Lightning Address in LN_ADDRESS
Run it like:
bun install
LN_ADDRESS=you@wallet.example bun run index.ts
Step 1: define a priced capability (advertise what costs money)
First, the server advertises that calling tools/call on mint_badge requires payment.
Thatâs the pricedCapabilities array:
const pricedCapabilities: PricedCapability[] = [
{
method: âtools/callâ,
name: âmint_badgeâ,
amount: OPENING_PRICE,
maxAmount: OPENING_PRICE + 365 * DAILY_INCREMENT,
currencyUnit: âsatsâ,
description: Mint a "${BADGE_NAME}" badge. Price starts at ${OPENING_PRICE} sats and increases by ${DAILY_INCREMENT} sats each day!,
},
];
Two details matter:
The advertised amount is discovery. It helps clients and UIs show a price, but itâs not the final quote.
maxAmount is an honesty bound. It communicates âthis wonât surprise you beyond this ceiling,â and gives clients something to reason about.
Step 2: dynamic price resolution (compute the final quote)
In this experiment, the quote is time-based. The quote happens in resolvePrice, which runs at the payment gate, before the tool executes. We also check if the user has already minted a badge and reject the request if soâdemonstrating how price resolution can be integrated with your business logic.
const resolvePrice: ResolvePriceFn = async ({ clientPubkey }) => {
const currentPrice = getCurrentPrice();
const pricingInfo = getPricingInfo();
if (hasExistingAward.get(clientPubkey)) {
return {
rejected: true,
amount: currentPrice,
reason: âYou already have the badge!â,
};
}
return {
amount: currentPrice,
description: Day ${pricingInfo.daysElapsed} price: ${currentPrice} sats,
_meta: {
daysElapsed: pricingInfo.daysElapsed,
openingDate: pricingInfo.openingDate,
},
};
};
This is where CEP-8âs separation of concerns becomes practical:
The capability declaration says âthis is priced.â
The quote callback says âthis is what it costs right now.â
You can swap the pricing logic without touching transports, tool handlers, or client code.
Reject without charging (accountability at the payment gate)
The experiment includes a âno duplicatesâ policy: if you already have the badge, the server refuses to sell you another.
Thatâs implemented by querying SQLite (see hasExistingAward) and returning a rejection object from resolvePrice.
In CEP-8/CEP-21 terms, this is the âreject without chargingâ branch: donât mint an invoice, donât take money, and donât forward the request.
Step 3: configure a payment processor (how settlement happens)
The server still needs a concrete way to ask for payment and verify it.
In this repo, thatâs a Lightning Address based processor: LnBolt11ZapPaymentProcessor, configured via LN_ADDRESS.
const paymentProcessor = new LnBolt11ZapPaymentProcessor({
lnAddress: LN_ADDRESS,
});
If you use a different processor (NWC / LNbits / your own custom processor), the rest of the CEP-8 flow stays the same: a processor issues a pay_req, later verifies it, and the middleware gates execution.
Step 4: wrap the transport (turn a server into a paid server)
The base server transport is Nostr-native and injects the callerâs pubkey for us: injectClientPubkey: true. We inject the pubkey so the tool can use it to mint the badge to the correct recipient.
const baseTransport = new NostrServerTransport({
signer,
relayHandler: SERVER_RELAYS,
serverInfo: {
name: âLemonade Legends Badge Serverâ,
website: âhttps://lemonade.contextvm.orgâ,
},
injectClientPubkey: true,
});
Then we gate it:
const paidTransport = withServerPayments(baseTransport, {
processors: [paymentProcessor],
pricedCapabilities,
resolvePrice,
});
That wrapper is the line between âfree toolâ and âpaid capability.â
Step 5: implement the tool (mint the badge after payment)
Badges are perfect for demos because theyâre public, inspectable artifacts.
Lemonade Legends publishes two kinds of events:
Badge Definition (kind 30009) via createBadgeDefinitionEvent()
Badge Award (kind 8) via createBadgeAwardEvent()
Once signed and published signAndPublishEvent()), the result is a verifiable public record that the capability was fulfilled.
The paid tool itself is registered at server.registerTool("mint_badge", ...). Notice how it relies on injected identity instead of trusting user input:
It reads the caller pubkey from _meta _meta?.clientPubkey).
It publishes the badge award.
It stores a record in SQLite.
End-to-end flow (request â quote â pay â mint)
Hereâs the lifecycle you can keep in your head:
Client calls tools/call â mint_badge.
Payments middleware matches it against pricedCapabilities.
The server calls resolvePrice to compute the quote (and possibly reject).
If accepted, the server emits notifications/payment_required with a PMI-scoped pay_req.
A compatible client pays using its handler.
The server verifies settlement via paymentProcessor.
Only then does the call reach mint_badge and mint the badge.
The server records the award in SQLite insertAward.run(...)).
That âverify before forwardâ property is the backbone of CEP-8: priced calls fail closed.
The dual API: humans via UI, agents via MCP
One of the most interesting aspects of this experiment is how the same capability can be accessed through two completely different interfacesâwithout the server knowing or caring which path the request took.
The human path: UI-only payment handler
The companion website at lemonade.contextvm.org](https://lemonade.contextvm.org) demonstrates a pattern thatâs particularly useful for consumer-facing applications: the UI-only payment handler. Instead of requiring users to configure a wallet or understand NWC connection strings, the site implements a handler that bridges the CEP-8 payment flow with a responsive Svelte UI.
The implementation lives in createUiOnlyPaymentHandler(), which creates a payment handler that:
Intercepts the notifications/payment_required message from the server
Renders a payment dialog with the Lightning invoice QR code
Polls for payment completion in the background
Resolves the promise once settlement is confirmed
import { withClientPayments } from â@contextvm/sdk/paymentsâ;
import { createUiOnlyPaymentHandler } from â$lib/payments/payments-ui.svelteâ;
const transport = withClientPayments(baseTransport, {
handlers: [createUiOnlyPaymentHandler()]
});
This pattern is powerful because it allows you to build rich, responsive UIs around paid capabilities. The user sees a clean interface with real-time payment status, while the underlying CEP-8 protocol remains unchanged. The server emits the same notifications/payment_required, the handler responds with the same settlement proofâeverything else is presentation layer.
The agent path: MCP with payments
The same mint_badge tool can be called by an LLM agent using a CVM client with payments configured. The agent doesnât need a UI; it needs a payment handler that can settle invoices programmaticallyâperhaps via NWC, LNbits, or any other automated Lightning backend.
const agentTransport = withClientPayments(baseTransport, {
handlers: [nwcPaymentHandler], // Automated settlement
});
// The agent can now call mint_badge just like any other tool
const result = await client.callTool(âmint_badgeâ, {});
The beauty here is interface parity. Whether the caller is a human or an agent, the capability behaves identically:
The same price resolution logic runs
The same payment verification occurs
The same badge gets minted
The same SQLite record gets written
This is what we mean by a dual API: one capability, multiple access patterns.
Key takeaways you can reuse
Priced capability declaration is the discovery surface pricedCapabilities).
resolvePrice is where you quote* (dynamic pricing, conversion, quotas, eligibility) resolvePrice).
Middleware gates executionâyour tool handler stays focused on business logic withServerPayments()).
SQLite makes policy enforceable (eligibility + audit trail) without inventing a new system badge_awards).
Build on ContextVM
Lemonade Legends is just one example of what you can build with ContextVM. Whether youâre creating paid tools, public infrastructure, or agent-to-agent services, CEP-8 gives you a standard way to handle payments without reinventing the wheel.
Get started: contextvm.org
Read the docs: docs.contextvm.org
Explore the code: Server · Web App
Weâd love to see what you build đ