MESH ONLINECODENAME:
v0.34
payments

The lifecycle

One PaymentEngine runs the provider side of a paid capability; one CallerPaymentFlow runs the caller side. They meet at the quote. Nothing here is decided in a language binding — the bindings marshal arguments and project results; the lifecycle lives in the net-payments core.

The snippets on this page are compiled and run in CI as examples/docs_payments.rs, which drives the whole loop in one process against the mock facilitator.

Provider side: quote → verify → settle → serve → bill

  1. Price at discovery. The provider authors net.pricing.terms@1 for a capability and announces it. Displaying a price never implies authorization to spend it.
  2. Quote. On request, the engine issues a signed, expiring net.payment.quote@1 bound to the caller and the invocation's input hash.
  3. Verify. When the caller presents proof of an x402 payment, the engine verifies it — at a tier, never as a bare boolean.
  4. Settle. Settlement happens on-chain via the facilitator; Net records a net.settlement.ref@1 pointing at the transaction.
  5. Serve. The capability handler runs only after the quote is redeemed, at-most-once, against the same engine. A paid capability with no payment configured fails closed — the handler never sees an unpaid call.
  6. Bill. The engine emits an immutable net.billing.event@1.

The gate is the seam: the SDK exposes ToolPaymentGate (native) and the MCP adapter exposes PaymentAdmission; net-payments implements both over the one engine, so a quote paid over the wire is the quote the gate redeems.

Standing up a provider

The engine takes five things: the identity it signs with, a facilitator, an admission policy, the asset registry it validates requirements against, and a path for its state. Attaching a BillingLog is what turns step 6 on.

rust
use std::sync::Arc;
 
use net::adapter::net::identity::EntityKeypair;
use net_payments::billing::BillingLog;
use net_payments::core::registry::{default_mock_registry, AssetRegistry};
use net_payments::engine::{AdmitAll, PaymentEngine};
use net_payments::facilitator::mock::MockFacilitator;
 
let provider_keys = Arc::new(EntityKeypair::generate());
let registry: AssetRegistry = default_mock_registry(provider_keys.entity_id().clone());
let billing = Arc::new(BillingLog::new(dir.join("billing.jsonl")));
 
let engine = Arc::new(
    PaymentEngine::new(
        provider_keys.clone(),
        Arc::new(MockFacilitator::new()),
        Arc::new(AdmitAll),          // replace with a real admission policy
        registry.clone(),
        dir.join("engine.json"),     // the one store, under one lock
    )?
    .with_billing_log(billing.clone()),
);

AdmitAll is the shape, not the recommendation — it's what the mock path uses. A real provider implements ProviderAdmissionPolicy to decide which callers may even be quoted, before any money is discussed.

The price itself is an x402 PaymentRequirements carried verbatim (see x402 and Net) and wrapped in signed terms:

rust
use net_payments::core::terms::PricingTerms;
use net_payments::facilitator::mock::{MOCK_NETWORK, MOCK_SCHEME};
use net_payments::x402::requirements::PaymentRequirements;
use net_payments::x402::X402Carry;
 
let template = X402Carry::author(&PaymentRequirements {
    scheme: MOCK_SCHEME.into(),
    network: MOCK_NETWORK.into(),
    amount: "2500".into(),                       // atomic units, as a string
    asset: "musd".into(),
    pay_to: "mock-provider-settle-addr".into(),
    max_timeout_seconds: 60,
    extra: None,
})?;
 
let terms = PricingTerms::new(
    provider_keys.entity_id().clone(),
    "docs-provider/summarize",
    vec![template],
    registry.reference()?,
);

Announce terms with the capability and the price travels with discovery.

Caller side: pricing → spend policy → pay → invoke

  1. Read the price from discovery (describe surfaces pricing_terms; null = free).
  2. Spend policy runs first. Before anything leaves, the spend policy either clears the spend, asks for a human approval, or denies. The model does not decide.
  3. Pay. On clearance, the caller settles the x402 payment (signing only a typed intent — see Non-custodial signing) and attaches the proof to the invocation.
  4. Invoke. The call carries the quote; the provider's gate redeems it and serves.

If the provider refuses, the denial can carry a machine-actionable failure schematic beside the human error, so the caller's agent can branch on why and what's safe to do next rather than parse prose.

Calling a paid capability

CallerPaymentFlow is constructed once per caller identity and reused. It owns the spend policy, so steps 2–4 above happen inside run:

rust
use net_payments::flow::{CallerDecision, CallerPaymentFlow, InProcessProvider};
use net_payments::policy::spend::{SpendPolicyEngine, SpendProfile};
 
let flow = CallerPaymentFlow::new(
    caller_keys,
    SpendPolicyEngine::new(&spend_path, SpendProfile::Production),
    registry,
    provider_channel,   // the machine boundary; InProcessProvider in tests
    clock,
);
 
match flow.run("docs-provider/summarize", &terms_json).await {
    CallerDecision::Paid { quote_id, proof, .. } => {
        // `quote_id` is the redemption binding the invocation must carry.
        // `proof` holds the settlement refs and the signed billing event.
    }
    CallerDecision::RequiresPaymentApproval { quote_id, policy_reason, approve_hint } => {
        // Nothing has been spent. Surface this to a human — see spend policy.
    }
    CallerDecision::Denied { policy_reason } => { /* policy said no */ }
    CallerDecision::Failed { message, retryable } => { /* transport / facilitator */ }
}

Four outcomes, and only one of them spends money. RequiresPaymentApproval is not an error — it's the flow refusing to decide something a human should, and the provider has billed nothing at that point.

The ProviderChannel parameter is where the machine boundary lives. InProcessProvider (used above and in the compiled example) puts the engine in the same process for tests; over a real mesh it's the nRPC channel to the provider.

Engine ownership

The same PaymentEngine serves the quote/pay wire and gates priced tools in the integrated path. Settlement, verification, billing, and redemption use one store under its lock, which is the boundary for the engine's at-most-once bookkeeping.

What the engine store keeps, and for how long

The engine store is bookkeeping, not the audit trail. Every operation parses the whole file and rewrites it when something changed, so the engine compacts terminal quote records 6 hours past quote expiry by default — a payment that settled, billed, published, and was redeemed has nothing left in its record that the billing event does not already carry durably.

Two things follow for anything you build on top:

  • status() returns None for a completed quote past that horizon. It is a live-lifecycle view, not a receipt store. Reconciliation, receipts, and "was this paid?" belong on the billing stream, which compaction never touches.
  • Replay protection is never compacted. Settlement-transaction tombstones are permanent — one on-chain settlement serves exactly one quote, however long an attacker waits. Records that are unredeemed or frozen are kept too.

A provider that re-verifies settlements out of band — on a slower rail, or at a deeper finality requirement — should widen the window past its own re-verification period, or turn compaction off entirely, because compaction does not consult how far a record's verification got:

rust
let engine = PaymentEngine::new(provider, facilitator, admission, registry, state_path)?
    .with_terminal_record_retention_ns(None);  // keep terminal records indefinitely