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.
- abapay_sdk-0.1.0/.gitignore +46 -0
- abapay_sdk-0.1.0/PKG-INFO +125 -0
- abapay_sdk-0.1.0/README.md +104 -0
- abapay_sdk-0.1.0/pyproject.toml +36 -0
- abapay_sdk-0.1.0/src/abapay/__init__.py +25 -0
- abapay_sdk-0.1.0/src/abapay/agent.py +154 -0
- abapay_sdk-0.1.0/src/abapay/cli.py +124 -0
- abapay_sdk-0.1.0/src/abapay/types.py +80 -0
- abapay_sdk-0.1.0/src/abapay/x402.py +159 -0
- abapay_sdk-0.1.0/tests/test_agent.py +79 -0
- abapay_sdk-0.1.0/tests/test_x402.py +129 -0
|
@@ -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
|