Skip to main content
Glama

⚡ 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, discoverable on 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).

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 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 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: Celo transactions carry an ERC-8021 attribution tag (src/lib/attribution.ts) crediting the Celo Builders program; a no-op on Base.

  • 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 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 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_codes 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 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 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 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.


Related MCP server: @coinbase/payments-mcp

🛠️ 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

  • 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

  • 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.tsBase: 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 Builders on-chain attribution tag (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 (⚠️ NOT AUDITED)
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_ADDRESS=0xYourAdminWalletAddress
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 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_<wallet>_<timestamp> transaction row is written (the same pattern the web app uses 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).

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://<your-domain>/.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 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.

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.

NEXT_PUBLIC_X402_ENABLED=                      # Default ON. Set to "false" to use the contract call instead

x402 is the default settlement rail on both chainsUSDC, 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.

⚠️ 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.orgConnect 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.

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 (free) hitting POST https://<your-domain>/api/schedules/run with header Authorization: Bearer <CRON_SECRET> (or x-cron-secret: <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://<your-domain>/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.

Dune dashboards — refreshed daily, automatically:

DUNE_API_KEY=your_dune_api_key        # Required by /api/cron/dune-refresh

There are three public dashboards on the abapay Dune team, and /api/cron/dune-refresh re-runs all of them:

?dashboard=

What it covers

Queries

main (default)

The original combined dashboard — Celo and Base, split by chain — dune.com/abapay/abapay-ecosystem-traction

6

base

Base mainnet only, both AbaPay deployments and both settlement rails (contract calls and x402) — dune.com/abapay/abapay-on-base

9

celo

Celo mainnet only, both AbaPay deployments (V3 → V4) and all three settlement rails (direct wallet, agent/relayer, and x402) — dune.com/abapay/abapay-on-celo

9

The per-chain dashboards exist because on the combined one every per-chain figure is a slice of a Celo+Base total, so per-chain user counts, DAU and new-vs-returning are all mixed. Each is scoped to its own chain at the source, and each tracks both contract deployments on that chain so history doesn't restart at a redeploy:

  • Base: 0xC0A4dAA04DEd9c54D1239507B5A5E645761ef488 (AbaPayV4, current) and 0xF3AeFF0c326B1277A2D8623b7694aEB5E6A565e5 (the original AbaPay V1). SQL in dune/base-chain/, deployed with node scripts/dune-base-setup.mjs.

  • Celo: 0x5df8aE2B963165b735B18Ca86B1ea448d2AA032C (AbaPayV4, current) and 0x42Fa463798Ed129a9B5Ee51721CB6db1bfCBe3b9 (AbaPayV3, original). SQL in dune/celo-chain/, deployed with node scripts/dune-celo-setup.mjs. Its agent-vs-direct-vs-x402 rail split (12_by_rail.sql) is deliberately kept as the dashboard's headline chart, unlike Base's equivalent — see that directory's README for why.

See each directory's own README for the full detail.

Automatic daily refresh — two mechanisms, both required:

Layer

What keeps it fresh

When

Data — one materialized view per dashboard (dune.abapay.result_abapay_unified_payments, dune.abapay.result_abapay_base_events, dune.abapay.result_abapay_celo_events)

Dune's own matview cron

02:00 UTC daily

Panels — the queries that have charts across all three dashboards

.github/workflows/dune-refresh.yml/api/cron/dune-refresh

03:15 UTC daily

The workflow needs two repository secrets, APP_URL and CRON_SECRET.

⚠️ Something outside this repo also calls ?dashboard=main. On 2026-08-15 the only workflow run started 03:51 UTC, yet the five main panel queries were executed again at 05:15:03–05:15:10 — 1.5s apart, which is this route's own SPACING_MS, so it is this endpoint being called by another scheduler (a Vercel dashboard cron or an external cron service predating the workflow). It is not harmful, but it masks failures: main gets a second attempt each day and therefore always looks healthy, while base — which nothing else covers — stays stale whenever the workflow run fails. Worth finding and removing so both dashboards have the same single owner.

A refresh is only "done" when the execution COMPLETES. /execute returning an execution_id means Dune accepted the job, not that the query ran: an accepted execution can still end QUERY_STATE_FAILED, leaving the panel on yesterday's result while the cron reports a clean 200. The route therefore polls every execution to a terminal state and makes one spaced retry pass over whatever genuinely didn't refresh, and the two dashboards are separated by 60s so the second one doesn't start into a rate limiter the first one just saturated. base is always the second call, which is why it was always the casualty.

A dashboard panel renders the last execution of the query behind it, and refreshing a matview does not count as an execution of that query. So the matview cron alone never moves a panel — the combined dashboard sat six days stale while its matviews were refreshing every six hours — and executing the queries alone would only re-aggregate a stale table. Both halves, every day.

Why not use Dune's own scheduler? Its built-in query scheduler runs only on the medium and large engines, and the community_fluid_engine_v2 plan has neither — requesting medium returns "Performance medium is not supported for this dataset", so the in-app schedule never fires however it is configured. Matview crons are the one piece of Dune-native scheduling that does work on this plan, which is why the data layer uses them and the panel layer uses the API.

Each dashboard has a root query that feeds the matview the rest aggregate. The cron deliberately does not execute the roots: neither has a chart of its own, so running one costs credits to update nothing. Every query the cron does execute reads a matview rather than raw chain tables — well under 1 credit for all ten, against a 2,500/month quota. A few queries are deployed but kept off the dashboards; the cron deliberately skips those, because executing a query with no panel spends credits updating something nobody can see.


🚀 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

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 <network>     # Deploy V4 (agent-initiated payments, adjustable withdrawal delay)
npx hardhat run scripts/register8004.ts --network <network> # Register the agent identity (ERC-8004)
npx hardhat run scripts/update8004uri.ts --network <network> # Re-push the agent URI so an indexer (8004scan) re-reads it

AbaPayV2.sol — hardened contract (⚠️ NOT YET AUDITED)

contracts/AbaPayV2.sol is a security-hardened successor to the original AbaPay.sol, addressing the findings in AUDIT_REPORT.md. payBill's signature and the PaymentReceived event are byte-for-byte identical to V1, so the frontend, the /api/pay calldata decoder, and the webhook's event cross-validation all 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 (⚠️ NOT AUDITED)

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

⚠️ Not audited. The contract itself carries this warning in its header. Deploy to testnet for demos; on mainnet, keep maxAgentPaymentPerTx and maxRefundPerTx small (scripts/deployV4.ts defaults to a $10-equivalent per token) until a professional audit is done, then raise them via setMaxAgentPayment / setMaxRefund.

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). It still defaults to 24h, so nothing changes unless the owner deliberately changes it. 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.

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.

# --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" 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 (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 — the x402 zero-setup path, and the fully headless (wallet-signature-only) way to self-serve an MCP API key. Writing TypeScript? sdk/ (abapay-sdk on npm) wraps both into two functions. Python? 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/ 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_codes 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

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, some, or all active schedules for the linked wallet

