# ⚡ AbaPay Protocol AbaPay is a decentralized, Web3-native utility payment platform built on **Base** (the default chain) and **Celo**. It lets users pay for real-world bills — Airtime, Mobile Data, Electricity, Cable TV, Bank Transfers, Education PINs, and International Airtime/Data — using on-chain stablecoins (**USDT**, **USDC**, **USAT**), with instant fiat settlement handled server-side via the VTpass API. Payments can be made directly in the web app, or hands-free through a conversational, autonomous AI agent ("DeAI") on Telegram, WhatsApp, and X — a real on-chain identity under [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004), discoverable on [8004scan.io](https://8004scan.io) — that can pay bills unattended, run recurring/scheduled autopay, and settle multi-recipient batch payments, all spending from a bounded, user-revocable on-chain allowance — no custody, no server-side keys. Designed for low fees, cross-border utility vending (Nigeria + every country VTpass's live international catalogue returns), and mobile-first accessibility — MiniPay, Valora, Farcaster Mini Apps, Coinbase Smart Wallet / Base Account, MetaMask, and any other WalletConnect-compatible wallet (see [Supported Wallets & Environments](#-supported-wallets--environments)). **Operator:** Masonode Technologies Limited (RC 9524980), Nigeria. --- ## 🌟 Key Features * **Multi-Chain Payments:** Pay bills directly with USDT, USDC, or USAT on Base (Mainnet/Sepolia) or Celo (Mainnet/Alfajores). **Base is the default chain**; Celo remains fully supported and switchable. The app auto-detects the connected chain and filters/reorders available stablecoins accordingly — **USDC leads on Base, USD₮ leads on Celo**, and **USAT is Celo-mainnet-exclusive**. * **Live VTpass Catalogue — nothing about a provider is hardcoded any more:** every provider name, logo, and amount limit for airtime, data, electricity, cable and education is fetched live from VTpass (`src/lib/vtpassCatalog.ts`, served to the browser by `/api/providers`) rather than from four separate hardcoded lists. The app, chat, MCP and the admin dashboard all read the same in-process cache, so there is exactly one source of truth. See [Live provider catalogue](#live-provider-catalogue-vtpass-sourced) below. * **Per-Provider Amount Limits, Enforced Live:** the ceiling is VTpass's real published `minimium_amount`/`maximum_amount` *per provider*, not one flat number per service — airtime alone ranges MTN ₦200,000 / Glo ₦100,000 / Airtel ₦50,000 / 9mobile ₦50,000, and electricity minimums range ₦100 (Ikeja, Aba) to ₦2,000 (Ibadan). A flat cap either wrongly refused a valid MTN top-up or wrongly accepted an Airtel one that VTpass rejects *after* the user has already paid on-chain. * **International Bill Pay:** Users can select a country and pay for foreign airtime/data in that country's own currency and rate — transaction history and receipts reflect the *local* currency, not just Naira. The country list is fetched live from VTpass (`/get-international-airtime-countries`) on every channel, so the app, chat and MCP can never disagree about which countries are covered. * **Instant Vending:** Automated API integration with VTpass for instant token generation, airtime top-ups, and data bundle delivery. * **Education PINs in every channel:** WAEC result-checker and WAEC registration PINs are buyable from the app, from chat (Telegram/WhatsApp/X/in-app), and over MCP — not app-only. ⚠️ **JAMB is a deliberate honesty caveat:** the code path exists end-to-end (intent parsing, profile-ID verification, `variation_code` handling), but `jamb` is not enabled on the current VTpass merchant account — VTpass answers `{"code":"011","content":{"errors":"Service is Not Valid"}}` — so it does not appear in the live catalogue and cannot currently be sold. If the account is enabled for it, it appears automatically with no code change. * **Smart Merchant Verification:** Validates electricity meters, smartcard/IUC numbers, and account details *before* accepting crypto payments, eliminating user errors and failed vends. * **AbaPoints Loyalty System:** Users earn points pegged 1:1 to stablecoin value spent, trackable via the in-app points badge and a dedicated API endpoint. * **Automatic Refund Safety Net:** Failed vends after confirmed on-chain payment are automatically flagged, verified, and refunded on-chain to the user's wallet. * **DND-Fallback SMS:** Automated SMS delivery of electricity tokens/PINs, bypassing the Nigerian Do-Not-Disturb (DND) registry for critical transaction alerts. * **Multi-Channel Support & Notifications:** Built-in support ticketing, plus webhook integrations for Telegram, WhatsApp, and X (Twitter) so users and admins can transact/get notified from their preferred channel. * **Conversational AI Agent ("DeAI"):** A natural-language assistant (`/api/deai`) that lets users check balances and pay bills via chat-style commands, backed by Claude (Anthropic). Reachable via Telegram, WhatsApp, X, and an in-app chat widget (`src/components/AIChat.tsx`) on the storefront itself. Understands intent, not just menu numbers — replying "Celo" or "usdt" works exactly like replying "1" or "2" — and shows the live balance and approved agent limit for every token at the moment you're asked to pick one, so you're never choosing blind. If a session goes cold (network drop, abandoned mid-flow) it's recognised and cleaned up automatically rather than left dangling; and if a network hiccup happens right after you enter your PIN, the payment is never silently lost or double-spent — it's tracked through to a confirmed on-chain outcome before the agent reports back. * **Agent-Initiated Payments (AbaPayV3):** Users can grant the DeAI agent a bounded, on-chain, revocable spending allowance (`setSpendingAllowance`) — chosen independently per chain and per stablecoin from the Agent Hub tab — so it can pay bills on their behalf from Telegram/WhatsApp/X with no wallet signature needed at payment time and no custody of user funds. If no allowance is approved for the chain/token a chat payment needs, the agent detects that up front and offers a straight choice: approve it now, or complete this one payment via a signed deep link instead. See [AbaPayV3 — agent allowances](#abapayv3sol--agent-initiated-payments) below. * **Autonomous Scheduling & Autopay Agent:** Beyond one-off chat payments, users can ask the DeAI agent to set up recurring bills (monthly/weekly/daily), a one-time future payment ("pay this in 10 minutes"), or a single request covering **multiple recipients/accounts at once** — the agent groups them by chain/token and settles each leg through the same allowance-bounded relayer, unattended, on schedule, with zero further interaction required from the user. * **On-Chain Attribution:** every Celo transaction AbaPay sends (app payments, approvals, the agent relayer, admin vault actions) carries one ERC-8021 suffix with both of our Celo tags (`src/lib/attribution.ts`): `celo_2719403c6aff` for the Celo Open Rails hackathon and `celo_9d71588659ec` for Celo Builders. x402 payments are submitted on-chain by the facilitator, not by AbaPay, so they carry no tag. Base transactions carry Base's own builder code instead. * **On-Chain Agent Identity (ERC-8004):** AbaPay's DeAI agent is registered as a real on-chain identity on **both Celo and Base** via the ERC-8004 "Trustless Agents" registry, so it's discoverable on 8004scan.io / AgentScan — independent of, and unrelated to, how it moves money. See [ERC-8004 agent identity](#erc-8004-agent-identity) below. * **MCP Server (AI Agent Payments):** AbaPay is reachable by any MCP-speaking AI client (Claude, or any other agent that supports the Model Context Protocol) as a real tool server — `describe_capabilities`, `check_balance`, `list_plans`, `pay_bill`, multi-recipient `pay_bill_batch`, and recurring/one-off `schedule_bill`/`list_schedules`/`cancel_schedule` — over Streamable HTTP JSON-RPC at `/api/mcp`. This is a fourth channel alongside Telegram/WhatsApp/X, not a new trust boundary: it runs through the exact same allowance-bounded, kill-switch-gated, discount-aware execution pipeline as the chat channels, on **either Celo or Base** depending on what the linking wallet approved. See [MCP Server](#mcp-server-ai-agent-payments) below. * **MCP OAuth 2.1 (authorize once, not once per conversation):** the connector supports a full OAuth 2.1 authorization-code + PKCE (S256) flow with Dynamic Client Registration (`/api/oauth/register`, `/api/oauth/authorize`, `/api/oauth/token`, discovery under `/.well-known/`). A user authorizes once in a browser — proving their API key **and** PIN on AbaPay's own hand-rendered consent page — and every future conversation reconnects with a Bearer token instead of retyping an API key. **OAuth never authorizes a spend:** the PIN is still required on every single `pay_bill` call, and a Bearer token alone can only read a balance. The `api_key` tool argument remains the fallback for clients that can't do OAuth. * **`list_plans` — real VTpass plan codes and prices, never guessed:** for DATA/CABLE/EDUCATION, `list_plans` returns the currently purchasable plans with their exact `variation_code`s and live VTpass prices, and both the tool description and the server instructions tell the client to call it before `pay_bill` rather than guessing. * **x402 Settlement (main app, both chains):** Payments made directly in the web app settle via the [x402](https://x402.org) HTTP-payment protocol — Celo's own facilitator for **USDC/USD₮/USAT on Celo**, the Coinbase CDP facilitator for **USDC on Base** — so they're genuinely indexed on x402scan, not relabeled contract calls. Anything without EIP-3009 uses the on-chain `payBill` flow, including Base's sponsored-gas path. ⚠️ x402 needs an EIP-3009 `transferWithAuthorization` signature, which is structurally what a drainer asks for, so some wallet scanners flag it as risky — a known, deliberate trade for x402scan visibility; `NEXT_PUBLIC_X402_ENABLED=false` opts out. The signature-free agent-initiated flow is untouched either way. See [x402 settlement](#x402-settlement-main-app-only) below. * **Dynamic Exchange Engine:** Live market rate conversions with admin-configurable exchange rate and automated profit spread calculation, verified server-side to prevent underpayment exploits. * **Executive Admin Dashboard:** Real-time monitoring of VTpass fiat balance, on-chain vault balances per token/chain, transaction analytics, manual refund tools, and CSV export — protected behind admin auth. * **Kill Switches That Actually Stop Every Channel:** the dashboard's "pause a service" toggles are a **two-level** model — a per-service master (`MASTER_AIRTIME`, `MASTER_INTERNET`, `MASTER_ELECTRICITY`, `MASTER_CABLE`, `MASTER_EDUCATION`, `MASTER_INTERNATIONAL`) plus a per-provider switch keyed by VTpass serviceID (`AIRTIME_mtn`, `INTERNET_airtel-data`, `ELEC_ikeja-electric`, `CABLE_dstv`, `EDU_waec`). A payment is refused when **either** level is off. `src/lib/serviceRules.ts`'s `killSwitchKeysFor()` maps an agent intent (+ provider, normalised through `resolveServiceId` so `ELEC_ikeja` can't miss `ELEC_ikeja-electric`) onto exactly those keys, so chat, MCP and the autonomous scheduler now honour the same switches the web app does. See [Kill switches](#kill-switches-two-level-master--per-provider) below. * **Sponsored Gas on Base:** Coinbase Smart Wallet / Base Account users can pay with zero gas fees — the app detects paymaster support via EIP-5792 and batches approval + payment into a single sponsored transaction. Wallets without this capability (MetaMask, WalletConnect, Valora, etc.) transparently fall back to the normal self-paid flow. * **Shareable & Downloadable Receipts:** Every receipt can be shared as an image straight to WhatsApp/Telegram/etc. via the device's native share sheet, or saved directly as a PNG or PDF. * **Farcaster Mini App Ready:** Ships with Farcaster frame metadata so AbaPay can be launched directly inside Farcaster clients. * **Nimiq Pay mini app, paid in NIM:** [nim.abapays.com](https://nim.abapays.com) (`src/app/nim/`, mapped by `middleware.ts`) sells everything the main app sells, paid in the NIM token only, from inside Nimiq Pay (`@nimiq/mini-app-sdk`). `POST /api/pay/nim` prices the bill in NIM at what NIM sells for: the lowest best-bid across qualifying CoinGecko exchange markets for `nimiq-2` (USD-stablecoin pairs, fresh, not anomalous, ≥ $500 volume), never above the blended CoinGecko price, which is also the fallback — plus `NIMIQ_PRICE_BUFFER` (default 2%), rounded up to a whole NIM, at the admin's `platform_settings.exchange_rate` (the same naira rate as abapays.com). It reads the payer's balance on the Nimiq chain (`getAccountByAddress`) before recording the order and refuses an unaffordable one (`INSUFFICIENT_NIM`, nothing charged); an unreadable balance never blocks. The balance the app shows comes from the same chain read (`GET /api/pay/nim/balance?address=`), not Nimiq Pay's `getBalance`, which answered 0 for funded wallets; the host figure is only the fallback. Nimiq Pay keeps a user's NIM in HTLCs their own address funded (the basic account reads 0), so a wallet's balance is its basic balance plus every unexpired HTLC it is the sender of, and a payment that arrives from such an HTLC counts as the ordering wallet's when the chain shows that wallet funded it (`htlcFunder`). Every admin control abapays.com obeys applies through the same `checkWebPayment` gate (kill switches, provider breaker, live min/max), and the quote (`GET /api/pay/nim`) carries per-service availability so the app greys out what is off; `POST { check: true }` checks one provider and amount without writing anything and returns the treasury address (`NIMIQ_TREASURY_ADDRESS`) and an order reference; Nimiq Pay sends the NIM with the reference as hex-encoded data; the server reads the transaction from Albatross JSON-RPC (`NIMIQ_RPC_URL`, default `https://rpc.nimiqwatch.com`) and requires the treasury as recipient, at least the quoted luna, this order's reference, the ordering wallet as sender and `NIMIQ_MIN_CONFIRMATIONS` (default 10) before vending through the same pipeline as every other rail (`src/lib/nimiq.ts`, `src/lib/nimSettle.ts`). `/api/cleanup` settles orders whose app closed before reporting the payment (found by reference in the treasury's history) and expires orders unpaid after an hour. NIM rows are `blockchain = 'NIMIQ'`, `token_used = 'NIM'`, with the NIM charged in `amount_usdt` and the quote price in `nim_usd_price` (migration 042). The server holds no Nimiq key: a failed vend queues a refund that the operator sends from the treasury wallet and records in Admin → Ops → Refunds, where the hash is verified on the Nimiq chain. A NIM order the system did not queue itself (stuck, or VTpass has no record) is queued from Admin → Ledger → Refund (`POST /api/admin/refunds { action: "QUEUE_NIM" }`: paid on chain, undelivered, not already queued). The app connects only on request: declining the Nimiq Pay prompt leaves a Connect wallet button, and Disconnect forgets the account on that device. The app's **History** tab (`POST /api/pay/nim/history`) shows the payer's own orders — including ones still **awaiting payment** (Nimiq Pay can hold a send as pending), **expired · not paid** (nothing charged) and late payments being refunded, each with a one-line explanation; the internal order placeholder is never returned as a payment hash — with meter tokens and PINs: the user signs a short message in Nimiq Pay, and `src/lib/nimiqAuth.ts` checks the Ed25519 signature over Nimiq's signed-message digest and that the public key derives to the address the message names, so a signature by one wallet can never read another's orders. Provider pickers are searchable bottom sheets showing each provider's logo, country flag or operator image from VTpass. --- ## 🛠️ Tech Stack * **Frontend:** Next.js 16 (App Router, React 19), Tailwind CSS 4, Lucide Icons, next-themes (dark mode) * **Web3 / Wallets:** Wagmi, Viem (incl. EIP-5792 `sendCalls` for sponsored transactions), WalletConnect Modal, Base Account SDK, Solidity smart contract (Hardhat) * **Backend:** Next.js Route Handlers (serverless functions) * **Receipts:** html2canvas (image capture), jsPDF (PDF export) * **AI:** Claude (Anthropic API) for the DeAI conversational agent and in-app chat widget * **Agent Identity & Payments:** ERC-8004 (on-chain agent identity, Celo + Base) and x402 (signed in-house with the connected wallet — `src/lib/x402Pay.ts`) for HTTP-native, facilitator-settled payments in the main app * **Who Paid (admin only):** every x402, MCP and A2A payment records the request's User-Agent and any agent name / ERC-8004 id it declares (`X-Agent-Name`, `X-Agent-Id`) in `payment_client_info` — for MCP/A2A also the protocol, the sign-in method and the OAuth client's registered name (the routes run each tool call under `runWithAgentClient`, and `executeAgentPayment` records it; our own chat agent and the scheduler record nothing) — and after the response looks the paying wallet up on-chain (`src/lib/payerIdentity.ts`): the ERC-8004 agent identities it owns or is the declared wallet of, each confirmed with `ownerOf`/`getAgentWallet` (ids from Blockscout, falling back to the registry's log on `CELO_RPC_URL`), and its Self Agent ID status (verified, human proof, proof fresh) from the SelfAgentRegistry. Cached a day in `payer_identities`. Both tables are service-role only (migration 044) and shown only on the admin ledger, never on receipts or public APIs; a lookup never affects the payment. How support reads it: docs/runbooks/README.md, "Supporting a developer whose agent paid". * **Agent Tool Access:** MCP (Model Context Protocol) — Streamable HTTP/JSON-RPC server at `/api/mcp` exposing balance-check and bill-pay tools to any MCP client — and A2A (Agent2Agent) at `/api/a2a`, card at `/.well-known/agent-card.json`, exposing the same tools to peer agents. The x402 endpoint is also listed in an x402 discovery manifest at `/.well-known/x402` (`{ version: 1, resources: [...] }`), the file x402 indexers such as agent402.tools read before probing the live 402 challenge; AbaPay is indexed there ([agent402.tools/api/index?seller=agents.abapays.com](https://agent402.tools/api/index?seller=agents.abapays.com)). **Cursor:** listed at [cursor.directory/plugins/abapay](https://cursor.directory/plugins/abapay); the plugin lives in `integrations/cursor-plugin/` (`.cursor-plugin/plugin.json` + `mcp.json` pointing at `https://agents.abapays.com/api/mcp`, registered for the marketplace by the repo-root `.cursor-plugin/marketplace.json`); OAuth accepts Cursor's native callback (`cursor://anysphere.cursor-mcp/oauth/callback`, exact match only, PKCE required) and its newer loopback one. **Grok:** add it at grok.com/connectors → New Connector → Custom with the same URL * **Utility Provider:** VTpass API (bills, airtime, data, education, international airtime) * **Bank Transfer Provider:** Monnify API (Moniepoint Inc.) — account auto-detect, Name Enquiry verification, and the real NUBAN payout, debited from a Moniepoint Microfinance Bank business account * **Database / Ledger:** Supabase (PostgreSQL) — transactions, platform settings, points, refunds * **Email:** Resend (transactional receipt emails) * **Notifications & Bots:** Telegram Bot API, WhatsApp Cloud API, X (Twitter) API, VTpass Messaging API (SMS) --- ## 👛 Supported Wallets & Environments AbaPay runs in three distinct runtime environments, detected at load in `src/app/page.tsx` (`environment` = `MINIPAY` | `FARCASTER` | `WEB`, with `LOADING` as the pre-detection state and a 2-second timeout that falls back to `WEB`). Wallet connectivity for the `WEB` case comes from `src/config/wagmi.ts`, which registers exactly three connectors: `injected()`, `baseAccount()`, and `walletConnect()`. | Wallet / environment | How it connects | Notes | |---|---|---| | **MiniPay** (Opera Mini's built-in Celo wallet) | Detected directly via `window.ethereum.isMiniPay`; the app builds its own viem wallet client and locks to Celo | Gas is paid in a stablecoin (`txConfig.feeCurrency`), so users need no CELO. Network switching is intentionally disabled here. | | **Farcaster Mini App** | Detected via `@farcaster/miniapp-sdk`'s `sdk.context`; uses `sdk.wallet.ethProvider`, locked to Base | Addresses are read with a *silent* `getAddresses()` so opening the app never forces a wallet popup. Frame metadata ships in `public/.well-known/farcaster.json`. Has its own Exit button next to the (non-interactive) network badge. | | **Valora** | **WalletConnect only** — the injected path is deliberately skipped inside Valora's in-app browser (`isValoraBrowser()`) | Pinned to the top of the WalletConnect modal's recommended list via `explorerRecommendedWalletIds`. Celo-only, which the app follows automatically (`walletApprovedChainIds()`). See "Valora is WalletConnect-only" below for why the injected path is off. | | **MetaMask** and other injected browser wallets | Whichever **EIP-6963-discovered** connector the wallet announced, falling back to the generic `injected()` one | wagmi discovers one connector per installed wallet (`multiInjectedProviderDiscovery`, on by default). See "How the Connect button chooses" below for why this, rather than reading `window.ethereum` directly, is what keeps web3-browser users off a QR code. | | **Coinbase Smart Wallet / Base Account** | `baseAccount()` connector | The only wallets that get **sponsored gas** — the app probes EIP-5792 paymaster capability and batches approve + pay into one sponsored call. Everything else falls back to the normal self-paid flow. | | **Base App** (the site opened inside Base App's own in-app browser, detected via `isBaseAppBrowser()`) | Same `baseAccount()` connector as above, but auto-connected like MiniPay/Farcaster (see the allowlist below) and **locked to Base** in the UI — the network switcher, footer network text and token picker all show Base only, with no Celo to switch to | Distinct from the general "Coinbase Smart Wallet" row above: picking that connector from an ordinary browser still gets both chains: this row is only when the page itself is running inside Base App. Has its own Exit button next to the (non-interactive) network badge, same as Farcaster. | | **Any other WalletConnect v2 wallet** (Trust, Rainbow, Ledger Live, …) | `walletConnect()` connector with the QR modal | Nothing wallet-specific in the code — if it speaks WalletConnect and supports Celo or Base, it works. | #### How the Connect button chooses An injected wallet is always preferred: it touches no third-party host, which is why it keeps working on networks that filter the WalletConnect relay. WalletConnect is the fallback for a browser that has **no** wallet in it — a plain desktop browser, or a phone browser pairing with a wallet app. Which wallets exist is established by **asking**, never by reading `window.ethereum`: `probeInjectedConnectors()` (`src/lib/walletEnv.ts`) takes wagmi's discovered connectors, gets each one's own provider, and sends it a timed-out `eth_accounts` — a call that never prompts, so it is safe on every page load. Each wallet comes back `authorized` (already approved this site), `available` (real, not yet approved) or `none` (absent, or a stub that never answered). - **Any wallet `authorized`** → nothing happens on its own. `authorized` decides which wallets the chooser can offer *without* a permission popup, not whether to connect. See "Auto-connect is an allowlist" below. - The chooser lists every usable injected wallet **plus Base Account plus WalletConnect**, each with its own EIP-6963 logo and a status badge (`Recent` for a wallet that already approved this site, `Installed` otherwise). Cancelling ends the attempt rather than falling through to a QR code. One extension that is both EIP-6963-announced and parked on `window.ethereum` is de-duplicated, so it can't appear twice. - **Base Account is offered alongside injected wallets.** The connector lives in `src/config/wagmi.ts`; `probeInjectedConnectors()` returns `injected`-type connectors, and Base Account (its own connector type) is added to the option list separately. It matters most on Base, the default chain, where it is the smart-account experience carrying sponsored gas; `verifySignatureAcrossChains` validates the ERC-1271 signatures it produces. - Because those two are always present, the chooser always appears — a browser with no extension still gets a real choice between signing in with Base Account and pairing a phone wallet, rather than being dropped straight onto a QR code. 🔵 **WalletConnect is always an option, never only a fallback.** The chooser's option list is built first and the chooser itself is decided from *that* list's length — so even the common "one extension installed" case still offers WalletConnect as a real route to pairing a phone wallet, not just a fallback for zero-extension browsers. 🔵 **Why not `window.ethereum`:** under EIP-6963 a wallet announces itself over an event rather than claiming that global — which is how several extensions coexist without fighting over one slot. Probing the EIP-6963-announced connectors (rather than the bare global) means a browser with a perfectly good wallet is never reported as walletless just because `window.ethereum` is unset or points at a different wallet. Prompts also say **where** to approve. Over WalletConnect the request lands in a separate app that nothing brings to the foreground, so the copy says to open it (`walletApprovalPrompt`). #### Valora is WalletConnect-only 🔵 **Why the injected path is skipped inside Valora.** Inside Valora's in-app browser, the page can see something that answers `eth_accounts` — real enough to report a wallet, real enough for auto-connect to fire, real enough for the UI to look connected. Not real enough to pay with: Valora's injected provider takes a payment authorization request as a connection handshake, consumes it, and returns nothing to the page — so the request never resolves. Valora's supported rail is WalletConnect, and over WalletConnect it behaves normally: a real session request with a real response. So the injected path is skipped inside Valora — `isValoraBrowser()` suppresses auto-connect and empties the Connect button's injected candidate list, dropping the click through to WalletConnect. 🔵 **Detecting Valora needs the WalletConnect session, not the page's own globals.** `isValoraBrowser()` looks for an `isValora` flag or the name in the user agent, and in Valora's in-app browser **neither is present**: it injects no provider and its webview reports a stock Android Chrome user agent. The only thing that names the wallet is the session — WalletConnect exchanges peer metadata on connect, and `session.peer.metadata.name` is the wallet's own name for itself. So `connectedWalletIsValora()` reads that instead, and a **restored** Valora session is dropped on mount so the user pairs fresh. That is deliberately narrow, because the friction only buys something in one place: - **Valora only** — every other wallet keeps its restored session. - **Restored sessions only** — a connection the user just asked for is never yanked away (`userInitiatedConnect`). - **Once per mount**, so it can't fight a connect that's mid-flight. The trade-off is that peer metadata only exists *after* connecting, so this shapes what happens next rather than pre-empting the connection. Both detectors are word-bounded — a false positive would drop a working session (or strip a real in-browser wallet off the rail it should use), which is the more expensive mistake. Covered in `tests/walletEnv.test.ts`. #### Cancelling in a wallet is not always an answer Every cancellation path assumes the wallet reports the rejection — EIP-1193 says it should, and injected wallets do. **Valora over WalletConnect does not**: dismissing its sheet sends nothing back over the relay, so there is no rejection to catch, no error and no event. The request stays open and the page waits on a decision that was already made. `withWalletTimeout` fires at 90s — that budget has to stay 90s, because it is also how long someone gets to read a prompt before approving. So after 15s of processing the status banner grows a **STOP WAITING** control. It cannot abort the in-flight request (nothing on this side can) and deliberately does **not** claim the payment was cancelled: if the user approves a moment later it still settles, and saying otherwise is how someone pays twice. #### Auto-connect is an allowlist: MiniPay, Base App, Farcaster — and nothing else On the web, **the Connect button is the only way in.** No wallet is connected until the user asks for it, even one whose extension approved this site months ago. 🔵 **`reconnectOnMount={false}` is set deliberately in `Providers.tsx`.** wagmi's default `reconnectOnMount` persists the connector and silently re-establishes it on *every page load* — inside the provider, before any effect in `page.tsx` runs. Setting it `false` means only an explicit `connect()` call ever establishes a session. Auto-connect itself is an allowlist (`AUTO_CONNECT_SURFACES`), not a rule that connects any previously-`authorized` wallet by default and carves out exceptions by name — an allowlist is the shape that keeps a silent connect from reappearing under a different wallet's name later. Those three are different in kind, not degree: the app is running **inside** the wallet, so there is exactly one account it could mean, the user chose it by opening AbaPay there, and no chooser is being suppressed because there is nothing to choose between. MiniPay and Farcaster are connected by their own SDKs and never touch wagmi; Base App arrives through wagmi and is matched by `looksLikeBaseApp()` — which deliberately refuses the Coinbase **desktop extension**, since that sets the same `isCoinbaseWallet` flag while being an ordinary injected wallet on an ordinary page. ⚠️ **The trade:** a refresh ends a web session and the user presses **Connect** again. Being asked is the point, but it is a real cost on a page people reload. #### A connection the page did not establish is not a connection `reconnectOnMount={false}` stops wagmi **re-establishing** the connector. It does not stop it **rehydrating**: the config persists to `cookieStorage` with `ssr: true`, so on load wagmi restores `connections`/`current` from the cookie and `useAccount()` reports `isConnected` with an address — while no provider has been set up and no relay socket exists. 🔵 A rehydrated cookie session and a live session look identical from `useAccount()` — both report `isConnected` with an address — so a connection this page did not itself establish is dropped on mount (`userInitiatedConnect` is what separates the two). Base App is unaffected — its silent connect calls `connect()` explicitly. #### Proving the wallet is yours, once per session 🔵 **A filter written by the client is not a permission.** History is never read straight from the browser with the anon key scoped by an address parameter — a client-supplied filter like `.ilike('wallet_address', address)` is only as trustworthy as the client, and a wallet address is public information anyone could pass. After connecting, the wallet signs a plainly-worded ownership message (`src/lib/walletSession.ts` — shared by browser and server, because two copies of that string means one stray character failing every signature as "invalid signature"). `GET /api/history` derives the address **from that signature** and queries with the service-role client, so no parameter remains that could point at another person's records. - `verifySignatureAcrossChains` already covers EOAs *and* ERC-1271/6492 smart accounts, so Base Account and Safe are not locked out by the signature being a shape we could not check. - A **rejection** disconnects — the user declined to prove the address is theirs. - Any **other** failure leaves them connected but unproven: they can still pay, because paying is authorised by the payment signature itself, and only history is withheld — never a hard lockout over a signature shape this endpoint didn't need to check. - Read-only, and for a session rather than five minutes, because a wallet popup on every History refresh trains people to sign whatever they are shown. It is a bearer credential for that window; mutations keep their own fresh, per-action signatures (`verifyWalletOwnership`). #### A restored WalletConnect session is not a live one 🔵 wagmi persists the WalletConnect session (`cookieStorage`) and restores it on load, producing an address — and an address is all the UI needs to look connected: balances render (they come from a public RPC and never touch the wallet), the pay button enables, everything reads as normal. But a WalletConnect request only reaches the phone if the **relay socket is open**. Over a dead socket, `eth_sendTransaction` is written to a closed pipe: no prompt appears in the wallet, nothing comes back, and **there is no error to catch, because nothing rejected** — the request simply goes nowhere, indistinguishable from a user who hasn't looked at their wallet yet. `walletConnectSessionLive()` (`src/lib/walletEnv.ts`) checks the relay before any wallet interaction; a dead session is reported in one sentence and disconnected so **Connect** pairs fresh instead of restoring the same corpse. A missing socket internal is treated as *live* — a false negative would disconnect working wallets on every payment. Injected wallets return `null`: they are in-process and have no socket to lose. Every wallet call also has a timeout now, including the chain-switch handshake and the Base `sendTransaction`, which had none. On a wallet app, a timeout is reported as "your wallet never received the request" with a reconnect, since that is what it almost always means. `walletApprovedChainIds()` is a related guard: a WalletConnect wallet silently drops requests for a chain outside its approved session, so if the connected wallet never approved the active chain the app follows it to one it did. #### The default chain is Base `DEFAULT_CHAIN` in `src/constants/index.ts` is **`BASE`**, and everything forward-looking reads from it: the chain a freshly connected wallet lands on, the token picker's seed before a wallet is connected, and the chain an agent link approves when the caller doesn't name one. Celo is fully supported and switchable — nothing was dropped, it just isn't where you start. Chains registered in `wagmi.ts`, in order: **Base, Base Sepolia, Celo, Celo Alfajores**. wagmi treats `chains[0]` as the default and offers the rest as *optional* WalletConnect namespaces, so a Celo-only wallet still connects fine (see the Valora row above). Note the app's own non-wagmi paths (`src/lib/chain.ts`, `page.tsx`) use viem's **`celoSepolia`** as the Celo testnet, while `wagmi.ts` still lists `celoAlfajores`; mainnet is unaffected, but they should be reconciled if testnet WalletConnect flows are exercised. `LEGACY_RECORD_CHAIN` is the deliberate counterpart, and it stays **`CELO`**. It is how a *stored* row with an empty `blockchain` column is read — such rows predate the column being written and were all on Celo. It must not follow `DEFAULT_CHAIN`: reading an old Celo payment as Base would send the webhook hunting for a receipt on the wrong chain and strand a real payment as unvended. Stablecoins: **USD₮** and **USDC** on both chains, plus **USAT** on Celo mainnet only. Which token a chain *leads* with, and in what order the rest follow, is `TOKEN_ORDER_BY_CHAIN` in `src/constants/index.ts` — **Base: USDC then USD₮; Celo: USD₮, USDC, USAT**. One `tokensForChain()` serves the Pay tab, the Agent Hub, the chat agent and the MCP tools — one function, so all four always agree on which tokens a chain offers. --- ## 📁 Project Structure ``` src/ ├── app/ │ ├── page.tsx # Main storefront (pay flow, wallet connect, history, env detection) │ ├── admin/page.tsx # Admin ops dashboard (incl. the kill-switch toggles) │ ├── docs/page.tsx # Docs & FAQ page │ ├── terms/, privacy/ # Legal pages (standalone routes; the in-app modals live in components/Modals.tsx) │ ├── .well-known/ # OAuth discovery metadata, incl. the RFC path-insertion variants │ │ ├── oauth-authorization-server/{route.ts, api/mcp/route.ts} │ │ └── oauth-protected-resource/{route.ts, api/mcp/route.ts} │ └── api/ │ ├── pay/ # Core payment + vending endpoint (pay/x402/ is the x402 rail) │ ├── paymaster/ # Server-side proxy for Base gas-sponsorship (keeps the CDP paymaster key off the client) │ ├── providers/ # Live VTpass provider catalogue for the browser's pickers │ ├── requery/ # Delayed/timeout transaction requery │ ├── rate/, admin/rate/ # Exchange rate endpoints │ ├── variations/ # VTpass service variation lookups │ ├── intl/, foreign/ # International bill pay (countries/products/operators/rates) │ ├── verify/ # Meter/account/customer verification │ ├── admin/ # Admin data, actions, refunds, health │ ├── discounts/ # Discount campaign lookup │ ├── schedules/ # Recurring + one-off scheduled bill execution │ ├── user/points/ # AbaPoints balance │ ├── agent/ # Agent link/allowance management (Agent Hub) │ ├── deai/ # Conversational AI agent │ ├── mcp/ # MCP server (describe_capabilities, check_balance, list_plans, pay_bill, pay_bill_batch, schedule_bill, list_schedules, cancel_schedule) │ ├── oauth/{register,authorize,token}/ # OAuth 2.1 (DCR, consent page, token endpoint) for MCP │ ├── cleanup/ # Stale pre-flight intent sweeper │ ├── webhook/, webhook/vtpass/ # VTpass + on-chain webhooks │ ├── monnify/ # Moniepoint bank list, account resolve/verify, transfer webhook │ ├── telegram/webhook/, whatsapp/webhook/, x/webhook/ # Bot channel webhooks │ └── support/ # Support ticket submission ├── components/ # Shared UI (AppFooter, Modals — Terms/Privacy/FAQ/Receipt —, tabs, AIChat, AgentHub, Admin panels) ├── config/wagmi.ts # Wallet/chain configuration (injected, Base Account, WalletConnect) ├── constants/ # Supported tokens, services, initial country list ├── lib/ │ ├── vtpassCatalog.ts # ⭐ Live VTpass provider catalogue + per-provider amount limits │ ├── providerFallback.ts # Offline seed used only when VTpass is unreachable │ ├── monnify.ts # Moniepoint (Monnify) API client — banks, verify, transfer │ ├── monnifyVend.ts # Bank transfer vend + finalize (success/failure/refund) │ ├── serviceRules.ts # Kill switches, operator agent caps, min/max amounts │ ├── refunds.ts # Refund queue (enqueue on vend failure + user notification) │ ├── vend.ts # Shared vend execution for the contract and x402 rails │ ├── attribution.ts # Celo (Open Rails + Builders) and Base on-chain attribution (ERC-8021 dataSuffix) │ ├── parity.ts # Shared validation so chat/MCP match the web form │ ├── deai/ # Intent parsing, capabilities, selection, relayer (payBillFor), │ │ # mcpAuth.ts (API key), mcpOAuth.ts (OAuth token lifecycle) │ └── ... # VTpass, Telegram, WhatsApp, scheduler, discount helpers └── utils/ # Supabase client, admin auth, PIN hashing contracts/ ├── AbaPay.sol # V1 — original escrow/vault smart contract ├── AbaPayV2.sol # V2 — hardened (see below) └── AbaPayV3.sol # V3 — adds agent-initiated payments scripts/ └── deployV4.ts # Deploy V4 (whitelists tokens, sets relayer + per-tx caps) ``` --- ## ⚙️ Environment Variables Create a `.env.local` file in the project root. **Never commit this file to GitHub.** ### App / Network Config ``` NEXT_PUBLIC_APP_MODE=sandbox # sandbox | production NEXT_PUBLIC_NETWORK=celo-sepolia # celo-sepolia | celo | base | base-sepolia NEXT_PUBLIC_FIXED_RATE=1550.00 # Fallback NGN exchange rate NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id NEXT_PUBLIC_WC_RELAY_URL= # Optional. Override the WalletConnect relay — see "Blocked networks" below ``` #### Blocked networks Connecting an external wallet depends on third-party hosts that some networks filter — chiefly `relay.walletconnect.org` (the WalletConnect relay) and `api.web3modal.org` (the wallet chooser). Because the relay is a WebSocket, a block produces **silence** rather than an error, which reads to the user as "the Connect button is broken". This is confirmed on at least one carrier — the connect flow works over a VPN and hangs without one — but we have no data on how many networks or regions are affected. **No user-facing copy names a carrier or country**, deliberately: telling someone their problem is carrier X when they are not on carrier X just makes them distrust the message. `/network-check` reports what is actually blocked for the user in front of it. Two things address this: - **`/network-check`** — a page any user can open that probes each dependency from their own connection and names the ones that fail. It is linked from the connect-failure banner and from the FAQ, and doubles as the evidence to quote in a complaint to whichever carrier or regulator turns out to be involved. - **`NEXT_PUBLIC_WC_RELAY_URL`** — point this at a WebSocket reverse proxy on a domain of yours that isn't filtered (e.g. `wss://relay.abapays.com` forwarding to `wss://relay.walletconnect.org`) and WalletConnect wallets start working on those networks. Relay traffic is end-to-end encrypted, so the proxy is a pipe, not a man-in-the-middle. Note that **Vercel functions cannot proxy long-lived WebSockets** — host it on Cloudflare Workers, Fly.io, or a VPS running nginx with `proxy_pass` and the `Upgrade` headers. **MiniPay, Base App and Farcaster need none of these hosts** — the first two inject a provider straight into the page and Farcaster supplies its own wallet through the Mini App SDK. They stay reliable on a filtered network, and are what the app recommends when a connect fails (`RELAY_FREE_SURFACES` in `src/lib/walletEnv.ts`). ### Smart Contracts (per chain) ``` NEXT_PUBLIC_ABAPAY_ADDRESS=0xYourDefaultContractAddress NEXT_PUBLIC_ABAPAY_CELO_ADDRESS=0xYourCeloContractAddress NEXT_PUBLIC_ABAPAY_BASE_ADDRESS=0xYourBaseContractAddress ADMIN_WALLET_ADDRESSES=0xYourOpsWallet # optional; admins for /admin (never the vault owner). See ENV_SETUP.md CELO_PRIVATE_KEY=your_deployer_private_key # Used only by Hardhat for deployment — never expose client-side ``` ### Paymaster (Base Gas Sponsorship) ``` PAYMASTER_URL=https://api.developer.coinbase.com/rpc/v1/base/your_cdp_api_key # Server-only — never NEXT_PUBLIC. The app proxies wallet paymaster requests through /api/paymaster so this key never reaches the browser. ``` ⚠️ Two things this env var alone won't cover, both configured in external dashboards: - **Coinbase Developer Platform:** create a Paymaster Policy allowlisting your `NEXT_PUBLIC_ABAPAY_BASE_ADDRESS` contract (and ideally the specific `payBill`/`approve` selectors), with a funded/budgeted balance to sponsor from. - **Alchemy webhook config:** make sure the **"Token"** activity category is enabled on your Base webhook (not just "External"). Under gas sponsorship, the top-level transaction's `to` is the bundler/EntryPoint contract, not your AbaPay contract directly — only Token-category (ERC-20 Transfer log) monitoring reliably fires regardless of call depth. ### VTpass (Bill Payment Provider) ``` VTPASS_API_KEY=your_api_key VTPASS_PUBLIC_KEY=PK_your_public_key VTPASS_SECRET_KEY=SK_your_secret_key VTPASS_MSG_TOKEN=VT_PK_your_token VTPASS_MSG_SECRET=VT_SK_your_secret ``` ### Monnify (Moniepoint's API — Bank Transfer Provider) ``` MONNIFY_API_KEY=MK_your_api_key MONNIFY_SECRET_KEY=your_secret_key MONNIFY_CONTRACT_CODE=your_contract_code MONNIFY_SOURCE_ACCOUNT_NUMBER=your_wallet_account_number ``` See `ENV_SETUP.md` §9b for where to find these and the MFA/webhook setup steps. ### Supabase (Database) ``` NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key SUPABASE_SERVICE_ROLE_KEY=your_service_role_key # Server-side only — full DB access ``` ### Email (Resend) ``` RESEND_API_KEY=re_your_resend_key ``` ### AI Agent (DeAI) ``` ANTHROPIC_API_KEY=sk-ant-... # Claude powers the DeAI intent engine (replaced Gemini). DEAI_INTERNAL_SECRET=any_long_random_string # Optional. Signs internal calls to the DeAI brain so /api/deai/* can't be hit directly from the internet, AND signs the agent's payment deep links. Falls back to SUPABASE_SERVICE_ROLE_KEY if unset. ``` **How DeAI actually pays (non-custodial):** there is no server-side key for the user (there must never be one; that would make AbaPay a custodian), so the agent does everything *except* hold keys. Two paths exist: 1. **Deep link (V1/V2 contracts, or a user without an allowance):** the agent parses the request with Claude, verifies the meter/account against real VTpass, confirms details in chat, then returns a **signed, 15-minute deep link** that opens the app pre-filled. The user taps, their own wallet signs, and the payment runs through the same verified pipeline as the web app. 2. **Delegated allowance (AbaPayV3, `src/lib/deai/relayer.ts`):** if the user has granted an on-chain `spendingAllowance` (see [AbaPayV3](#abapayv3sol--agent-initiated-payments) below), the relayer calls `payBillFor()` directly — no deep link, no signature at payment time — bounded entirely by the allowance the user set and revocable by them at any moment. Before broadcasting, a `preflight__` transaction row is written (the same idea as the web app's server-issued `preflight_` intent ahead of a signature), then renamed to the real tx hash once confirmed — so the payment is vended through the exact same verified pipeline as every other rail, and a stale/abandoned attempt is swept automatically rather than left dangling. If the RPC can't confirm the receipt in time (a network hiccup right after broadcast — including right after the user enters their PIN), the agent reports it as *pending*, not failed, and will never hand out a duplicate payment link for that same intent — avoiding both a lost payment and a double-charge. If no allowance is approved for the chain/token a payment needs, the agent detects that before ever attempting the relay and offers a choice: approve it now in the Agent Hub, or complete just this one payment via a signed deep link. ### Telegram ``` TELEGRAM_BOT_TOKEN=your_admin_bot_token TELEGRAM_ADMIN_CHAT_ID=your_admin_chat_id TELEGRAM_CHAT_ID=your_default_chat_id TELEGRAM_WEBHOOK_SECRET=your_webhook_secret SUPPORT_TELEGRAM_BOT_TOKEN=your_support_bot_token DEAI_TELEGRAM_BOT_TOKEN=your_deai_bot_token ``` ### WhatsApp Cloud API ``` WHATSAPP_ACCESS_TOKEN=your_whatsapp_access_token WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id WHATSAPP_VERIFY_TOKEN=your_verify_token WHATSAPP_APP_SECRET=your_meta_app_secret # ⚠️ REQUIRED. Verifies the X-Hub-Signature-256 on inbound webhooks so senders can't be spoofed. WHATSAPP_SCHEDULE_TEMPLATE_NAME=schedule_update # Approved utility template used when the 24h window has closed. Unset = scheduled payments go unreported on WhatsApp. WHATSAPP_SCHEDULE_TEMPLATE_LANG=en # Must match the template's language exactly ('en' and 'en_US' are different templates). WHATSAPP_PIN_FLOW_ID= # Published secure-PIN WhatsApp Flow (scripts/whatsapp-pin-flow.mjs). Set = PINs are entered in a masked WhatsApp form, never typed into the chat. Unset = PIN asked as text. WHATSAPP_PIN_FLOW_MODE= # 'draft' only while testing an unpublished Flow. WHATSAPP_BUSINESS_ACCOUNT_ID= # WABA id; only the Flow scripts need it. WHATSAPP_CHECKOUT_FLOW_ID= # Published checkout Flow (scripts/whatsapp-checkout-flow.mjs). Set = "menu"/"buy"/greetings open a one-form purchase (src/lib/whatsappCheckout.ts); ~3 billed replies per purchase instead of 6–8. WHATSAPP_CHECKOUT_FLOW_MODE= # 'draft' only while testing an unpublished checkout Flow. WHATSAPP_FLOW_PRIVATE_KEY= # PEM private key for the Flow data endpoint (/api/whatsapp/flow). Its public key is registered with WhatsApp. Sensitive. WHATSAPP_DAILY_USER_CAP=20 # Bot replies per user per day (src/lib/whatsappBudget.ts). Past it: one note pointing to Telegram/web, then silence until midnight WAT. A chat waiting for its PIN is never cut off. WHATSAPP_DAILY_USER_CAP_PAID=10 # The cap once the month passes Meta's free allowance. WHATSAPP_FREE_MONTHLY_REPLIES=1000 # Meta's free service replies per number per month. WHATSAPP_MONTHLY_ALERT_AT=800 # Telegram alert to the operator at this many replies in a month (and again past the free allowance). ``` #### The 24-hour window, and why the scheduler needs a template 🔵 WhatsApp lets a business send **free-form text** only within **24 hours of the user's last message**. Outside that window Meta rejects the send with error **131047** and the *only* thing that gets through is a pre-approved template. **Business Verification does not lift this.** Verification governs how *many* unique people you may message outside a window (250 → 1,000 → higher); it has no bearing on *what* you may send them. The two are independent. `src/lib/scheduler.ts` is the caller this affects: a payment scheduled for tomorrow reports back long after the chat that created it went quiet — outside the 24h window by design, which is why that report always goes through the approved `WHATSAPP_SCHEDULE_TEMPLATE_NAME` template instead of free-form text. `sendWhatsAppMessage()` now sends text first (free, and correct while the window is open) and retries through the template **only** on 131047. Any other failure — expired token, blocked recipient — is not retried, since re-sending costs quality rating for nothing. **To make it work, create the template** in WhatsApp Manager → Templates, category **Utility**, with exactly one body variable: ``` AbaPay scheduled payment update: {{1}} Open AbaPay to see the full receipt in your History. ``` Then set `WHATSAPP_SCHEDULE_TEMPLATE_NAME` to its name. Utility templates sent *inside* an open window are free, so the fallback costs nothing in the common case. ⚠️ **Template body parameters may not contain newlines, tabs, or 4+ consecutive spaces** — Meta rejects the whole send. Every scheduler message is multi-line, so `toTemplateParameter()` flattens them (paragraph breaks become `—`) and truncates at Meta's 1024-character cap. Covered in `tests/whatsapp.test.ts`. ⚠️ **`WHATSAPP_APP_SECRET` is required, not optional.** The webhook **fails closed**: with it unset, `POST /api/whatsapp/webhook` returns **503 `Webhook not configured`** and every delivery from Meta is rejected — the bot goes completely silent with no other symptom. That's deliberate: without the secret there's no way to verify the `X-Hub-Signature-256` on an inbound webhook, which would otherwise let anyone impersonate any sender. It does mean *forgetting to set it looks exactly like the bot being broken*. To check a live deployment, POST an unsigned body at the webhook and read the status: `503` = the secret is missing; `401 Invalid signature` = the secret is set and the gate is working. Find the value in Meta App Dashboard → **App Settings → Basic → App Secret**. The same fail-closed rule applies to `TELEGRAM_WEBHOOK_SECRET` and `X_CONSUMER_SECRET`. ### X (Twitter) ``` X_BEARER_TOKEN=your_bearer_token X_CONSUMER_SECRET=your_consumer_secret # ⚠️ REQUIRED — the webhook returns 503 without it (same fail-closed rule as WhatsApp). X_BOT_ACCOUNT_ID=your_bot_account_id ``` ### On-Chain Webhooks (Alchemy) ``` ALCHEMY_WEBHOOK_SECRET=your_alchemy_base_webhook_secret ALCHEMY_CELO_WEBHOOK_SECRET=your_alchemy_celo_webhook_secret ``` ### Contract Verification ``` ETHERSCAN_API_KEY=your_etherscan_or_celoscan_api_key ``` ### Agent Relayer (AbaPayV3 — autonomous bill payments) ``` RELAYER_PRIVATE_KEY=0x... # ⚠️ HOT KEY. Only needed if you deploy AbaPayV3 and enable agent payments. NEXT_PUBLIC_APP_URL=https://abapays.com # Used to build agent payment deep links. ``` ⚠️ **Understand the blast radius before enabling this.** The relayer key can spend **at most each user's remaining on-chain allowance**, and only via `payBillFor`. It **cannot** drain a user's wallet, raise anyone's allowance, or withdraw the vault — those bounds are enforced by the *contract*, not the backend. If the key leaks, the owner calls `setRelayer(address(0))` and it is instantly dead. Fund it with gas only; it should never hold token balances. ### Agent Identity (ERC-8004) — one-time registration only ``` ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json # Used only by scripts/register8004.ts ERC8004_REGISTRY_CELO_MAINNET=0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 # Optional override ERC8004_REGISTRY_CELO_SEPOLIA=0x8004A818BFB912233c491871b3d84c89A494BD9e # Optional override ERC8004_REGISTRY_BASE_MAINNET=0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 # Optional override — same address as Celo mainnet, confirmed byte-identical via eth_getCode NEXT_PUBLIC_ERC8004_AGENT_ID= # Optional. Set after registering, for UI display. ``` Uses the same `CELO_PRIVATE_KEY` Hardhat already has configured — this is identity registration only, it never touches payments. **How to register:** identity is **per-chain** — there's no cross-chain agent record, so this is run once per chain, and both registrations point at the *same* `agent.json` URL. 1. Deploy `public/.well-known/agent.json` (edit its `wallet.address` to your real `RELAYER_ADDRESS` first) so it's reachable at `https:///.well-known/agent.json`. 2. Set `ERC8004_AGENT_URI` above to that URL. 3. `npx hardhat run scripts/register8004.ts --network sepolia` first — confirm the tx on [Celo Sepolia Celoscan](https://sepolia.celoscan.io) and check the `Registered` event for the correct URI and agent ID. 4. Only after that passes: `npx hardhat run scripts/register8004.ts --network celo` — spends real gas, mints the Celo identity permanently (AbaPay's live Celo agent ID: **9687**). 5. Separately, `npx hardhat run scripts/register8004.ts --network base` — mints the *Base* identity (AbaPay's live Base agent ID: **59561**). Same URI, different registry/chain, different agent ID. 6. Set `NEXT_PUBLIC_ERC8004_AGENT_ID` to the agent ID the script prints. Look up either identity at [8004scan.io](https://8004scan.io). Both registrations only ever store the **URL**, not the card's contents, so editing `agent.json` (e.g. to add a new declared service) changes what the URL *returns* with no new transaction. But that alone is not enough for a scanner like 8004scan to notice: indexers appear to snapshot the card at registration time rather than polling the URL on a schedule, so there's no on-chain signal telling them anything changed. `scripts/update8004uri.ts` closes that gap — it calls the registry's `setAgentURI(agentId, sameURI)`, re-emitting a fresh `URIUpdated` event (without changing the URI itself) purely to give an indexer something new to react to: ``` ERC8004_AGENT_ID=9687 ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json npx hardhat run scripts/update8004uri.ts --network celo ERC8004_AGENT_ID=59561 ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json npx hardhat run scripts/update8004uri.ts --network base ``` Run this any time `agent.json`'s contents change (like the `mcp` service entry above) and you want an already-registered identity to be re-read. ### x402 Settlement (main app, Celo + USDC/USD₮/USA₮) ``` CELO_X402_API_KEY=your_x402_celo_org_api_key # Server-side: settles via api.x402.celo.org ``` No client-side SDK key is needed: the payer's EIP-3009 authorization is signed by the wallet the user already connected (`src/lib/x402Pay.ts`), not by a second wallet SDK. ```env NEXT_PUBLIC_X402_ENABLED= # Default ON. Set to "false" to use the contract call instead ``` **x402 is the default settlement rail on both chains** — **USDC, USD₮, or USA₮ on Celo** (each settling against its own EIP-712 domain) and **USDC on Base** — so payments are genuinely indexed on x402scan rather than being relabeled contract calls. Anything without EIP-3009 uses the normal contract call. It never touches the agent-initiated flow, since x402 needs a fresh signature per payment. Distinct infra from `RELAYER_PRIVATE_KEY` above. **Which chain a challenge offers — Celo is the agent default.** A request that names a chain (`blockchain`, and `token`) is challenged on exactly that. One that names none, such as a crawler's probe, a generic x402 client or Celo's [buy](https://usebuy.ai) marketplace CLI, gets **Celo first, with all three stablecoins** (USDC, USD₮, USA₮, each under its own EIP-712 domain), then Base USDC when Base is configured, all at one price. The paid retry settles on the chain and token its payment header was signed for. `AGENT_DEFAULT_CHAIN` (`src/constants`) is Celo for every agent-facing path (x402, MCP, A2A, agent links, the relayer); `DEFAULT_CHAIN` (Base) is only the consumer app's. Trade-off, chosen deliberately: Coinbase's validator (`POST /platform/v2/x402/validate`) judges `accepts[0]`, and with Celo first a bare probe no longer passes it, so the Coinbase Bazaar listing of this endpoint is not expected. Every Celo index (Celo's buy catalogue, agent402.tools, x402scan) reads the Celo entry. ⚠️ **Expect some wallets to warn about the signature, and know why.** x402 settles via an EIP-3009 `transferWithAuthorization` — a signature permitting a third party to move the tokens. That is structurally the same request a token-drainer makes, so some wallet security scanners flag it: **Zerion has shown AbaPay's own request as "Malicious Request — Approving this may risk total asset loss."** on a routine bill payment, while the same payment via the contract call reads as an ordinary Send with *"No Risks Found"*. This is inherent to how x402 works — the very property that makes a payment provable on x402scan is what the scanners object to — not a fault in the request. It is a deliberate trade. Escape hatches, per chain: `NEXT_PUBLIC_X402_ENABLED=false` moves everything to the contract-call rail; `NEXT_PUBLIC_BASE_X402_ENABLED=false` moves only Base. Both default to on. x402 runs on **every wallet, every environment and both chains** — Valora's WalletConnect hang (see the wallet-connection section above) happens at the same point on a plain `eth_sendTransaction` with no signature involved, so restricting x402 to in-browser wallets would buy nothing there; the settlement rail isn't the variable. 🔵 **The one real limit is the TOKEN, not the chain and not the wallet.** x402 settles on an EIP-3009 `transferWithAuthorization` signature, so it only works on tokens that implement one. Celo's USDC, USD₮, and USA₮ all do; on Base, USDC does and **Tether's USD₮ does not** — there is no such function on that contract to sign against. That is why the chain's *lead* stablecoin matters so much. Base leads with USDC (`TOKEN_ORDER_BY_CHAIN`), so the default path on Base **is** x402. The token-reset effect is keyed on chain ID: switching chain always resets the selected token to that chain's lead stablecoin (unless the user deliberately picked the other one), rather than only resetting when the previously-selected token doesn't exist on the new chain at all — since USD₮ exists on both chains but only settles via x402 on Celo, a reset that fires solely on token-existence would silently carry USD₮ over from Celo to Base and demote that user to the contract-call rail. Settlement runs through **Celo's own x402 facilitator** (`api.x402.celo.org` mainnet / `api.x402.sepolia.celo.org` testnet — built by Celo Core Co.), not thirdweb. thirdweb is still used client-side only, for `useFetchWithPayment`'s wallet-signing plumbing (protocol- generic — it reads the payment challenge from the response body, which works against any compliant facilitator, not just thirdweb's own). Chosen over thirdweb's own facilitator because: flat **$0.001/settlement** via prepaid credits vs. thirdweb's ~0.3% cut, **no billing plan required** to settle on mainnet (thirdweb requires one or every mainnet settlement fails with `DELEGATION_CHECK_FAILED`), and genuinely non-custodial — the signed payment authorization pays the vault directly, with no intermediate hop through the facilitator's own wallet. **How to get the API key:** 1. Go to [x402.celo.org](https://x402.celo.org) → **Connect wallet** (any wallet works — this is just to sign a free, gasless message, not a transaction). 2. You're issued an API key instantly, plus free credits (500 mainnet, 1000 testnet at time of writing) — **the full key is shown only once**, copy it immediately. 3. Set `CELO_X402_API_KEY` to that key — the same key works for both the mainnet and testnet endpoints, which are tracked as separate credit pools. 4. Top up credits (USDC deposit, $1 ≈ 1,000 credits) from the same dashboard before you run out — `/settle` starts returning 402 at 0 credits, and the app sends a Telegram alert when that happens (see `src/app/api/pay/x402/route.ts`). 5. Nothing else to sign up for — the client side needs no SDK account. The payment authorization is signed by the wallet the user already connected, through the app's own viem wallet client (`src/lib/x402Pay.ts`). 6. Add the var to `.env.local` **and** your hosting provider's production environment variables, then redeploy — `NEXT_PUBLIC_*` vars are baked in at build time, so existing deployments won't pick up a change without a rebuild. ### Private RPC (optional, recommended) ``` CELO_RPC_URL=https://celo-mainnet.core.chainstack.com/ # Server-only. Tried FIRST for Celo mainnet. BASE_RPC_URL=https://base-mainnet.core.chainstack.com/ # Server-only. Tried FIRST for Base mainnet. ``` A paid endpoint (e.g. Chainstack) goes in front of the public ones in `src/lib/chain.ts`'s `rpcUrlsFor`; the public endpoints stay behind it as automatic fallbacks, so an outage of either side doesn't stop payments. These are **deliberately not `NEXT_PUBLIC_`**: the URL contains the provider's access key, and Next.js never ships a non-public variable to the browser, so the browser keeps using the public list and the key never leaves the server. `BASE_RPC_URL` is also what `hardhat.config.ts` uses for Base deploys. The nightly fork tests take their own `CELO_FORK_RPC_URL` / `BASE_FORK_RPC_URL` GitHub secrets, which need archive access. ### Cron / Maintenance ``` CRON_SECRET=any_long_random_string # Optional. Protects /api/cleanup and both /api/schedules/run* endpoints. ``` Stale abandoned pre-flight intents are swept automatically and opportunistically from inside the webhook (throttled, non-blocking) — this needs **no Vercel cron and works on the free/Hobby plan**. `/api/cleanup` remains available for manual runs or an external free scheduler (cron-job.org, GitHub Actions) if you want a guaranteed cadence during quiet periods. **Scheduled Bills / Autopay Agent — these two DO need an external cron to actually run:** unlike the webhook-driven cleanup above, nothing calls these on its own. - `/api/schedules/run` — recurring bills (monthly/weekly/daily). Register once or twice a day at [cron-job.org](https://cron-job.org) (free) hitting `POST https:///api/schedules/run` with header `Authorization: Bearer ` (or `x-cron-secret: `). - `/api/schedules/run-instant` — one-off future payments from the DeAI chat ("buy me MTN airtime in the next 10 minutes"). Needs a much tighter cadence to actually land close to the requested time — register a **separate** free cron-job.org job hitting `POST https:///api/schedules/run-instant` every **1–5 minutes**. It's cheap even at that frequency: the query is scoped to `frequency = 'once'` rows only, so most ticks find nothing due and return immediately. Without registering these, users can still create schedules (recurring or one-off) from the chat, but nothing will ever execute them — they'll sit `is_active` forever with no cron to pick them up. **On-chain stats — AbaPay's own index of the chain:** [stats.abapays.com](https://stats.abapays.com) is its own site (like docs.abapays.com): [Overview](https://stats.abapays.com) (both chains), [Celo](https://stats.abapays.com/celo) and [Base](https://stats.abapays.com/base). `middleware.ts` rewrites that host onto `src/app/stats/`; the old `agents.abapays.com/stats` links redirect there. It shows every payment into an AbaPay vault as a dashboard. One sticky 7D / 30D / 90D / All switch drives every range-able panel: counters (payments, unique wallets, volume, agent-native share, new and returning wallets, average payment, hackathon-tagged payments), interactive daily charts (payments and volume by rail, new vs returning wallets, cumulative volume), pie charts (volume by chain, rail and token) and attribution-tag counts. An Active wallets section shows DAU, WAU and MAU as of today, DAU/MAU stickiness, and a DAU/WAU/MAU line chart. Then come the latest payments, each linked to its transaction. Every panel has a description, a "How it's calculated" note, a camera button that saves the panel as a PNG, a shareable link (`#panel-id`), and a footer naming the chain and date, so an image of one panel stands on its own. Times are Nigerian time (WAT) and days are Lagos days (migration 040). An open page refreshes its data every 5 minutes. Charts use Recharts. `/api/onchain/export?chain=CELO` (or `BASE`, or `ALL`) downloads the full list as CSV. The agents home page's hero numbers come from the same index. - **Indexer:** [`src/lib/onchain/indexer.ts`](src/lib/onchain/indexer.ts) reads vault events and stablecoin transfers into the vaults over each chain's RPC (`CELO_RPC_URL` / `BASE_RPC_URL` first, then the public endpoints), and classifies each payment by rail. It covers all five vaults: Celo V1, V3 and V4, and Base V1 and V4. - **Storage:** `onchain_payments` and `onchain_cursors` (migration 038). `onchain_summary()` returns the all-time numbers and daily series in one query; `onchain_windows()` (migration 041) returns the rolling 7/30/90-day and all-time windows plus the DAU/WAU/MAU series. Without 041 the site still loads, with all-time counters only. No customer data is stored: the bill account number in `PaymentReceived` is never read into the table. - **Schedule:** [`.github/workflows/onchain-index.yml`](.github/workflows/onchain-index.yml) calls `POST /api/cron/onchain-index` every 15 minutes (`APP_URL` + `CRON_SECRET` secrets). Each call resumes from the last indexed block. After applying 038, run the workflow once by hand with **backfill** ticked to read the history. - **Reliable 15-minute trigger:** GitHub delays scheduled workflows on busy runners, sometimes by hours, so register a free [cron-job.org](https://cron-job.org) job as the main trigger, like the schedule runners above: `POST https://www.abapays.com/api/cron/onchain-index` every 15 minutes, header `x-cron-secret: `, request timeout as high as the plan allows. Progress is saved after every block range, so even a call cut short moves the index forward. The GitHub schedule stays as a backup; overlapping calls are harmless because rows are upserted. --- ## 🚀 Installation & Setup 1. **Clone the repository** ``` git clone https://github.com/investorphem/abapay.git cd abapay ``` 2. **Install dependencies** ``` npm install ``` 3. **Set up environment variables** — copy the variables above into `.env.local`. 4. **Run the development server** ``` npm run dev ``` 5. **Access the application** * User Storefront: http://localhost:3000 * Docs & FAQ: http://localhost:3000/docs * Admin Ops Center: http://localhost:3000/admin ### Smart Contract Development (Hardhat) ``` npx hardhat compile # Compile contracts npm run test:contracts # Run the Solidity test suite npx hardhat run scripts/deployV4.ts --network # Deploy V4 (agent-initiated payments, adjustable withdrawal delay) npx hardhat run scripts/register8004.ts --network # Register the agent identity (ERC-8004) npx hardhat run scripts/update8004uri.ts --network # Re-push the agent URI so an indexer (8004scan) re-reads it ``` #### `AbaPayV2.sol` — hardened contract `contracts/AbaPayV2.sol` is a security-hardened successor to the original `AbaPay.sol`, addressing the hardening gaps below. **`payBill`'s signature and the `PaymentReceived` event are byte-for-byte identical to V1**, so the frontend and the shared `PaymentReceived` verifier (`src/lib/paymentProof.ts`) work with no backend changes. | Hardening | Why | |---|---| | `SafeERC20` | Non-compliant tokens (e.g. some USDT deployments) don't return a bool; raw `require(transfer(...))` breaks on them. | | `ReentrancyGuard` | `setTokenSupport` can whitelist *any* token; a hook-bearing token would otherwise make `payBill` reentrant. | | `Pausable` | V1 had no kill switch — a post-deploy vulnerability could not be stopped. Refunds stay live while paused so users can be made whole. | | `Ownable2Step` | Prevents permanently bricking the contract by transferring ownership to a typo'd address. | | **Timelocked withdrawals** | **The biggest V1 risk:** a single compromised owner key could drain the entire pooled vault instantly. Withdrawals must now be queued, then executed after a delay — alert on `WithdrawalQueued` and cancel if it wasn't you. Fixed at 24h in V2/V3; **owner-adjustable in V4** (see below). | | **Capped refunds** | V1's `refundUser` was an unrestricted "send any amount anywhere" path that bypassed any withdrawal control. Now bounded per-token (and fails closed until a cap is set). | | Balance-delta accounting | Emits the amount *actually received*, so fee-on-transfer tokens can't cause the backend to over-vend. | **Before mainnet:** 1. **Get a professional audit.** This contract holds pooled customer funds; a static review is not sufficient. 2. **Set `ABAPAY_OWNER` to a multisig (Safe), not an EOA.** The timelock buys detection time — it only *stops* an attacker if a stolen key can't unilaterally cancel and re-queue, and it buys nothing at all if the delay has been set to 0 (see V4 below). 3. Deploy to testnet and run the full payment flow end-to-end first. 4. Call `setMaxRefund` for each token — **refunds revert until a cap is configured.** > `payBill` still uses `transferFrom(msg.sender, …)`, so the payer must be the signer. Delegated > spending (the DeAI "pay from social media" feature) needs an additional on-chain allowance > mechanism and is deliberately **out of scope** for this hardening pass — it should be designed > and audited as its own change. #### `AbaPayV3.sol` — agent-initiated payments `contracts/AbaPayV3.sol` builds on V2 to solve the problem above: on Telegram/WhatsApp there is no wallet to sign with, so the agent could previously only hand the user a deep link to sign in the app. V3 adds a **session-key / delegated-spend** pattern instead: 1. The user, from their own wallet, does two things once: a standard ERC-20 `approve(AbaPayV3, X)`, and `setSpendingAllowance(token, X)` — an on-chain cap **they** control. 2. After that, the authorised **relayer** (a backend hot key, `RELAYER_PRIVATE_KEY`) may call `payBillFor()` on their behalf — but the *contract itself* checks and decrements the remaining allowance on every call, so the cap is enforced on-chain, not by the backend. | Bound | Enforced by | |---|---| | Per-user total exposure | `spendingAllowance[user][token]` — settable only by the user, revocable instantly to 0 | | Per-transaction ceiling | `maxAgentPaymentPerTx[token]` — owner-set, a second bound on top of the user's own allowance | | Blast radius of a stolen relayer key | Can spend **at most** a user's remaining allowance, only via `payBillFor` — cannot drain a wallet, raise anyone's allowance, or withdraw the vault | | Kill switch | Owner calls `setRelayer(address(0))` to instantly disable the agent, or `pause()` to halt all payments | Deploy to testnet for demos; on mainnet, start `maxAgentPaymentPerTx` and `maxRefundPerTx` small (`scripts/deployV4.ts` defaults to a $10-equivalent per token), then raise them via `setMaxAgentPayment` / `setMaxRefund`. The live V4 vaults are set to **50** per agent payment and **400** per refund for each supported stablecoin. The expected values are in [`config/vaults.json`](config/vaults.json), and `node scripts/vault-config.mjs` diffs them against the chain. `payBillFor` emits the same `PaymentReceived` event as V1/V2 (so the webhook needs no changes), plus an additional `AgentPayment` event so the backend/any observer can distinguish "the user signed" from "the agent spent an allowance." #### `AbaPayV4.sol` — adjustable withdrawal delay V4 is V3 plus one change: the withdrawal timelock is no longer a hardcoded 24 hours. It is a variable, `withdrawalDelay`, that the owner can raise, lower, or set to **0** via `setWithdrawalDelay(n)`. A fresh deploy defaults to 24h. **Both live vaults run at 0**, a deliberate owner choice, so withdrawals from them are instant (see the warning below). V4 is what is deployed on **both mainnets**: | Chain | AbaPayV4 | |---|---| | Base | `0xC0A4dAA04DEd9c54D1239507B5A5E645761ef488` | | Celo | `0x5df8aE2B963165b735B18Ca86B1ea448d2AA032C` | ⚠️ The **previous Celo contract `0x42Fa4637…` is a V3** — it has no `setWithdrawalDelay`, so its 24h timelock is fixed and it can never be made instant. That is why Celo was redeployed rather than reconfigured. It is now **retired**: every token is unsupported, its relayer is `0x0` and it is paused. The old Celo V1 (`0x1d125198…`) no longer accepts any token either. The queue itself is not removable — it is compiled into the bytecode and there is no direct `withdraw()`. At delay 0 a withdrawal is `queueWithdrawal` then `executeWithdrawal` back to back: two transactions, no waiting. ⚠️ **Changing the delay is not retroactive.** A withdrawal's `executableAt` is stamped when it is queued, so lowering the delay does not free one that is already sitting in the queue — you have to `cancelWithdrawal` and re-queue it under the new delay. Cancelling moves no money; the tokens never leave the vault. ⚠️ **A delay of 0 removes the protection the timelock exists for.** It is the reason a stolen owner key cannot drain the vault before anyone notices. At 0, whoever holds the key can queue and execute in the same minute. Treat it as an emergency setting and raise it back afterwards. ```bash # --chain defaults to base; pass --chain celo for the Celo vault node scripts/instant-withdrawals.mjs --chain celo # show live state, change nothing node scripts/instant-withdrawals.mjs --chain celo --apply # delay -> 0, clear a stuck queue entry node scripts/instant-withdrawals.mjs --apply --withdraw # …and push the queued one through node scripts/instant-withdrawals.mjs --chain celo --restore-delay 86400 # put the 24h timelock back ``` #### ERC-8004 Agent Identity `scripts/register8004.ts` registers AbaPay's DeAI agent as a real on-chain identity on Celo via the [ERC-8004 "Trustless Agents"](https://eips.ethereum.org/EIPS/eip-8004) registry (`0x8004A169FB4a3325136EB29fA0ceB6D2e539a432` on mainnet), so it's discoverable and browsable on 8004scan.io / AgentScan the same way any on-chain identity is. Registration mints an ERC-721 whose tokenId is the agent's ID, pointing at a public agent card (`public/.well-known/agent.json`) that names the operational relayer wallet as the agent's on-chain address. **This is identity only — it does not touch payments.** The relayer's signature-free `payBillFor` flow above is completely unaffected; registering (or not) has zero effect on how bills get paid. Before running on mainnet, verify the `register(string)` selector against the registry's verified source on Celoscan — see the script's header comment. #### MCP Server (AI Agent Payments) `src/app/api/mcp/route.ts` implements a real [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server — the same open standard Claude and other AI agents use to call tools — over **Streamable HTTP** (JSON-RPC 2.0: `initialize`, `tools/list`, `tools/call`), no extra dependency required. It's a **fourth channel into the same execution engine** that already backs Telegram/WhatsApp/X, not a parallel system with its own rules: > Building an agent that integrates AbaPay itself, with no human ever opening abapays.com? > See **[docs/AGENT_INTEGRATION.md](docs/AGENT_INTEGRATION.md)** — the x402 zero-setup path, > and the fully headless (wallet-signature-only) way to self-serve an MCP API key. Writing > TypeScript? **[`sdk/`](sdk)** (`abapay-sdk` on npm) wraps both into two functions. Python? > **[`python-sdk/`](python-sdk)** mirrors it field-for-field and is on PyPI too — > `pip install abapay-sdk`, published via a Trusted Publisher (no token secret) off the > `py-sdk-v*` tag; CI (not a local run — no Python interpreter existed in the environment > that wrote it) is what actually verified it. Prefer a full book over a single doc? **[`docs/ > gitbook/`](docs/gitbook)** covers the same ground as 12 chapters (`SUMMARY.md` is the table > of contents — Markdown, ready to sync into GitBook or any docs host that reads a repo > directly). | Tool | What it does | Needs | |---|---|---| | `describe_capabilities` | Human-readable menu of what AbaPay can pay and what's currently paused | Nothing — public | | `list_plans` | The **real, currently purchasable** plans for DATA / CABLE / EDUCATION, with exact `variation_code`s and live VTpass prices | Nothing — public | | `list_international_options` | Browses the live international catalogue (140+ countries) one level at a time — country → product type → operator → priced plan | Nothing — public | | `check_balance` | Reads the linked wallet's live balance + approved agent limit, **per token**, on a chain | OAuth Bearer token *or* `api_key` | | `transaction_history` | Lists recent real transactions for the linked wallet — same data as the app's History tab | OAuth Bearer token *or* `api_key` | | `get_payment_status` | Looks up one payment by tx hash or request id: delivered, still confirming, failed or refunded, with refund state. Scoped to the linked wallet | OAuth Bearer token *or* `api_key` | | `pay_bill` | Pays a real bill (airtime, data, electricity, cable TV, **education PIN**, or **international airtime/data**) end-to-end, on-chain | (OAuth Bearer token *or* `api_key`) **+ `pin`, always** | | `pay_bill_batch` | Pays airtime/data to 2-20 recipients in one call, one PIN for the whole batch | (OAuth Bearer token *or* `api_key`) **+ `pin`, always** | | `schedule_bill` | Sets up a recurring/one-off airtime, data, electricity, or cable payment — same automation Telegram/WhatsApp/X support | (OAuth Bearer token *or* `api_key`) **+ `pin`, always** | | `list_schedules` | Lists active schedules for the linked wallet | OAuth Bearer token *or* `api_key` | | `cancel_schedule` | Cancels one schedule by `id`, or several by `provider` / `all: true` (those need the PIN); a call with none of them is refused | OAuth Bearer token *or* `api_key` (+ PIN for bulk) | `list_plans` exists so `variation_code` is never something the agent has to invent. Its description, and the server-level `instructions`, both tell the client to call it before `pay_bill` for those three services and to pass back a returned code verbatim — never to guess a plan, a code, or a price. If it returns nothing usable (which genuinely happens — JAMB is not enabled on this merchant account), the correct behaviour is to say so, not to fabricate a code. `pay_bill` covers **EDUCATION** as well as airtime/data/electricity/cable, because MCP is meant to be the same trust boundary as chat, not a narrower one. Every rule it needs is shared and already existed: `requiresVariation()` forces a `variation_code`, `checkAccountNumber()` enforces JAMB's ≥10-character profile ID, and `requiresVerifiedName()` decides that only JAMB merchant-verifies (WAEC has no account to verify). It also validates the provider against the **live** VTpass catalogue up front, so an agent can no longer pass a provider VTpass cannot sell and discover it only after the money has moved. **`service: "INTERNATIONAL"` completes the purchase, unlike chat.** Chat's INTERNATIONAL handling (`src/app/api/deai/core/route.ts`) only validates a request and then tells the user to finish it in the app — it has never actually vended one. MCP's `pay_bill` does: `list_international_options` walks VTpass's country → product type → operator → variation chain, and `pay_bill` re-fetches the chosen variation itself to derive the NGN-equivalent price from its own `variation_rate`/ `charged_amount` — never trusting a client-supplied amount, since that number is what prices the on-chain charge. Only **fixed-price** plans are payable this way for now; flexible-amount plans still redirect to the app. **Every successful `pay_bill` now returns a premium receipt, not just a text line.** Alongside the confirmation text, the response includes a branded receipt card (rendered server-side with `next/og`'s `ImageResponse` — no extra dependency — see `src/lib/deai/receiptCard.tsx`) and a link to a shareable, public receipt page at `/receipt/[request_id]` (`src/app/receipt/`). The link is keyed by `request_id`, not `tx_hash`: a transaction hash is visible to anyone watching the vault address on-chain, and the receipt page — being public and shareable by design — must not let a blockchain observer correlate a payment to the customer's verified name/address, so it never shows the purchased code/PIN either. `transaction_history` gets the same rich treatment — a statement card image alongside the plain-text list — for browsing past activity without opening the app. **`pay_bill`, `pay_bill_batch`, `transaction_history`, `check_balance`, `list_schedules`, and `describe_capabilities` also render as a real interactive card via MCP Apps (SEP-1865)** — an open MCP extension (`io.modelcontextprotocol/ui`, shipped as the protocol's first official extension 2026-01-26), not a first-party-only mechanism. The flat PNG above is a fixed image with Satori's font-subsetting limits (₦/₮ have to be worked around — see `receiptCard.tsx`'s own comment on that); the interactive card (`src/lib/deai/mcpUiTemplates.ts`) is real HTML/CSS/JS rendered by the host in a sandboxed iframe, fed the tool's `structuredContent` over `ui/notifications/tool-result` — real ₦/₮ glyphs, theme-aware (reads the host's CSS variables for light/dark), with a "View receipt" link wired through `ui/open-link`. Declared via `_meta.ui.resourceUri` on those six tools and served through two new `/api/mcp` methods, `resources/list`/`resources/read` (`src/app/api/mcp/route.ts`) — additive only: a host that never negotiates the extension just never calls `resources/read`, and every tool behaves exactly as before (text ± PNG image), per the spec's own graceful-degradation rule. **The receipt card covers every real outcome of `pay_bill`, not just full success.** `finalizePayBillResult` builds the full receipt (image + `structuredContent`) for **any** outcome that produced a real `txHash` — `SUCCESS`, `PENDING` (still confirming on-chain), or `FAILED_VENDING` (charged, refund pending) — each with its own status colour, label, and icon on the card; only a payment that never reached the chain falls back to text-only. **The card is now a genuinely premium surface, and it's where "everything AbaPay offers" lives.** Every view (`receipt`, `history`, `balance`, `schedules`, `batch`, `capabilities`) shares one gradient hairline border and drop shadow, real inline SVG icons (a distinct one per status — success/pending/failed-vending/failed — and per service — airtime/data/electricity/cable/ education/bank/international), and a fade-in transition that retriggers on every re-render, so paging through history or hitting Refresh/Cancel doesn't feel like a static swap. `describe_capabilities`'s card (the new `capabilities` view, via `getCapabilitiesForCard()` in `src/lib/deai/capabilities.ts`) lists every AbaPay service with its example phrasing, whether it's usable right here over MCP vs. app-only, and a live Paused/Available badge sourced from the same kill-switch state chat reads — the closest thing to a full menu that can be shown safely without a PIN field inside a third-party-rendered iframe (see the trust-boundary note below on why forms with a PIN aren't on the card). **The card calls tools back, not just displays them.** `transaction_history`'s card pages through history with Prev/Next (re-calling the tool with a shifted `offset` — `transaction_history` now accepts one), `check_balance`'s card has a Refresh button, and `list_schedules`' card has a Cancel button per schedule — all using the spec's "Interactive Updates" pattern (the View sending `tools/call` back through the host and getting a normal result straight back, not via a notification). Deliberately **not** extended to `pay_bill`/`pay_bill_batch`/`schedule_bill`: those need a PIN, and typing a spending PIN into a sandboxed third-party iframe is a weaker trust boundary than typing it directly into the chat, which is the boundary the rest of this doc is built around protecting. `cancel_schedule` for a single schedule `id` needs no PIN, which is why it's the one write action the card exposes. Cancelling several at once (`provider`, or an explicit `all: true`) does need the PIN, so the card never offers it. **Already-connected clients pick up new/changed tools without a manual reconnect** — mostly. `initialize` declares `tools: { listChanged: true }`, and `GET /api/mcp` with `Accept: text/event-stream` opens a real SSE stream that pushes `notifications/tools/list_changed` the moment a client starts listening, prompting a fresh `tools/list`. This matters because an already-connected client does NOT re-run `initialize` just because a new chat starts — it reuses one persistent connection to the connector indefinitely. ⚠️ Not a guarantee by itself: Anthropic's own MCP connector tracker has open, acknowledged reports of a remote connector's tool list staying stale even across a manual reconnect (`anthropics/claude-ai-mcp#137`, `#476`) — a client/platform-side caching issue outside this server's control. Until that's fixed platform-side, "Refresh tools list" from the connector's own ⋮ menu in Claude.ai is the fastest manual fallback — lighter than a full disconnect/reconnect, no re-authorization needed. Both accept optional `chain`/`token` overrides — they default to whatever was approved when the API key was created, but a caller isn't stuck with that default if it comes up short. `check_balance` returns balance + approved limit for **every** stablecoin on the chain (not just the default one), so an agent can see upfront whether an alternative is even viable. If `pay_bill` is attempted with the default and it's short on balance or on-chain allowance, the error itself checks whether another token on that same chain already has enough of both and says so by name — e.g. *"USD₮ is short, but USDC already has enough balance and an approved limit — retry with token: 'USDC'"* — rather than a dead-end message naming only the token that failed. **`pay_bill_batch` — multiple recipients in one call, matching chat's batch payments.** Chat's intent engine has parsed "send 500 to X and 1000 to Y" into multiple recipients since `intentEngine.ts`'s rule 14; MCP's `pay_bill` only ever took one. `pay_bill_batch` reuses the exact same primitives chat's own batch handler calls (`groupByChainToken`, `checkAutonomousCapacity`, `executeAgentPayment` — all in `src/lib/deai/batch.ts`): recipients are grouped by `(chain, token)` and each group's capacity is checked against its own subtotal, but if **any** group comes up short the whole batch is refused before anything moves. Once capacity clears, recipients are paid one at a time (never in parallel, to avoid relayer nonce contention) and the response reports each individually — a vend failure partway through is never reported as if the whole batch failed. One difference makes this MORE capable than chat, not just at parity: chat's `ParsedRecipient` shape has no per-recipient `variation_code` field, so a chat-driven DATA batch has no way to name each recipient's plan — `pay_bill_batch`'s structured schema does, so it requires one per DATA recipient instead of inheriting that gap. AIRTIME and DATA only, capped at 20 recipients per call. **`schedule_bill`/`list_schedules`/`cancel_schedule` bring MCP to parity with chat's automations.** Telegram/WhatsApp/X support recurring and one-off scheduled bills (the `scheduled_bills` table, run by `src/lib/scheduler.ts`'s cron); MCP has the same capability, so an agent can pay a bill immediately or schedule one for later. `schedule_bill` collects in one call what chat gathers over a multi-turn conversation, reusing pay_bill's exact validation (`requiresVariation`, `checkAccountNumber`, `checkAmountLive`, the live-catalogue provider check) and PIN gate, since a schedule is a standing spend. It charges nothing itself: if the wallet's approved on-chain allowance already covers the amount, the schedule auto-pays itself each time it's due; otherwise it's saved notify-only and something else has to call `pay_bill` when it comes due. EDUCATION and INTERNATIONAL aren't schedulable, matching chat. MCP has no persistent channel to push a "your bill just ran" message back into later on its own, so `schedule_bill` accepts an optional `customer_email` for that — without one, check `list_schedules`/`transaction_history` yourself to see what happened. **Why this exists:** third-party agent scanners like [8004scan.io](https://8004scan.io) only run a health check against a declared `a2a` or `mcp` service — see `public/.well-known/agent.json`'s `services` array, which declares both. The MCP server behind that declaration is real and working, not a stub added just to satisfy the scanner. **Same trust model as chat, not a new one.** `pay_bill` doesn't reimplement any security logic — it calls straight into the functions already backing Telegram/WhatsApp/X and the multi-recipient batch flow: `checkPinAllowed`/`verifyPin`/`recordPinFailure` (same escalating lockout), `checkServiceAllowed` (kill switches), `checkAccountNumber`/`checkAmount` (parity validation), `checkAgentSpendAllowed` (operator per-tx/daily caps), `checkAutonomousCapacity` + `executeAgentPayment` (on-chain allowance check, the shared discount engine, and vend), and `notifySpendOutOfBand` (email + every other linked channel is told the instant money moves, so a leaked API key is caught exactly like a stolen chat session would be). **Rate limiting is defense in depth, layered on top of the above, not a substitute for it** — a blanket per-IP limit across every MCP tool call, plus a tighter per-identity limit on `pay_bill`, `schedule_bill`, and `pay_bill_batch` specifically. Full numbers and mechanics: see [Rate Limiting](#rate-limiting) under Security Architecture. **Chain-agnostic — Celo or Base, whichever the linking wallet approved.** An MCP key inherits the `approved_chain`/`approved_token` recorded when it was created (same fields Telegram/WhatsApp/X already use), and `check_balance` accepts an explicit `chain` override. There is nothing Celo-specific or Base-specific in the MCP layer itself — it's the same multi-chain relayer (`src/lib/deai/relayer.ts`) and balance reader (`src/lib/deai/services.ts`) every other channel shares. **OAuth 2.1 — authorize once, in a browser, and never retype an API key again.** The `api_key`/`pin` tool *arguments* work, but a brand-new Claude conversation remembers nothing, so the human had to paste their API key every single time. The server now implements a full OAuth 2.1 authorization-code flow with **PKCE (S256 only)** and **Dynamic Client Registration**: | Endpoint | Purpose | |---|---| | `/.well-known/oauth-protected-resource` (+ `/.well-known/oauth-protected-resource/api/mcp`) | Tells the client this resource is OAuth-protected and where its authorization server is | | `/.well-known/oauth-authorization-server` (+ `/.well-known/oauth-authorization-server/api/mcp`) | Authorization-server metadata | | `/api/oauth/register` | Dynamic Client Registration (RFC 7591) — no manual client setup | | `/api/oauth/authorize` | The consent page — the only page a human ever sees | | `/api/oauth/token` | Code exchange + refresh, with refresh-token rotation | Both discovery documents are served at the plain `/.well-known/…` path **and** at the RFC path-insertion variant with `/api/mcp` appended, because different clients probe different ones. Things worth knowing about this implementation: - **The consent page proves *both* the API key and the PIN** before an authorization code is ever issued. A stolen API key alone is not enough to authorize a connector. - **OAuth never authorizes a spend.** The PIN is still required on *every* `pay_bill` call, exactly as on Telegram/WhatsApp. A Bearer token on its own can read a balance and nothing more. - **Exactly one condition returns a real HTTP 401** (with `WWW-Authenticate` + `resource_metadata`): *no credential supplied at all*. That 401 is the only signal an MCP client uses to discover "this server supports OAuth" and show a connect button. A **wrong** API key, a bad PIN, or a malformed argument stay in-band tool errors — turning those into 401s would make the client re-run the whole browser flow over a typo. - **`redirect_uri` is validated against the registered list *before* anything renders**, and a failure renders a plain error page rather than redirecting — redirecting to an unvalidated URI is precisely the open-redirect vulnerability that would leak the authorization code. - The consent page is a hand-rendered, fully self-contained HTML string (inline `