Search projects, hackathons, sponsors and tech

    ← All projects

    Reserva AI

    USDC-backed loyalty on Arc, run from chat. Points can't go underwater.

    Demo

    About

    Reserva is a loyalty system where every point a merchant issues is backed by real USDC held in an on-chain vault. A café, salon, gym, or small retailer funds a program once. From that moment, the vault is the ceiling: the contract will not issue more points than it holds. When a customer redeems, settlement releases from escrow on Arc in the same transaction — not as a favour the merchant later chooses to honour. The problem it targets is simple and almost never audited. Small-business loyalty lives in spreadsheets. Airlines reserve against their miles because regulators force them to; a shop with 400 members reserves against nothing. Customers have learned not to trust those balances — punch cards from shops that closed, points that quietly expired — so they do not redeem, behaviour never changes, and the merchant concludes loyalty does not work. Meanwhile the owner has no live number for what they already owe. Reserva fixes both sides at once: the customer can verify the money exists, and the merchant sees outstanding liability as funded USDC rather than a guess. This only belongs on-chain because settlement finally costs less than the reward. A $2 cashback cannot survive a $3 network fee. Arc is Circle’s EVM L1 with native USDC, sub-cent fees, and fast finality, so frequent micro-settlements are economically honest for the first time. The merchant is not a crypto user and never will be. They land on one web page, sign in with email or phone via Privy, and an embedded wallet is created for the business with spend policies attached at creation — daily limits, and a second approver on anything that withdraws from the program. They fund the vault in USDC. That is the only time they see a browser. Everything afterwards happens in Telegram: “Start 5% cashback for the next month.” “How much have I got left?” “What do I owe right now?” A Python agent translates chat into Reserva API calls. It can propose a treasury action; it cannot complete a withdrawal alone. Policy sits between agent intent and any irreversible transaction. Customers join by message, pass a one-time World ID Selfie Check so signup-bonus farming with disposable numbers is uneconomical, earn on purchase, and redeem at the counter with a one-time code. Points are non-transferable by contract design — no secondary market, no farming. If the program is fully committed, issuance stops rather than going underwater. That refusal is the product. The vault enforces `issued ≤ funded` on-chain, so even a compromised API cannot over-issue. Liability (funded versus issued) is also exposed as an agent-callable service via Bazantic’s x402/MPP gateway, so other tools can read program state without custom wiring. Reserva is not a dashboard, not a token, and not tradeable points. It is a funded, policy-gated, agent-operated obligation pool — loyalty first, because that pain is immediate, but the same primitive covers deposits, refund guarantees, service credits, and any small-business promise a customer currently has to take on faith.

    How it's made

    Reserva is three services talking to one vault. **On-chain (Circle / Arc).** `ProgramVault` is a Solidity 0.8.24 UUPS proxy, deployed once on Arc testnet (chain id 5042002) with Foundry and OpenZeppelin. It is multi-tenant: a merchant calls `createProgram(issuer)` from their own wallet and gets a `programId`. Every program shares the same USDC `asset`, baked in as an immutable on the implementation. Points are not an ERC-20. They are an internal balance plus an append-only `PointOp` ledger in the same contract, so the vault and the history cannot disagree, and there is no transfer function to farm. The invariant `issued ≤ funded` is a revert (`ExceedsFundedBalance`), not an app-layer convention. 1 point = 1 unit of USDC (1e-6). Cashback bps live on-chain as campaign config, but the actual issuance amount is computed off-chain and passed in explicitly. Redemption is two steps: commit a code hash, then settle with the preimage so the counter code is one-time. Surplus withdrawal is owner-only and cannot dip below outstanding points. **Custody split (Privy).** That owner/issuer split is the whole security story. The merchant signs in with email/phone via Privy’s Auth SDK. An embedded wallet is created in the browser. A Next.js route then POSTs a custom allowlist policy to Privy’s Policies API and attaches it to that wallet with `@privy-io/node` and the user’s JWT. The policy is not “spend under $X.” It is narrower: the wallet may `approve` USDC only to the vault, and may call only `createProgram`, `fund`, `configureCampaign`, `withdraw`, and `setIssuer` on the vault, on Arc. Private-key export is denied. Owner transactions (fund, withdraw, campaign) are signed in the browser through the embedded provider (`eth_sendTransaction` via viem-encoded calldata). Day-to-day issuance and redemption settlement are signed by a separate issuer key on the FastAPI side, which the contract treats as `issuer`, not `owner`. The agent is never given withdrawal rights. The demo of that gate is deliberate: the withdraw page tries a raw USDC `transfer` to `0xdead`. The policy denies it. A vault `withdraw` of unused surplus, signed while logged in, is allowed. **Agent + API (FastAPI / LangGraph / Telegram).** A Python FastAPI service owns Postgres (SQLAlchemy async + Alembic; SQLite works for local). Telegram was the chat channel because WhatsApp Business approval would not fit a one-week build. The webhook maps a chat id to merchant or customer, then a LangGraph ReAct agent (Claude or OpenAI, with a keyword fallback if no LLM key is set) calls tools: liability, members, issue-on-ticket, enroll link, redeem codes, runway. Tools talk to the vault over web3.py. The agent prompt is explicit: it cannot withdraw; if the vault reverts `ExceedsFundedBalance`, that is correct behaviour, not a bug to hide. Off-chain it also enforces a weekly issue cap so a chat-driven issuer cannot dump the vault in one afternoon. **Liveness (World).** Enrolment is a Next.js page opened from Telegram (`/enroll/[code]`). IDKit 4’s `selfieCheckLegacy` runs a World ID Selfie Check. Selfie Check is access-gated — an `app_id` is not enough; the RP had to be registered and the beta flag requested. The RP signing key never ships to the browser. FastAPI packs the World 4 RP message (version byte, nonce, timestamps, action field-hash) and signs it; Next.js is only a BFF. After IDKit returns, we POST the proof to `developer.world.org/api/v4/verify/{rp_id}`, extract the selfie nullifier, and store it uniquely so one person cannot farm welcome bonuses across disposable numbers. **Web console (Next.js 16 / React 19 / Tailwind / shadcn).** Onboarding, reserve, campaign, members, activity, redeem, and the policy-block withdraw page. The console is for money-in and irreversible actions. Chat is for running the program. What is hacky on purpose: the Privy policy is an allowlist, so the “blocked withdrawal” demo is a transfer the wallet is not allowed to make, not a mocked error. The issuer key is a hot key on the API — acceptable because it cannot drain surplus. LangGraph checkpoints are in-memory (`MemorySaver`), fine for a demo bot, not for two replicas. The World RP signature is hand-packed to IDKit 4’s 81-byte layout because the SDK does not do that server-side for you. And we never issued a points token, which is the least-hacky decision in the repo: if there is no `transfer`, there is no secondary market.