OAuth Bearer token or api_key

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 txHashSUCCESS, 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 offsettransaction_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 needs no PIN already (same as calling it from chat), which is why it's the one write action the card exposes.

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 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. /api/mcp applies a blanket per-IP limit across every tool call. On top of that, pay_bill, schedule_bill, and pay_bill_batch each apply their own per-identity limit (keyed by the agent_links row id, not the IP) once the PIN has been checked — the escalating PIN lockout only ever fires on a wrong PIN, so on its own it does nothing to slow down a run of correct calls from a leaked API key or OAuth token. Neither limit is the real backstop (the on-chain allowance is, same as everywhere else in this doc) — they just make a leaked credential slower to drain before its owner sees the out-of-band alert and revokes it.

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 <style>, no scripts, no fonts, no third-party assets) so it satisfies the app's CSP without exception and can't be broken by anything else in the app, styled with AbaPay's own emerald wordmark.

  • Refresh tokens rotate on every use (OAuth 2.1's requirement for public clients), so a stolen refresh token stops working as soon as the legitimate client refreshes — and the theft becomes detectable instead of silent.

Linking (no new env vars, no new table): agent_links.channel gained an 'MCP' value (supabase/migrations/019_mcp_channel.sql) alongside TELEGRAM/WHATSAPP/X. Unlike those, there's no bot to "claim" a link code with — from the app's Agent Hub tab, pick the MCP (AI Agents) tile, set a PIN, and it mints a 256-bit API key shown exactly once (only its SHA-256 hash is ever stored). A wallet can hold several MCP keys at once — one per agent/tool — each with its own label.

Connecting an AI agent (end users):

  1. In the AbaPay app, approve an on-chain spend limit for whichever chain/token you want the agent to use (Agent Hub → step 1) — this is the real ceiling; nothing below can exceed it.

  2. Still in Agent Hub, pick MCP (AI Agents), optionally label the key (e.g. "Claude"), set a PIN, and save the API key it shows you — it will not be shown again.

  3. Point your MCP client at https://agents.abapays.com/api/mcp as a remote (Streamable HTTP) server:

    • Easiest — claude.ai (web), no file editing: Settings → Connectors ("Integrations" on some accounts) → Add custom connector → paste https://agents.abapays.com/api/mcp → Save. It's now available as a tool source in any new chat.

    • Claude Desktop (local config file) — merge this into %APPDATA%\Claude\claude_desktop_config.json (don't overwrite the whole file if you already have other servers configured there):

      {
        "mcpServers": {
          "abapay": { "url": "https://agents.abapays.com/api/mcp" }
        }
      }
    • Any other MCP client: the same URL, Streamable HTTP transport — no API key or auth header at the connection level; see step 4.

  4. Authorize. If your client supports OAuth (claude.ai and Claude Desktop do), it will offer a Connect button — click it, and AbaPay's own consent page asks for your API key and PIN once, in the browser. Every future conversation reconnects automatically. If your client can't do OAuth, pass the API key as the api_key tool argument instead — it's a tool argument, not an HTTP header, so there's no separate app-level auth step.

  5. Your PIN is asked for on every payment either way. Authorizing the connector does not authorize spending. If an agent claims it can pay without your PIN, something is wrong.

Every tool declares annotations (title, readOnlyHint/destructiveHint, idempotentHint, openWorldHint) — pay_bill is correctly flagged destructive/non-idempotent (it moves real money and calling it twice pays twice), while describe_capabilities/check_balance are read-only — so a client can warn a user appropriately before letting an agent invoke it.

Getting listed in claude.ai's Connectors Directory (so users can find AbaPay by browsing/ searching instead of pasting the URL) is a separate step from what's built here — it's an organizational submission through Anthropic, not a code change:

  • Requires a Team or Enterprise claude.ai organization (submission happens in admin settings → Directory → New submission); only Owners (or a delegated role on Enterprise) can submit.

  • Requirements confirmed against Anthropic's own submission docs: tool title + readOnlyHint/destructiveHint annotations (✅ done above), a public documentation URL (https://abapays.com/docs — live), a privacy policy URL (https://abapays.com/privacy — live), an icon, and reviewer test-account credentials.

  • OAuth 2.0 is in place. The directory requires OAuth for authenticated connectors, and AbaPay has it (see the OAuth section above): authorization code + PKCE, Dynamic Client Registration, discovery metadata, and a real 401 so clients can discover it. What remains for a directory listing is the organizational submission itself (a Team/Enterprise org, an icon, and reviewer test-account credentials) — not code.

  • Until submitted/approved, "Add custom connector" with the URL (above) is a fully working, unrestricted way to use it today — the directory only adds discoverability, not capability.

A2A Server (Agent2Agent)

The same tools, reachable by other agents over A2A instead of MCP. Two files, no new capability:

Surface

Path

Agent Card (discovery)

/.well-known/agent-card.jsonsrc/app/.well-known/agent-card.json/route.ts

JSON-RPC endpoint

/api/a2asrc/app/api/a2a/route.ts

One implementation, two protocols. The tool definitions and their implementations were moved out of src/app/api/mcp/route.ts into src/lib/deai/mcpTools.ts, which both routes import. /api/mcp and /api/a2a are now transport shims over one callTool(). That is deliberate: A2A has no private path to money — the PIN gate, escalating lockout, on-chain allowance ceiling, kill switches and operator spend caps all live below both routes, so a new protocol changes how an agent asks, never what it may do. (The move was mechanical; tool logic is byte-identical.)

⚠️ /.well-known/agent-card.json is not /.well-known/agent.json. The latter is AbaPay's ERC-8004 registration card — the on-chain identity 8004scan and Aigora read. The two specs collided on the agent.json filename historically, which is exactly why A2A moved its card to agent-card.json. Different documents, different consumers; overwriting one with the other silently breaks the agent's on-chain listing.

No LLM in the invocation path. Chat channels route free text through parseIntent() because a human typed it. A2A is machine-to-machine, so invocation is a structured DataPart carrying { skill, args }, validated against the same TOOLS schema MCP publishes. A text part gets the skill catalogue back rather than a guess — re-interpreting "send 5000" with a language model in an agent-to-agent payment path adds a failure mode with no upside.

Synchronous by design. Every skill completes inside the request, so message/send returns a final Message (spec-legal) and no Task is created. The card therefore declares streaming: false and pushNotifications: false, and tasks/* + message/stream return -32004 UnsupportedOperation rather than being half-implemented — there is no task store to query, and declaring capabilities we don't have would strand a peer waiting on updates that never arrive.

Auth. Authorization: Bearer … accepts either credential MCP accepts, by prefix: an aba_mcp_… Agent Hub key, or an OAuth 2.1 access token. A2A has no per-call api_key argument, so the header is the only place a credential can arrive. A missing credential and a wrong one stay distinct conditions, same rule as MCP. pay_bill still requires the PIN on every call.

Operator control. CHANNEL_A2A in platform_settings.kill_switches — separate from CHANNEL_MCP so pausing one surface doesn't pause the other. No migration needed; the column is free-form JSONB and a missing key reads as enabled.

Call it:

curl -X POST https://agents.abapays.com/api/a2a \
  -H "Authorization: Bearer aba_mcp_…" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{
        "kind":"message","role":"user","messageId":"1",
        "parts":[{"kind":"data","data":{"skill":"check_balance","args":{}}}]}}}'

x402 Settlement (main app only)

⚠️ On by default; NEXT_PUBLIC_X402_ENABLED=false falls back to the contract call. Note the wallet-warning trade-off documented in the env section above — the EIP-3009 signature x402 requires is structurally what a drainer asks for, so some scanners flag it.

The web app's payment flow settles via x402 — through Celo's own facilitator (api.x402.celo.org, built by Celo Core Co. — see src/app/api/pay/x402/route.ts), not thirdweb — whenever the user is paying with USDC, USD₮, or USA₮ on Celo. Each token settles against its own EIP-712 domain (X402_TOKEN_EIP712 in that route) since Circle's USDC and Tether's USD₮/USA₮ deployments don't share one. The same "Confirm & Pay" button routes through x402 for any of the three tokens on Celo and through the normal payBill contract call for everything else (a token without EIP-3009, Base when NEXT_PUBLIC_BASE_X402_ENABLED=false, or x402 unconfigured). This makes the payment genuinely visible on x402scan — not a relabeled transaction — because x402 settlement requires an EIP-3009 (transferWithAuthorization) signature from the payer for that specific payment. That requirement is also precisely why the scanners object.

The 402 challenge itself is built in-house (a plain x402 v1, body-based response) rather than relying on any SDK's default — that's a deliberate choice, since thirdweb's own settlePayment() always delivers a fresh challenge via a base64 header with an empty JSON body, which generic x402 scanners (x402scan's discovery crawler included) don't parse, causing registration to silently fail with a correct-looking 402 status but no usable challenge.

Client-side, the challenge is read, signed and retried by src/lib/x402Pay.ts using the same viem wallet client every contract call uses — one wallet connection for the whole flow, no second stack alongside wagmi. Signing in-house also lets the page read the server's actual answer: a settlement failure that carries a tx_hash means the money already moved, and the app must never "retry" it on the contract-call rail.

A refused settlement is retried before the rail is abandoned

🔵 Why a refused x402 settlement gets a real retry instead of an immediate fallback. A transferWithAuthorization simulation can revert (the facilitator answers unable to estimate gas / invalid_payload) with no transaction and nothing moved. Falling back to the contract call in that case costs two more prompts (approve + payBill) for a bill the x402 rail can often settle on a second attempt costing one — so the retry happens automatically, at three levels:

  1. Before the facilitator is called at all. src/lib/x402Settle.ts decides the EIP-3009 revert conditions that are visible in the payload — an expired validity window, a clock-skewed validAfter, a wrong recipient, an amount that disagrees with what the server is about to declare as required — so a payload that cannot settle is answered with a fresh challenge instead of an opaque revert. The signed value, not the recomputed price, is what paymentRequirements declares and what the DB row and any refund record: the two can differ by an exchange-rate tick, and a facilitator handed a mismatched pair refuses.

  2. Server-side, once, on the same authorization. Safe by construction — an EIP-3009 nonce is single-use, so the token accepts it at most once however many times it is submitted. A retry can duplicate a request but never a transfer.

  3. Client-side, once, on a fresh signature — only when the server marked the refusal retryable, and never when it carries a tx_hash. The second prompt is announced, because an unexplained one reads as an app that ignored the first.

🔵 No retry is offered until the CHAIN says the money did not move. This is the important one. unable to estimate gas / invalid_payload is ambiguous by construction: because EIP-3009 nonces are single-use, re-simulating an authorization that already succeeded necessarily reverts, and a revert during estimation is reported the same way whether nothing has happened yet or everything already has. Message-matching alone can't separate those two cases — offering a retry on the wrong read means asking the payer to sign a fresh nonce and pay a second time for one bill. So the code doesn't rely on the message at all. Every EIP-3009 token exposes authorizationState(authorizer, nonce) -> bool, the chain's own record of whether that exact authorization was consumed, and it is asked before any retry is offered (buildAuthorizationStateCall, and authorizationWasConsumed in the settle route). Spent means the payment is reported as settled with no transaction hash — the client stops, the contract-call fallback is suppressed, and an operator alert carries the payer and nonce for manual reconciliation. An unreadable answer is treated the same as spent: a needless fallback costs two prompts, a wrongly-offered retry costs the payer real money.

Wording is still used as a cheap first filterFiatTokenV2: authorization is used or canceled is refused outright, while nonce too low and replacement transaction underpriced (the facilitator's own EOA racing itself, a different nonce entirely) stay retryable — but the chain, not the filter, is what actually authorises a retry.

Which rail a payment takes also depends on the wallet, not just the token. x402 settles on an eth_signTypedData_v4 signature that has to come back to the page, and a WalletConnect session that never negotiated that method drops the request on the floor — no prompt, no error, nothing back. Only that is checked, from the session itself: see walletCanSignTypedData in src/lib/walletEnv.ts.

🔵 Routing is never by wallet NAME. Every wallet is asked whether it can sign typed data, and its own answer decides — not a hardcoded assumption about a specific wallet's behavior (Valora, for instance, renders a typed-data request as "Verify wallet" and can report a successful connection without ever returning a signature). When the answer never comes, the signature times out, nothing has been sent to settle (src/lib/x402Pay.ts posts the settle request itself, on the line after the signature is awaited, so unwinding it means a late signature reaches nobody) and the page falls back to the contract call on its own.

That signature requirement is exactly why this is scoped to the main app only: the agent-initiated flow above depends on paying with zero signature at payment time (the whole point of setSpendingAllowance), which is fundamentally incompatible with x402's per-payment-signature model. Telegram/WhatsApp/X and the autonomous scheduler never use x402 and are unaffected — those payments already execute from RELAYER_ADDRESS, the same wallet registered under the ERC-8004 identity below, so they're already attributable to the agent without needing x402.

  • Scope: Celo + USDC/USD₮/USA₮, confirmed live — not just a caution. Native Celo USDC (Circle's FiatTokenV2) and native Celo USD₮ (Tether's deployment) both implement EIP-3009 transferWithAuthorization; USDm doesn't (Mento tokens expose only EIP-2612 permit()), so there's no signature scheme to settle it — which is exactly why USAT replaced it in the picker with. Not a self-imposed limit — if support is added for another token later, no code change is needed beyond adding its EIP-712 domain, since the token/decimals are already resolved generically via resolveTokenOnChain.

  • Prepaid credits, not a billing subscription. Celo's facilitator charges a flat $0.001/settlement from a prepaid USDC credit balance (CELO_X402_API_KEY) — top up at x402.celo.org. At 0 credits, /settle starts returning 402 and the app sends a Telegram alert (this is an operator problem, not a payer one — retrying won't help until topped up).

  • No automatic fallback to the contract-call flow on x402 failure. If x402 errors after reaching the server, retrying via the contract-call path could double-charge the user if the facilitator's settlement actually landed but the response was lost in transit — the same class of risk processBlockchainPayment's own paymaster-fallback logic is careful about. The user sees a clear error and can retry manually instead.

  • Funds land in the same vault. payTo is set to the existing AbaPayV3 contract address — the same one the admin dashboard already reads balances from and manages refunds/withdrawals for. The vault's balanceOf doesn't care how tokens arrived, so x402-settled funds are indistinguishable from contract-call funds to all existing admin tooling. No contract changes.

  • Vend/refund logic is shared, not duplicated. Both the on-chain path (/api/pay) and the x402 path (/api/pay/x402) call the same executeVend() (src/lib/vend.ts) once payment is verified — so the automatic refund safety net applies identically to both rails.

  • transactions.payment_method (010_x402_payment_method.sql) distinguishes CONTRACT from X402 at a glance, alongside the existing source_channel (a different axis — UI channel vs. settlement rail).


🔌 Provider Data & Operator Controls

Live provider catalogue (VTpass-sourced)

src/lib/vtpassCatalog.ts is the single source of truth for every provider list in the app. It calls VTpass's /services?identifier=… and returns the provider's real serviceID, VTpass's own product name, VTpass's own logo URL, and its published minimium_amount (sic) and maximum_amount.

Five categories are supported, keyed by the identifiers VTpass actually accepts (two of which had to be discovered — the obvious guesses return 011 "Category Does not Exist"):

App concept

VTpass identifier

Airtime

airtime (with foreign-airtime filtered out — international is its own flow)

Data / Internet

datanot mobile-data/internet

Electricity

electricity-bill

Cable TV

tv-subscriptionnot cable-tv

Education

education

Caching and failure behaviour (this is the important part):

  • A module-level Map cache with a 1-hour TTL, shared in-process. The browser reaches it through GET /api/providers?category=… (rate-limited 60/min per IP, Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400); chat and MCP call getCatalog() directly. Both land on the same cache, so there is exactly one source of truth.

  • The cache is never evicted on failure — only overwritten on success. The fallback chain is fresh cache → live fetch → stale cache → bundled seed, and getCatalog() never throws and never returns an empty list, so a picker can be rendered unconditionally and a VTpass blip can't blank it mid-purchase.

  • A stale answer is returned with stale: true, and /api/providers then serves it no-store so a brief outage can't get frozen into an edge cache for an hour.

  • src/lib/providerFallback.ts is the last-resort offline seed — the only hardcoded provider data left. Its logos are deliberately local files, because the one code path that exists for "VTpass is unreachable" must not render a dozen broken remote images.

Why this replaced the hardcoded lists: the old lists advertised showmax, spectranet and jamb — all three return Service is Not Valid on this merchant account, so a user could pick one, fill the form, pay on-chain, and only then have the vend fail into the refund path. They also omitted glo-sme-data and 9mobile-sme-data, which are live and were unreachable.

Amount limits come from the same records: limitsFor(category, serviceID) and limitsForIntent(intent, provider) return the live per-provider {min, max}, and null when VTpass publishes none — so "unknown" falls back to the caller's service-level default rather than being mistaken for "unlimited".

Kill switches (two-level: master + per-provider)

platform_settings.kill_switches holds a two-level key system written by the admin dashboard:

Level

Keys

Per-service master

MASTER_AIRTIME, MASTER_INTERNET, MASTER_ELECTRICITY, MASTER_CABLE, MASTER_EDUCATION, MASTER_INTERNATIONAL

Per-provider (keyed by VTpass serviceID)

AIRTIME_mtn, INTERNET_airtel-data, ELEC_ikeja-electric, CABLE_dstv, EDU_waec, …

A switch is on unless explicitly false (a missing key means enabled), and a payment is refused when either level is off — the same || the web app uses.

killSwitchKeysFor(intent, provider) in src/lib/serviceRules.ts maps an agent intent onto exactly those keys, normalising the provider through resolveServiceId first so a loose "ikeja" from chat resolves to the ELEC_ikeja-electric key the operator actually toggled. checkServiceAllowed() is then the gate every non-web channel must pass. Settings are cached for 30 seconds, so flipping a switch takes effect within half a minute everywhere.

⚠️ Why this mapping matters: killSwitchKeysFor() is what makes "pause Electricity" in the dashboard actually stop chat, MCP and the autonomous scheduler, not just the website — all four surfaces resolve the same intent to the same MASTER_/per-provider keys, so a switch flipped in one place is enforced everywhere.

Separate from the per-service switches, checkAgentSpendAllowed() enforces the operator's controls over the agent specifically: agent_enabled (master kill for all agent payments), agent_autonomous_enabled (kills only unattended/scheduled execution), agent_max_ngn_per_tx, agent_daily_cap_ngn (per user, per UTC day), and ai_chat_enabled for the in-app widget. These sit on top of the on-chain allowance, never instead of it.

Bank Transfer has a standalone dashboard toggle (BANK key — no per-provider breakdown, since it settles through Monnify/Moniepoint rather than a picker of VTpass providers), checked by both the agent gate (BANK_TRANSFER in serviceRules.ts) and the web app's isCurrentServiceDisabled in page.tsx.

Four more standalone switches pause an entire channel rather than a product — CHANNEL_WHATSAPP, CHANNEL_TELEGRAM, CHANNEL_X, CHANNEL_MCP — enforced by isChannelEnabled() in serviceRules.ts. WhatsApp/Telegram/X are checked once at the top of the shared /api/deai/core engine (all three route through it); MCP is checked at tools/call in /api/mcp. Same "missing key = enabled" default as every other switch here.


🧪 Testing & CI

npm test              # Run the unit test suite (Vitest)
npm run test:watch    # Watch mode
npm run test:coverage # Coverage report
npm run typecheck     # tsc --noEmit — catches type errors before they hit a deploy

Tests currently cover the security-critical pure logic: PIN hashing/verification, internal service auth, and the payment amount/token verification invariants (the checks that stand between a user and an unpaid bill).

CI runs on every push/PR via .github/workflows/ci.yml: typecheck → lint → build → tests → dependency audit. The typecheck step exists specifically to catch TypeScript errors before they reach a production deploy.


🗄️ Database Setup

Beyond the core tables, run the migrations in supabase/migrations/ in order in the Supabase SQL editor:

  • 001_rate_limits.sql — creates the rate_limits table required by src/lib/rateLimit.ts. Rate limiting silently fails open without this table, so apply it before relying on the throttles protecting your billable VTpass / WhatsApp / Claude endpoints.

  • 002_customer_details.sql — customer details captured on receipts.

  • 003_scheduled_bills.sql — Bill Pay & Autopay Agent scheduling.

  • 004_agent_links.sql — links a wallet to a Telegram/WhatsApp/X identity so the DeAI agent can recognise a user. The security boundary is the on-chain spendingAllowance in AbaPayV3, not this table — it's a UX mirror only.

  • 005_autonomous_schedules.sql — upgrades scheduled bills for true unattended execution, safe specifically because AbaPayV3's on-chain allowance bounds worst-case exposure.

  • 006_agent_admin_controls.sql — operator kill switches for the agent (agent_enabled, agent_autonomous_enabled, ai_chat_enabled, per-tx/daily NGN caps), settable from the admin dashboard's Agent tab without a redeploy or contract call.

  • 007_transaction_source_channel.sql — records which channel (web app / Telegram / WhatsApp / X / an unattended schedule) originated each transaction, for operator alerting.

  • 008_refund_queue.sql — queued refund pipeline for vends that fail after payment is taken.

  • 009_support_tickets.sql — support tickets from the web app and every social channel, with admin replies routed back to the user's original chat.

  • 010_x402_payment_method.sql — adds payment_method (CONTRACT | X402) to transactions, distinguishing the settlement rail (see x402 settlement).

  • 011_one_off_schedules.sql — adds run_once_at and batch_id to scheduled_bills, so a single chat request can create a one-time future payment (frequency = 'once') or a multi-recipient batch, on top of the existing recurring monthly/weekly/daily schedules.

  • 012_schedule_notify_channel.sql — records which channel a schedule should report back on.

  • 014018_discount_*.sql — the discount-campaign engine: campaigns, per-campaign caps, destination/IP caps, per-phone caps + a fraud toggle, and exclusions/full-status counting. (There is no 013; numbering skips it.)

  • 019_mcp_channel.sql — adds 'MCP' to agent_links.channel alongside TELEGRAM/WHATSAPP/X, so an AI agent is a first-class linked channel.

  • 020_mcp_oauth.sql — the OAuth 2.1 tables: dynamically registered clients, single-use authorization codes (with their PKCE challenge), and access/refresh token records (hashed, with rotation and revocation).


📱 Testing with MiniPay

AbaPay is highly optimized for mobile Web3 experiences. To test the dApp within the Celo MiniPay environment:

  1. Deploy the project (e.g. to Vercel).

  2. Set NEXT_PUBLIC_NETWORK to celo-sepolia (testnet) or celo (mainnet), and NEXT_PUBLIC_APP_MODE to sandbox or production accordingly.

  3. Open the Opera Mini browser on Android, navigate to the MiniPay tab, and enter your deployed URL.

Testing as a Farcaster Mini App

The app ships with Farcaster frame metadata (public/.well-known/farcaster.json and frame config in layout.tsx). Deploy to a public URL, then share the link in a Farcaster client that supports Mini Apps to launch it directly.


🛡️ Security Architecture

  • No-Log Keys: VTpass secret keys, Supabase service role key, Telegram tokens, and all other secrets are strictly contained within server-side API routes — never exposed to the client bundle.

  • Replay Protection: Every blockchain transaction hash is recorded and checked against a persistent ledger (a Supabase table with a unique constraint on the tx hash) before a utility vend is triggered. ⚠️ In-memory tracking alone is not safe in serverless environments: state resets on cold starts and isn't shared across concurrent instances, which would allow the same transaction hash to be replayed for multiple vends.

  • On-Chain Verification: Every payment is independently verified against the blockchain (transaction receipt, contract address, and amount) server-side before any bill is vended — the client-submitted payload is never trusted blindly. Under Base gas sponsorship, the top-level transaction's to can be a bundler/EntryPoint contract rather than the AbaPay contract itself, so the webhook additionally decodes the transaction's logs and requires that the AbaPay contract genuinely emitted PaymentReceived — this holds regardless of how deeply nested the call was.

  • Event Cross-Validation: The webhook decodes the PaymentReceived event and requires that its payer, token, amount, and account number all match the pending record before vending. This blocks the class of attack where a user has a small pending intent and then manually sends a different (or larger/smaller) transfer to the contract hoping it gets attached to the wrong record.

  • Stale Intent Expiry: Pre-flight intents (records created before signing) that never result in an on-chain transaction are automatically expired by a scheduled cleanup (/api/cleanup, every 15 min) so they don't linger as PENDING forever. This only ever touches preflight_-prefixed rows, so a real broadcast transaction can never be expired.

  • Webhook Acknowledgment: The webhook always returns 2xx once a request passes signature verification, even when no matching transaction record is found (test pings, unrelated activity, or a payment intent that hasn't synced yet are normal, expected outcomes — not delivery failures). Returning a non-2xx here would cause Alchemy to eventually auto-disable the webhook after repeated "failures" that were never really failures.

  • VTpass Delayed-Status Webhook: /api/webhook/vtpass replies {"response": "success"} immediately — the exact acknowledgement VTpass parses for — and only then does the real work (via after()), because VTpass requires a prompt, lightweight reply and retries anything else as an unacknowledged delivery. The push itself is never trusted: the handler re-queries VTpass server-to-server with our API keys and acts only on that confirmed status.

  • Rate Verification: The crypto amount paid is checked server-side against the platform's live exchange rate before vending, preventing underpayment exploits even if the client is tampered with.

  • Smart Contract Vault: User stablecoins go directly into the immutable AbaPay.sol smart contract vault. Only the contract owner's cryptographically signed transaction can withdraw funds — no backend service ever holds custody of user funds directly.

  • Automatic Refunds: If a verified on-chain payment fails to vend (provider outage, invalid details, etc.), the transaction is flagged and refunded back to the user's wallet, with the refund transaction hash recorded on the ledger.

  • Refund Verification: /api/admin/refund verifies the refund on-chain (token, recipient, and amount all decoded from the transaction's ERC-20 Transfer logs) before marking a transaction REFUNDED — an admin cannot record a refund that never actually happened.

  • RPC Failover: On-chain reads use viem's fallback() transport across multiple RPC endpoints (src/lib/chain.ts), so a single downed provider doesn't halt payment verification.

  • Content-Security-Policy: Shipped in Content-Security-Policy-Report-Only mode (next.config.ts) — surfaces violations without risking breakage to wallet connections. Promote to enforcing (Content-Security-Policy) once verified against real wallet flows.

  • Admin Auth: Admin-only API routes and the /admin dashboard are gated behind dedicated authentication (src/utils/adminAuth.ts), separate from the public storefront. Auth is a wallet-signature challenge verified against the contract owner, with a 12-hour session expiry and timestamp replay protection.

  • Internal-Only AI Routes: The DeAI "brain" (/api/deai/*) is reachable only by the app's own bot webhooks via a signed internal-service token (src/utils/internalAuth.ts). This prevents the public internet from impersonating any user by their chat ID / phone number / X ID, or burning the Claude API budget.

  • Bot Webhook Signatures: The WhatsApp and X webhooks verify Meta's X-Hub-Signature-256 / X's x-twitter-webhooks-signature HMAC on every inbound payload (when the corresponding secret is configured), and Telegram verifies its secret token — so message events can't be forged.

  • Hashed Transaction PINs: DeAI PINs are stored as salted scrypt hashes (src/utils/pinSecurity.ts), never plaintext, with legacy plaintext values transparently upgraded on next use and a 4-attempt lockout.

  • Scoped Paymaster Proxy: The gas-sponsorship proxy (/api/paymaster) allowlists only ERC-7677 paymaster JSON-RPC methods, so it can't be abused as a general-purpose RPC relay running on your CDP key.


📖 User-Facing Documentation Surfaces

Four surfaces tell users what AbaPay does. They are not generated from anything — they go stale silently unless deliberately updated, so treat them as part of the change, not as an afterthought:

Surface

Where

Reached from

Docs & FAQ page

src/app/docs/page.tsx

"Docs & FAQ" link in AppFooter

Terms of Service (full)

src/app/terms/page.tsx

"Terms" link in AppFooter

Privacy Policy (full)

src/app/privacy/page.tsx

"Privacy" link in AppFooter

This README

README.md

GitHub

⚠️ /terms and /privacy are written by engineers, not lawyers, and have not had legal review. Neither should be treated as legally vetted until a qualified lawyer has reviewed them; /terms carries a visible notice at the bottom saying exactly that, and it must survive any rewrite of the page.

/docs is the single docs + FAQ surface — there is no separate in-app Terms/Privacy/FAQ modal.


AbaPay is operated by Masonode Technologies Limited, a company registered with the Corporate Affairs Commission (CAC) of the Federal Republic of Nigeria under RC 9524980.


📄 License

Code in this repository is MIT licensed — free to use, fork, and build on, including for independent agents/apps integrating AbaPay's MCP/x402 rails. This covers the code only; it does not license the AbaPay name/brand or make any claim about the legal terms of using the live abapays.com service, which are set separately by /terms.


👨‍💻 Maintainer

Built and maintained by Oluwafemi Olagoke (@investorphem).

Focusing on Web3, Decentralized AI, and scalable blockchain applications.

Available Tools

10 tools
cancel_scheduleCancel ScheduleAInspect

Cancel one or more active schedules. Pass id for exactly one, provider for all of that provider's, or neither to cancel everything.

ParametersJSON Schema
NameRequiredDescriptionDefault
idNo
api_keyNo
providerNo

TDQS

A3.9/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description discloses important behavior: it cancels only active schedules, and the 'neither' case cancels everything, which is a critical side effect. However, with no annotations provided, it leaves the agent to infer whether cancellation is irreversible, whether confirmation is returned, and what happens on invalid inputs.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two short sentences with no filler. The first sentence states the action and scope; the second front-loads the exact parameter selection rules. Every phrase earns its place.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness3/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The core invocation behavior is well covered, especially the dangerous cancellation of all schedules. Missing are return/confirmation behavior, error handling, and any role of api_key; with no annotations and no output schema, the description should provide at least a hint about these.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The description provides strong semantics for id and provider, including exclusivity and the empty-argument behavior, which is crucial given 0% schema description coverage. However, the api_key parameter is never mentioned, so one of the three parameters remains entirely unexplained.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description uses a specific verb ('Cancel') and resource ('one or more active schedules'), and clearly distinguishes the three targeting modes. It is immediately obvious what the tool does and how it differs from schedule-creation or listing siblings.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives explicit guidance on when to pass id versus provider versus neither, which is strong usage direction. It does not explicitly mention alternatives or when not to use the tool, but no sibling exists for cancellation, so the context is clear.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

check_balanceCheck BalanceAInspect

Check a linked wallet's stablecoin balances and remaining agent spending allowance. Works with no arguments once authorized via OAuth; otherwise pass the api_key.

ParametersJSON Schema
NameRequiredDescriptionDefault
chainNoDefaults to the chain approved when the key was created.
api_keyNoAbaPay MCP API key (starts with aba_mcp_). Not needed over OAuth.

TDQS

A3.8/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries full responsibility for behavioral disclosure. It does reveal the auth requirement and that arguments are optional in OAuth mode. However, it does not explicitly state that this is a read-only, non-destructive operation (though 'check' implies it), nor does it mention rate limits or potential errors. There is a clear gap in side-effect disclosure.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two sentences, front-loaded with the core purpose and followed by the key operational nuance. Every word earns its place, with no fluff or redundancy. It is appropriately sized for a tool with two optional parameters.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the low complexity (two optional params, no output schema, no annotations), the description covers the essential aspects: what the tool does and how authentication works. It does not describe the return value in detail, but 'check balances' implicitly conveys the result. Minor gaps remain around explicit read-only assurance and what happens on invalid credentials, but these are not critical for correct invocation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so the baseline is 3. The description adds the conditional context that api_key is only needed without OAuth, but the schema already states 'Not needed over OAuth'. No extra meaning is added for the chain parameter. The description does not compensate beyond the schema's existing parameter descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific action ('Check') on a specific resource ('a linked wallet's stablecoin balances and remaining agent spending allowance'). This clearly differentiates it from siblings like transaction_history or pay_bill. An agent can immediately understand the tool's function without opening the schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The purpose sentence implies when to use the tool (to check balances/allowance), but there is no explicit mention of alternatives or when not to use it. The auth guidance ('Works with no arguments once authorized via OAuth; otherwise pass the api_key') is operational rather than usage-selection guidance. No sibling is named as a substitute.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

describe_capabilitiesDescribe CapabilitiesAInspect

List what AbaPay can pay (airtime, data, electricity, cable, etc.), any services currently paused, and example requests. Call this first if unsure what is supported.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.5/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden of explaining behavior. It clearly states this is a listing/information operation and specifies the kinds of information returned (supported services, paused items, example requests). There are no hidden side effects implied, and the read-only nature is strongly conveyed by 'List' and 'Call this first.'

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two sentences with no filler. The first sentence front-loads the core purpose and output content, and the second sentence adds a practical usage directive. Every word earns its place.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given there is no output schema, the description covers return content by naming the categories of information an agent can expect. It also provides situational context ('if unsure what is supported') and has no parameters to document. The tool is fully specified for correct invocation and interpretation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool has zero parameters, so the description does not need to explain parameter meaning. The baseline of 4 applies because param semantics are trivially satisfied and no additional explanation is required.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description uses a specific verb ('List') and identifies the resource ('what AbaPay can pay'), along with two additional output categories (paused services, example requests). This clearly distinguishes it from the other tools, which handle balances, plans, payments, and schedules. The phrase 'Call this first if unsure what is supported' reinforces its role as a general discovery tool.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explicitly states when to use the tool: 'if unsure what is supported.' While it does not name sibling tools or describe when not to use it, the broad capabilities focus makes the primary use case obvious. It could be stronger by naming alternatives, but the guidance is clear and actionable.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_international_optionsList International OptionsAInspect

Browse the live international top-up catalogue (170+ countries) one level at a time: country → product type → operator → priced plan.

ParametersJSON Schema
NameRequiredDescriptionDefault
countryNo
operator_idNo
product_type_idNo

TDQS

A3.5/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the burden of disclosing behavior. It implies a read-only operation ('browse') and indicates live data, but does not explicitly state it has no side effects, what happens with partial or empty parameters, or any rate limits or pagination. The hierarchical navigation is mentioned but not fully specified.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single sentence with no wasted words. It front-loads the core action and resource, then adds the hierarchy, making it efficient and easy to parse.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given no output schema, no annotations, and optional parameters, the description is incomplete. It does not state what is returned at each level (e.g., list of countries when empty, list of plans when operator_id provided), nor does it specify the required sequence or behavior with empty parameters. An agent lacks enough detail to call the tool correctly without additional inference.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must compensate. It maps the parameters (country, product_type_id, operator_id) to the hierarchy, giving each meaning and indicating the traversal order. However, it does not clarify formats, required combinations, or whether levels can be skipped, leaving some ambiguity.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool browses the live international top-up catalogue and specifies the hierarchy (country → product type → operator → priced plan). It uses a specific verb 'browse' and a clear resource, distinguishing it from domestic list_plans implicitly, though it does not name alternatives explicitly.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Usage is implied by 'browse the live international top-up catalogue' and the hierarchy, but there is no explicit guidance on when to use this tool versus list_plans or other siblings. No mention of when not to use it or what conditions warrant it.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_plansList PlansAInspect

List the REAL, currently purchasable plans for DATA, CABLE, or EDUCATION — exact codes and current prices. Always call before pay_bill for these three services.

ParametersJSON Schema
NameRequiredDescriptionDefault
serviceYes
providerYes

TDQS

A3.8/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the full behavioral burden. It adds useful context that plans returned are REAL and currently purchasable, not stale or hypothetical, and that codes/prices are exact. However, it does not disclose whether the operation is read-only, whether any auth is needed, or any other side effects.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, front-loaded sentence followed by a short workflow directive. Every word earns its place, and the core purpose is immediately visible.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness3/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple 2-parameter tool this is adequate, and the pay_bill prerequisite adds workflow context. However, there is no output schema and the description does not explain the return structure beyond 'exact codes and current prices,' and the provider parameter remains undocumented, leaving the tool somewhat incomplete.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must compensate, but it only covers the service parameter by naming the three enum values. The provider parameter is completely unexplained, including its format or whether it corresponds to a listed provider. This is a meaningful gap for an agent trying to invoke the tool correctly.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb (List) and resource (real, currently purchasable plans) with an explicit service scope (DATA, CABLE, EDUCATION) and promises exact codes and current prices. This clearly distinguishes it from sibling tools like list_international_options, so an agent can identify what this tool is for.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explicitly tells the agent to always call this tool before pay_bill for these three services, which is strong contextual guidance. It does not spell out when not to use it or name alternative tools, but the workflow dependency is clearly stated.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_schedulesList SchedulesCInspect

List active recurring/one-off bill schedules for the linked wallet. No PIN required.

ParametersJSON Schema
NameRequiredDescriptionDefault
api_keyNo

TDQS

C2.9/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full behavioral burden. The 'No PIN required' disclosure is genuinely useful, and 'active' scoping clarifies what is returned. However, it does not describe output format, ordering, or pagination behavior, which is a gap for a list tool.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two sentences with zero waste. The core purpose is front-loaded and the behavioral note (No PIN required) is appended efficiently. Every word earns its place.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple one-param tool the purpose is stated, but the api_key parameter is left undocumented, no output format is described, and there is no return-value guidance. Given no output schema and no annotations, the description should compensate more.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters1/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% and the sole parameter api_key is completely undocumented in both the schema and the description. The description adds no meaning to the parameter, failing to compensate for the coverage gap.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a clear verb (List) + resource (active recurring/one-off bill schedules) + scope (linked wallet). It distinguishes from siblings by action type: schedule_bill creates, cancel_schedule cancels, while this reads. It is specific but doesn't explicitly name which sibling it is not, so it falls just short of a 5.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No explicit when-to-use guidance, no alternatives named, and no exclusion conditions. Usage is only implied by the verb 'List' in contrast to sibling action verbs. An agent gets no help deciding between this and transaction_history or list_plans.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

pay_billPay BillAInspect

Pay a real bill — Nigerian (airtime, data, electricity, cable, WAEC/JAMB) or international airtime/data — from the linked wallet, settled on-chain. Executes immediately; no delay parameter exists.

ParametersJSON Schema
NameRequiredDescriptionDefault
pinYes
chainNo
tokenNoUSD₮ and USA₮ are Celo-only; Base supports USDC only.
api_keyNo
countryNo
serviceYes
providerNo
amount_ngnNo
meter_typeNo
operator_idNo
account_numberYes
customer_emailNo
variation_codeNo
product_type_idNo

TDQS

A3.7/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the burden of behavior disclosure. It does state key traits: immediate execution, on-chain settlement, and funding from the linked wallet. Yet it omits important details such as irreversibility, failure modes, confirmation behavior, or the requirement of a PIN, which is a required schema parameter.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two tight sentences that front-load the core purpose and scope, followed by the behavioral constraint. No filler, repetition of the title, or extraneous detail.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity—14 parameters, 4 enums, no annotations, and no output schema—this two-sentence description is far from sufficient. It does not explain how to construct a valid request for different bill types, which parameters are conditional, or what the response/outcome looks like.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is only 7%, so the description must compensate. It offers some genre-level context (Nigerian vs international, airtime/data/etc., on-chain settlement) but leaves most parameters—api_key, provider, meter_type, operator_id, variation_code, product_type_id, customer_email, amount_ngn—entirely unexplained.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly identifies the operation: paying a real bill from a linked wallet, settled on-chain. It scopes the supported categories (Nigerian airtime/data/electricity/cable/education, international airtime/data) and notes immediate execution, which differentiates it from scheduling tools.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description conveys that this tool executes immediately and notes 'no delay parameter exists', effectively signaling it is for instant payments. However, it does not explicitly name alternative tools like schedule_bill or pay_bill_batch, nor does it provide explicit when-not-to-use conditions.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

pay_bill_batchPay Bill BatchBInspect

Pay airtime or data to 2-20 recipients in one call, one PIN for the whole batch. All-or-nothing on capacity.

ParametersJSON Schema
NameRequiredDescriptionDefault
pinYes
chainNo
tokenNo
api_keyNo
recipientsYes
customer_emailNo

TDQS

B3.2/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description carries the behavioral disclosure burden. It does add meaningful traits: a single PIN for the whole batch and all-or-nothing capacity behavior. However, it omits other important operational details like failure handling, authorization scope, or financial effects beyond the atomic capacity constraint.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two tight sentences with the core action front-loaded and no filler. The batch size, PIN behavior, and atomicity constraint each earn their place.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The tool has six parameters, nested recipient objects, no output schema, and no annotations, but the description covers only the batch cardinality and partial atomicity. An agent would not know expected return values, error semantics, or how to correctly populate most recipient fields.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description should compensate, but it only echoes 'airtime or data' and the 2-20 recipient range already present in the schema. It does not explain pin, chain, token, api_key, customer_email, provider, account_number, amount_ngn, or variation_code.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the action ('Pay airtime or data') and the batch scope ('to 2-20 recipients in one call'). It distinguishes itself from the likely sibling pay_bill via the batch size, though it does not explicitly name pay_bill or other alternatives.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The '2-20 recipients' phrasing implies this tool is for multi-recipient payments, which gives usable context. However, it does not explicitly state when not to use it, such as using pay_bill for a single recipient or schedule_bill for future payments.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

schedule_billSchedule BillBInspect

Set up a recurring or future one-off bill payment. Charges nothing when this runs — money only moves later, when the schedule fires and the allowance still covers it.

ParametersJSON Schema
NameRequiredDescriptionDefault
pinYes
chainNo
tokenNo
api_keyNo
serviceYes
providerNo
frequencyYes
amount_ngnYes
meter_typeNo
day_of_weekNo
day_of_monthNo
account_numberYes
customer_emailNo
variation_codeNo
schedule_in_minutesNo

TDQS

B3.3/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden and does useful work: it discloses that no charge occurs at scheduling time and that the payment only executes later if the allowance still covers it. It does not cover failure behavior or authorization needs, but the deferred-payment detail is significant and clearly stated.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, front-loaded sentence that conveys the action and the key behavioral caveat with no filler. Every phrase contributes useful information.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a tool with 15 parameters, no output schema, and no annotations, this description is too thin. It explains the scheduling concept but leaves conditional parameters like variation_code, meter_type, day_of_week, and schedule_in_minutes entirely undocumented, so an agent would have to guess how to construct a valid call.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters1/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% and the description provides no parameter-level meaning for any of the 15 properties. Terms like 'allowance' only vaguely connect to balance, and required fields such as pin, service, account_number, amount_ngn, and frequency receive no explanation or format guidance.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific action (set up) and resource (recurring or future one-off bill payment), making the core purpose clear. It is distinguishable from immediate bill payment conceptually, but it does not explicitly name or contrast any sibling tools.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The phrase 'recurring or future one-off' implies when the tool should be used, and the note that money moves 'later' differentiates it from immediate payment. However, there is no explicit guidance about when not to use it or which sibling tool to choose for immediate payments, scheduling, or cancellations.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

transaction_historyTransaction HistoryBInspect

List recent real transactions for the linked wallet — service, provider, amount, status, tx hash. No PIN required.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNo
offsetNo
api_keyNo

TDQS

B3.2/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description must carry the behavioral burden. It adds context by noting 'real transactions' (not simulated) and 'No PIN required' (auth requirement). However, it omits details about pagination (limit/offset), whether an api_key is needed, ordering, or error behavior, leaving gaps in what the agent should expect.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single concise sentence that front-loads the verb and purpose, lists the return fields, and includes a security note. It is efficient and not overlong, though it could be expanded slightly to cover parameters without becoming verbose.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a list tool with no output schema and zero schema description coverage, the description should explain the parameters and return format. It lists the fields but does not clarify limit/offset semantics or the role of api_key, nor does it specify the 'recent' time window. This leaves critical operational details missing, making it incomplete for reliable invocation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters1/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% and the description provides no explanation of the three parameters (limit, offset, api_key). It does not clarify their purpose, formats, or defaults, leaving the agent to guess. The description's only hint is 'No PIN required', which is unrelated to the api_key parameter, so it fails to compensate for the missing schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb ('List'), a specific resource ('recent real transactions for the linked wallet'), and enumerates the fields returned (service, provider, amount, status, tx hash). It clearly distinguishes itself from siblings like check_balance, which concerns balance, not transactions.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage by describing the action ('List recent real transactions') but does not explicitly state when to prefer this tool over alternatives (e.g., check_balance) or mention any exclusions. The 'No PIN required' note gives a small usage hint but lacks a clear when-to-use/when-not-to-use framing.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 10 tool updatesv0.1.0
    • First observedcancel_schedule
    • First observedcheck_balance
    • First observeddescribe_capabilities
    • First observedlist_international_options
    • First observedlist_plans
    • First observedlist_schedules
    • First observedpay_bill
    • First observedpay_bill_batch
    • First observedschedule_bill
    • First observedtransaction_history

TDQS

A3.7/5.0

Scored across 10 tools

Disambiguation5/5

Each tool targets a distinct operation: capabilities discovery, balance check, plan listing, international catalogue browsing, transaction history, schedule management (list/cancel/create), and payment (single or batch). The only close pair pay_bill/pay_bill_batch is clearly separated by recipient count (1 vs 2-20), and descriptions explicitly disambiguate.

Naming Consistency4/5

Most tools follow verb_noun (check_balance, list_plans, cancel_schedule, pay_bill, schedule_bill, pay_bill_batch), and list_* groups are consistent. transaction_history breaks the pattern as a noun phrase rather than a verb-based action (e.g., get_transactions), but overall the naming is predictable.

Tool Count5/5

10 tools provide a well-scoped surface for a bill-payment platform, covering capabilities, balances, plans, history, single/batch payments, and schedules without bloat. Each tool serves a clear purpose and the count is appropriate.

Completeness4/5

The server covers the main lifecycle: pay, schedule, list schedules, cancel, balance, history, and plan discovery. The only notable gap is no update operation for schedules—users must cancel and recreate—but this is a minor workaround. Overall the surface is nearly complete.

Maintenance

ActivityActive
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    A budget-bound x402 payment wallet for AI agents: it autonomously pays HTTP 402 payment-gated URLs across every major chain (EVM, Solana, and many non-EVM families). Self-custodial and backendless, your key, your RPC, with spend caps enforced before any on-chain send.
    8
    9
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Keyless crypto payments for AI agents. One MCP call turns any wallet address into a non-custodial crypto payment link or tip jar, no API key and no account, with funds settling straight to your wallet at a 0% platform fee (USDC/USDT, BTC, LTC, DASH, DOGE, ZCASH).
    3
    34 npm
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Accept crypto payments from AI agents: create an invoice in one call and get a hosted checkout link (USDC/USDT on Celo, Base, Arbitrum, Polygon, BSC). No API key, instant self-custody settlement.
    MIT