AbaPay
Non-custodial stablecoin bill-pay rails on Celo & Base for AI agents, settled on-chain via MCP.
Open source Repository Open in the app JSON README (API)
About
Non-custodial stablecoin bill-pay rails on Celo & Base for AI agents, settled on-chain via MCP.
Details
- Kind
- MCP servers
- Topic
- No topic detected
- Publisher
- investorphem
- Origin
- official
- Category
- ferramentas
- Transport
- http
- Version
- 1.0.0
- Stars
- 1
- Open pull requests
- 1
- Last push
- 2026-09-11T23:35:08Z
- Repository state
- ativo
- Language
- TypeScript
- License
- MIT
- Added
- 2026-09-12 00:04:05
- Updated
- 2026-09-12 00:04:05
- Origin id
io.github.investorphem/abapay
README
# ⚡ AbaPay Protocol
AbaPay is a decentralized, Web3-native utility payment platform built on **Base** (the default chain) and **Celo**. It lets users pay for real-world bills — Airtime, Mobile Data, Electricity, Cable TV, Bank Transfers, Education PINs, and International Airtime/Data — using on-chain stablecoins (**USDT**, **USDC**, **USAT**), with instant fiat settlement handled server-side via the VTpass API. Payments can be made directly in the web app, or hands-free through a conversational, autonomous AI agent ("DeAI") on Telegram, WhatsApp, and X — a real on-chain identity under [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004), discoverable on [8004scan.io](https://8004scan.io) — that can pay bills unattended, run recurring/scheduled autopay, and settle multi-recipient batch payments, all spending from a bounded, user-revocable on-chain allowance — no custody, no server-side keys.
Designed for low fees, cross-border utility vending (Nigeria + every country VTpass's live international catalogue returns), and mobile-first accessibility — MiniPay, Valora, Farcaster Mini Apps, Coinbase Smart Wallet / Base Account, MetaMask, and any other WalletConnect-compatible wallet (see [Supported Wallets & Environments](#-supported-wallets--environments)).
**Operator:** Masonode Technologies Limited (RC 9524980), Nigeria.
---
## 🌟 Key Features
* **Multi-Chain Payments:** Pay bills directly with USDT, USDC, or USAT on Base (Mainnet/Sepolia) or Celo (Mainnet/Alfajores). **Base is the default chain**; Celo remains fully supported and switchable. The app auto-detects the connected chain and filters/reorders available stablecoins accordingly — **USDC leads on Base, USD₮ leads on Celo**, and **USAT is Celo-mainnet-exclusive**.
* **Live VTpass Catalogue — nothing about a provider is hardcoded any more:** every provider name, logo, and amount limit for airtime, data, electricity, cable and education is fetched live from VTpass (`src/lib/vtpassCatalog.ts`, served to the browser by `/api/providers`) rather than from four separate hardcoded lists. The app, chat, MCP and the admin dashboard all read the same in-process cache, so there is exactly one source of truth. See [Live provider catalogue](#live-provider-catalogue-vtpass-sourced) below.
* **Per-Provider Amount Limits, Enforced Live:** the ceiling is VTpass's real published `minimium_amount`/`maximum_amount` *per provider*, not one flat number per service — airtime alone ranges MTN ₦200,000 / Glo ₦100,000 / Airtel ₦50,000 / 9mobile ₦50,000, and electricity minimums range ₦100 (Ikeja, Aba) to ₦2,000 (Ibadan). A flat cap either wrongly refused a valid MTN top-up or wrongly accepted an Airtel one that VTpass rejects *after* the user has already paid on-chain.
* **International Bill Pay:** Users can select a country and pay for foreign airtime/data in that country's own currency and rate — transaction history and receipts reflect the *local* currency, not just Naira. The country list is fetched live from VTpass (`/get-international-airtime-countries`) on every channel, so the app, chat and MCP can never disagree about which countries are covered.
* **Instant Vending:** Automated API integration with VTpass for instant token generation, airtime top-ups, and data bundle delivery.
* **Education PINs in every channel:** WAEC result-checker and WAEC registration PINs are buyable from the app, from chat (Telegram/WhatsApp/X/in-app), and over MCP — not app-only. ⚠️ **JAMB is a deliberate honesty caveat:** the code path exists end-to-end (intent parsing, profile-ID verification, `variation_code` handling), but `jamb` is not enabled on the current VTpass merchant account — VTpass answers `{"code":"011","content":{"errors":"Service is Not Valid"}}` — so it does not appear in the live catalogue and cannot currently be sold. If the account is enabled for it, it appears automatically with no code change.
* **Smart Merchant Verification:** Validates electricity meters, smartcard/IUC numbers, and account details *before* accepting crypto payments, eliminating user errors and failed vends.
* **AbaPoints Loyalty System:** Users earn points pegged 1:1 to stablecoin value spent, trackable via the in-app points badge and a dedicated API endpoint.
* **Automatic Refund Safety Net:** Failed vends after confirmed on-chain payment are automatically flagged, verified, and refunded on-chain to the user's wallet.
* **DND-Fallback SMS:** Automated SMS delivery of electricity tokens/PINs, bypassing the Nigerian Do-Not-Disturb (DND) registry for critical transaction alerts.
* **Multi-Channel Support & Notifications:** Built-in support ticketing, plus webhook integrations for Telegram, WhatsApp, and X (Twitter) so users and admins can transact/get notified from their preferred channel.
* **Conversational AI Agent ("DeAI"):** A natural-language assistant (`/api/deai`) that lets users check balances and pay bills via chat-style commands, backed by Claude (Anthropic). Reachable via Telegram, WhatsApp, X, and an in-app chat widget (`src/components/AIChat.tsx`) on the storefront itself. Understands intent, not just menu numbers — replying "Celo" or "usdt" works exactly like replying "1" or "2" — and shows the live balance and approved agent limit for every token at the moment you're asked to pick one, so you're never choosing blind. If a session goes cold (network drop, abandoned mid-flow) it's recognised and cleaned up automatically rather than left dangling; and if a network hiccup happens right after you enter your PIN, the payment is never silently lost or double-spent — it's tracked through to a confirmed on-chain outcome before the agent reports back.
* **Agent-Initiated Payments (AbaPayV3):** Users can grant the DeAI agent a bounded, on-chain, revocable spending allowance (`setSpendingAllowance`) — chosen independently per chain and per stablecoin from the Agent Hub tab — so it can pay bills on their behalf from Telegram/WhatsApp/X with no wallet signature needed at payment time and no custody of user funds. If no allowance is approved for the chain/token a chat payment needs, the agent detects that up front and offers a straight choice: approve it now, or complete this one payment via a signed deep link instead. See [AbaPayV3 — agent allowances](#abapayv3sol--agent-initiated-payments-️-not-audited) 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](#erc-8004-agent-identity) below.
* **MCP Server (AI Agent Payments):** AbaPay is reachable by any MCP-speaking AI client (Claude, or any other agent that supports the Model Context Protocol) as a real tool server — `describe_capabilities`, `check_balance`, `list_plans`, `pay_bill`, multi-recipient `pay_bill_batch`, and recurring/one-off `schedule_bill`/`list_schedules`/`cancel_schedule` — over Streamable HTTP JSON-RPC at `/api/mcp`. This is a fourth channel alongside Telegram/WhatsApp/X, not a new trust boundary: it runs through the exact same allowance-bounded, kill-switch-gated, discount-aware execution pipeline as the chat channels, on **either Celo or Base** depending on what the linking wallet approved. See [MCP Server](#mcp-server-ai-agent-payments) below.
* **MCP OAuth 2.1 (authorize once, not once per conversation):** the connector supports a full OAuth 2.1 authorization-code + PKCE (S256) flow with Dynamic Client Registration (`/api/oauth/register`, `/api/oauth/authorize`, `/api/oauth/token`, discovery under `/.well-known/`). A user authorizes once in a browser — proving their API key **and** PIN on AbaPay's own hand-rendered consent page — and every future conversation reconnects with a Bearer token instead of retyping an API key. **OAuth never authorizes a spend:** the PIN is still required on every single `pay_bill` call, and a Bearer token alone can only read a balance. The `api_key` tool argument remains the fallback for clients that can't do OAuth.
* **`list_plans` — real VTpass plan codes and prices, never guessed:** `variation_code` used to be something an agent had to invent for DATA/CABLE/EDUCATION. `list_plans` returns the currently purchasable plans with their exact 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](https://x402.org) HTTP-payment protocol — Celo's own facilitator for **USDC/USD₮/USAT on Celo**, the Coinbase CDP facilitator for **USDC on Base** — so they're genuinely indexed on x402scan, not relabeled contract calls. Anything without EIP-3009 uses the on-chain `payBill` flow, including Base's sponsored-gas path. ⚠️ x402 needs an EIP-3009 `transferWithAuthorization` signature, which is structurally what a drainer asks for, so some wallet scanners flag it as risky — a known, deliberate trade for x402scan visibility; `NEXT_PUBLIC_X402_ENABLED=false` opts out. The signature-free agent-initiated flow is untouched either way. See [x402 settlement](#x402-settlement-main-app-only) below.
* **Dynamic Exchange Engine:** Live market rate conversions with admin-configurable exchange rate and automated profit spread calculation, verified server-side to prevent underpayment exploits.
* **Executive Admin Dashboard:** Real-time monitoring of VTpass fiat balance, on-chain vault balances per token/chain, transaction analytics, manual refund tools, and CSV export — protected behind admin auth.
* **Kill Switches That Actually Stop Every Channel:** the dashboard's "pause a service" toggles are a **two-level** model — a per-service master (`MASTER_AIRTIME`, `MASTER_INTERNET`, `MASTER_ELECTRICITY`, `MASTER_CABLE`, `MASTER_EDUCATION`, `MASTER_INTERNATIONAL`) plus a per-provider switch keyed by VTpass serviceID (`AIRTIME_mtn`, `INTERNET_airtel-data`, `ELEC_ikeja-electric`, `CABLE_dstv`, `EDU_waec`). A payment is refused when **either** level is off. `src/lib/serviceRules.ts`'s `killSwitchKeysFor()` maps an agent intent (+ provider, normalised through `resolveServiceId` so `ELEC_ikeja` can't miss `ELEC_ikeja-electric`) onto exactly those keys, so chat, MCP and the autonomous scheduler now honour the same switches the web app does. See [Kill switches](#kill-switches-two-level-master--per-provider) below.
* **Sponsored Gas on Base:** Coinbase Smart Wallet / Base Account users can pay with zero gas fees — the app detects paymaster support via EIP-5792 and batches approval + payment into a single sponsored transaction. Wallets without this capability (MetaMask, WalletConnect, Valora, etc.) transparently fall back to the normal self-paid flow.
* **Shareable & Downloadable Receipts:** Every receipt can be shared as an image straight to WhatsApp/Telegram/etc. via the device's native share sheet, or saved directly as a PNG or PDF.
* **Farcaster Mini App Ready:** Ships with Farcaster frame metadata so AbaPay can be launched directly inside Farcaster clients.
---
## 🛠️ 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 — reading `window.ethereum` instead of these is what used to send web3-browser users to 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 was configured but never offered.** The connector has been in
`src/config/wagmi.ts` all along, yet `probeInjectedConnectors()` only returns connectors of type
`injected` and Base Account is its own type — so nothing ever put it in front of a user. It
matters most on Base, the default chain, where it is the smart-account experience carrying
sponsored gas; `verifySignatureAcrossChains` already validates the ERC-1271 signatures it
produces, so nothing else had to change to accept it.
- 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 used to require
*two or more* injected wallets before it appeared, so the very common "one extension installed"
browser connected to that extension silently and was never offered WalletConnect at all — there
was no route to pairing a phone wallet short of uninstalling the extension. The option list is
now built first and the chooser decided from *its* length, which is what turns the single-wallet
case into a real choice.
🔴 **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. So a browser with a perfectly good wallet can have `window.ethereum` undefined, or pointing
at a different wallet than the user means. Probing only the global reported "no wallet", skipped
the injected path entirely, and showed a QR code for a wallet sitting in the same browser.
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
🔴 **The "first prompt works, the second never comes" hang.** Inside Valora's in-app browser the
page can see something that answers `eth_accounts` — real enough for the probe above to report a
wallet, real enough for auto-connect to fire, real enough for the entire UI to look connected.
Not real enough to pay with. The first request raises a prompt; the user taps **Allow**; Valora
toasts *"Connection to AbaPay was successful!"* — it has taken a payment authorization for a
connection handshake, consumed it, and returned nothing to the page. Nothing rejected, so there
is nothing to catch. The spinner runs forever.
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.
🔴 **But the page's own globals are not enough to spot Valora.** `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 — *"I cancelled the pop up and it kept
loading for life"*.
`withWalletTimeout` does fire, but 90s of frozen spinner after you've tapped cancel reads as
broken — and 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.
🔴 **The auto-connect nobody could find was in `WagmiProvider` itself.** wagmi persists the
connector and, with the default `reconnectOnMount`, silently re-establishes it on *every page
load* — inside the provider, before any effect in `page.tsx` runs and regardless of what those
effects decide. So the app came up connected on its own no matter how carefully the rules
downstream were written, and every attempt to fix it by editing those rules was editing the wrong
thing. `Providers.tsx` now passes `reconnectOnMount={false}`.
The rule downstream was also the wrong shape: it auto-connected **any** `authorized` wallet and
carved out Valora by name. That made silent connect the default and removed wallets only after
someone complained, which is how *"it connects by itself and there's no Connect button"* kept
coming back wearing a different wallet's name. It is an allowlist now (`AUTO_CONNECT_SURFACES`).
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.
🔴 That is one bug wearing two faces, and both were reported: *"Valora still auto connects"*, and
then *"your wallet connection has dropped — tap Connect"* when paying a wallet that looks
perfectly connected. Nothing had dropped. There was never a live session, only a cookie
describing one. A connection this page did not itself establish is now 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 used to be read straight from
the browser with the anon key, scoped only by `.ilike('wallet_address', address)`. Swap the
address and PostgREST returns someone else's rows — phone numbers, meter numbers, amounts. A
provider that merely *claimed* an address it did not hold was enough, because a wallet address is
public information.
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. Locking someone out
of paying for owning an unusual wallet would be worse than the bug being closed.
- 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
🔴 **The "auto-connects, then hangs forever" failure.** wagmi persists the WalletConnect session
(`cookieStorage`) and restores it on load — that is the *"it auto-connects after a while"* users
describe. Restoring produces 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**. Restored 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 went nowhere. From the page it is indistinguishable from a user who hasn't looked at their
wallet yet, which is why it presented as an eternal spinner.
`walletConnectSessionLive()` (`src/lib/walletEnv.ts`) checks the relay before any wallet
interaction; a dead session is reported in one sentence and disconnected so **Connect** pairs
fresh instead of restoring the same corpse. A missing socket internal is treated as *live* —
a false negative would disconnect working wallets on every payment. Injected wallets return
`null`: they are in-process and have no socket to lose.
Every wallet call also has a timeout now, including the chain-switch handshake and the Base
`sendTransaction`, which had none. On a wallet app, a timeout is reported as "your wallet never
received the request" with a reconnect, since that is what it almost always means.
`walletApprovedChainIds()` is a related guard: a WalletConnect wallet silently drops requests for
a chain outside its approved session, so if the connected wallet never approved the active chain
the app follows it to one it did.
#### The default chain is Base
`DEFAULT_CHAIN` in `src/constants/index.ts` is **`BASE`**, and everything forward-looking reads
from it: the chain a freshly connected wallet lands on, the token picker's seed before a wallet
is connected, and the chain an agent link approves when the caller doesn't name one. Celo is
fully supported and switchable — nothing was dropped, it just isn't where you start.
Chains registered in `wagmi.ts`, in order: **Base, Base Sepolia, Celo, Celo Alfajores**. wagmi
treats `chains[0]` as the default and offers the rest as *optional* WalletConnect namespaces, so
a Celo-only wallet still connects fine (see the Valora row above). Note the app's own non-wagmi
paths (`src/lib/chain.ts`, `page.tsx`) use viem's **`celoSepolia`** as the Celo testnet, while
`wagmi.ts` still lists `celoAlfajores`; mainnet is unaffected, but they should be reconciled if
testnet WalletConnect flows are exercised.
`LEGACY_RECORD_CHAIN` is the deliberate counterpart, and it stays **`CELO`**. It is how a
*stored* row with an empty `blockchain` column is read — such rows predate the column being
written and were all on Celo. It must not follow `DEFAULT_CHAIN`: reading an old Celo payment as
Base would send the webhook hunting for a receipt on the wrong chain and strand a real payment
as unvended.
Stablecoins: **USD₮** and **USDC** on both chains, plus **USAT** on Celo mainnet only. Which token
a chain *leads* with, and in what order the rest follow, is `TOKEN_ORDER_BY_CHAIN` in
`src/constants/index.ts` — **Base: USDC then USD₮; Celo: USD₮, USDC, USAT**. One
`tokensForChain()` serves the Pay tab, the Agent Hub, the chat agent and the MCP tools, which
each used to filter `SUPPORTED_TOKENS` themselves and could therefore disagree.
---
## 📁 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/
├── deploy.ts # Deploy V1
├── deployV2.ts # Deploy V2
└── deployV3.ts # Deploy V3 (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](#abapayv3sol--agent-initiated-payments-️-not-audited)
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, and conflating them is why this looked like it should already work.
`src/lib/scheduler.ts` is the caller this bites: a payment scheduled for tomorrow reports back
long after the chat that created it went quiet, so "your electricity bill was paid" was rejected
every time — and swallowed, so the only symptom was a user who never heard back and had to find
the receipt in History themselves.
`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 is deliberate
(an unset secret used to skip verification entirely, leaving anyone able to impersonate any
sender), but it means *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](https://sepolia.celoscan.io) and check the `Registered` event for the correct URI and agent ID.
4. Only after that passes: `npx hardhat run scripts/register8004.ts --network celo` — spends real gas, mints the Celo identity permanently (AbaPay's live Celo agent ID: **9687**).
5. Separately, `npx hardhat run scripts/register8004.ts --network base` — mints the *Base* identity (AbaPay's live Base agent ID: **59561**). Same URI, different registry/chain, different agent ID.
6. Set `NEXT_PUBLIC_ERC8004_AGENT_ID` to the agent ID the script prints. Look up either identity at [8004scan.io](https://8004scan.io).
Both registrations only ever store the **URL**, not the card's contents, so editing `agent.json` (e.g. to add a new declared service) changes what the URL *returns* with no new transaction. But that alone is not enough for a scanner like 8004scan to notice: indexers appear to snapshot the card at registration time rather than polling the URL on a schedule, so there's no on-chain signal telling them anything changed. `scripts/update8004uri.ts` closes that gap — it calls the registry's `setAgentURI(agentId, sameURI)`, re-emitting a fresh `URIUpdated` event (without changing the URI itself) purely to give an indexer something new to react to:
```
ERC8004_AGENT_ID=9687 ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json npx hardhat run scripts/update8004uri.ts --network celo
ERC8004_AGENT_ID=59561 ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json npx hardhat run scripts/update8004uri.ts --network base
```
Run this any time `agent.json`'s contents change (like the `mcp` service entry above) and you want an already-registered identity to be re-read.
### x402 Settlement (main app, Celo + USDC/USD₮)
```
CELO_X402_API_KEY=your_x402_celo_org_api_key # Server-side: settles via api.x402.celo.org
```
No client-side SDK key is needed: the payer's EIP-3009 authorization is signed by the wallet
the user already connected (`src/lib/x402Pay.ts`), not by a second wallet SDK.
```env
NEXT_PUBLIC_X402_ENABLED= # Default ON. Set to "false" to use the contract call instead
```
**x402 is the default settlement rail on both chains** — **USDC or USD₮ 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**. An earlier version restricted it
to in-browser wallets, on the theory that Valora's *"Verify wallet"* prompt swallowing the x402
signature was why it hung; testing disproved that — routed to the contract call, Valora hung at
exactly the same point on a plain `eth_sendTransaction` with no signature involved. The settlement
rail was never the problem, so making every other environment pay for it bought nothing.
🔴 **The one remaining 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 and USD₮ both 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 used to
fire only when the selected token didn't exist on the new chain — and USD₮ exists on *both*, so
arriving on Base from Celo silently kept USD₮ selected and quietly demoted every Base user to the
contract call. It is now keyed on the chain ID, so switching chain resets to that chain's lead
stablecoin while never fighting a user who deliberately picks the other token in place.
Settlement runs through **Celo's own x402 facilitator** (`api.x402.celo.org` mainnet /
`api.x402.sepolia.celo.org` testnet — built by Celo Core Co.), not thirdweb. thirdweb is
still used client-side only, for `useFetchWithPayment`'s wallet-signing plumbing (protocol-
generic — it reads the payment challenge from the response body, which works against any
compliant facilitator, not just thirdweb's own). Chosen over thirdweb's own facilitator
because: flat **$0.001/settlement** via prepaid credits vs. thirdweb's ~0.3% cut, **no
billing plan required** to settle on mainnet (thirdweb requires one or every mainnet
settlement fails with `DELEGATION_CHECK_FAILED`), and genuinely non-custodial — the signed
payment authorization pays the vault directly, with no intermediate hop through the
facilitator's own wallet.
**How to get the API key:**
1. Go to [x402.celo.org](https://x402.celo.org) → **Connect wallet** (any wallet works — this is just to sign a free, gasless message, not a transaction).
2. You're issued an API key instantly, plus free credits (500 mainnet, 1000 testnet at time of writing) — **the full key is shown only once**, copy it immediately.
3. Set `CELO_X402_API_KEY` to that key — the same key works for both the mainnet and testnet endpoints, which are tracked as separate credit pools.
4. Top up credits (USDC deposit, $1 ≈ 1,000 credits) from the same dashboard before you run out — `/settle` starts returning 402 at 0 credits, and the app sends a Telegram alert when that happens (see `src/app/api/pay/x402/route.ts`).
5. Nothing else to sign up for — the client side needs no SDK account. The payment authorization is signed by the wallet the user already connected, through the app's own viem wallet client (`src/lib/x402Pay.ts`).
6. Add the var to `.env.local` **and** your hosting provider's production environment variables, then redeploy — `NEXT_PUBLIC_*` vars are baked in at build time, so existing deployments won't pick up a change without a rebuild.
### Cron / Maintenance
```
CRON_SECRET=any_long_random_string # Optional. Protects /api/cleanup and both /api/schedules/run* endpoints.
```
Stale abandoned pre-flight intents are swept automatically and opportunistically from inside the webhook (throttled, non-blocking) — this needs **no Vercel cron and works on the free/Hobby plan**. `/api/cleanup` remains available for manual runs or an external free scheduler (cron-job.org, GitHub Actions) if you want a guaranteed cadence during quiet periods.
**Scheduled Bills / Autopay Agent — these two DO need an external cron to actually run:**
unlike the webhook-driven cleanup above, nothing calls these on its own.
- `/api/schedules/run` — recurring bills (monthly/weekly/daily). Register once or twice a
day at [cron-job.org](https://cron-job.org) (free) hitting `POST https://<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](https://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](https://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](https://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/`](dune/base-chain/), deployed with `node scripts/dune-base-setup.mjs`.
- Celo: `0x5df8aE2B963165b735B18Ca86B1ea448d2AA032C` (AbaPayV4, current) and
`0x42Fa463798Ed129a9B5Ee51721CB6db1bfCBe3b9` (AbaPayV3, original). SQL in
[`dune/celo-chain/`](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`](.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 ext