abapay-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
42
+
43
+ # Hardhat build output
44
+ artifacts/
45
+ cache/
46
+ typechain-types/
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: abapay-sdk
3
+ Version: 0.1.0
4
+ Summary: Pay real-world bills (airtime, data, electricity, cable) from an agent's own Celo wallet — zero-setup x402, or a linked-wallet client for the fuller MCP tool catalog.
5
+ Project-URL: Homepage, https://agents.abapays.com
6
+ Project-URL: Repository, https://github.com/investorphem/AbaPay
7
+ Project-URL: Documentation, https://docs.abapays.com
8
+ Project-URL: Issues, https://github.com/investorphem/AbaPay/issues
9
+ Author: Masonode Technologies Limited
10
+ License-Expression: MIT
11
+ Keywords: a2a,abapay,agent-payments,celo,cli,eip-3009,mcp,stablecoin,x402
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: eth-account<1.0,>=0.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-httpserver>=1.0; extra == 'dev'
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # abapay-sdk (Python)
23
+
24
+ Pay real-world bills — airtime, mobile data, electricity, cable TV — from an agent's own Celo
25
+ wallet. Mirrors [abapay-sdk (npm)](https://www.npmjs.com/package/abapay-sdk) field-for-field.
26
+ Two ways in, matching [agents.abapays.com](https://agents.abapays.com):
27
+
28
+ - **`pay_bill_via_x402`** — zero setup. No account, no API key, no PIN, no visit to
29
+ abapays.com. Your agent's wallet signs one EIP-3009 authorization and the bill is paid.
30
+ - **`AbaPayAgent`** — a linked-wallet client for the fuller MCP tool catalog (balances,
31
+ history, schedules). One on-chain signature to link, then an API key + PIN per payment.
32
+
33
+ Full protocol details: [docs.abapays.com](https://docs.abapays.com). Celo mainnet only.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install abapay-sdk
39
+ ```
40
+
41
+ `eth-account` is the only runtime dependency — used for EIP-712/personal_sign signing, the
42
+ Python-ecosystem equivalent of what viem does for the TypeScript SDK.
43
+
44
+ ## Zero setup: pay a bill via x402
45
+
46
+ ```python
47
+ from eth_account import Account
48
+ from abapay import pay_bill_via_x402, BillDetails
49
+
50
+ account = Account.from_key("0x...") # PRIVATE_KEY env var, not committed
51
+
52
+ result = pay_bill_via_x402(
53
+ account,
54
+ BillDetails(
55
+ serviceID="mtn", serviceCategory="AIRTIME", network="MTN",
56
+ billersCode="08012345678", nairaAmount=1000, token="USDT",
57
+ ),
58
+ )
59
+ print(result.status, result.tx_hash)
60
+ ```
61
+
62
+ That's the whole integration. `account` never touches abapays.com beyond this one HTTP call —
63
+ no account creation, no API key, no PIN. Full wire format:
64
+ [agents.abapays.com/x402](https://agents.abapays.com/x402) — nothing here is hidden, read
65
+ `src/abapay/x402.py` to see or port it to another language yourself.
66
+
67
+ ## Linked wallet: the fuller tool catalog
68
+
69
+ ```python
70
+ from eth_account import Account
71
+ from abapay import AbaPayAgent, LinkParams
72
+
73
+ account = Account.from_key("0x...")
74
+
75
+ # One-time: prove wallet ownership, mint an Agent Hub api_key. Save agent.api_key somewhere —
76
+ # there is no recovery flow other than linking again.
77
+ agent = AbaPayAgent.link(LinkParams(signer=account, pin="1234"))
78
+
79
+ # Elsewhere, an on-chain approve() + setSpendingAllowance() grants AbaPay a bounded, revocable
80
+ # allowance — see examples/agent-quickstart.mjs in the main repo for the two calls. Nothing in
81
+ # this SDK can raise that allowance on your behalf; only your own wallet can.
82
+
83
+ print(agent.check_balance())
84
+
85
+ agent.pay_bill(pin="1234", service="AIRTIME", provider="mtn", account_number="08012345678", amount_ngn=1000)
86
+ ```
87
+
88
+ Reattach to a previously-minted key without signing again:
89
+
90
+ ```python
91
+ agent = AbaPayAgent.from_api_key(saved_api_key, wallet_address)
92
+ ```
93
+
94
+ ## CLI
95
+
96
+ ```bash
97
+ pip install abapay-sdk
98
+
99
+ PRIVATE_KEY=0x... abapay pay --service AIRTIME --provider mtn --to 08012345678 --amount 1000
100
+ PRIVATE_KEY=0x... abapay link --pin 1234
101
+ abapay balance --api-key aba_mcp_xxxxx
102
+ abapay history --api-key aba_mcp_xxxxx --limit 5
103
+ ```
104
+
105
+ ## Errors
106
+
107
+ Every failure throws `AbaPayError` (`.message`, and `.response` carrying whatever AbaPay's API
108
+ returned, when there was one). Full error code reference:
109
+ [agents.abapays.com/errors](https://agents.abapays.com/errors).
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ pip install -e ".[dev]"
115
+ pytest
116
+ ```
117
+
118
+ Tests run against a real local HTTP server (`pytest-httpserver`), not a patched `urlopen` —
119
+ `test_x402.py` specifically recovers the signer's address from the signed EIP-712 typed data
120
+ and asserts it matches, the same correctness guarantee the TypeScript SDK's own test suite
121
+ makes.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,104 @@
1
+ # abapay-sdk (Python)
2
+
3
+ Pay real-world bills — airtime, mobile data, electricity, cable TV — from an agent's own Celo
4
+ wallet. Mirrors [abapay-sdk (npm)](https://www.npmjs.com/package/abapay-sdk) field-for-field.
5
+ Two ways in, matching [agents.abapays.com](https://agents.abapays.com):
6
+
7
+ - **`pay_bill_via_x402`** — zero setup. No account, no API key, no PIN, no visit to
8
+ abapays.com. Your agent's wallet signs one EIP-3009 authorization and the bill is paid.
9
+ - **`AbaPayAgent`** — a linked-wallet client for the fuller MCP tool catalog (balances,
10
+ history, schedules). One on-chain signature to link, then an API key + PIN per payment.
11
+
12
+ Full protocol details: [docs.abapays.com](https://docs.abapays.com). Celo mainnet only.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install abapay-sdk
18
+ ```
19
+
20
+ `eth-account` is the only runtime dependency — used for EIP-712/personal_sign signing, the
21
+ Python-ecosystem equivalent of what viem does for the TypeScript SDK.
22
+
23
+ ## Zero setup: pay a bill via x402
24
+
25
+ ```python
26
+ from eth_account import Account
27
+ from abapay import pay_bill_via_x402, BillDetails
28
+
29
+ account = Account.from_key("0x...") # PRIVATE_KEY env var, not committed
30
+
31
+ result = pay_bill_via_x402(
32
+ account,
33
+ BillDetails(
34
+ serviceID="mtn", serviceCategory="AIRTIME", network="MTN",
35
+ billersCode="08012345678", nairaAmount=1000, token="USDT",
36
+ ),
37
+ )
38
+ print(result.status, result.tx_hash)
39
+ ```
40
+
41
+ That's the whole integration. `account` never touches abapays.com beyond this one HTTP call —
42
+ no account creation, no API key, no PIN. Full wire format:
43
+ [agents.abapays.com/x402](https://agents.abapays.com/x402) — nothing here is hidden, read
44
+ `src/abapay/x402.py` to see or port it to another language yourself.
45
+
46
+ ## Linked wallet: the fuller tool catalog
47
+
48
+ ```python
49
+ from eth_account import Account
50
+ from abapay import AbaPayAgent, LinkParams
51
+
52
+ account = Account.from_key("0x...")
53
+
54
+ # One-time: prove wallet ownership, mint an Agent Hub api_key. Save agent.api_key somewhere —
55
+ # there is no recovery flow other than linking again.
56
+ agent = AbaPayAgent.link(LinkParams(signer=account, pin="1234"))
57
+
58
+ # Elsewhere, an on-chain approve() + setSpendingAllowance() grants AbaPay a bounded, revocable
59
+ # allowance — see examples/agent-quickstart.mjs in the main repo for the two calls. Nothing in
60
+ # this SDK can raise that allowance on your behalf; only your own wallet can.
61
+
62
+ print(agent.check_balance())
63
+
64
+ agent.pay_bill(pin="1234", service="AIRTIME", provider="mtn", account_number="08012345678", amount_ngn=1000)
65
+ ```
66
+
67
+ Reattach to a previously-minted key without signing again:
68
+
69
+ ```python
70
+ agent = AbaPayAgent.from_api_key(saved_api_key, wallet_address)
71
+ ```
72
+
73
+ ## CLI
74
+
75
+ ```bash
76
+ pip install abapay-sdk
77
+
78
+ PRIVATE_KEY=0x... abapay pay --service AIRTIME --provider mtn --to 08012345678 --amount 1000
79
+ PRIVATE_KEY=0x... abapay link --pin 1234
80
+ abapay balance --api-key aba_mcp_xxxxx
81
+ abapay history --api-key aba_mcp_xxxxx --limit 5
82
+ ```
83
+
84
+ ## Errors
85
+
86
+ Every failure throws `AbaPayError` (`.message`, and `.response` carrying whatever AbaPay's API
87
+ returned, when there was one). Full error code reference:
88
+ [agents.abapays.com/errors](https://agents.abapays.com/errors).
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ pip install -e ".[dev]"
94
+ pytest
95
+ ```
96
+
97
+ Tests run against a real local HTTP server (`pytest-httpserver`), not a patched `urlopen` —
98
+ `test_x402.py` specifically recovers the signer's address from the signed EIP-712 typed data
99
+ and asserts it matches, the same correctness guarantee the TypeScript SDK's own test suite
100
+ makes.
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "abapay-sdk"
7
+ version = "0.1.0"
8
+ description = "Pay real-world bills (airtime, data, electricity, cable) from an agent's own Celo wallet — zero-setup x402, or a linked-wallet client for the fuller MCP tool catalog."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Masonode Technologies Limited" }]
13
+ keywords = ["abapay", "celo", "x402", "a2a", "mcp", "agent-payments", "stablecoin", "eip-3009", "cli"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Intended Audience :: Developers",
18
+ ]
19
+ dependencies = [
20
+ "eth-account>=0.10,<1.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7", "pytest-httpserver>=1.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://agents.abapays.com"
28
+ Repository = "https://github.com/investorphem/AbaPay"
29
+ Documentation = "https://docs.abapays.com"
30
+ Issues = "https://github.com/investorphem/AbaPay/issues"
31
+
32
+ [project.scripts]
33
+ abapay = "abapay.cli:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/abapay"]
@@ -0,0 +1,25 @@
1
+ """abapay — pay real-world bills from a Celo wallet, no browser required.
2
+
3
+ Mirrors abapay-sdk (npm) field-for-field. Two ways in:
4
+
5
+ - `pay_bill_via_x402` — zero setup. No account, no API key, no PIN.
6
+ - `AbaPayAgent` — a linked-wallet client for the fuller MCP tool catalog.
7
+
8
+ See https://agents.abapays.com/sdk and https://agents.abapays.com/tools.
9
+ """
10
+
11
+ from .agent import AbaPayAgent
12
+ from .types import AbaPayError, BillDetails, LinkParams, X402PayResult
13
+ from .x402 import DEFAULT_BASE_URL, pay_bill_via_x402
14
+
15
+ __all__ = [
16
+ "AbaPayAgent",
17
+ "AbaPayError",
18
+ "BillDetails",
19
+ "LinkParams",
20
+ "X402PayResult",
21
+ "DEFAULT_BASE_URL",
22
+ "pay_bill_via_x402",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,154 @@
1
+ """AbaPayAgent — mirrors sdk/src/agent.ts. Links a wallet ONCE (a signature, not a deposit)
2
+ and then calls the fuller MCP tool catalog with an api_key + a PIN on every payment. See
3
+ https://agents.abapays.com/a2a for why this and x402.py have genuinely different trust models.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import time
11
+ import urllib.error
12
+ import urllib.request
13
+ from typing import Any, Optional
14
+
15
+ from .types import AbaPayError, LinkParams
16
+ from .x402 import DEFAULT_BASE_URL
17
+
18
+ _PIN_RE = re.compile(r"^\d{4,6}$")
19
+
20
+
21
+ def _http_json(url: str, method: str, body: Optional[dict] = None, headers: Optional[dict] = None) -> dict:
22
+ data = json.dumps(body).encode("utf-8") if body is not None else None
23
+ all_headers = {"Content-Type": "application/json"}
24
+ if headers:
25
+ all_headers.update(headers)
26
+ req = urllib.request.Request(url, data=data, headers=all_headers, method=method)
27
+ try:
28
+ with urllib.request.urlopen(req) as res:
29
+ return json.loads(res.read().decode("utf-8"))
30
+ except urllib.error.HTTPError as e:
31
+ raw = e.read().decode("utf-8")
32
+ try:
33
+ return json.loads(raw)
34
+ except json.JSONDecodeError:
35
+ raise AbaPayError(f"AbaPay returned a non-JSON response (HTTP {e.code}): {raw[:300]}")
36
+
37
+
38
+ class AbaPayAgent:
39
+ """A linked AbaPay agent session — one api_key, reusable across as many tool calls as
40
+ needed. Get one via `AbaPayAgent.link(...)`."""
41
+
42
+ def __init__(self, api_key: str, wallet_address: str, base_url: str = DEFAULT_BASE_URL):
43
+ self.api_key = api_key
44
+ self.wallet_address = wallet_address
45
+ self._base_url = base_url
46
+
47
+ @staticmethod
48
+ def link(params: LinkParams) -> "AbaPayAgent":
49
+ """Prove ownership of a wallet with a plain signed message (no OAuth, no browser) and
50
+ mint an Agent Hub api_key. This does NOT grant AbaPay any spending allowance by
51
+ itself — that's a separate on-chain step (approve + setSpendingAllowance), left to the
52
+ caller since it moves the caller's own funds.
53
+
54
+ Raises AbaPayError if the signature is rejected or the PIN is malformed.
55
+ """
56
+ if not _PIN_RE.match(params.pin):
57
+ raise AbaPayError("PIN must be 4-6 digits.")
58
+
59
+ timestamp = str(int(time.time() * 1000))
60
+ message = f"AbaPay Agent Action: POST:/api/agent/link: {timestamp}"
61
+ signed = params.signer.sign_message(_personal_sign_message(message))
62
+ signature = signed.signature.hex()
63
+ if not signature.startswith("0x"):
64
+ signature = "0x" + signature
65
+
66
+ data = _http_json(
67
+ f"{params.base_url.rstrip('/')}/api/agent/link",
68
+ "POST",
69
+ body={
70
+ "wallet_address": params.signer.address,
71
+ "channel": "MCP",
72
+ "pin": params.pin,
73
+ "approved_chain": params.approved_chain,
74
+ "approved_token": params.approved_token,
75
+ "mcp_key_label": params.label,
76
+ },
77
+ headers={
78
+ "x-wallet-address": params.signer.address,
79
+ "x-wallet-signature": signature,
80
+ "x-wallet-timestamp": timestamp,
81
+ },
82
+ )
83
+ if not data.get("success") or not data.get("api_key"):
84
+ raise AbaPayError(data.get("message") or "Link failed.", response=data)
85
+ return AbaPayAgent(data["api_key"], params.signer.address, params.base_url)
86
+
87
+ @staticmethod
88
+ def from_api_key(api_key: str, wallet_address: str, base_url: str = DEFAULT_BASE_URL) -> "AbaPayAgent":
89
+ """Reattach to an api_key minted earlier — no new signature needed."""
90
+ return AbaPayAgent(api_key, wallet_address, base_url)
91
+
92
+ def call_tool(self, name: str, args: Optional[dict] = None) -> str:
93
+ """Low-level: call any MCP tool by name. check_balance/pay_bill/etc below cover the
94
+ common ones with typed args."""
95
+ body = {
96
+ "jsonrpc": "2.0",
97
+ "id": 1,
98
+ "method": "tools/call",
99
+ "params": {"name": name, "arguments": {"api_key": self.api_key, **(args or {})}},
100
+ }
101
+ data = _http_json(f"{self._base_url.rstrip('/')}/api/mcp", "POST", body=body)
102
+ if data.get("error"):
103
+ raise AbaPayError(data["error"].get("message") or "MCP tool call failed.", response=data)
104
+ content = (data.get("result") or {}).get("content") or []
105
+ text = next((c.get("text") for c in content if c.get("type") == "text"), None)
106
+ if text is None:
107
+ raise AbaPayError("MCP tool returned no text content.", response=data)
108
+ return text
109
+
110
+ def check_balance(self, chain: str = "CELO") -> str:
111
+ return self.call_tool("check_balance", {"chain": chain})
112
+
113
+ def pay_bill(
114
+ self,
115
+ pin: str,
116
+ service: str,
117
+ provider: str,
118
+ account_number: str,
119
+ amount_ngn: float,
120
+ chain: Optional[str] = None,
121
+ token: Optional[str] = None,
122
+ variation_code: Optional[str] = None,
123
+ ) -> str:
124
+ args: dict[str, Any] = {
125
+ "pin": pin,
126
+ "service": service,
127
+ "provider": provider,
128
+ "account_number": account_number,
129
+ "amount_ngn": amount_ngn,
130
+ }
131
+ if chain:
132
+ args["chain"] = chain
133
+ if token:
134
+ args["token"] = token
135
+ if variation_code:
136
+ args["variation_code"] = variation_code
137
+ return self.call_tool("pay_bill", args)
138
+
139
+ def schedule_bill(self, **args: Any) -> str:
140
+ return self.call_tool("schedule_bill", args)
141
+
142
+ def transaction_history(self, limit: Optional[int] = None, offset: Optional[int] = None) -> str:
143
+ args: dict[str, Any] = {}
144
+ if limit is not None:
145
+ args["limit"] = limit
146
+ if offset is not None:
147
+ args["offset"] = offset
148
+ return self.call_tool("transaction_history", args)
149
+
150
+
151
+ def _personal_sign_message(message: str):
152
+ from eth_account.messages import encode_defunct
153
+
154
+ return encode_defunct(text=message)
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """abapay-cli (Python) — pay real-world bills from a Celo wallet, no browser required.
3
+ Mirrors sdk/src/cli.ts's abapay command-for-command; see that file's own header comment for
4
+ why this exists as a thin argparse wrapper over the library, not a second implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import os
11
+ import sys
12
+
13
+ from .agent import AbaPayAgent
14
+ from .types import AbaPayError, BillDetails, LinkParams
15
+ from .x402 import pay_bill_via_x402
16
+
17
+
18
+ def _fail(message: str) -> None:
19
+ print(f"abapay: {message}", file=sys.stderr)
20
+ sys.exit(1)
21
+
22
+
23
+ def _require_private_key() -> str:
24
+ pk = os.environ.get("PRIVATE_KEY")
25
+ if not pk:
26
+ _fail("PRIVATE_KEY env var is required (a 0x-prefixed hex private key).")
27
+ return pk # type: ignore[return-value]
28
+
29
+
30
+ def _account(pk: str):
31
+ from eth_account import Account
32
+
33
+ return Account.from_key(pk)
34
+
35
+
36
+ def cmd_pay(args: argparse.Namespace) -> None:
37
+ account = _account(_require_private_key())
38
+ print(f"Paying as {account.address} — {args.service} {args.amount} NGN to {args.to}…")
39
+ bill = BillDetails(
40
+ serviceID=(args.provider or args.service).lower(),
41
+ serviceCategory=args.service,
42
+ network=args.network or (args.provider.upper() if args.provider else args.service),
43
+ billersCode=args.to,
44
+ nairaAmount=float(args.amount),
45
+ token=args.token,
46
+ )
47
+ try:
48
+ result = pay_bill_via_x402(account, bill)
49
+ print(f"✓ {result.status} — tx: {result.tx_hash or '(pending)'}")
50
+ except AbaPayError as e:
51
+ _fail(f"{e.message}" + (f"\n{e.response}" if e.response else ""))
52
+
53
+
54
+ def cmd_link(args: argparse.Namespace) -> None:
55
+ account = _account(_require_private_key())
56
+ print(f"Linking {account.address}…")
57
+ try:
58
+ agent = AbaPayAgent.link(
59
+ LinkParams(signer=account, pin=args.pin, approved_chain=args.chain, approved_token=args.token, label=args.label or "abapay-cli-py")
60
+ )
61
+ print(f"✓ Linked. api_key: {agent.api_key}")
62
+ print(" This does NOT grant a spending allowance by itself — see agents.abapays.com/agents/developers/quickstart.")
63
+ except AbaPayError as e:
64
+ _fail(e.message)
65
+
66
+
67
+ def cmd_balance(args: argparse.Namespace) -> None:
68
+ agent = AbaPayAgent.from_api_key(args.api_key, args.wallet or "")
69
+ try:
70
+ print(agent.check_balance(args.chain))
71
+ except AbaPayError as e:
72
+ _fail(e.message)
73
+
74
+
75
+ def cmd_history(args: argparse.Namespace) -> None:
76
+ agent = AbaPayAgent.from_api_key(args.api_key, args.wallet or "")
77
+ try:
78
+ print(agent.transaction_history(limit=args.limit, offset=args.offset))
79
+ except AbaPayError as e:
80
+ _fail(e.message)
81
+
82
+
83
+ def main() -> None:
84
+ parser = argparse.ArgumentParser(prog="abapay", description="Pay real-world bills from a Celo wallet.")
85
+ sub = parser.add_subparsers(dest="command")
86
+
87
+ p_pay = sub.add_parser("pay", help="Pay a bill via x402 — zero setup.")
88
+ p_pay.add_argument("--service", required=True)
89
+ p_pay.add_argument("--provider")
90
+ p_pay.add_argument("--to", required=True)
91
+ p_pay.add_argument("--amount", required=True)
92
+ p_pay.add_argument("--token", default="USDT")
93
+ p_pay.add_argument("--network")
94
+ p_pay.set_defaults(func=cmd_pay)
95
+
96
+ p_link = sub.add_parser("link", help="Link a wallet, mint an api_key.")
97
+ p_link.add_argument("--pin", required=True)
98
+ p_link.add_argument("--chain", default="CELO")
99
+ p_link.add_argument("--token")
100
+ p_link.add_argument("--label")
101
+ p_link.set_defaults(func=cmd_link)
102
+
103
+ p_bal = sub.add_parser("balance", help="Check a linked wallet's balance.")
104
+ p_bal.add_argument("--api-key", required=True)
105
+ p_bal.add_argument("--wallet")
106
+ p_bal.add_argument("--chain", default="CELO")
107
+ p_bal.set_defaults(func=cmd_balance)
108
+
109
+ p_hist = sub.add_parser("history", help="Recent transactions for a linked wallet.")
110
+ p_hist.add_argument("--api-key", required=True)
111
+ p_hist.add_argument("--wallet")
112
+ p_hist.add_argument("--limit", type=int)
113
+ p_hist.add_argument("--offset", type=int)
114
+ p_hist.set_defaults(func=cmd_history)
115
+
116
+ args = parser.parse_args()
117
+ if not getattr(args, "command", None):
118
+ parser.print_help()
119
+ return
120
+ args.func(args)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
@@ -0,0 +1,80 @@
1
+ """Shared types for the abapay Python SDK — mirrors sdk/src/types.ts field-for-field so the
2
+ two SDKs can never silently drift on wire shape."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Optional
8
+
9
+
10
+ class AbaPayError(Exception):
11
+ """Raised for any AbaPay-side failure — a bad request, a rejected signature, or a
12
+ settlement AbaPay itself reports as unsuccessful. Mirrors sdk/src/types.ts's AbaPayError."""
13
+
14
+ def __init__(self, message: str, cause: Optional[Exception] = None, response: Any = None):
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.cause = cause
18
+ self.response = response
19
+
20
+
21
+ @dataclass
22
+ class BillDetails:
23
+ """The same fields public/openapi.json's /api/pay/x402 requestBody documents.
24
+ Celo mainnet only — see the SDK's file-level comments in x402.py for why."""
25
+
26
+ serviceID: str
27
+ serviceCategory: str # AIRTIME | DATA | ELECTRICITY | CABLE | EDUCATION | BANK
28
+ network: str
29
+ billersCode: str
30
+ nairaAmount: float
31
+ token: str # "USDC" or "USDT"
32
+ wallet_address: Optional[str] = None
33
+
34
+ def to_dict(self) -> dict:
35
+ d = {
36
+ "serviceID": self.serviceID,
37
+ "serviceCategory": self.serviceCategory,
38
+ "network": self.network,
39
+ "billersCode": self.billersCode,
40
+ "nairaAmount": self.nairaAmount,
41
+ "token": self.token,
42
+ }
43
+ if self.wallet_address:
44
+ d["wallet_address"] = self.wallet_address
45
+ return d
46
+
47
+
48
+ @dataclass
49
+ class X402PayResult:
50
+ success: bool
51
+ status: str # "SUCCESS" | "FAILED_VENDING" | "TIMEOUT" | "SYSTEM_CRASH" | str
52
+ purchased_code: Optional[str] = None
53
+ units: Optional[str] = None
54
+ request_id: Optional[str] = None
55
+ tx_hash: Optional[str] = None
56
+ message: Optional[str] = None
57
+
58
+ @staticmethod
59
+ def from_dict(d: dict) -> "X402PayResult":
60
+ return X402PayResult(
61
+ success=bool(d.get("success", False)),
62
+ status=str(d.get("status", "")),
63
+ purchased_code=d.get("purchased_code"),
64
+ units=d.get("units"),
65
+ request_id=d.get("request_id"),
66
+ tx_hash=d.get("tx_hash"),
67
+ message=d.get("message"),
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class LinkParams:
73
+ """Params for AbaPayAgent.link(). `signer` is any eth_account LocalAccount."""
74
+
75
+ signer: Any
76
+ pin: str
77
+ approved_chain: str = "CELO"
78
+ approved_token: Optional[str] = None
79
+ label: str = "abapay-sdk-py"
80
+ base_url: str = field(default="https://www.abapays.com")
@@ -0,0 +1,159 @@
1
+ """Zero-setup x402 payments — mirrors sdk/src/x402.ts's payBillViaX402 field-for-field. The
2
+ agent's own wallet signs an EIP-3009 transferWithAuthorization for exactly the amount AbaPay's
3
+ server names in its 402 challenge; nothing here ever touches abapays.com, an Agent Hub
4
+ account, or a PIN. Celo mainnet only in this version.
5
+
6
+ Verify this against the real wire format any time: https://agents.abapays.com/x402
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from typing import Any, Optional
18
+
19
+ from eth_account.messages import encode_typed_data
20
+
21
+ from .types import AbaPayError, BillDetails, X402PayResult
22
+
23
+ DEFAULT_BASE_URL = "https://www.abapays.com"
24
+
25
+ # ⚡ MUST MATCH src/lib/x402Settle.ts's TRANSFER_WITH_AUTHORIZATION_TYPES BYTE-FOR-BYTE (and
26
+ # sdk/src/x402.ts's copy of the same) — that's the code that reconstructs this signature
27
+ # server-side to verify it. Three independent copies now (TypeScript app, TypeScript SDK,
28
+ # this one) because none of them can depend on each other across language/publish boundaries;
29
+ # a drift in any one is a signature that silently fails to verify. Each carries this exact
30
+ # comment as the tripwire.
31
+ TRANSFER_WITH_AUTHORIZATION_TYPES = {
32
+ "TransferWithAuthorization": [
33
+ {"name": "from", "type": "address"},
34
+ {"name": "to", "type": "address"},
35
+ {"name": "value", "type": "uint256"},
36
+ {"name": "validAfter", "type": "uint256"},
37
+ {"name": "validBefore", "type": "uint256"},
38
+ {"name": "nonce", "type": "bytes32"},
39
+ ],
40
+ }
41
+
42
+
43
+ def _random_nonce() -> str:
44
+ return "0x" + os.urandom(32).hex()
45
+
46
+
47
+ def _caip2_chain_id(network: str) -> int:
48
+ parts = network.split(":")
49
+ if len(parts) != 2 or parts[0] != "eip155":
50
+ raise AbaPayError(
51
+ f'Unsupported x402 network "{network}" — this SDK version only understands '
52
+ "eip155:* (EVM) networks, and only Celo (eip155:42220) is currently offered by AbaPay."
53
+ )
54
+ try:
55
+ return int(parts[1])
56
+ except ValueError:
57
+ raise AbaPayError(f'Unsupported x402 network "{network}" — non-numeric chain id.')
58
+
59
+
60
+ def _post_json(url: str, body: dict, extra_headers: Optional[dict] = None) -> tuple[int, dict]:
61
+ headers = {"Content-Type": "application/json"}
62
+ if extra_headers:
63
+ headers.update(extra_headers)
64
+ data = json.dumps(body).encode("utf-8")
65
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
66
+ try:
67
+ with urllib.request.urlopen(req) as res:
68
+ raw = res.read().decode("utf-8")
69
+ return res.status, json.loads(raw)
70
+ except urllib.error.HTTPError as e:
71
+ raw = e.read().decode("utf-8")
72
+ try:
73
+ return e.code, json.loads(raw)
74
+ except json.JSONDecodeError:
75
+ raise AbaPayError(f"AbaPay returned a non-JSON response (HTTP {e.code}): {raw[:300]}")
76
+
77
+
78
+ def pay_bill_via_x402(
79
+ signer: Any,
80
+ bill: BillDetails,
81
+ base_url: str = DEFAULT_BASE_URL,
82
+ ) -> X402PayResult:
83
+ """Pay a real-world bill (airtime, data, electricity, cable) via x402 — zero setup.
84
+
85
+ `signer` is any eth_account LocalAccount (e.g. from
86
+ `eth_account.Account.from_key(private_key)`). Its private key never leaves this process —
87
+ only `sign_message`/typed-data signing is used, matching the SDK's own no-custody design.
88
+
89
+ Raises AbaPayError on any HTTP failure, a challenge this version can't act on, or a final
90
+ response AbaPay itself reports as unsuccessful.
91
+ """
92
+ if bill.wallet_address is None:
93
+ bill = BillDetails(**{**bill.__dict__, "wallet_address": signer.address})
94
+ endpoint = f"{base_url.rstrip('/')}/api/pay/x402"
95
+
96
+ # ── 1. Ask for a price. No X-PAYMENT header yet — this MUST come back 402. ──────────────
97
+ status, challenge = _post_json(endpoint, bill.to_dict())
98
+ if status != 402:
99
+ raise AbaPayError(f"Expected HTTP 402 (payment required), got {status}.", response=challenge)
100
+
101
+ accepts = challenge.get("accepts") or []
102
+ accept = next((a for a in accepts if a.get("scheme") == "exact"), None)
103
+ if accept is None:
104
+ raise AbaPayError("402 challenge carried no usable 'exact' payment option.", response=challenge)
105
+
106
+ # ── 2. Sign the exact amount the challenge named, with the wallet's OWN key. ─────────────
107
+ chain_id = _caip2_chain_id(accept["network"])
108
+ now = int(time.time())
109
+ authorization = {
110
+ "from": signer.address,
111
+ "to": accept["payTo"],
112
+ "value": int(accept["amount"]),
113
+ # 60s of slack behind "now" absorbs ordinary clock skew.
114
+ "validAfter": now - 60,
115
+ "validBefore": now + int(accept.get("maxTimeoutSeconds") or 3600),
116
+ "nonce": _random_nonce(),
117
+ }
118
+
119
+ domain = {
120
+ "name": accept["extra"]["name"],
121
+ "version": accept["extra"]["version"],
122
+ "chainId": chain_id,
123
+ "verifyingContract": accept["asset"],
124
+ }
125
+ signable = encode_typed_data(
126
+ domain_data=domain,
127
+ message_types=TRANSFER_WITH_AUTHORIZATION_TYPES,
128
+ message_data=authorization,
129
+ )
130
+ signed = signer.sign_message(signable)
131
+ signature = signed.signature.hex()
132
+ if not signature.startswith("0x"):
133
+ signature = "0x" + signature
134
+
135
+ # ── 3. Retry with the signed authorization attached. ─────────────────────────────────────
136
+ payment_header = base64.b64encode(
137
+ json.dumps(
138
+ {
139
+ "x402Version": 1,
140
+ "scheme": "exact",
141
+ "network": "celo",
142
+ "payload": {
143
+ "signature": signature,
144
+ "authorization": {
145
+ **authorization,
146
+ "value": str(authorization["value"]),
147
+ "validAfter": str(authorization["validAfter"]),
148
+ "validBefore": str(authorization["validBefore"]),
149
+ },
150
+ },
151
+ }
152
+ ).encode("utf-8")
153
+ ).decode("ascii")
154
+
155
+ settle_status, result = _post_json(endpoint, bill.to_dict(), {"X-PAYMENT": payment_header})
156
+ parsed = X402PayResult.from_dict(result)
157
+ if settle_status >= 300 or parsed.success is False:
158
+ raise AbaPayError(parsed.message or f"Settlement failed (HTTP {settle_status}).", response=result)
159
+ return parsed
@@ -0,0 +1,79 @@
1
+ import json
2
+
3
+ from eth_account import Account
4
+ from eth_account.messages import encode_defunct
5
+
6
+ from abapay.agent import AbaPayAgent
7
+ from abapay.types import AbaPayError, LinkParams
8
+
9
+ TEST_PRIVATE_KEY = "0x" + "22" * 32
10
+
11
+
12
+ def test_link_signs_and_returns_agent(httpserver):
13
+ account = Account.from_key(TEST_PRIVATE_KEY)
14
+ captured = {}
15
+
16
+ def handler(request):
17
+ from werkzeug.wrappers import Response
18
+
19
+ captured["body"] = json.loads(request.data)
20
+ captured["headers"] = dict(request.headers)
21
+ return Response(json.dumps({"success": True, "api_key": "aba_mcp_test123"}), status=200, content_type="application/json")
22
+
23
+ httpserver.expect_request("/api/agent/link", method="POST").respond_with_handler(handler)
24
+
25
+ agent = AbaPayAgent.link(LinkParams(signer=account, pin="1234", base_url=httpserver.url_for("")))
26
+
27
+ assert agent.api_key == "aba_mcp_test123"
28
+ assert agent.wallet_address == account.address
29
+ assert captured["body"]["pin"] == "1234"
30
+ assert captured["body"]["wallet_address"] == account.address
31
+
32
+ # The signature really does verify against the message it claims to sign.
33
+ timestamp = captured["headers"]["X-Wallet-Timestamp"]
34
+ message = encode_defunct(text=f"AbaPay Agent Action: POST:/api/agent/link: {timestamp}")
35
+ recovered = Account.recover_message(message, signature=captured["headers"]["X-Wallet-Signature"])
36
+ assert recovered.lower() == account.address.lower()
37
+
38
+
39
+ def test_link_rejects_malformed_pin():
40
+ account = Account.from_key(TEST_PRIVATE_KEY)
41
+ try:
42
+ AbaPayAgent.link(LinkParams(signer=account, pin="12"))
43
+ assert False, "expected AbaPayError"
44
+ except AbaPayError as e:
45
+ assert "4-6 digits" in e.message
46
+
47
+
48
+ def test_call_tool_round_trips(httpserver):
49
+ def handler(request):
50
+ from werkzeug.wrappers import Response
51
+
52
+ body = json.loads(request.data)
53
+ assert body["method"] == "tools/call"
54
+ assert body["params"]["name"] == "check_balance"
55
+ assert body["params"]["arguments"]["api_key"] == "aba_mcp_test123"
56
+ return Response(
57
+ json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "USDT 1.88"}]}}),
58
+ status=200,
59
+ content_type="application/json",
60
+ )
61
+
62
+ httpserver.expect_request("/api/mcp", method="POST").respond_with_handler(handler)
63
+
64
+ agent = AbaPayAgent.from_api_key("aba_mcp_test123", "0xabc", base_url=httpserver.url_for(""))
65
+ text = agent.check_balance("CELO")
66
+ assert text == "USDT 1.88"
67
+
68
+
69
+ def test_call_tool_raises_on_jsonrpc_error(httpserver):
70
+ httpserver.expect_request("/api/mcp", method="POST").respond_with_json(
71
+ {"jsonrpc": "2.0", "id": 1, "error": {"message": "Invalid or revoked API key."}}
72
+ )
73
+
74
+ agent = AbaPayAgent.from_api_key("aba_mcp_bad", "0xabc", base_url=httpserver.url_for(""))
75
+ try:
76
+ agent.check_balance()
77
+ assert False, "expected AbaPayError"
78
+ except AbaPayError as e:
79
+ assert "Invalid or revoked" in e.message
@@ -0,0 +1,129 @@
1
+ """Correctness, not just "it runs": asserts the signed typed-data this SDK produces recovers
2
+ to the signer's own address — the same guarantee sdk/test/x402.test.ts makes for the
3
+ TypeScript SDK ("a test asserts the signed typed-data's domain and message match a real 402
4
+ challenge byte-for-byte"). Runs against a real local HTTP server (pytest-httpserver), not a
5
+ patched urlopen, so the request/response shapes on the wire are exercised for real.
6
+ """
7
+
8
+ import json
9
+
10
+ from eth_account import Account
11
+ from eth_account.messages import encode_typed_data
12
+
13
+ from abapay.types import BillDetails
14
+ from abapay.x402 import TRANSFER_WITH_AUTHORIZATION_TYPES, pay_bill_via_x402
15
+
16
+ TEST_PRIVATE_KEY = "0x" + "11" * 32 # deterministic, test-only — never a real funded key
17
+ VAULT = "0x5df8aE2B963165b735B18Ca86B1ea448d2AA032C"
18
+ USDT = "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e"
19
+
20
+ CHALLENGE = {
21
+ "x402Version": 1,
22
+ "error": "Payment required",
23
+ "accepts": [
24
+ {
25
+ "scheme": "exact",
26
+ "network": "eip155:42220",
27
+ "amount": "1492500",
28
+ "maxAmountRequired": "1492500",
29
+ "payTo": VAULT,
30
+ "asset": USDT,
31
+ "maxTimeoutSeconds": 86400,
32
+ "extra": {"name": "Tether USD", "version": "1", "primaryType": "TransferWithAuthorization"},
33
+ }
34
+ ],
35
+ }
36
+
37
+
38
+ def test_signature_recovers_to_signer_address(httpserver):
39
+ account = Account.from_key(TEST_PRIVATE_KEY)
40
+
41
+ captured = {}
42
+
43
+ def handler(request):
44
+ from werkzeug.wrappers import Response
45
+
46
+ payment_header = request.headers.get("X-PAYMENT")
47
+ if payment_header is None:
48
+ return Response(json.dumps(CHALLENGE), status=402, content_type="application/json")
49
+
50
+ captured["payment_header"] = payment_header
51
+ return Response(
52
+ json.dumps({"success": True, "status": "SUCCESS", "tx_hash": "0xdeadbeef", "request_id": "req_test"}),
53
+ status=200,
54
+ content_type="application/json",
55
+ )
56
+
57
+ httpserver.expect_request("/api/pay/x402", method="POST").respond_with_handler(handler)
58
+
59
+ bill = BillDetails(
60
+ serviceID="mtn",
61
+ serviceCategory="AIRTIME",
62
+ network="MTN",
63
+ billersCode="08012345678",
64
+ nairaAmount=1000,
65
+ token="USDT",
66
+ )
67
+ result = pay_bill_via_x402(account, bill, base_url=httpserver.url_for(""))
68
+
69
+ assert result.success is True
70
+ assert result.status == "SUCCESS"
71
+ assert result.tx_hash == "0xdeadbeef"
72
+ assert "payment_header" in captured
73
+
74
+ # ── The actual correctness check: recover the signer from the signed typed data and
75
+ # confirm it's the account that supposedly signed it — exactly the check AbaPay's own
76
+ # server-side verification (src/lib/x402Settle.ts) performs to accept or reject a payment.
77
+ import base64
78
+
79
+ decoded = json.loads(base64.b64decode(captured["payment_header"]))
80
+ auth = decoded["payload"]["authorization"]
81
+ signature = decoded["payload"]["signature"]
82
+
83
+ domain = {
84
+ "name": "Tether USD",
85
+ "version": "1",
86
+ "chainId": 42220,
87
+ "verifyingContract": USDT,
88
+ }
89
+ message = {
90
+ "from": auth["from"],
91
+ "to": auth["to"],
92
+ "value": int(auth["value"]),
93
+ "validAfter": int(auth["validAfter"]),
94
+ "validBefore": int(auth["validBefore"]),
95
+ "nonce": auth["nonce"],
96
+ }
97
+ signable = encode_typed_data(domain_data=domain, message_types=TRANSFER_WITH_AUTHORIZATION_TYPES, message_data=message)
98
+ recovered = Account.recover_message(signable, signature=signature)
99
+
100
+ assert recovered.lower() == account.address.lower()
101
+ assert auth["from"].lower() == account.address.lower()
102
+ assert auth["to"].lower() == VAULT.lower()
103
+ assert auth["value"] == "1492500"
104
+
105
+
106
+ def test_non_402_first_response_raises(httpserver):
107
+ from abapay.types import AbaPayError
108
+
109
+ httpserver.expect_request("/api/pay/x402", method="POST").respond_with_json({"error": "bad request"}, status=400)
110
+
111
+ account = Account.from_key(TEST_PRIVATE_KEY)
112
+ bill = BillDetails(serviceID="mtn", serviceCategory="AIRTIME", network="MTN", billersCode="08012345678", nairaAmount=1000, token="USDT")
113
+
114
+ try:
115
+ pay_bill_via_x402(account, bill, base_url=httpserver.url_for(""))
116
+ assert False, "expected AbaPayError"
117
+ except AbaPayError as e:
118
+ assert "402" in e.message
119
+
120
+
121
+ def test_unsupported_network_raises():
122
+ from abapay.types import AbaPayError
123
+ from abapay.x402 import _caip2_chain_id
124
+
125
+ try:
126
+ _caip2_chain_id("solana:mainnet")
127
+ assert False, "expected AbaPayError"
128
+ except AbaPayError as e:
129
+ assert "eip155" in e.message