Security
Keys stay yours
Signing happens in your own process or wallet. There is no Ripar server to transmit a key
to — the SDK is a library, and an agent serving paid endpoints holds no key at all,
because settlement goes caller → payTo without the server touching it. See Custody
model.
The one place a key does appear is the calling side: RiparClient takes a mnemonic
or a secretKey and signs in-process. Keep it in the environment, never on a command
line — that is why ripar call has no --mnemonic flag.
Spend caps
maxPrice and maxPerDay live in RiparClient and are checked in the process that
holds the wallet, before it signs. No server, facilitator or contract enforces a
caller's budget — there is nothing on the network that knows what your budget is. A
cap you did not set is a cap that does not exist, and a second process using the same
mnemonic has its own separate counter.
Set them where the key is:
import { RiparClient } from "@ripar/sdk";
const ripar = new RiparClient({
mnemonic: process.env.RIPAR_MNEMONIC,
maxPrice: "0.02", // refuse any single quote above this
maxPerDay: "5.00", // refuse once this much is spent in a rolling 24h
});or on the command line:
ripar call https://agent.example.com/summarize \
--body '{"text":"…"}' --max-price 0.02 --max-per-day 5.00| Cap | Where it lives | Guards against |
|---|---|---|
maxPrice | RiparClient, in your process | One absurdly-priced endpoint |
maxPerDay | SpendLedger, in your process, in memory | Slow drift nobody notices |
How they actually work, because the mechanism decides what they are worth:
- The quote is read before anything is signed.
call()fetches the 402 first, decodes it, and throwsprice_above_maxordaily_cap_reachedrather than paying. Without a cap set, the wrapped fetch settles whatever it is quoted. - An unreadable quote fails closed. If a cap is set and the 402 cannot be decoded,
the client throws
unreadable_quoteinstead of paying blind. - The day is a rolling 24 hours, not a calendar day. A calendar cap resets at midnight, so a retry loop drains twice the budget across the boundary and shows two normal-looking days.
- The ledger is in memory. Restart the process and the window restarts with it. It is a guard, not a guarantee.
A cap turns an unbounded loss into a bounded one. It does not make an agent correct. Pair it with validation on every response, and fund a dedicated wallet rather than pointing an autonomous process at your treasury.
Rate limiting, on the earning side
serve() can cap how often one caller invokes your paid endpoints, keyed on the Algorand
address inside the payment header — so a caller cannot get a fresh budget by changing IP:
await serve(agent, { rateLimit: { perMinute: 60, per: "payer" } });Two caveats that decide whether this protects anything:
- It is in-process. Two replicas allow two windows.
- The payment header is not signature-checked before the limiter reads it, so the
payer identity is a claim rather than proof. See the known-gap note in the SDK's
src/identity.tsbefore relying on it to isolate one caller from another.
per: "ip" keys on the socket address instead and counts every request to a paid route,
which is the mode that stops a flood from one host.
What runs before payment
Order is rate limit → idempotency → input validation → payment → handler, and the
position is the feature: a request rejected by any of the first three has not been
charged, because the payment middleware was never reached.
Idempotency is opt-in and in-process:
await serve(agent, { idempotency: { windowMs: 10 * 60_000 } });The guarantee is exactly "a completed 2xx is replayed, not re-run". It is not "a dropped
connection can never be charged twice" — a request that settled and then lost its socket
before writing a response releases its claim, and the retry does settle again.
Attribution
Every settled call carries the transaction that paid for it. A running agent keeps the last calls in a ring buffer:
curl -s https://your-host/_ripar/runs | jq '.runs[] | {endpoint, status, ms, txId}'That is in-memory and capped (100 by default), so it is an operational view, not an
accounting record. For accounting, read the chain: the MCP
server's ripar_settlements lists real USDC transfers for an address
from the Algorand indexer.
Reporting a vulnerability
Email security@ripar.io. Please include reproduction steps and give a reasonable window
before disclosure.
What this does not cover
Being honest about the boundary:
- Endpoint quality. Anyone can serve an x402 endpoint. Nothing audits it, and there is no ranking system that would filter one out. Validate what you get back.
- On-chain reputation. A credit requires the settling USDC transfer to be a transaction in the same atomic group, sent from the client's registered address to the server's — both resolved through the IdentityRegistry. So the amount is read off a transfer the AVM has already validated and cannot be fabricated. What a score still does not tell you is whether the work was any good: it counts money that moved, not results anyone judged. A validator verdict is a separate field.
- Prompt injection. If your agent feeds untrusted text to an LLM that can call paid
endpoints,
maxPricebounds the damage per call — it does not prevent the call. - Your own handler. Code you run holds the secrets you give it.