Back to Browse

AbaPay MCP Server

Developer ToolsModerate6.2MCP RegistryLocalRemote
Free

Server data from the Official MCP Registry

Non-custodial stablecoin bill-pay rails on Celo & Base for AI agents, settled on-chain via MCP.

About

Non-custodial stablecoin bill-pay rails on Celo & Base for AI agents, settled on-chain via MCP.

Remote endpoints: streamable-http: https://agents.abapays.com/api/mcp

Security Report

6.2
Moderate6.2Moderate Risk

AbaPay is a complex Web3 payment platform with reasonable authentication and authorization controls, but contains several moderate security concerns that warrant attention. Key issues include: (1) a hot private key (`RELAYER_PRIVATE_KEY`) exposed in environment without adequate safeguards documented for production use, (2) potential data exfiltration risks through multiple third-party integrations (VTpass, Monnify, Telegram, WhatsApp, X) without explicit user consent frameworks, (3) insufficient input validation patterns across numerous API endpoints handling financial transactions, and (4) reliance on external webhooks with signature verification that could be bypassed if secrets are misconfigured. The permissions are appropriate for a developer/financial tool category, but the credential handling and integration architecture introduce real risks that should be mitigated before production deployment.

1 file analyzed · 12 issues found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

HTTP Network Access

Connects to external APIs or services over the internet.

network_websocket

Check that this permission is expected for this type of plugin.

env_vars

Check that this permission is expected for this type of plugin.

File System Read

Reads files on your machine. Normal for tools that analyze or process local data.

File System Write

Writes or modifies files on your machine. Check that this is expected for the tool.

database

Check that this permission is expected for this type of plugin.

system_info

Check that this permission is expected for this type of plugin.

process_spawn

Check that this permission is expected for this type of plugin.

How to Install & Connect

Available as Local & Remote

This plugin can run on your machine or connect to a hosted endpoint. during install.

Documentation

View on GitHub

From the project's GitHub README.

⚡ AbaPay Protocol

AbaPay is a decentralized, Web3-native utility payment platform built on Base (the default chain) and Celo. It lets users pay for real-world bills — Airtime, Mobile Data, Electricity, Cable TV, Bank Transfers, Education PINs, and International Airtime/Data — using on-chain stablecoins (USDT, USDC, USAT), with instant fiat settlement handled server-side via the VTpass API. Payments can be made directly in the web app, or hands-free through a conversational, autonomous AI agent ("DeAI") on Telegram, WhatsApp, and X — a real on-chain identity under ERC-8004, discoverable on 8004scan.io — that can pay bills unattended, run recurring/scheduled autopay, and settle multi-recipient batch payments, all spending from a bounded, user-revocable on-chain allowance — no custody, no server-side keys.

