Non-custodial stablecoin bill-pay rails on Celo & Base for AI agents, settled on-chain via MCP.
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.
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.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./get-international-airtime-countries) on every channel, so the app, chat and MCP can never disagree about which countries are covered.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./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.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.src/lib/attribution.ts) crediting the Celo Builders program; a no-op on Base.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./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.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.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.sendCalls for sponsored transactions), WalletConnect Modal, Base Account SDK, Solidity smart contract (Hardhat)src/lib/x402Pay.ts) for HTTP-native, facilitator-settled payments in the main app/api/mcp exposing balance-check and bill-pay tools to any MCP client — and A2A (Agent2Agent) at /api/a2a, card at /.well-known/agent-card.json, exposing the same tools to peer agents. The x402 endpoint is also listed in an x402 discovery manifest at /.well-known/x402 ({ version: 1, resources: [...] }), the file x402 indexers such as agent402.tools read before probing the live 402 challenge. Cursor: a marketplace plugin lives in integrations/cursor-plugin/ (.cursor-plugin/plugin.json + mcp.json pointing at https://agents.abapays.com/api/mcp, registered for the marketplace by the repo-root .cursor-plugin/marketplace.json); OAuth accepts Cursor's native callback (cursor://anysphere.cursor-mcp/oauth/callback, exact match only, PKCE required) and its newer loopback one. Grok: add it at grok.com/connectors → New Connector → Custom with the same URLAbaPay 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. |
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).
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.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.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.🔵 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).
🔵 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:
userInitiatedConnect).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.
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.
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.
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.
🔵 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.verifyWalletOwnership).🔵 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.
DEFAULT_CHAIN in src/constants/index.ts is BASE, and everything forward-looking reads
from it: the chain a freshly connected wallet lands on, the token picker's seed before a wallet
is connected, and the chain an agent link approves when the caller doesn't name one. Celo is
fully supported and switchable — nothing was dropped, it just isn't where you start.
Chains registered in wagmi.ts, in order: Base, Base Sepolia, Celo, Celo Alfajores. wagmi
treats chains[0] as the default and offers the rest as optional WalletConnect namespaces, so
a Celo-only wallet still connects fine (see the Valora row above). Note the app's own non-wagmi
paths (src/lib/chain.ts, page.tsx) use viem's celoSepolia as the Celo testnet, while
wagmi.ts still lists celoAlfajores; mainnet is unaffected, but they should be reconciled if
testnet WalletConnect flows are exercised.
LEGACY_RECORD_CHAIN is the deliberate counterpart, and it stays CELO. It is how a
stored row with an empty blockchain column is read — such rows predate the column being
written and were all on Celo. It must not follow DEFAULT_CHAIN: reading an old Celo payment as
Base would send the webhook hunting for a receipt on the wrong chain and strand a real payment
as unvended.
Stablecoins: USD₮ and USDC on both chains, plus USAT on Celo mainnet only. Which token
a chain leads with, and in what order the rest follow, is TOKEN_ORDER_BY_CHAIN in
src/constants/index.ts — Base: USDC then USD₮; Celo: USD₮, USDC, USAT. One
tokensForChain() serves the Pay tab, the Agent Hub, the chat agent and the MCP tools — one
function, so all four always agree on which tokens a chain offers.
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)
Create a .env.local file in the project root. Never commit this file to GitHub.
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
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).
NEXT_PUBLIC_ABAPAY_ADDRESS=0xYourDefaultContractAddress
NEXT_PUBLIC_ABAPAY_CELO_ADDRESS=0xYourCeloContractAddress
NEXT_PUBLIC_ABAPAY_BASE_ADDRESS=0xYourBaseContractAddress
ADMIN_WALLET_ADDRESSES=0xYourOpsWallet # optional; admins for /admin (never the vault owner). See ENV_SETUP.md
CELO_PRIVATE_KEY=your_deployer_private_key # Used only by Hardhat for deployment — never expose client-side
PAYMASTER_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:
NEXT_PUBLIC_ABAPAY_BASE_ADDRESS contract (and ideally the specific payBill/approve selectors), with a funded/budgeted balance to sponsor from.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_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_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.
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
RESEND_API_KEY=re_your_resend_key
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:
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
idea as the web app's server-issued preflight_<uuid> intent ahead of a signature), then renamed to the real tx hash once
confirmed — so the payment is vended through the exact same verified pipeline as every other
rail, and a stale/abandoned attempt is swept automatically rather than left dangling. If the
RPC can't confirm the receipt in time (a network hiccup right after broadcast — including
right after the user enters their PIN), the agent reports it as pending, not failed, and
will never hand out a duplicate payment link for that same intent — avoiding both a lost
payment and a double-charge. If no allowance is approved for the chain/token a payment needs,
the agent detects that before ever attempting the relay and offers a choice: approve it now
in the Agent Hub, or complete just this one payment via a signed deep link.TELEGRAM_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_ACCESS_TOKEN=your_whatsapp_access_token
WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id
WHATSAPP_VERIFY_TOKEN=your_verify_token
WHATSAPP_APP_SECRET=your_meta_app_secret # ⚠️ REQUIRED. Verifies the X-Hub-Signature-256 on inbound webhooks so senders can't be spoofed.
WHATSAPP_SCHEDULE_TEMPLATE_NAME=schedule_update # Approved utility template used when the 24h window has closed. Unset = scheduled payments go unreported on WhatsApp.
WHATSAPP_SCHEDULE_TEMPLATE_LANG=en # Must match the template's language exactly ('en' and 'en_US' are different templates).
🔵 WhatsApp 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_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
ALCHEMY_WEBHOOK_SECRET=your_alchemy_base_webhook_secret
ALCHEMY_CELO_WEBHOOK_SECRET=your_alchemy_celo_webhook_secret
ETHERSCAN_API_KEY=your_etherscan_or_celoscan_api_key
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.
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.
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.ERC8004_AGENT_URI above to that URL.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.npx hardhat run scripts/register8004.ts --network celo — spends real gas, mints the Celo identity permanently (AbaPay's live Celo agent ID: 9687).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.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.
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 "fals
This listing does not have a supported local package template. Use the maintainer’s documentation for its hosted endpoint, authentication, and client-specific setup. No install command has been inferred.