describe_capabilities
Describe Capabilities
List what AbaPay can pay (airtime, data, electricity, cable, etc.), any services currently paused, and example requests. Call this first if unsure what is supported.
無需輸入參數。

io.github.investorphem由 GitHub 帳號 investorphem 發布v1.1.0更新於 Oct 26, 2026
Non-custodial stablecoin bill-pay rails on Celo & Base for AI agents, settled on-chain via MCP.
遠端 MCP 伺服器,讓助理用 Celo 或 Base 上已綁定的穩定幣錢包支付真實帳單:通話費、行動數據、電費、有線電視與教育 PIN。
Web executable,透過 Streamable HTTP。 遠端服務在工作區中設定後即可從網頁執行環境執行。
把這段提示詞貼上到 Claude Code、Codex、Cursor 等代理程式,或 SourceWeft 對話中。代理程式會讀取該伺服器的安裝指南,先向你展示發布者、端點和登入方式,經你同意後再把它新增到你的客戶端。金鑰不會寫進任何命令。
閱讀 https://sourceweft.com/mcp/io.github.investorphem%2Fabapay/install.md,並按其中說明把這個 MCP 伺服器接入我的客戶端。修改任何設定之前,先告訴我它的發布者、端點和登入方式,等我確認。不要把 API 金鑰寫進命令或我會提交的檔案;問我金鑰應從哪裡取得。把它新增到你客戶端的 mcpServers 設定中。
{
"mcpServers": {
"abapay": {
"type": "http",
"url": "https://agents.abapays.com/api/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): celo_2719403c6aff for the Celo Open Rails hackathon and celo_9d71588659ec for Celo Builders. x402 payments are submitted on-chain by the facilitator, not by AbaPay, so they carry no tag. Base transactions carry Base's own builder code instead.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.src/app/nim/, mapped by middleware.ts) sells everything the main app sells, paid in the NIM token only, from inside Nimiq Pay (@nimiq/mini-app-sdk). POST /api/pay/nim prices the bill in NIM at what NIM sells for: the lowest best-bid across qualifying CoinGecko exchange markets for nimiq-2 (USD-stablecoin pairs, fresh, not anomalous, ≥ $500 volume), never above the blended CoinGecko price, which is also the fallback — plus NIMIQ_PRICE_BUFFER (default 2%), rounded up to a whole NIM, at the admin's platform_settings.exchange_rate (the same naira rate as abapays.com). It does not check the payer's balance before recording an order: the Mini App API has no wallet-total method and a computed figure can miss one of Nimiq Pay's HTLCs, so Nimiq Pay (which knows the exact balance) refuses an unaffordable send itself; nothing is charged until a payment is on chain. The balance the app shows (only when something is found — otherwise "—", see Nimiq Pay) comes from a chain read (GET /api/pay/nim/balance?address=), not Nimiq Pay's getBalance, which answered 0 for funded wallets; the host figure is only the fallback. Nimiq Pay keeps a user's NIM in HTLCs their own address funded (the basic account reads 0), so a wallet's balance is its basic balance plus every unexpired HTLC it is the sender of — including the new HTLC Nimiq Pay rolls the remainder into before the old one times out (HTLC → HTLC, so it is found by following the wallet's HTLCs, not only its own outgoing transactions) — and a payment that arrives from such an HTLC counts as the ordering wallet's when the chain shows that wallet funded it (htlcFunder). Every admin control abapays.com obeys applies through the same checkWebPayment gate (kill switches, provider breaker, live min/max), and the quote (GET /api/pay/nim) carries per-service availability so the app greys out what is off; POST { check: true } checks one provider and amount without writing anything and returns the treasury address (NIMIQ_TREASURY_ADDRESS) and an order reference; Nimiq Pay sends the NIM with the reference as the payment's plain-text message (Nimiq Pay stores data as UTF-8, and a basic transaction carries at most 64 bytes — MAX_NIM_MEMO_BYTES; the 47-byte reference must not be hex-encoded, which doubled it past the limit and left payments stuck Pending in Nimiq Pay). Every Nimiq Pay payment is spent from the user's HTLC and co-signed by Nimiq's cosigner; the server reads the transaction from Albatross JSON-RPC (NIMIQ_RPC_URL, default https://rpc.nimiqwatch.com) and requires the treasury as recipient, at least the quoted luna, this order's reference, the ordering wallet as sender and NIMIQ_MIN_CONFIRMATIONS (default 10) before vending through the same pipeline as every other rail (src/lib/nimiq.ts, src/lib/nimSettle.ts). /api/cleanup settles orders whose app closed before reporting the payment (found by reference in the treasury's history) and expires orders unpaid after an hour. NIM rows are blockchain = 'NIMIQ', token_used = 'NIM', with the NIM charged in amount_usdt and the quote price in nim_usd_price (migration 042). NIM discount: one switch and one percentage in Admin → Discounts (platform_settings.nim_discount_enabled / nim_discount_percent, migration 045, src/lib/nimDiscount.ts), separate from the discount campaigns, which never apply to NIM orders. While on, every NIM order takes that percentage off the bill (fees in full, at least 0% and below 100%), the order records discount_ngn and discount_percent, and the customer sees "NIM discount applied: X% off" at checkout, on the result, in History and on the receipt; while off, NIM orders are priced as before. Switching it on or changing the percentage needs a fresh admin wallet signature; switching it off doesn't. Changes are live within a minute (30-second cache). The server holds no Nimiq key: a failed vend queues a refund that the operator sends from the treasury wallet and records in Admin → Ops → Refunds, where the hash is verified on the Nimiq chain. A NIM order the system did not queue itself (stuck, or VTpass has no record) is queued from Admin → Ledger → Refund (POST /api/admin/refunds { action: "QUEUE_NIM" }: paid on chain, undelivered, not already queued). The app connects only on request: declining the Nimiq Pay prompt leaves a Connect wallet button, and Disconnect forgets the account on that device. The app's History tab (POST /api/pay/nim/history) shows the payer's own orders — including ones still awaiting payment (Nimiq Pay can hold a send as pending), expired · not paid (nothing charged) and late payments being refunded, each with a one-line explanation; the internal order placeholder is never returned as a payment hash — with meter tokens and PINs: the user signs a short message in Nimiq Pay, and src/lib/nimiqAuth.ts checks the Ed25519 signature over Nimiq's signed-message digest and that the public key derives to the address the message names, so a signature by one wallet can never read another's orders. Provider pickers are searchable bottom sheets showing each provider's logo, country flag or operator image from VTpass.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 appsrc/lib/selfAgent.ts): an agent may sign its request (x-self-agent-address / -signature / -timestamp, over keccak256(timestamp + METHOD + path?query + keccak256(body))); AbaPay checks the signer on the SelfAgentRegistry (verified, fresh human proof, Self's provider) and shows the result on every response (X-AbaPay-Self-Agent header; self_agent on JSON; result._meta["abapay/self_agent"] on JSON-RPC) and with the payment. Self is optional; a signature that doesn't check out is refused with 401 SELF_AGENT_INVALID; expired, revoked or unregistered agents still pay, unverified. Policy and examples: docs/gitbook/self-agent-id.md.X-Agent-Name, X-Agent-Id) in payment_client_info — for MCP/A2A also the protocol, the sign-in method and the OAuth client's registered name (the routes run each tool call under runWithAgentClient, and executeAgentPayment records it; our own chat agent and the scheduler record nothing) — and after the response looks the paying wallet up on-chain (src/lib/payerIdentity.ts): the ERC-8004 agent identities it owns or is the declared wallet of, each confirmed with ownerOf/getAgentWallet (ids from Blockscout, falling back to the registry's log on CELO_RPC_URL), and its Self Agent ID status (verified, human proof, proof fresh) from the SelfAgentRegistry. Cached a day in payer_identities. Both tables are service-role only (migration 044) and shown only on the admin ledger, never on receipts or public APIs; a lookup never affects the payment. How support reads it: docs/runbooks/README.md, "Supporting a developer whose agent paid"./api/mcp exposing balance-check and bill-pay tools to any MCP client — and A2A (Agent2Agent) at /api/a2a, card at /.well-known/agent-card.json, exposing the same tools to peer agents. The x402 endpoint is also listed in an x402 discovery manifest at /.well-known/x402 ({ version: 1, resources: [...] }), the file x402 indexers such as agent402.tools read before probing the live 402 challenge; AbaPay is indexed there (agent402.tools/api/index?seller=agents.abapays.com). Celo buy: listed in its skills registry as abapay/bill-payments (experimental) since @celo/buy 0.8.5 (celo-org/buy-skill#58); agents find it with buy skills search airtime or buy_discover over MCP. If the x402 routes, prices or tokens change, open a new request there so the listing is updated. Cursor: listed at cursor.directory/plugins/abapay; the plugin lives in integrations/cursor-plugin/ (.cursor-plugin/plugin.json + mcp.json pointing at https://agents.abapays.com/api/mcp, registered for the marketplace by the repo-root .cursor-plugin/marketplace.json); OAuth accepts Cursor's native callback (cursor://anysphere.cursor-mcp/oauth/callback, exact match only, PKCE required) and its newer loopback one. Grok: add it at grok.com/connectors → New Connector → Custom with the same 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().
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.
Create a .env.local file in the project root. Never commit this file to GitHub.
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).
⚠️ 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.See ENV_SETUP.md §9b for where to find these and the MFA/webhook setup steps.
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.🔵 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:
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.
⚠️ 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.
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:
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.
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.
x402 is the default settlement rail on both chains — USDC, USD₮, or USA₮ on Celo (each settling
against its own EIP-712 domain) and USDC on Base — so payments are genuinely indexed on
x402scan rather than being relabeled contract calls. Anything without EIP-3009 uses the normal
contract call. It never touches the agent-initiated flow, since x402 needs a fresh signature per
payment. Distinct infra from RELAYER_PRIVATE_KEY above.
Which chain a challenge offers — Celo is the agent default. A request that names a chain
(blockchain, and token) is challenged on exactly that. One that names none, such as a
crawler's probe, a generic x402 client or Celo's buy marketplace CLI, gets
Celo first, with all three stablecoins (USDC, USD₮, USA₮, each under its own EIP-712 domain),
then Base USDC when Base is configured, all at one price. The paid retry settles on the chain and
token its payment header was signed for. AGENT_DEFAULT_CHAIN (src/constants) is Celo for every
agent-facing path (x402, MCP, A2A, agent links, the relayer); DEFAULT_CHAIN (Base) is only the
consumer app's. Trade-off, chosen deliberately: Coinbase's validator
(POST /platform/v2/x402/validate) judges accepts[0], and with Celo first a bare probe no longer
passes it, so the Coinbase Bazaar listing of this endpoint is not expected. Every Celo index
(Celo's buy catalogue, agent402.tools, x402scan) reads the Celo entry.
⚠️ Expect some wallets to warn about the signature, and know why. x402 settles via an
EIP-3009 transferWithAuthorization — a signature permitting a third party to move the tokens.
That is structurally the same request a token-drainer makes, so some wallet security scanners
flag it: Zerion has shown AbaPay's own request as "Malicious Request — Approving this may risk
total asset loss." on a routine bill payment, while the same payment via the contract call
reads as an ordinary Send with "No Risks Found". This is inherent to how x402 works — the very
property that makes a payment provable on x402scan is what the scanners object to — not a fault
in the request. It is a deliberate trade.
Escape hatches, per chain: NEXT_PUBLIC_X402_ENABLED=false moves everything to the contract-call
rail; NEXT_PUBLIC_BASE_X402_ENABLED=false moves only Base. Both default to on.
x402 runs on every wallet, every environment and both chains — Valora's WalletConnect hang
(see the wallet-connection section above) happens at the same point on a plain
eth_sendTransaction with no signature involved, so restricting x402 to in-browser wallets would
buy nothing there; the settlement rail isn't the variable.
🔵 The one real limit is the TOKEN, not the chain and not the wallet. x402 settles on an
EIP-3009 transferWithAuthorization signature, so it only works on tokens that implement one.
Celo's USDC, USD₮, and USA₮ all do; on Base, USDC does and Tether's USD₮ does not — there is no such
function on that contract to sign against.
That is why the chain's lead stablecoin matters so much. Base leads with USDC
(TOKEN_ORDER_BY_CHAIN), so the default path on Base is x402. The token-reset effect is
keyed on chain ID: switching chain always resets the selected token to that chain's lead
stablecoin (unless the user deliberately picked the other one), rather than only resetting when
the previously-selected token doesn't exist on the new chain at all — since USD₮ exists on both
chains but only settles via x402 on Celo, a reset that fires solely on token-existence would
silently carry USD₮ over from Celo to Base and demote that user to the contract-call rail.
Settlement runs through Celo's own x402 facilitator (api.x402.celo.org mainnet /
api.x402.sepolia.celo.org testnet — built by Celo Core Co.), not thirdweb. thirdweb is
still used client-side only, for useFetchWithPayment's wallet-signing plumbing (protocol-
generic — it reads the payment challenge from the response body, which works against any
compliant facilitator, not just thirdweb's own). Chosen over thirdweb's own facilitator
because: flat $0.001/settlement via prepaid credits vs. thirdweb's ~0.3% cut, no
billing plan required to settle on mainnet (thirdweb requires one or every mainnet
settlement fails with DELEGATION_CHECK_FAILED), and genuinely non-custodial — the signed
payment authorization pays the vault directly, with no intermediate hop through the
facilitator's own wallet.
How to get the API key:
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./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).src/lib/x402Pay.ts)..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.A paid endpoint (e.g. Chainstack) goes in front of the public ones in src/lib/chain.ts's rpcUrlsFor; the public endpoints stay behind it as automatic fallbacks, so an outage of either side doesn't stop payments. These are deliberately not NEXT_PUBLIC_: the URL contains the provider's access key, and Next.js never ships a non-public variable to the browser, so the browser keeps using the public list and the key never leaves the server. BASE_RPC_URL is also what hardhat.config.ts uses for Base deploys. The nightly fork tests take their own CELO_FORK_RPC_URL / BASE_FORK_RPC_URL GitHub secrets, which need archive access.
Stale abandoned pre-flight intents are swept automatically and opportunistically from inside the webhook (throttled, non-blocking) — this needs no Vercel cron and works on the free/Hobby plan. /api/cleanup remains available for manual runs or an external free scheduler (cron-job.org, GitHub Actions) if you want a guaranteed cadence during quiet periods.
Scheduled Bills / Autopay Agent — these two DO need an external cron to actually run: unlike the webhook-driven cleanup above, nothing calls these on its own.
/api/schedules/run — recurring bills (monthly/weekly/daily). Register once or twice a
day at cron-job.org (free) hitting POST https://<your-domain>/api/schedules/run
with header Authorization: Bearer <CRON_SECRET> (or x-cron-secret: <CRON_SECRET>)./api/schedules/run-instant — one-off future payments from the DeAI chat ("buy me MTN
airtime in the next 10 minutes"). Needs a much tighter cadence to actually land close to
the requested time — register a separate free cron-job.org job hitting
POST https://<your-domain>/api/schedules/run-instant every 1–5 minutes. It's cheap
even at that frequency: the query is scoped to frequency = 'once' rows only, so most
ticks find nothing due and return immediately.Without registering these, users can still create schedules (recurring or one-off) from the
chat, but nothing will ever execute them — they'll sit is_active forever with no cron to
pick them up.
On-chain stats — AbaPay's own index of the chain:
stats.abapays.com is its own site (like docs.abapays.com):
Overview (both chains), Celo and
Base. middleware.ts rewrites that host onto src/app/stats/;
the old agents.abapays.com/stats links redirect there. It shows every payment into an AbaPay
vault as a dashboard. One sticky 7D / 30D / 90D / All switch drives every range-able panel:
counters (payments, unique wallets, volume, agent-native share, new and returning wallets,
average payment, rails in use), interactive daily charts (payments and volume by
rail, new vs returning wallets, cumulative volume) and pie charts (volume by chain, rail and token). An Active wallets section shows DAU, WAU and MAU as of today, DAU/MAU
stickiness, and a DAU/WAU/MAU line chart. Then come the latest payments, each
linked to its transaction. Every panel has a description, a "How it's calculated" note, a
camera button that saves the panel as a PNG, a shareable link (#panel-id), and a
footer naming the chain and date, so an image of one panel stands on its own. Times are Nigerian
time (WAT) and days are Lagos days (migration 040). An open page refreshes its data every 5
minutes. Charts use Recharts. /api/onchain/export?chain=CELO (or BASE, or ALL) downloads the full
list as CSV. The agents home page's hero numbers come from the same index.
src/lib/onchain/indexer.ts reads vault events and
stablecoin transfers into the vaults over each chain's RPC (CELO_RPC_URL / BASE_RPC_URL
first, then the public endpoints), and classifies each payment by rail. It covers
all five vaults: Celo V1, V3 and V4, and Base V1 and V4.onchain_payments and onchain_cursors (migration 038). onchain_summary()
returns the all-time numbers and daily series in one query; onchain_windows() (migration 041)
returns the rolling 7/30/90-day and all-time windows plus the DAU/WAU/MAU series. Without 041
the site still loads, with all-time counters only. No customer data is stored: the bill account
number in PaymentReceived is never read into the table..github/workflows/onchain-index.yml
calls POST /api/cron/onchain-index every 15 minutes (APP_URL + CRON_SECRET secrets).
Each call resumes from the last indexed block. After applying 038, run the workflow once by
hand with backfill ticked to read the history.POST https://www.abapays.com/api/cron/onchain-index every 15
minutes, header x-cron-secret: <CRON_SECRET>, request timeout as high as the plan allows. Progress
is saved after every block range, so even a call cut short moves the index forward. The GitHub
schedule stays as a backup; overlapping calls are harmless because rows are upserted.Clone the repository
Install dependencies
Set up environment variables — copy the variables above into .env.local.
Run the development server
Access the application
AbaPayV2.sol — hardened contractcontracts/AbaPayV2.sol is a security-hardened successor to the original AbaPay.sol,
addressing the hardening gaps below. payBill's signature and the
PaymentReceived event are byte-for-byte identical to V1, so the frontend and the shared
PaymentReceived verifier (src/lib/paymentProof.ts) work with no backend changes.
Before mainnet:
ABAPAY_OWNER to a multisig (Safe), not an EOA. The timelock buys detection time — it only stops an attacker if a stolen key can't unilaterally cancel and re-queue, and it buys nothing at all if the delay has been set to 0 (see V4 below).setMaxRefund for each token — refunds revert until a cap is configured.
payBillstill usestransferFrom(msg.sender, …), so the payer must be the signer. Delegated spending (the DeAI "pay from social media" feature) needs an additional on-chain allowance mechanism and is deliberately out of scope for this hardening pass — it should be designed and audited as its own change.
AbaPayV3.sol — agent-initiated paymentscontracts/AbaPayV3.sol builds on V2 to solve the problem above: on Telegram/WhatsApp there is
no wallet to sign with, so the agent could previously only hand the user a deep link to sign in
the app. V3 adds a session-key / delegated-spend pattern instead:
approve(AbaPayV3, X),
and setSpendingAllowance(token, X) — an on-chain cap they control.RELAYER_PRIVATE_KEY) may call
payBillFor() on their behalf — but the contract itself checks and decrements the remaining
allowance on every call, so the cap is enforced on-chain, not by the backend.Deploy to testnet for demos; on mainnet, start maxAgentPaymentPerTx and maxRefundPerTx small
(scripts/deployV4.ts defaults to a $10-equivalent per token), then raise them via
setMaxAgentPayment / setMaxRefund. The live V4 vaults are set to 50 per agent payment
and 400 per refund for each supported stablecoin. The expected values are in
config/vaults.json, and node scripts/vault-config.mjs diffs them
against the chain.
payBillFor emits the same PaymentReceived event as V1/V2 (so the webhook needs no changes),
plus an additional AgentPayment event so the backend/any observer can distinguish "the user
signed" from "the agent spent an allowance."
AbaPayV4.sol — adjustable withdrawal delayV4 is V3 plus one change: the withdrawal timelock is no longer a hardcoded 24 hours. It is a
variable, withdrawalDelay, that the owner can raise, lower, or set to 0 via
setWithdrawalDelay(n). A fresh deploy defaults to 24h. Both live vaults run at 0, a deliberate owner
choice, so withdrawals from them are instant (see the warning below). V4 is what is deployed on
both mainnets:
⚠️ The previous Celo contract 0x42Fa4637… is a V3 — it has no setWithdrawalDelay, so its
24h timelock is fixed and it can never be made instant. That is why Celo was redeployed rather
than reconfigured. It is now retired: every token is unsupported, its relayer is 0x0 and it
is paused. The old Celo V1 (0x1d125198…) no longer accepts any token either.
The queue itself is not removable — it is compiled into the bytecode and there is no direct
withdraw(). At delay 0 a withdrawal is queueWithdrawal then executeWithdrawal back to
back: two transactions, no waiting.
⚠️ Changing the delay is not retroactive. A withdrawal's executableAt is stamped when it is
queued, so lowering the delay does not free one that is already sitting in the queue — you have
to cancelWithdrawal and re-queue it under the new delay. Cancelling moves no money; the tokens
never leave the vault.
⚠️ A delay of 0 removes the protection the timelock exists for. It is the reason a stolen owner key cannot drain the vault before anyone notices. At 0, whoever holds the key can queue and execute in the same minute. Treat it as an emergency setting and raise it back afterwards.
scripts/register8004.ts registers AbaPay's DeAI agent as a real on-chain identity on Celo via
the ERC-8004 "Trustless Agents" registry
(0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 on mainnet), so it's discoverable and browsable on
8004scan.io / AgentScan the same way any on-chain identity is. Registration mints an ERC-721
whose tokenId is the agent's ID, pointing at a public agent card (public/.well-known/agent.json)
that names the operational relayer wallet as the agent's on-chain address.
This is identity only — it does not touch payments. The relayer's signature-free
payBillFor flow above is completely unaffected; registering (or not) has zero effect on how
bills get paid. Before running on mainnet, verify the register(string) selector against the
registry's verified source on Celoscan — see the script's header comment.
src/app/api/mcp/route.ts implements a real MCP (Model
Context Protocol) server — the same open standard Claude and other AI agents use to call
tools — over Streamable HTTP (JSON-RPC 2.0: initialize, tools/list, tools/call), no
extra dependency required. It's a fourth channel into the same execution engine that
already backs Telegram/WhatsApp/X, not a parallel system with its own rules:
Building an agent that integrates AbaPay itself, with no human ever opening abapays.com? See docs/AGENT_INTEGRATION.md — the x402 zero-setup path, and the fully headless (wallet-signature-only) way to self-serve an MCP API key. Writing TypeScript?
sdk/(abapay-sdkon npm) wraps both into two functions. Python?python-sdk/mirrors it field-for-field and is on PyPI too —pip install abapay-sdk, published via a Trusted Publisher (no token secret) off thepy-sdk-v*tag; CI (not a local run — no Python interpreter existed in the environment that wrote it) is what actually verified it. Prefer a full book over a single doc?docs/ gitbook/covers the same ground as 12 chapters (SUMMARY.mdis the table of contents — Markdown, ready to sync into GitBook or any docs host that reads a repo directly). Want a working agent to start from? abapay-reference-agent (MIT) pays bills on Celo over x402 from its own wallet, with a spending cap, an approval prompt, optional plain-English requests via Claude, and optional Self Agent ID. Quickstart under 10 minutes.
list_plans exists so variation_code is never something the agent has to invent.
Its description, and the server-level instructions, both tell the client to call it before
pay_bill for those three services and to pass back a returned code verbatim — never to guess a
plan, a code, or a price. If it returns nothing usable (which genuinely happens — JAMB is not
enabled on this merchant account), the correct behaviour is to say so, not to fabricate a code.
pay_bill covers EDUCATION as well as airtime/data/electricity/cable, because MCP is meant
to be the same trust boundary as chat, not a narrower one. Every rule it needs is shared and
already existed: requiresVariation() forces a variation_code, checkAccountNumber() enforces
JAMB's ≥10-character profile ID, and requiresVerifiedName() decides that only JAMB
merchant-verifies (WAEC has no account to verify). It also validates the provider against the
live VTpass catalogue up front, so an agent can no longer pass a provider VTpass cannot sell
and discover it only after the money has moved.
service: "INTERNATIONAL" completes the purchase, unlike chat. Chat's INTERNATIONAL handling
(src/app/api/deai/core/route.ts) only validates a request and then tells the user to finish it
in the app — it has never actually vended one. MCP's pay_bill does: list_international_options
walks VTpass's country → product type → operator → variation chain, and pay_bill re-fetches the
chosen variation itself to derive the NGN-equivalent price from its own variation_rate/
charged_amount — never trusting a client-supplied amount, since that number is what prices the
on-chain charge. Only fixed-price plans are payable this way for now; flexible-amount plans
still redirect to the app.
Every successful pay_bill now returns a premium receipt, not just a text line. Alongside the
confirmation text, the response includes a branded receipt card (rendered server-side with
next/og's ImageResponse — no extra dependency — see src/lib/deai/receiptCard.tsx) and a link
to a shareable, public receipt page at /receipt/[request_id] (src/app/receipt/). The link is
keyed by request_id, not tx_hash: a transaction hash is visible to anyone watching the vault
address on-chain, and the receipt page — being public and shareable by design — must not let a
blockchain observer correlate a payment to the customer's verified name/address, so it never shows
the purchased code/PIN either. transaction_history gets the same rich treatment — a statement
card image alongside the plain-text list — for browsing past activity without opening the app.
pay_bill, pay_bill_batch, transaction_history, check_balance, list_schedules, and
describe_capabilities also render as a real interactive card via MCP Apps (SEP-1865) — an open
MCP extension (io.modelcontextprotocol/ui, shipped as the protocol's first official extension
2026-01-26), not a first-party-only mechanism. The flat PNG above is a fixed image with Satori's
font-subsetting limits (₦/₮ have to be worked around — see receiptCard.tsx's own comment on
that); the interactive card (src/lib/deai/mcpUiTemplates.ts) is real HTML/CSS/JS rendered by
the host in a sandboxed iframe, fed the tool's structuredContent over
ui/notifications/tool-result — real ₦/₮ glyphs, theme-aware (reads the host's CSS variables for
light/dark), with a "View receipt" link wired through ui/open-link. Declared via
_meta.ui.resourceUri on those six tools and served through two new /api/mcp methods,
resources/list/resources/read (src/app/api/mcp/route.ts) — additive only: a host that never
negotiates the extension just never calls resources/read, and every tool behaves exactly as
before (text ± PNG image), per the spec's own graceful-degradation rule.
The receipt card covers every real outcome of pay_bill, not just full success.
finalizePayBillResult builds the full receipt (image + structuredContent) for any
outcome that produced a real txHash — SUCCESS, PENDING
(still confirming on-chain), or FAILED_VENDING (charged, refund pending) — each with its own
status colour, label, and icon on the card; only a payment that never reached the chain falls back
to text-only.
The card is now a genuinely premium surface, and it's where "everything AbaPay offers" lives.
Every view (receipt, history, balance, schedules, batch, capabilities) shares one
gradient hairline border and drop shadow, real inline SVG icons (a distinct one per status —
success/pending/failed-vending/failed — and per service — airtime/data/electricity/cable/
education/bank/international), and a fade-in transition that retriggers on every re-render, so
paging through history or hitting Refresh/Cancel doesn't feel like a static swap. describe_capabilities's
card (the new capabilities view, via getCapabilitiesForCard() in src/lib/deai/capabilities.ts)
lists every AbaPay service with its example phrasing, whether it's usable right here over MCP vs.
app-only, and a live Paused/Available badge sourced from the same kill-switch state chat reads —
the closest thing to a full menu that can be shown safely without a PIN field inside a
third-party-rendered iframe (see the trust-boundary note below on why forms with a PIN aren't on
the card).
The card calls tools back, not just displays them. transaction_history's card pages through
history with Prev/Next (re-calling the tool with a shifted offset — transaction_history now
accepts one), check_balance's card has a Refresh button, and list_schedules' card has a Cancel
button per schedule — all using the spec's "Interactive Updates" pattern (the View sending
tools/call back through the host and getting a normal result straight back, not via a
notification). Deliberately not extended to pay_bill/pay_bill_batch/schedule_bill: those
need a PIN, and typing a spending PIN into a sandboxed third-party iframe is a weaker trust
boundary than typing it directly into the chat, which is the boundary the rest of this doc is
built around protecting. cancel_schedule for a single schedule id needs no PIN, which is why it's the one write
action the card exposes. Cancelling several at once (provider, or an explicit all: true)
does need the PIN, so the card never offers it.
Already-connected clients pick up new/changed tools without a manual reconnect — mostly.
initialize declares tools: { listChanged: true }, and GET /api/mcp with
Accept: text/event-stream opens a real SSE stream that pushes
notifications/tools/list_changed the moment a client starts listening, prompting a fresh
tools/list. This matters because an already-connected client does NOT re-run initialize just
because a new chat starts — it reuses one persistent connection to the connector indefinitely.
⚠️ Not a guarantee by itself: Anthropic's own MCP
connector tracker has open, acknowledged reports of a remote connector's tool list staying stale
even across a manual reconnect (anthropics/claude-ai-mcp#137, #476) — a client/platform-side
caching issue outside this server's control. Until that's fixed platform-side, "Refresh tools
list" from the connector's own ⋮ menu in Claude.ai is the fastest manual fallback — lighter than a
full disconnect/reconnect, no re-authorization needed.
Both accept optional chain/token overrides — they default to whatever was approved when the
API key was created, but a caller isn't stuck with that default if it comes up short. check_balance
returns balance + approved limit for every stablecoin on the chain (not just the default one),
so an agent can see upfront whether an alternative is even viable. If pay_bill is attempted with
the default and it's short on balance or on-chain allowance, the error itself checks whether
another token on that same chain already has enough of both and says so by name — e.g. "USD₮ is
short, but USDC already has enough balance and an approved limit — retry with token: 'USDC'" —
rather than a dead-end message naming only the token that failed.
pay_bill_batch — multiple recipients in one call, matching chat's batch payments.
Chat's intent engine has parsed "send 500 to X and 1000 to Y" into multiple recipients since
intentEngine.ts's rule 14; MCP's pay_bill only ever took one. pay_bill_batch reuses the
exact same primitives chat's own batch handler calls (groupByChainToken, checkAutonomousCapacity,
executeAgentPayment — all in src/lib/deai/batch.ts): recipients are grouped by (chain, token)
and each group's capacity is checked against its own subtotal, but if any group comes up
short the whole batch is refused before anything moves. Once capacity clears, recipients are
paid one at a time (never in parallel, to avoid relayer nonce contention) and the response
reports each individually — a vend failure partway through is never reported as if the whole
batch failed. One difference makes this MORE capable than chat, not just at parity: chat's
ParsedRecipient shape has no per-recipient variation_code field, so a chat-driven DATA batch
has no way to name each recipient's plan — pay_bill_batch's structured schema does, so it
requires one per DATA recipient instead of inheriting that gap. AIRTIME and DATA only, capped at
20 recipients per call.
schedule_bill/list_schedules/cancel_schedule bring MCP to parity with chat's
automations. Telegram/WhatsApp/X support recurring and one-off scheduled bills
(the scheduled_bills table, run by src/lib/scheduler.ts's cron); MCP has the same
capability, so an agent can pay a bill immediately or schedule one for later. schedule_bill collects
in one call what chat gathers over a multi-turn conversation, reusing pay_bill's exact validation
(requiresVariation, checkAccountNumber, checkAmountLive, the live-catalogue provider check)
and PIN gate, since a schedule is a standing spend. It charges nothing itself: if the wallet's
approved on-chain allowance already covers the amount, the schedule auto-pays itself each time
it's due; otherwise it's saved notify-only and something else has to call pay_bill when it comes
due. EDUCATION and INTERNATIONAL aren't schedulable, matching chat. MCP has no persistent channel
to push a "your bill just ran" message back into later on its own, so schedule_bill accepts an
optional customer_email for that — without one, check list_schedules/transaction_history
yourself to see what happened.
Why this exists: third-party agent scanners like 8004scan.io only run
a health check against a declared a2a or mcp service — see public/.well-known/agent.json's
services array, which declares both. The MCP server behind that declaration is real and
working, not a stub added just to satisfy the scanner.
Same trust model as chat, not a new one. pay_bill doesn't reimplement any security logic —
it calls straight into the functions already backing Telegram/WhatsApp/X and the multi-recipient
batch flow: checkPinAllowed/verifyPin/recordPinFailure (same escalating lockout),
checkServiceAllowed (kill switches), checkAccountNumber/checkAmount (parity validation),
checkAgentSpendAllowed (operator per-tx/daily caps), checkAutonomousCapacity +
executeAgentPayment (on-chain allowance check, the shared discount engine, and vend), and
notifySpendOutOfBand (email + every other linked channel is told the instant money moves, so a
leaked API key is caught exactly like a stolen chat session would be).
Rate limiting is defense in depth, layered on top of the above, not a substitute for it — a
blanket per-IP limit across every MCP tool call, plus a tighter per-identity limit on pay_bill,
schedule_bill, and pay_bill_batch specifically. Full numbers and mechanics: see
Rate Limiting under Security Architecture.
Chain-agnostic — Celo or Base, whichever the linking wallet approved. An MCP key inherits the
approved_chain/approved_token recorded when it was created (same fields Telegram/WhatsApp/X
already use), and check_balance accepts an explicit chain override. There is nothing
Celo-specific or Base-specific in the MCP layer itself — it's the same multi-chain relayer
(src/lib/deai/relayer.ts) and balance reader (src/lib/deai/services.ts) every other channel
shares.
OAuth 2.1 — authorize once, in a browser, and never retype an API key again.
The api_key/pin tool arguments work, but a brand-new Claude conversation remembers
nothing, so the human had to paste their API key every single time. The server now implements a
full OAuth 2.1 authorization-code flow with PKCE (S256 only) and Dynamic Client
Registration:
Both discovery documents are served at the plain /.well-known/… path and at the RFC
path-insertion variant with /api/mcp appended, because different clients probe different ones.
Things worth knowing about this implementation:
pay_bill call,
exactly as on Telegram/WhatsApp. A Bearer token on its own can read a balance and nothing more.WWW-Authenticate +
resource_metadata): no credential supplied at all. That 401 is the only signal an MCP
client uses to discover "this server supports OAuth" and show a connect button. A wrong
API key, a bad PIN, or a malformed argument stay in-band tool errors — turning those into 401s
would make the client re-run the whole browser flow over a typo.redirect_uri is validated against the registered list before anything renders, and a
failure renders a plain error page rather than redirecting — redirecting to an unvalidated URI
is precisely the open-redirect vulnerability that would leak the authorization code.<style>, no
scripts, no fonts, no third-party assets) so it satisfies the app's CSP without exception and
can't be broken by anything else in the app, styled with AbaPay's own emerald wordmark.Linking (no new env vars, no new table): agent_links.channel gained an 'MCP' value
(supabase/migrations/019_mcp_channel.sql) alongside TELEGRAM/WHATSAPP/X. Unlike those,
there's no bot to "claim" a link code with — from the app's Agent Hub tab, pick the MCP (AI
Agents) tile, set a PIN, and it mints a 256-bit API key shown exactly once (only its
SHA-256 hash is ever stored). A wallet can hold several MCP keys at once — one per agent/tool —
each with its own label.
Connecting an AI agent (end users):
https://agents.abapays.com/api/mcp as a remote (Streamable HTTP)
server:
https://agents.abapays.com/api/mcp → Save.
It's now available as a tool source in any new chat.%APPDATA%\Claude\claude_desktop_config.json (don't overwrite the whole file if you already
have other servers configured there):
api_key tool argument instead — it's a tool
argument, not an HTTP header, so there's no separate app-level auth step.Every tool declares annotations (title, readOnlyHint/destructiveHint, idempotentHint,
openWorldHint) — pay_bill is correctly flagged destructive/non-idempotent (it moves real
money and calling it twice pays twice), while describe_capabilities/check_balance are
read-only — so a client can warn a user appropriately before letting an agent invoke it.
Getting listed in claude.ai's Connectors Directory (so users can find AbaPay by browsing/ searching instead of pasting the URL) is a separate step from what's built here — it's an organizational submission through Anthropic, not a code change:
title +
readOnlyHint/destructiveHint annotations (✅ done above), a public documentation URL
(https://abapays.com/docs — live), a privacy policy URL (https://abapays.com/privacy —
live), an icon, and reviewer test-account credentials.
The same tools, reachable by other agents over A2A instead of MCP. Two files, no new capability:
One implementation, two protocols. The tool definitions and their implementations were moved
out of src/app/api/mcp/route.ts into src/lib/deai/mcpTools.ts, which both routes import.
/api/mcp and /api/a2a are now transport shims over one callTool(). That is deliberate:
A2A has no private path to money — the PIN gate, escalating lockout, on-chain allowance ceiling,
kill switches and operator spend caps all live below both routes, so a new protocol changes how
an agent asks, never what it may do. (The move was mechanical; tool logic is byte-identical.)
⚠️ /.well-known/agent-card.json is not /.well-known/agent.json. The latter is AbaPay's
ERC-8004 registration card — the on-chain identity 8004scan and Aigora read. The two specs
collided on the agent.json filename historically, which is exactly why A2A moved its card to
agent-card.json. Different documents, different consumers; overwriting one with the other
silently breaks the agent's on-chain listing.
No LLM in the invocation path. Chat channels route free text through parseIntent() because a
human typed it. A2A is machine-to-machine, so invocation is a structured DataPart carrying
{ skill, args }, validated against the same TOOLS schema MCP publishes. A text part gets the
skill catalogue back rather than a guess — re-interpreting "send 5000" with a language model in an
agent-to-agent payment path adds a failure mode with no upside.
Synchronous by design. Every skill completes inside the request, so message/send returns a
final Message (spec-legal) and no Task is created. The card therefore declares
streaming: false and pushNotifications: false, and tasks/* + message/stream return
-32004 UnsupportedOperation rather than being half-implemented — there is no task store to
query, and declaring capabilities we don't have would strand a peer waiting on updates that never
arrive.
Auth. Authorization: Bearer … accepts either credential MCP accepts, by prefix: an
aba_mcp_… Agent Hub key, or an OAuth 2.1 access token. A2A has no per-call api_key argument,
so the header is the only place a credential can arrive. A missing credential and a wrong one
stay distinct conditions, same rule as MCP. pay_bill still requires the PIN on every call.
Operator control. CHANNEL_A2A in platform_settings.kill_switches — separate from
CHANNEL_MCP so pausing one surface doesn't pause the other. No migration needed; the column is
free-form JSONB and a missing key reads as enabled.
Call it:
⚠️ On by default; NEXT_PUBLIC_X402_ENABLED=false falls back to the contract call. Note the
wallet-warning trade-off documented in the env section above — the EIP-3009 signature x402
requires is structurally what a drainer asks for, so some scanners flag it.
The web app's payment flow settles via x402 — through Celo's own
facilitator (api.x402.celo.org, built by Celo Core Co. — see
src/app/api/pay/x402/route.ts), not thirdweb — whenever the user is paying with USDC, USD₮,
or USA₮ on Celo. Each token settles against its own EIP-712 domain (X402_TOKEN_EIP712 in that
route) since Circle's USDC and Tether's USD₮/USA₮ deployments don't share one. The same "Confirm &
Pay" button routes through x402 for any of the three tokens on Celo and through the normal payBill
contract call for everything else (a token without EIP-3009, Base when NEXT_PUBLIC_BASE_X402_ENABLED=false,
or x402 unconfigured). This makes the payment genuinely visible on x402scan — not a
relabeled transaction — because x402 settlement requires an EIP-3009
(transferWithAuthorization) signature from the payer for that specific payment. That
requirement is also precisely why the scanners object.
The 402 challenge itself is built in-house (a plain x402 v1, body-based response) rather
than relying on any SDK's default — that's a deliberate choice, since thirdweb's own
settlePayment() always delivers a fresh challenge via a base64 header with an empty JSON
body, which generic x402 scanners (x402scan's discovery crawler included) don't parse,
causing registration to silently fail with a correct-looking 402 status but no usable
challenge.
Client-side, the challenge is read, signed and retried by src/lib/x402Pay.ts using the same
viem wallet client every contract call uses — one wallet connection for the whole flow, no
second stack alongside wagmi. Signing in-house also lets the page read the
server's actual answer: a settlement failure that carries a tx_hash means the money already
moved, and the app must never "retry" it on the contract-call rail.
The body-based (v1) challenge also carries a hint field — outside the x402 spec, additive, and
ignored by any client that only reads the fields it knows about. Being able to sign an
EIP-3009 authorization and having a client that drives the challenge/sign/retry sequence are
two different capabilities; a wallet with the former but not the latter gets a bare 402 with
nowhere to go. hint points that caller at the ordinary guided flow instead — abapays.com,
which needs no x402 at all.
🔵 Why a refused x402 settlement gets a real retry instead of an immediate fallback. A
transferWithAuthorization simulation can revert (the facilitator answers unable to estimate gas / invalid_payload) with no transaction and nothing moved. Falling back to the contract
call in that case costs two more prompts (approve + payBill) for a bill the x402 rail can
often settle on a second attempt costing one — so the retry happens automatically, at three
levels:
src/lib/x402Settle.ts decides the EIP-3009
revert conditions that are visible in the payload — an expired validity window, a
clock-skewed validAfter, a wrong recipient, an amount that disagrees with what the server
is about to declare as required — so a payload that cannot settle is answered with a fresh
challenge instead of an opaque revert. The signed value, not the recomputed price, is what
paymentRequirements declares and what the DB row and any refund record: the two can differ
by an exchange-rate tick, and a facilitator handed a mismatched pair refuses.retryable, and never when it carries a tx_hash. The second prompt is announced, because an
unexplained one reads as an app that ignored the first.🔵 No retry is offered until the CHAIN says the money did not move. This is the important
one. unable to estimate gas / invalid_payload is ambiguous by construction: because EIP-3009
nonces are single-use, re-simulating an authorization that already succeeded necessarily
reverts, and a revert during estimation is reported the same way whether nothing has happened
yet or everything already has. Message-matching alone can't separate those two cases — offering
a retry on the wrong read means asking the payer to sign a fresh nonce and pay a second time for
one bill. So the code doesn't rely on the message at all. Every
EIP-3009 token exposes authorizationState(authorizer, nonce) -> bool, the chain's own record
of whether that exact authorization was consumed, and it is asked before any retry is offered
(buildAuthorizationStateCall, and authorizationWasConsumed in the settle route). Spent means
the payment is reported as settled with no transaction hash — the client stops, the
contract-call fallback is suppressed, and an operator alert carries the payer and nonce for
manual reconciliation. An unreadable answer is treated the same as spent: a needless fallback
costs two prompts, a wrongly-offered retry costs the payer real money.
Wording is still used as a cheap first filter — FiatTokenV2: authorization is used or canceled is refused outright, while nonce too low and replacement transaction underpriced
(the facilitator's own EOA racing itself, a different nonce entirely) stay retryable — but the
chain, not the filter, is what actually authorises a retry.
Which rail a payment takes also depends on the wallet, not just the token. x402 settles on
an eth_signTypedData_v4 signature that has to come back to the page, and a WalletConnect
session that never negotiated that method drops the request on the floor — no prompt, no error,
nothing back. Only that is checked, from the session itself: see walletCanSignTypedData in
src/lib/walletEnv.ts.
🔵 Routing is never by wallet NAME. Every wallet is asked whether it can sign typed data, and
its own answer decides — not a hardcoded assumption about a specific wallet's behavior (Valora,
for instance, renders a typed-data request as "Verify wallet" and can report a successful
connection without ever returning a signature). When the answer never comes, the signature
times out, nothing has been sent to settle (src/lib/x402Pay.ts posts the settle request itself,
on the line after the signature is awaited, so unwinding it means a late signature reaches
nobody) and the page falls back to the contract call on its own.
That signature requirement is exactly why this is scoped to the main app only: the
agent-initiated flow above depends on paying with zero signature at payment time (the whole
point of setSpendingAllowance), which is fundamentally incompatible with x402's
per-payment-signature model. Telegram/WhatsApp/X and the autonomous scheduler never use x402 and
are unaffected — those payments already execute from RELAYER_ADDRESS, the same wallet
registered under the ERC-8004 identity below, so they're already attributable to the agent
without needing x402.
transferWithAuthorization; USDm doesn't (Mento tokens expose only EIP-2612 permit()), so there's no signature scheme to settle it — which is exactly why USAT replaced it in the picker
with. Not a self-imposed limit — if support is added for another token later, no code change
is needed beyond adding its EIP-712 domain, since the token/decimals are already resolved
generically via resolveTokenOnChain.CELO_X402_API_KEY) — top up at
x402.celo.org. At 0 credits, /settle starts returning 402 and the app sends a Telegram
alert (this is an operator problem, not a payer one — retrying won't help until topped up).processBlockchainPayment's own paymaster-fallback logic is careful about. The user
sees a clear error and can retry manually instead.payTo is set to the existing AbaPayV3 contract address —
the same one the admin dashboard already reads balances from and manages refunds/withdrawals
for. The vault's balanceOf doesn't care how tokens arrived, so x402-settled funds are
indistinguishable from contract-call funds to all existing admin tooling. No contract changes./api/pay) and the
x402 path (/api/pay/x402) call the same executeVend() (src/lib/vend.ts) once payment is
verified — so the automatic refund safety net applies identically to both rails.transactions.payment_method (010_x402_payment_method.sql) distinguishes CONTRACT from
X402 at a glance, alongside the existing source_channel (a different axis — UI channel vs.
settlement rail).src/lib/vtpassCatalog.ts is the single source of truth for every provider list in the app.
It calls VTpass's /services?identifier=… and returns the provider's real serviceID, VTpass's
own product name, VTpass's own logo URL, and its published minimium_amount (sic)
and maximum_amount.
Five categories are supported, keyed by the identifiers VTpass actually accepts (two of which
had to be discovered — the obvious guesses return 011 "Category Does not Exist"):
Caching and failure behaviour (this is the important part):
Map cache with a 1-hour TTL, shared in-process. The browser reaches it
through GET /api/providers?category=… (rate-limited 60/min per IP, Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400); chat and MCP call
getCatalog() directly. Both land on the same cache, so there is exactly one source of truth.getCatalog() never throws and
never returns an empty list, so a picker can be rendered unconditionally and a VTpass blip
can't blank it mid-purchase.stale: true, and /api/providers then serves it no-store
so a brief outage can't get frozen into an edge cache for an hour.src/lib/providerFallback.ts is the last-resort offline seed — the only hardcoded provider
data left. Its logos are deliberately local files, because the one code path that exists
for "VTpass is unreachable" must not render a dozen broken remote images.Why this replaced the hardcoded lists: the old lists advertised showmax, spectranet and
jamb — all three return Service is Not Valid on this merchant account, so a user could pick
one, fill the form, pay on-chain, and only then have the vend fail into the refund path. They
also omitted glo-sme-data and 9mobile-sme-data, which are live and were unreachable.
Amount limits come from the same records: limitsFor(category, serviceID) and
limitsForIntent(intent, provider) return the live per-provider {min, max}, and null when
VTpass publishes none — so "unknown" falls back to the caller's service-level default rather
than being mistaken for "unlimited".
platform_settings.kill_switches holds a two-level key system written by the admin
dashboard:
A switch is on unless explicitly false (a missing key means enabled), and a payment is
refused when either level is off — the same || the web app uses.
killSwitchKeysFor(intent, provider) in src/lib/serviceRules.ts maps an agent intent onto
exactly those keys, normalising the provider through resolveServiceId first so a loose
"ikeja" from chat resolves to the ELEC_ikeja-electric key the operator actually toggled.
checkServiceAllowed() is then the gate chat, MCP, A2A and the scheduler pass. The web rails
(/api/pay and /api/pay/x402) pass checkWebPayment(), which uses the same switches plus
VTpass's live per-provider amount limits, before anything is signed or settled. They used to
rely on the web page greying out its button, so a direct API or x402 caller could pay for a
switched-off service. International follows the web app's own rule: it is off unless
MASTER_INTERNATIONAL exists and is not false. Settings are cached for 30 seconds, so
flipping a switch takes effect within half a minute everywhere.
Automatic float breakers. When VTpass answers 018 (our float is empty) or Monnify answers
D04, a breaker in platform_settings.provider_circuits opens (src/lib/circuitBreaker.ts,
migration 028). While it's open, every service that provider fulfils is refused on every rail
before the customer pays, where previously each payer was charged and refunded. The breaker
closes by itself once the provider's balance is back above its alert threshold. The scheduled
balance check notices it, and so does a throttled probe when a customer next hits the breaker.
An operator can also close it with the admin action RESET_PROVIDER_CIRCUIT. Breakers are
stored apart from kill_switches and never change them, so an operator's own switch always wins.
CIRCUIT_BREAKER_ENABLED=false turns the feature off.
⚠️ Why this mapping matters:
killSwitchKeysFor()is what makes "pause Electricity" in the dashboard actually stop chat, MCP and the autonomous scheduler, not just the website — all four surfaces resolve the same intent to the sameMASTER_/per-provider keys, so a switch flipped in one place is enforced everywhere.
Separate from the per-service switches, checkAgentSpendAllowed() enforces the operator's
controls over the agent specifically: agent_enabled (master kill for all agent payments),
agent_autonomous_enabled (kills only unattended/scheduled execution), agent_max_ngn_per_tx,
agent_daily_cap_ngn (per user, per UTC day), and ai_chat_enabled for the in-app widget.
These sit on top of the on-chain allowance, never instead of it.
Bank Transfer has a standalone dashboard toggle (BANK key — no per-provider breakdown, since
it settles through Monnify/Moniepoint rather than a picker of VTpass providers), checked by
both the agent gate (BANK_TRANSFER in serviceRules.ts) and the web app's
isCurrentServiceDisabled in page.tsx.
Four more standalone switches pause an entire channel rather than a product —
CHANNEL_WHATSAPP, CHANNEL_TELEGRAM, CHANNEL_X, CHANNEL_MCP — enforced by
isChannelEnabled() in serviceRules.ts. WhatsApp/Telegram/X are checked once at the top of
the shared /api/deai/core engine (all three route through it); MCP is checked at
tools/call in /api/mcp. Same "missing key = enabled" default as every other switch here.
The Python tests that make HTTP round-trips run against a local server and need
pytest-httpserver; without the dev extra they skip (with the install command) instead of
erroring, and the rest still run. npm may print Unknown env config "manage-package-manager-versions"
— that comes from the machine's own npm config, not this repo, and doesn't affect the build.
Tests currently cover the security-critical pure logic: PIN hashing/verification, internal service auth, and the payment amount/token verification invariants (the checks that stand between a user and an unpaid bill).
CI runs on every push/PR via .github/workflows/ci.yml: typecheck → lint → build → tests →
dependency audit. The typecheck step exists specifically to catch TypeScript errors before
they reach a production deploy.
Beyond the core tables, run the migrations in supabase/migrations/ in order in the Supabase SQL editor:
001_rate_limits.sql — creates the rate_limits table required by src/lib/rateLimit.ts.
Rate limiting silently fails open without this table, so apply it before relying on the
throttles protecting your billable VTpass / WhatsApp / Claude endpoints.002_customer_details.sql — customer details captured on receipts.003_scheduled_bills.sql — Bill Pay & Autopay Agent scheduling.004_agent_links.sql — links a wallet to a Telegram/WhatsApp/X identity so the DeAI agent
can recognise a user. The security boundary is the on-chain spendingAllowance in
AbaPayV3, not this table — it's a UX mirror only.005_autonomous_schedules.sql — upgrades scheduled bills for true unattended execution,
safe specifically because AbaPayV3's on-chain allowance bounds worst-case exposure.006_agent_admin_controls.sql — operator kill switches for the agent (agent_enabled,
agent_autonomous_enabled, ai_chat_enabled, per-tx/daily NGN caps), settable from the
admin dashboard's Agent tab without a redeploy or contract call.007_transaction_source_channel.sql — records which channel (web app / Telegram / WhatsApp /
X / an unattended schedule) originated each transaction, for operator alerting.008_refund_queue.sql — queued refund pipeline for vends that fail after payment is taken.009_support_tickets.sql — support tickets from the web app and every social channel, with
admin replies routed back to the user's original chat.010_x402_payment_method.sql — adds payment_method (CONTRACT | X402) to transactions,
distinguishing the settlement rail (see x402 settlement).011_one_off_schedules.sql — adds run_once_at and batch_id to scheduled_bills, so a
single chat request can create a one-time future payment (frequency = 'once') or a
multi-recipient batch, on top of the existing recurring monthly/weekly/daily schedules.012_schedule_notify_channel.sql — records which channel a schedule should report back on.014–018_discount_*.sql — the discount-campaign engine: campaigns, per-campaign caps,
destination/IP caps, per-phone caps + a fraud toggle, and exclusions/full-status counting.
(There is no 013; numbering skips it.)019_mcp_channel.sql — adds 'MCP' to agent_links.channel alongside
TELEGRAM/WHATSAPP/X, so an AI agent is a first-class linked channel.020_mcp_oauth.sql — the OAuth 2.1 tables: dynamically registered clients, single-use
authorization codes (with their PKCE challenge), and access/refresh token records (hashed,
with rotation and revocation).AbaPay is highly optimized for mobile Web3 experiences. To test the dApp within the Celo MiniPay environment:
NEXT_PUBLIC_NETWORK to celo-sepolia (testnet) or celo (mainnet), and NEXT_PUBLIC_APP_MODE to sandbox or production accordingly.The app ships with Farcaster frame metadata (public/.well-known/farcaster.json and frame config in layout.tsx). Deploy to a public URL, then share the link in a Farcaster client that supports Mini Apps to launch it directly.
supabase/migrations/026_transaction_state_guard.sql) refuses to move a finished transaction back to pending, to change a real tx hash, or to change a transaction's provider reference once it has left pending, so a settled or refunded payment can't be re-vended. ⚠️ In-memory tracking alone is not safe in serverless environments: state resets on cold starts and isn't shared across concurrent instances.PaymentReceived for the token, amount, account, service and payer recorded when the payment was started, not the values in the request (src/lib/paymentProof.ts). Decimals come from the token the event names, so a payment in a different token can't pass for one in another. The event is emitted the same way for a direct wallet call, a sponsored ERC-4337 UserOperation on Base, or the agent relayer. The bill that is vended is read from the server's stored record, not from the client.PaymentReceived event and requires that its payer, token, amount, and account number all match the pending record before vending. This blocks the class of attack where a user has a small pending intent and then manually sends a different (or larger/smaller) transfer to the contract hoping it gets attached to the wrong record./api/cleanup, every 15 min) so they don't linger as PENDING forever. This only ever touches preflight_-prefixed rows, so a real broadcast transaction can never be expired./api/webhook/vtpass replies {"response": "success"} immediately — the exact acknowledgement VTpass parses for — and only then does the real work (via after()), because VTpass requires a prompt, lightweight reply and retries anything else as an unacknowledged delivery. The push itself is never trusted: the handler re-queries VTpass server-to-server with our API keys and acts only on that confirmed status.AbaPay.sol smart contract vault. Only the contract owner's cryptographically signed transaction can withdraw funds — no backend service ever holds custody of user funds directly./api/admin/refund verifies the refund on-chain (token, recipient, and amount all decoded from the transaction's ERC-20 Transfer logs) before marking a transaction REFUNDED — an admin cannot record a refund that never actually happened.fallback() transport across multiple RPC endpoints (src/lib/chain.ts), so a single downed provider doesn't halt payment verification.Content-Security-Policy-Report-Only mode (next.config.ts) — surfaces violations without risking breakage to wallet connections. Promote to enforcing (Content-Security-Policy) once verified against real wallet flows./admin dashboard are gated behind dedicated authentication (src/utils/adminAuth.ts), separate from the public storefront. An allowlisted ops wallet, never the vault owner, signs in once with a Sign-In with Ethereum message (domain-bound, single-use nonce). It gets an HttpOnly, Secure, SameSite=Strict session cookie that lasts 2h idle or 8h at most, and can be revoked with Sign out or Sign out everywhere. Only the session's hash is stored. State-changing admin requests also require a same-site Origin. On-chain vault actions still need the owner wallet, because the contract enforces it./api/deai/*) is reachable only by the app's own bot webhooks via a signed internal-service token (src/utils/internalAuth.ts). This prevents the public internet from impersonating any user by their chat ID / phone number / X ID, or burning the Claude API budget.X-Hub-Signature-256 / X's x-twitter-webhooks-signature HMAC on every inbound payload (when the corresponding secret is configured), and Telegram verifies its secret token — so message events can't be forged.src/utils/pinSecurity.ts), never plaintext, with legacy plaintext values transparently upgraded on next use and a 4-attempt lockout./api/paymaster) allowlists only ERC-7677 paymaster JSON-RPC methods, so it can't be abused as a general-purpose RPC relay running on your CDP key. It also decodes each UserOperation (the smart wallet's execute/executeBatch) and only sponsors AbaPay's own calls: a supported stablecoin's approve(vault, …), and the vault's payBill / setSpendingAllowance, with no native value and on Base only. Anything else gets a -32602 refusal and never reaches CDP. Requests are rate-limited per IP and per smart-wallet sender (src/lib/paymasterPolicy.ts).Two independent layers, both implemented in src/lib/rateLimit.ts and both defense in
depth — neither is the real backstop (the on-chain allowance, kill switches, and PIN lockout
are), they just slow down abuse of a leaked credential before its owner sees the out-of-band
alert and revokes it.
getClientKey), applied
per endpoint via enforceRateLimit. The IP is read in order of trust — x-vercel-forwarded-for,
then x-real-ip, then the rightmost entry of x-forwarded-for (never the leftmost, which a
caller can forge by prepending their own value to the header).pay_bill, schedule_bill, and
pay_bill_batch additionally rate-limit by the agent_links row id of the credential that
authenticated the call (checkSpendRateLimit in src/lib/deai/mcpTools.ts) — not the IP — so
a leaked API key or OAuth token can't outrun the limit by rotating source addresses. This runs
after the PIN check, since the escalating PIN lockout only ever triggers on a wrong PIN and
does nothing to slow down a run of correct calls.Mechanics: a Postgres-backed fixed window (rate_limits table), incremented atomically via
the rate_limit_hit RPC (supabase/migrations/023_rate_limit_atomic_increment.sql) to close a
race where concurrent requests could all read "under limit" before any of them writes — falling
back to a non-atomic read-modify-write if that migration isn't deployed yet. Fails open, not
closed: if the rate_limits table or RPC is unreachable, the request is allowed rather than
taking the whole API down — rate limiting is an abuse control, not an auth control, and must
never become a single point of failure (001_rate_limits.sql creates the table; without it,
limiting silently does nothing). A limited call returns HTTP 429 with a Retry-After header
and a JSON body: {"success": false, "error": "Too many requests..."}.
Four surfaces tell users what AbaPay does. They are not generated from anything — they go stale silently unless deliberately updated, so treat them as part of the change, not as an afterthought:
⚠️ /terms and /privacy are written by engineers, not lawyers, and have not had legal
review. Neither should be treated as legally vetted until a qualified lawyer has reviewed
them; /terms carries a visible notice at the bottom saying exactly that, and it must survive
any rewrite of the page.
/docs is the single docs + FAQ surface — there is no separate in-app Terms/Privacy/FAQ modal.
AbaPay is operated by Masonode Technologies Limited, a company registered with the Corporate Affairs Commission (CAC) of the Federal Republic of Nigeria under RC 9524980.
Code in this repository is MIT licensed — free to use, fork, and build on, including
for independent agents/apps integrating AbaPay's MCP/x402 rails. This covers the code only; it
does not license the AbaPay name/brand or make any claim about the legal terms of using the
live abapays.com service, which are set separately by /terms.
Built and maintained by Oluwafemi Olagoke (@investorphem).
Focusing on Web3, Decentralized AI, and scalable blockchain applications.
來源:README.md,提交 a77b375
Describe Capabilities
List what AbaPay can pay (airtime, data, electricity, cable, etc.), any services currently paused, and example requests. Call this first if unsure what is supported.
無需輸入參數。
Check Balance
Check a linked wallet's stablecoin balances and remaining agent spending allowance. Works with no arguments once this connector is authorized via OAuth.
chainapi_keyList Plans
List the REAL, currently purchasable plans for a service that needs one — DATA bundles, CABLE packages, or EDUCATION products (WAEC/JAMB) — with their exact codes and current VTpass prices. ALWAYS call this before pay_bill for these three services and pass back one of the returned codes as variation_code. Never guess a plan, a code, or a price — if this returns nothing usable, say so rather than inventing one.
service必填provider必填List International Options
Browse the REAL, live international top-up catalogue (140+ countries) one level at a time. Call with no country to see supported countries. Add country to see its product types. Add product_type_id to see operators. Add operator_id too to see real, currently purchasable plans with their exact codes, foreign-currency price, and NGN-equivalent cost. ALWAYS call this before pay_bill with service: INTERNATIONAL, and pass back the exact country/product_type_id/operator_id/variation_code shown — never guess any of them. Only plans marked fixed-price can be paid via pay_bill right now; flexible-amount plans must be completed in the AbaPay app.
countryoperator_idproduct_type_idTransaction History
List recent real transactions for the linked wallet — same data as the AbaPay app's History tab (service, provider, amount, status, tx hash). Read-only, no PIN required. The interactive card's own Next/Previous buttons page through results by re-calling this tool with a different offset — pass offset yourself only when asked for something like "the next page" or "transactions before that" in plain text.
limitoffsetapi_keyGet Payment Status
Look up one payment by its reference — the transaction hash (0x…) or the request id a payment returned — and get its current status in plain words: delivered, still being confirmed, failed, or refunded, including where any refund stands. Only finds payments made by the linked wallet. Read-only, no PIN required. Use this instead of paying again when a pay_bill result was lost.
api_keyreference必填Pay Bill
Pay a real bill — Nigerian (airtime, data, electricity, cable TV, a WAEC/JAMB education PIN) or international airtime/data across 140+ countries — from the linked wallet, settled on-chain and delivered via the same pipeline as the AbaPay app. For DATA, CABLE (when changing package), and EDUCATION, call list_plans first and use a real variation_code from it. For service: INTERNATIONAL, call list_international_options first and pass back its exact country/product_type_id/operator_id/variation_code — never guess any of these. ALWAYS requires the PIN — including when this connector is authorized via OAuth; ask the human for it every time and never guess or reuse a remembered one. Money moves for real — only call this once the human has clearly confirmed the exact amount, provider, and account. EXECUTES IMMEDIATELY, with no delay/schedule parameter of any kind — there is no way to queue this call for later on this connection. If the human asks to pay "in N minutes", "later today", "tomorrow", or any other future time, do NOT call this now: ask them to confirm they want it sent immediately instead, or tell them delayed/recurring automations can only be set up from the AbaPay app or by messaging the AbaPay agent on Telegram/WhatsApp/X — never silently pay right away when a delay was requested.
pin必填chaintokenapi_keycountryservice必填provideramount_ngnmeter_typeoperator_idcustomer_nameaccount_number必填另有 4 個Schedule Bill
Set up a recurring or future one-off bill payment — daily/weekly/monthly airtime, data, electricity, or cable — the same automation Telegram/WhatsApp/X support. Validates exactly like pay_bill (call list_plans first for DATA, or CABLE when changing package, to get a real variation_code) and ALWAYS requires the PIN, since this creates a standing spend. Nothing is charged when this tool runs — money only moves later, when the schedule actually fires, and only if the wallet still has a funded on-chain allowance at that time. If the approved agent limit already covers the amount right now, the schedule is created to auto-pay itself each time it is due; otherwise it is saved as notify-only and someone must call pay_bill manually when it comes due — the response says which. EDUCATION and INTERNATIONAL cannot be scheduled; pay those directly with pay_bill. Use list_schedules to see what is set up and cancel_schedule to remove one.
pin必填chaintokenapi_keyservice必填providerfrequency必填amount_ngn必填meter_typeday_of_weekday_of_monthaccount_number必填另有 4 個List Schedules
List active recurring/one-off bill schedules for the linked wallet — same data as the AbaPay app and Telegram/WhatsApp "show my schedules". Read-only, no PIN required. Returns each schedule's id — pass that to cancel_schedule to remove one.
api_keyCancel Schedule
Cancel active schedules for the linked wallet. Call list_schedules first to get a real id. Pass id to cancel exactly one (no PIN needed). Cancelling several at once — provider for every schedule of that provider, or all: true for every schedule on this wallet — requires the PIN, asked from the human each time. Calling with none of id/provider/all is refused rather than cancelling everything.
idallpinapi_keyproviderPay Bill Batch
Pay airtime or data to multiple recipients in ONE call — the same multi-recipient batch Telegram/WhatsApp/X support ("send 500 to X and 1000 to Y"). One PIN authorizes the whole batch. Recipients are grouped by (chain, token); each group's capacity (balance + approved agent limit) is checked against that group's own subtotal — but if ANY group is short, the ENTIRE batch is refused before anything moves (all-or-nothing on capacity; paying 6 of 8 recipients because the 7th was under-funded is worse than one clear error up front). Once capacity clears, recipients are paid one at a time and the response reports each individually, since a single vend failure partway through must not be reported as if the whole batch failed. AIRTIME and DATA only — electricity, cable, education, and international are not batchable; call pay_bill for those, one at a time. For DATA, call list_plans first and give each recipient needing one its own real variation_code. EXECUTES IMMEDIATELY: no delay/schedule option, same as pay_bill — for a delayed/recurring batch, call schedule_bill once per recipient instead.
pin必填chaintokenapi_keyrecipients必填customer_emailidempotency_key已滿足適用檢查項的 100%
該評分衡量此收錄條目的完整程度,依據註冊中心條目、端點與公開來源計算得出。它不是對伺服器本身的評價,也不表示伺服器安全或目前可用:可連通性是帶日期的檢查結果,評分也不包含安裝量或使用者評價。
src/├── app/│ ├── page.tsx # Main storefront (pay flow, wallet connect, history, env detection)│ ├── admin/page.tsx # Admin ops dashboard (incl. the kill-switch toggles)│ ├── docs/page.tsx # Docs & FAQ page│ ├── terms/, privacy/ # Legal pages (standalone routes; the in-app modals live in components/Modals.tsx)│ ├── .well-known/ # OAuth discovery metadata, incl. the RFC path-insertion variants│ │ ├── oauth-authorization-server/{route.ts, api/mcp/route.ts}│ │ └── oauth-protected-resource/{route.ts, api/mcp/route.ts}│ └── api/│ ├── pay/ # Core payment + vending endpoint (pay/x402/ is the x402 rail)│ ├── paymaster/ # Server-side proxy for Base gas-sponsorship (keeps the CDP paymaster key off the client)│ ├── providers/ # Live VTpass provider catalogue for the browser's pickers│ ├── requery/ # Delayed/timeout transaction requery│ ├── rate/, admin/rate/ # Exchange rate endpoints│ ├── variations/ # VTpass service variation lookups│ ├── intl/, foreign/ # International bill pay (countries/products/operators/rates)│ ├── verify/ # Meter/account/customer verification│ ├── admin/ # Admin data, actions, refunds, health│ ├── discounts/ # Discount campaign lookup│ ├── schedules/ # Recurring + one-off scheduled bill execution│ ├── user/points/ # AbaPoints balance│ ├── agent/ # Agent link/allowance management (Agent Hub)│ ├── deai/ # Conversational AI agent│ ├── mcp/ # MCP server (describe_capabilities, check_balance, list_plans, pay_bill, pay_bill_batch, schedule_bill, list_schedules, cancel_schedule)│ ├── oauth/{register,authorize,token}/ # OAuth 2.1 (DCR, consent page, token endpoint) for MCP│ ├── cleanup/ # Stale pre-flight intent sweeper│ ├── webhook/, webhook/vtpass/ # VTpass + on-chain webhooks│ ├── monnify/ # Moniepoint bank list, account resolve/verify, transfer webhook│ ├── telegram/webhook/, whatsapp/webhook/, x/webhook/ # Bot channel webhooks│ └── support/ # Support ticket submission├── components/ # Shared UI (AppFooter, Modals — Terms/Privacy/FAQ/Receipt —, tabs, AIChat, AgentHub, Admin panels)├── config/wagmi.ts # Wallet/chain configuration (injected, Base Account, WalletConnect)├── constants/ # Supported tokens, services, initial country list├── lib/│ ├── vtpassCatalog.ts # ⭐ Live VTpass provider catalogue + per-provider amount limits│ ├── providerFallback.ts # Offline seed used only when VTpass is unreachable│ ├── monnify.ts # Moniepoint (Monnify) API client — banks, verify, transfer│ ├── monnifyVend.ts # Bank transfer vend + finalize (success/failure/refund)│ ├── serviceRules.ts # Kill switches, operator agent caps, min/max amounts│ ├── refunds.ts # Refund queue (enqueue on vend failure + user notification)│ ├── vend.ts # Shared vend execution for the contract and x402 rails│ ├── attribution.ts # Celo (Open Rails + Builders) and Base on-chain attribution (ERC-8021 dataSuffix)│ ├── parity.ts # Shared validation so chat/MCP match the web form│ ├── deai/ # Intent parsing, capabilities, selection, relayer (payBillFor),│ │ # mcpAuth.ts (API key), mcpOAuth.ts (OAuth token lifecycle)│ └── ... # VTpass, Telegram, WhatsApp, scheduler, discount helpers└── utils/ # Supabase client, admin auth, PIN hashingcontracts/├── AbaPay.sol # V1 — original escrow/vault smart contract├── AbaPayV2.sol # V2 — hardened (see below)└── AbaPayV3.sol # V3 — adds agent-initiated paymentsscripts/└── deployV4.ts # Deploy V4 (whitelists tokens, sets relayer + per-tx caps)NEXT_PUBLIC_APP_MODE=sandbox # sandbox | productionNEXT_PUBLIC_NETWORK=celo-sepolia # celo-sepolia | celo | base | base-sepoliaNEXT_PUBLIC_FIXED_RATE=1550.00 # Fallback NGN exchange rateNEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_idNEXT_PUBLIC_WC_RELAY_URL= # Optional. Override the WalletConnect relay — see "Blocked networks" belowNEXT_PUBLIC_ABAPAY_ADDRESS=0xYourDefaultContractAddressNEXT_PUBLIC_ABAPAY_CELO_ADDRESS=0xYourCeloContractAddressNEXT_PUBLIC_ABAPAY_BASE_ADDRESS=0xYourBaseContractAddressADMIN_WALLET_ADDRESSES=0xYourOpsWallet # optional; admins for /admin (never the vault owner). See ENV_SETUP.mdCELO_PRIVATE_KEY=your_deployer_private_key # Used only by Hardhat for deployment — never expose client-sidePAYMASTER_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.VTPASS_API_KEY=your_api_keyVTPASS_PUBLIC_KEY=PK_your_public_keyVTPASS_SECRET_KEY=SK_your_secret_keyVTPASS_MSG_TOKEN=VT_PK_your_tokenVTPASS_MSG_SECRET=VT_SK_your_secretMONNIFY_API_KEY=MK_your_api_keyMONNIFY_SECRET_KEY=your_secret_keyMONNIFY_CONTRACT_CODE=your_contract_codeMONNIFY_SOURCE_ACCOUNT_NUMBER=your_wallet_account_numberNEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.coNEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_keySUPABASE_SERVICE_ROLE_KEY=your_service_role_key # Server-side only — full DB accessRESEND_API_KEY=re_your_resend_keyANTHROPIC_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.TELEGRAM_BOT_TOKEN=your_admin_bot_tokenTELEGRAM_ADMIN_CHAT_ID=your_admin_chat_idTELEGRAM_CHAT_ID=your_default_chat_idTELEGRAM_WEBHOOK_SECRET=your_webhook_secretSUPPORT_TELEGRAM_BOT_TOKEN=your_support_bot_tokenDEAI_TELEGRAM_BOT_TOKEN=your_deai_bot_tokenWHATSAPP_ACCESS_TOKEN=your_whatsapp_access_tokenWHATSAPP_PHONE_NUMBER_ID=your_phone_number_idWHATSAPP_VERIFY_TOKEN=your_verify_tokenWHATSAPP_APP_SECRET=your_meta_app_secret # ⚠️ REQUIRED. Verifies the X-Hub-Signature-256 on inbound webhooks so senders can't be spoofed.WHATSAPP_SCHEDULE_TEMPLATE_NAME=schedule_update # Approved utility template used when the 24h window has closed. Unset = scheduled payments go unreported on WhatsApp.WHATSAPP_SCHEDULE_TEMPLATE_LANG=en # Must match the template's language exactly ('en' and 'en_US' are different templates).WHATSAPP_PIN_FLOW_ID= # Published secure-PIN WhatsApp Flow (scripts/whatsapp-pin-flow.mjs). Set = PINs are entered in a masked WhatsApp form, never typed into the chat. Unset = PIN asked as text.WHATSAPP_PIN_FLOW_MODE= # 'draft' only while testing an unpublished Flow.WHATSAPP_BUSINESS_ACCOUNT_ID= # WABA id; only the Flow scripts need it.WHATSAPP_CHECKOUT_FLOW_ID= # Published checkout Flow (scripts/whatsapp-checkout-flow.mjs). Set = "menu"/"buy"/greetings open a one-form purchase (src/lib/whatsappCheckout.ts); ~3 billed replies per purchase instead of 6–8.WHATSAPP_CHECKOUT_FLOW_MODE= # 'draft' only while testing an unpublished checkout Flow.WHATSAPP_FLOW_PRIVATE_KEY= # PEM private key for the Flow data endpoint (/api/whatsapp/flow). Its public key is registered with WhatsApp. Sensitive.WHATSAPP_DAILY_USER_CAP=20 # Bot replies per user per day (src/lib/whatsappBudget.ts). Past it: one note pointing to Telegram/web, then silence until midnight WAT. A chat waiting for its PIN is never cut off.WHATSAPP_DAILY_USER_CAP_PAID=10 # The cap once the month passes Meta's free allowance.WHATSAPP_FREE_MONTHLY_REPLIES=1000 # Meta's free service replies per number per month.WHATSAPP_MONTHLY_ALERT_AT=800 # Telegram alert to the operator at this many replies in a month (and again past the free allowance).AbaPay scheduled payment update:
{{1}}
Open AbaPay to see the full receipt in your History.X_BEARER_TOKEN=your_bearer_tokenX_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_idALCHEMY_WEBHOOK_SECRET=your_alchemy_base_webhook_secretALCHEMY_CELO_WEBHOOK_SECRET=your_alchemy_celo_webhook_secretETHERSCAN_API_KEY=your_etherscan_or_celoscan_api_keyRELAYER_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.ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json # Used only by scripts/register8004.tsERC8004_REGISTRY_CELO_MAINNET=0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 # Optional overrideERC8004_REGISTRY_CELO_SEPOLIA=0x8004A818BFB912233c491871b3d84c89A494BD9e # Optional overrideERC8004_REGISTRY_BASE_MAINNET=0x8004A169FB4a3325136EB29fA0ceB6D2e539a432 # Optional override — same address as Celo mainnet, confirmed byte-identical via eth_getCodeNEXT_PUBLIC_ERC8004_AGENT_ID= # Optional. Set after registering, for UI display.ERC8004_AGENT_ID=9687 ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json npx hardhat run scripts/update8004uri.ts --network celoERC8004_AGENT_ID=59561 ERC8004_AGENT_URI=https://abapays.com/.well-known/agent.json npx hardhat run scripts/update8004uri.ts --network baseCELO_X402_API_KEY=your_x402_celo_org_api_key # Server-side: settles via api.x402.celo.orgNEXT_PUBLIC_X402_ENABLED= # Default ON. Set to "false" to use the contract call insteadCELO_RPC_URL=https://celo-mainnet.core.chainstack.com/<key> # Server-only. Tried FIRST for Celo mainnet.BASE_RPC_URL=https://base-mainnet.core.chainstack.com/<key> # Server-only. Tried FIRST for Base mainnet.CRON_SECRET=any_long_random_string # Optional. Protects /api/cleanup and both /api/schedules/run* endpoints.git clone https://github.com/investorphem/abapay.gitcd abapaynpm installnpm run devnpx hardhat compile # Compile contractsnpm run test:contracts # Run the Solidity test suitenpx hardhat run scripts/deployV4.ts --network <network> # Deploy V4 (agent-initiated payments, adjustable withdrawal delay)npx hardhat run scripts/register8004.ts --network <network> # Register the agent identity (ERC-8004)npx hardhat run scripts/update8004uri.ts --network <network> # Re-push the agent URI so an indexer (8004scan) re-reads it# --chain defaults to base; pass --chain celo for the Celo vaultnode scripts/instant-withdrawals.mjs --chain celo # show live state, change nothingnode scripts/instant-withdrawals.mjs --chain celo --apply # delay -> 0, clear a stuck queue entrynode scripts/instant-withdrawals.mjs --apply --withdraw # …and push the queued one throughnode scripts/instant-withdrawals.mjs --chain celo --restore-delay 86400 # put the 24h timelock back{ "mcpServers": { "abapay": { "url": "https://agents.abapays.com/api/mcp" } }}curl -X POST https://agents.abapays.com/api/a2a \ -H "Authorization: Bearer aba_mcp_…" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{ "kind":"message","role":"user","messageId":"1", "parts":[{"kind":"data","data":{"skill":"check_balance","args":{}}}]}}}'npm test # Run the unit test suite (Vitest)npm run test:watch # Watch modenpm run test:coverage # Coverage reportnpm run typecheck # tsc --noEmit — catches type errors before they hit a deploy# Web app (repo root) — Node.js 20+npm cinpm run typechecknpm testnpm run build # set the dummy NEXT_PUBLIC_* values from .github/workflows/ci.yml's Build step first
# TypeScript SDKcd sdk && npm ci && npm test
# Python SDK — install the dev extra, it brings pytest-httpservercd python-sdk && pip install -e ".[dev]" && pytest