Designed for low fees, cross-border utility vending (Nigeria + every country VTpass's live international catalogue returns), and mobile-first accessibility — MiniPay, Valora, Farcaster Mini Apps, Coinbase Smart Wallet / Base Account, MetaMask, and any other WalletConnect-compatible wallet (see Supported Wallets & Environments).

Operator: Masonode Technologies Limited (RC 9524980), Nigeria.


🌟 Key Features

  • Multi-Chain Payments: Pay bills directly with USDT, USDC, or USAT on Base (Mainnet/Sepolia) or Celo (Mainnet/Alfajores). Base is the default chain; Celo remains fully supported and switchable. The app auto-detects the connected chain and filters/reorders available stablecoins accordingly — USDC leads on Base, USD₮ leads on Celo, and USAT is Celo-mainnet-exclusive.
  • Live VTpass Catalogue — nothing about a provider is hardcoded any more: every provider name, logo, and amount limit for airtime, data, electricity, cable and education is fetched live from VTpass (src/lib/vtpassCatalog.ts, served to the browser by /api/providers) rather than from four separate hardcoded lists. The app, chat, MCP and the admin dashboard all read the same in-process cache, so there is exactly one source of truth. See Live provider catalogue below.
  • Per-Provider Amount Limits, Enforced Live: the ceiling is VTpass's real published minimium_amount/maximum_amount per provider, not one flat number per service — airtime alone ranges MTN ₦200,000 / Glo ₦100,000 / Airtel ₦50,000 / 9mobile ₦50,000, and electricity minimums range ₦100 (Ikeja, Aba) to ₦2,000 (Ibadan). A flat cap either wrongly refused a valid MTN top-up or wrongly accepted an Airtel one that VTpass rejects after the user has already paid on-chain.
  • International Bill Pay: Users can select a country and pay for foreign airtime/data in that country's own currency and rate — transaction history and receipts reflect the local currency, not just Naira. The country list is fetched live from VTpass (/get-international-airtime-countries) on every channel, so the app, chat and MCP can never disagree about which countries are covered.
  • Instant Vending: Automated API integration with VTpass for instant token generation, airtime top-ups, and data bundle delivery.
  • Education PINs in every channel: WAEC result-checker and WAEC registration PINs are buyable from the app, from chat (Telegram/WhatsApp/X/in-app), and over MCP — not app-only. ⚠️ JAMB is a deliberate honesty caveat: the code path exists end-to-end (intent parsing, profile-ID verification, variation_code handling), but jamb is not enabled on the current VTpass merchant account — VTpass answers {"code":"011","content":{"errors":"Service is Not Valid"}} — so it does not appear in the live catalogue and cannot currently be sold. If the account is enabled for it, it appears automatically with no code change.
  • Smart Merchant Verification: Validates electricity meters, smartcard/IUC numbers, and account details before accepting crypto payments, eliminating user errors and failed vends.
  • AbaPoints Loyalty System: Users earn points pegged 1:1 to stablecoin value spent, trackable via the in-app points badge and a dedicated API endpoint.
  • Automatic Refund Safety Net: Failed vends after confirmed on-chain payment are automatically flagged, verified, and refunded on-chain to the user's wallet.
  • DND-Fallback SMS: Automated SMS delivery of electricity tokens/PINs, bypassing the Nigerian Do-Not-Disturb (DND) registry for critical transaction alerts.
  • Multi-Channel Support & Notifications: Built-in support ticketing, plus webhook integrations for Telegram, WhatsApp, and X (Twitter) so users and admins can transact/get notified from their preferred channel.
  • Conversational AI Agent ("DeAI"): A natural-language assistant (/api/deai) that lets users check balances and pay bills via chat-style commands, backed by Claude (Anthropic). Reachable via Telegram, WhatsApp, X, and an in-app chat widget (src/components/AIChat.tsx) on the storefront itself. Understands intent, not just menu numbers — replying "Celo" or "usdt" works exactly like replying "1" or "2" — and shows the live balance and approved agent limit for every token at the moment you're asked to pick one, so you're never choosing blind. If a session goes cold (network drop, abandoned mid-flow) it's recognised and cleaned up automatically rather than left dangling; and if a network hiccup happens right after you enter your PIN, the payment is never silently lost or double-spent — it's tracked through to a confirmed on-chain outcome before the agent reports back.
  • Agent-Initiated Payments (AbaPayV3): Users can grant the DeAI agent a bounded, on-chain, revocable spending allowance (setSpendingAllowance) — chosen independently per chain and per stablecoin from the Agent Hub tab — so it can pay bills on their behalf from Telegram/WhatsApp/X with no wallet signature needed at payment time and no custody of user funds. If no allowance is approved for the chain/token a chat payment needs, the agent detects that up front and offers a straight choice: approve it now, or complete this one payment via a signed deep link instead. See AbaPayV3 — agent allowances below.
  • Autonomous Scheduling & Autopay Agent: Beyond one-off chat payments, users can ask the DeAI agent to set up recurring bills (monthly/weekly/daily), a one-time future payment ("pay this in 10 minutes"), or a single request covering multiple recipients/accounts at once — the agent groups them by chain/token and settles each leg through the same allowance-bounded relayer, unattended, on schedule, with zero further interaction required from the user.
  • On-Chain Attribution: every Celo transaction AbaPay sends (app payments, approvals, the agent relayer, admin vault actions) carries one ERC-8021 suffix with both of our Celo tags (src/lib/attribution.ts): celo_2719403c6aff for the Celo Open Rails hackathon and celo_9d71588659ec for Celo Builders. x402 payments are submitted on-chain by the facilitator, not by AbaPay, so they carry no tag. Base transactions carry Base's own builder code instead.
  • On-Chain Agent Identity (ERC-8004): AbaPay's DeAI agent is registered as a real on-chain identity on both Celo and Base via the ERC-8004 "Trustless Agents" registry, so it's discoverable on 8004scan.io / AgentScan — independent of, and unrelated to, how it moves money. See ERC-8004 agent identity below.
  • MCP Server (AI Agent Payments): AbaPay is reachable by any MCP-speaking AI client (Claude, or any other agent that supports the Model Context Protocol) as a real tool server — describe_capabilities, check_balance, list_plans, pay_bill, multi-recipient pay_bill_batch, and recurring/one-off schedule_bill/list_schedules/cancel_schedule — over Streamable HTTP JSON-RPC at /api/mcp. This is a fourth channel alongside Telegram/WhatsApp/X, not a new trust boundary: it runs through the exact same allowance-bounded, kill-switch-gated, discount-aware execution pipeline as the chat channels, on either Celo or Base depending on what the linking wallet approved. See MCP Server below.
  • MCP OAuth 2.1 (authorize once, not once per conversation): the connector supports a full OAuth 2.1 authorization-code + PKCE (S256) flow with Dynamic Client Registration (/api/oauth/register, /api/oauth/authorize, /api/oauth/token, discovery under /.well-known/). A user authorizes once in a browser — proving their API key and PIN on AbaPay's own hand-rendered consent page — and every future conversation reconnects with a Bearer token instead of retyping an API key. OAuth never authorizes a spend: the PIN is still required on every single pay_bill call, and a Bearer token alone can only read a balance. The api_key tool argument remains the fallback for clients that can't do OAuth.
  • list_plans — real VTpass plan codes and prices, never guessed: for DATA/CABLE/EDUCATION, list_plans returns the currently purchasable plans with their exact variation_codes and live VTpass prices, and both the tool description and the server instructions tell the client to call it before pay_bill rather than guessing.
  • x402 Settlement (main app, both chains): Payments made directly in the web app settle via the x402 HTTP-payment protocol — Celo's own facilitator for USDC/USD₮/USAT on Celo, the Coinbase CDP facilitator for USDC on Base — so they're genuinely indexed on x402scan, not relabeled contract calls. Anything without EIP-3009 uses the on-chain payBill flow, including Base's sponsored-gas path. ⚠️ x402 needs an EIP-3009 transferWithAuthorization signature, which is structurally what a drainer asks for, so some wallet scanners flag it as risky — a known, deliberate trade for x402scan visibility; NEXT_PUBLIC_X402_ENABLED=false opts out. The signature-free agent-initiated flow is untouched either way. See x402 settlement below.
  • Dynamic Exchange Engine: Live market rate conversions with admin-configurable exchange rate and automated profit spread calculation, verified server-side to prevent underpayment exploits.
  • Executive Admin Dashboard: Real-time monitoring of VTpass fiat balance, on-chain vault balances per token/chain, transaction analytics, manual refund tools, and CSV export — protected behind admin auth.
  • Kill Switches That Actually Stop Every Channel: the dashboard's "pause a service" toggles are a two-level model — a per-service master (MASTER_AIRTIME, MASTER_INTERNET, MASTER_ELECTRICITY, MASTER_CABLE, MASTER_EDUCATION, MASTER_INTERNATIONAL) plus a per-provider switch keyed by VTpass serviceID (AIRTIME_mtn, INTERNET_airtel-data, ELEC_ikeja-electric, CABLE_dstv, EDU_waec). A payment is refused when either level is off. src/lib/serviceRules.ts's killSwitchKeysFor() maps an agent intent (+ provider, normalised through resolveServiceId so ELEC_ikeja can't miss ELEC_ikeja-electric) onto exactly those keys, so chat, MCP and the autonomous scheduler now honour the same switches the web app does. See Kill switches below.
  • Sponsored Gas on Base: Coinbase Smart Wallet / Base Account users can pay with zero gas fees — the app detects paymaster support via EIP-5792 and batches approval + payment into a single sponsored transaction. Wallets without this capability (MetaMask, WalletConnect, Valora, etc.) transparently fall back to the normal self-paid flow.
  • Shareable & Downloadable Receipts: Every receipt can be shared as an image straight to WhatsApp/Telegram/etc. via the device's native share sheet, or saved directly as a PNG or PDF.
  • Farcaster Mini App Ready: Ships with Farcaster frame metadata so AbaPay can be launched directly inside Farcaster clients.
  • Nimiq Pay mini app, paid in NIM: nim.abapays.com — listed in Nimiq Pay's Mini Apps (nimiq/awesome#45, merged 2026-10-20; first live NIM purchase 2026-10-24) — (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). The server holds no Nimiq key: a failed vend queues a refund that the operator sends from the treasury wallet and records in Admin → Ops → Refunds, where the hash is verified on the Nimiq chain. A NIM order the system did not queue itself (stuck, or VTpass has no record) is queued from Admin → Ledger → Refund (POST /api/admin/refunds { action: "QUEUE_NIM" }: paid on chain, undelivered, not already queued). The app connects only on request: declining the Nimiq Pay prompt leaves a Connect wallet button, and Disconnect forgets the account on that device. The app's History tab (POST /api/pay/nim/history) shows the payer's own orders — including ones still awaiting payment (Nimiq Pay can hold a send as pending), expired · not paid (nothing charged) and late payments being refunded, each with a one-line explanation; the internal order placeholder is never returned as a payment hash — with meter tokens and PINs: the user signs a short message in Nimiq Pay, and src/lib/nimiqAuth.ts checks the Ed25519 signature over Nimiq's signed-message digest and that the public key derives to the address the message names, so a signature by one wallet can never read another's orders. Provider pickers are searchable bottom sheets showing each provider's logo, country flag or operator image from VTpass.

🛠️ Tech Stack

  • Frontend: Next.js 16 (App Router, React 19), Tailwind CSS 4, Lucide Icons, next-themes (dark mode)
  • Web3 / Wallets: Wagmi, Viem (incl. EIP-5792 sendCalls for sponsored transactions), WalletConnect Modal, Base Account SDK, Solidity smart contract (Hardhat)
  • Backend: Next.js Route Handlers (serverless functions)
  • Receipts: html2canvas (image capture), jsPDF (PDF export)
  • AI: Claude (Anthropic API) for the DeAI conversational agent and in-app chat widget
  • Agent Identity & Payments: ERC-8004 (on-chain agent identity, Celo + Base) and x402 (signed in-house with the connected wallet — src/lib/x402Pay.ts) for HTTP-native, facilitator-settled payments in the main app
  • Self Agent ID on MCP, A2A and x402 (src/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.
  • Grant report (admin → Grant): /api/admin/grant-report?from=&to=[&format=csv] (src/lib/grantReport.ts) counts, for a window, the successful Celo payments external agents made through the agent rails — x402 agents (source_channel AGENT) and MCP/A2A agents via the relayer — excluding the web app, AbaPay's own chat agent, refunds/failures and GRANT_EXCLUDED_WALLETS (+ admin/relayer/server wallets), with per-agent attribution (ERC-8004, OAuth client, Self Agent ID) against the Prezenti targets, and the per-payment CSV for the final report.
  • Who Paid (admin only): every x402, MCP and A2A payment records the request's User-Agent and any agent name / ERC-8004 id it declares (X-Agent-Name, X-Agent-Id) in payment_client_info — for MCP/A2A also the protocol, the sign-in method and the OAuth client's registered name (the routes run each tool call under runWithAgentClient, and executeAgentPayment records it; our own chat agent and the scheduler record nothing) — and after the response looks the paying wallet up on-chain (src/lib/payerIdentity.ts): the ERC-8004 agent identities it owns or is the declared wallet of, each confirmed with ownerOf/getAgentWallet (ids from Blockscout, falling back to the registry's log on CELO_RPC_URL), and its Self Agent ID status (verified, human proof, proof fresh) from the SelfAgentRegistry. Cached a day in payer_identities. Both tables are service-role only (migration 044) and shown only on the admin ledger, never on receipts or public APIs; a lookup never affects the payment. How support reads it: docs/runbooks/README.md, "Supporting a developer whose agent paid".
  • Agent Tool Access: MCP (Model Context Protocol) — Streamable HTTP/JSON-RPC server at /api/mcp exposing balance-check and bill-pay tools to any MCP client — and A2A (Agent2Agent) at /api/a2a, card at /.well-known/agent-card.json, exposing the same tools to peer agents. The x402 endpoint is also listed in an x402 discovery manifest at /.well-known/x402 ({ version: 1, resources: [...] }), the file x402 indexers such as agent402.tools read before probing the live 402 challenge; AbaPay is indexed there (agent402.tools/api/index?seller=agents.abapays.com). 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 URL
  • Utility Provider: VTpass API (bills, airtime, data, education, international airtime)
  • Bank Transfer Provider: Monnify API (Moniepoint Inc.) — account auto-detect, Name Enquiry verification, and the real NUBAN payout, debited from a Moniepoint Microfinance Bank business account
  • Database / Ledger: Supabase (PostgreSQL) — transactions, platform settings, points, refunds
  • Email: Resend (transactional receipt emails)
  • Notifications & Bots: Telegram Bot API, WhatsApp Cloud API, X (Twitter) API, VTpass Messaging API (SMS)

👛 Supported Wallets & Environments

AbaPay runs in three distinct runtime environments, detected at load in src/app/page.tsx (environment = MINIPAY | FARCASTER | WEB, with LOADING as the pre-detection state and a 2-second timeout that falls back to WEB). Wallet connectivity for the WEB case comes from src/config/wagmi.ts, which registers exactly three connectors: injected(), baseAccount(), and walletConnect().

Wallet / environmentHow it connectsNotes
MiniPay (Opera Mini's built-in Celo wallet)Detected directly via window.ethereum.isMiniPay; the app builds its own viem wallet client and locks to CeloGas is paid in a stablecoin (txConfig.feeCurrency), so users need no CELO. Network switching is intentionally disabled here.
Farcaster Mini AppDetected via @farcaster/miniapp-sdk's sdk.context; uses sdk.wallet.ethProvider, locked to BaseAddresses are read with a silent getAddresses() so opening the app never forces a wallet popup. Frame metadata ships in public/.well-known/farcaster.json. Has its own Exit button next to the (non-interactive) network badge.
ValoraWalletConnect only — the injected path is deliberately skipped inside Valora's in-app browser (isValoraBrowser())Pinned to the top of the WalletConnect modal's recommended list via explorerRecommendedWalletIds. Celo-only, which the app follows automatically (walletApprovedChainIds()). See "Valora is WalletConnect-only" below for why the injected path is off.
MetaMask and other injected browser walletsWhichever EIP-6963-discovered connector the wallet announced, falling back to the generic injected() onewagmi discovers one connector per installed wallet (multiInjectedProviderDiscovery, on by default). See "How the Connect button chooses" below for why this, rather than reading window.ethereum directly, is what keeps web3-browser users off a QR code.
Coinbase Smart Wallet / Base AccountbaseAccount() connectorThe only wallets that get sponsored gas — the app probes EIP-5792 paymaster capability and batches approve + pay into one sponsored call. Everything else falls back to the normal self-paid flow.
Base App (the site opened inside Base App's own in-app browser, detected via isBaseAppBrowser())Same baseAccount() connector as above, but auto-connected like MiniPay/Farcaster (see the allowlist below) and locked to Base in the UI — the network switcher, footer network text and token picker all show Base only, with no Celo to switch toDistinct from the general "Coinbase Smart Wallet" row above: picking that connector from an ordinary browser still gets both chains: this row is only when the page itself is running inside Base App. Has its own Exit button next to the (non-interactive) network badge, same as Farcaster.
Any other WalletConnect v2 wallet (Trust, Rainbow, Ledger Live, …)walletConnect() connector with the QR modalNothing wallet-specific in the code — if it speaks WalletConnect and supports Celo or Base, it works.
How the Connect button chooses

An injected wallet is always preferred: it touches no third-party host, which is why it keeps working on networks that filter the WalletConnect relay. WalletConnect is the fallback for a browser that has no wallet in it — a plain desktop browser, or a phone browser pairing with a wallet app.

Which wallets exist is established by asking, never by reading window.ethereum: probeInjectedConnectors() (src/lib/walletEnv.ts) takes wagmi's discovered connectors, gets each one's own provider, and sends it a timed-out eth_accounts — a call that never prompts, so it is safe on every page load. Each wallet comes back authorized (already approved this site), available (real, not yet approved) or none (absent, or a stub that never answered).

  • Any wallet authorized → nothing happens on its own. authorized decides which wallets the chooser can offer without a permission popup, not whether to connect. See "Auto-connect is an allowlist" below.
  • The chooser lists every usable injected wallet plus Base Account plus WalletConnect, each with its own EIP-6963 logo and a status badge (Recent for a wallet that already approved this site, Installed otherwise). Cancelling ends the attempt rather than falling through to a QR code. One extension that is both EIP-6963-announced and parked on window.ethereum is de-duplicated, so it can't appear twice.
  • Base Account is offered alongside injected wallets. The connector lives in src/config/wagmi.ts; probeInjectedConnectors() returns injected-type connectors, and Base Account (its own connector type) is added to the option list separately. It matters most on Base, the default chain, where it is the smart-account experience carrying sponsored gas; verifySignatureAcrossChains validates the ERC-1271 signatures it produces.
  • Because those two are always present, the chooser always appears — a browser with no extension still gets a real choice between signing in with Base Account and pairing a phone wallet, rather than being dropped straight onto a QR code.

🔵 WalletConnect is always an option, never only a fallback. The chooser's option list is built first and the chooser itself is decided from that list's length — so even the common "one extension installed" case still offers WalletConnect as a real route to pairing a phone wallet, not just a fallback for zero-extension browsers.

🔵 Why not window.ethereum: under EIP-6963 a wallet announces itself over an event rather than claiming that global — which is how several extensions coexist without fighting over one slot. Probing the EIP-6963-announced connectors (rather than the bare global) means a browser with a perfectly good wallet is never reported as walletless just because window.ethereum is unset or points at a different wallet.

Prompts also say where to approve. Over WalletConnect the request lands in a separate app that nothing brings to the foreground, so the copy says to open it (walletApprovalPrompt).

Valora is WalletConnect-only

🔵 Why the injected path is skipped inside Valora. Inside Valora's in-app browser, the page can see something that answers eth_accounts — real enough to report a wallet, real enough for auto-connect to fire, real enough for the UI to look connected. Not real enough to pay with: Valora's injected provider takes a payment authorization request as a connection handshake, consumes it, and returns nothing to the page — so the request never resolves.

Valora's supported rail is WalletConnect, and over WalletConnect it behaves normally: a real session request with a real response. So the injected path is skipped inside Valora — isValoraBrowser() suppresses auto-connect and empties the Connect button's injected candidate list, dropping the click through to WalletConnect.

🔵 Detecting Valora needs the WalletConnect session, not the page's own globals. isValoraBrowser() looks for an isValora flag or the name in the user agent, and in Valora's in-app browser neither is present: it injects no provider and its webview reports a stock Android Chrome user agent. The only thing that names the wallet is the session — WalletConnect exchanges peer metadata on connect, and session.peer.metadata.name is the wallet's own name for itself.

So connectedWalletIsValora() reads that instead, and a restored Valora session is dropped on mount so the user pairs fresh. That is deliberately narrow, because the friction only buys something in one place:

  • Valora only — every other wallet keeps its restored session.
  • Restored sessions only — a connection the user just asked for is never yanked away (userInitiatedConnect).
  • Once per mount, so it can't fight a connect that's mid-flight.

The trade-off is that peer metadata only exists after connecting, so this shapes what happens next rather than pre-empting the connection. Both detectors are word-bounded — a false positive would drop a working session (or strip a real in-browser wallet off the rail it should use), which is the more expensive mistake. Covered in tests/walletEnv.test.ts.

Cancelling in a wallet is not always an answer

Every cancellation path assumes the wallet reports the rejection — EIP-1193 says it should, and injected wallets do. Valora over WalletConnect does not: dismissing its sheet sends nothing back over the relay, so there is no rejection to catch, no error and no event. The request stays open and the page waits on a decision that was already made.

withWalletTimeout fires at 90s — that budget has to stay 90s, because it is also how long someone gets to read a prompt before approving. So after 15s of processing the status banner grows a STOP WAITING control. It cannot abort the in-flight request (nothing on this side can) and deliberately does not claim the payment was cancelled: if the user approves a moment later it still settles, and saying otherwise is how someone pays twice.

Auto-connect is an allowlist: MiniPay, Base App, Farcaster — and nothing else

On the web, the Connect button is the only way in. No wallet is connected until the user asks for it, even one whose extension approved this site months ago.

🔵 reconnectOnMount={false} is set deliberately in Providers.tsx. wagmi's default reconnectOnMount persists the connector and silently re-establishes it on every page load — inside the provider, before any effect in page.tsx runs. Setting it false means only an explicit connect() call ever establishes a session.

Auto-connect itself is an allowlist (AUTO_CONNECT_SURFACES), not a rule that connects any previously-authorized wallet by default and carves out exceptions by name — an allowlist is the shape that keeps a silent connect from reappearing under a different wallet's name later.

Those three are different in kind, not degree: the app is running inside the wallet, so there is exactly one account it could mean, the user chose it by opening AbaPay there, and no chooser is being suppressed because there is nothing to choose between. MiniPay and Farcaster are connected by their own SDKs and never touch wagmi; Base App arrives through wagmi and is matched by looksLikeBaseApp() — which deliberately refuses the Coinbase desktop extension, since that sets the same isCoinbaseWallet flag while being an ordinary injected wallet on an ordinary page.

⚠️ The trade: a refresh ends a web session and the user presses Connect again. Being asked is the point, but it is a real cost on a page people reload.

A connection the page did not establish is not a connection

reconnectOnMount={false} stops wagmi re-establishing the connector. It does not stop it rehydrating: the config persists to cookieStorage with ssr: true, so on load wagmi restores connections/current from the cookie and useAccount() reports isConnected with an address — while no provider has been set up and no relay socket exists.

🔵 A rehydrated cookie session and a live session look identical from useAccount() — both report isConnected with an address — so a connection this page did not itself establish is dropped on mount (userInitiatedConnect is what separates the two). Base App is unaffected — its silent connect calls connect() explicitly.

Proving the wallet is yours, once per session

🔵 A filter written by the client is not a permission. History is never read straight from the browser with the anon key scoped by an address parameter — a client-supplied filter like .ilike('wallet_address', address) is only as trustworthy as the client, and a wallet address is public information anyone could pass.

After connecting, the wallet signs a plainly-worded ownership message (src/lib/walletSession.ts — shared by browser and server, because two copies of that string means one stray character failing every signature as "invalid signature"). GET /api/history derives the address from that signature and queries with the service-role client, so no parameter remains that could point at another person's records.

  • verifySignatureAcrossChains already covers EOAs and ERC-1271/6492 smart accounts, so Base Account and Safe are not locked out by the signature being a shape we could not check.
  • A rejection disconnects — the user declined to prove the address is theirs.
  • Any other failure leaves them connected but unproven: they can still pay, because paying is authorised by the payment signature itself, and only history is withheld — never a hard lockout over a signature shape this endpoint didn't need to check.
  • Read-only, and for a session rather than five minutes, because a wallet popup on every History refresh trains people to sign whatever they are shown. It is a bearer credential for that window; mutations keep their own fresh, per-action signatures (verifyWalletOwnership).
A restored WalletConnect session is not a live one

🔵 wagmi persists the WalletConnect session (cookieStorage) and restores it on load, producing an address — and an address is all the UI needs to look connected: balances render (they come from a public RPC and never touch the wallet), the pay button enables, everything reads as normal.

But a WalletConnect request only reaches the phone if the relay socket is open. Over a dead socket, eth_sendTransaction is written to a closed pipe: no prompt appears in the wallet, nothing comes back, and there is no error to catch, because nothing rejected — the request simply goes nowhere, indistinguishable from a user who hasn't looked at their wallet yet.

walletConnectSessionLive() (src/lib/walletEnv.ts) checks the relay before any wallet interaction; a dead session is reported in one sentence and disconnected so Connect pairs fresh instead of restoring the same corpse. A missing socket internal is treated as live — a false negative would disconnect working wallets on every payment. Injected wallets return null: they are in-process and have no socket to lose.

Every wallet call also has a timeout now, including the chain-switch handshake and the Base sendTransaction, which had none. On a wallet app, a timeout is reported as "your wallet never received the request" with a reconnect, since that is what it almost always means.

walletApprovedChainIds() is a related guard: a WalletConnect wallet silently drops requests for a chain outside its approved session, so if the connected wallet never approved the active chain the app follows it to one it did.

The default chain is Base

DEFAULT_CHAIN in src/constants/index.ts is BASE, and everything forward-looking reads from it: the chain a freshly connected wallet lands on, the token picker's seed before a wallet is connected, and the chain an agent link approves when the caller doesn't name one. Celo is fully supported and switchable — nothing was dropped, it just isn't where you start.

Chains registered in wagmi.ts, in order: Base, Base Sepolia, Celo, Celo Alfajores. wagmi treats chains[0] as the default and offers the rest as optional WalletConnect namespaces, so a Celo-only wallet still connects fine (see the Valora row above). Note the app's own non-wagmi paths (src/lib/chain.ts, page.tsx) use viem's celoSepolia as the Celo testnet, while wagmi.ts still lists celoAlfajores; mainnet is unaffected, but they should be reconciled if testnet WalletConnect flows are exercised.

LEGACY_RECORD_CHAIN is the deliberate counterpart, and it stays CELO. It is how a stored row with an empty blockchain column is read — such rows predate the column being written and were all on Celo. It must not follow DEFAULT_CHAIN: reading an old Celo payment as Base would send the webhook hunting for a receipt on the wrong chain and strand a real payment as unvended.

Stablecoins: USD₮ and USDC on both chains, plus USAT on Celo mainnet only. Which token a chain leads with, and in what order the rest follow, is TOKEN_ORDER_BY_CHAIN in src/constants/index.ts — Base: USDC then USD₮; Celo: USD₮, USDC, USAT. One tokensForChain() serves the Pay tab, the Agent Hub, the chat agent and the MCP tools — one function, so all four always agree on which tokens a chain offers.


📁 Project Structure

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!