billmyagent 2.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.
- billmyagent-2.1.0/.gitignore +8 -0
- billmyagent-2.1.0/PKG-INFO +110 -0
- billmyagent-2.1.0/README.md +84 -0
- billmyagent-2.1.0/billmyagent/__init__.py +17 -0
- billmyagent-2.1.0/billmyagent/client.py +131 -0
- billmyagent-2.1.0/billmyagent/middleware/__init__.py +56 -0
- billmyagent-2.1.0/billmyagent/middleware/fastapi.py +62 -0
- billmyagent-2.1.0/billmyagent/middleware/flask.py +56 -0
- billmyagent-2.1.0/billmyagent/signer.py +36 -0
- billmyagent-2.1.0/pyproject.toml +44 -0
- billmyagent-2.1.0/tests/test_client.py +41 -0
- billmyagent-2.1.0/tests/test_middleware.py +30 -0
- billmyagent-2.1.0/tests/test_signer.py +17 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: billmyagent
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: x402 payment SDK for billmyagent — auto-pays HTTP 402 challenges with an eth-account signer and gates your own endpoints (FastAPI/Flask)
|
|
5
|
+
Project-URL: Homepage, https://billmyagent.ai
|
|
6
|
+
Project-URL: Source, https://github.com/RealMaxPower/payment-processor
|
|
7
|
+
Author: billmyagent
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai-agents,eip-3009,ethereum,payments,usdc,x402
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: eth-account>=0.13
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
Requires-Dist: x402[evm]<3,>=2.13
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: responses>=0.25; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
19
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
20
|
+
Provides-Extra: fastapi
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
22
|
+
Requires-Dist: httpx>=0.27; extra == 'fastapi'
|
|
23
|
+
Provides-Extra: flask
|
|
24
|
+
Requires-Dist: flask>=3.0; extra == 'flask'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# billmyagent (Python)
|
|
28
|
+
|
|
29
|
+
x402 payment SDK for Python. Pay x402-gated APIs as a **server/agent** and gate
|
|
30
|
+
your **own endpoints** with a paywall. The Python counterpart of
|
|
31
|
+
[`@billmyagent/payments-core`](../core), built on the official
|
|
32
|
+
[`x402`](https://pypi.org/project/x402/) library + `eth-account`.
|
|
33
|
+
|
|
34
|
+
Unlike the browser-oriented JS SDK, the agent here holds its private key
|
|
35
|
+
directly — which is why this SDK matters for autonomous Python agents
|
|
36
|
+
(LangChain, CrewAI, FastAPI services).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install billmyagent # buyer client
|
|
42
|
+
pip install 'billmyagent[fastapi]' # + FastAPI seller middleware
|
|
43
|
+
pip install 'billmyagent[flask]' # + Flask seller middleware
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Pay an x402-gated API (buyer / agent)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import os
|
|
50
|
+
from billmyagent import PaymentClient, create_signer
|
|
51
|
+
|
|
52
|
+
# The private key is held in-process and never leaves it.
|
|
53
|
+
signer = create_signer("base", os.environ["PRIVATE_KEY"])
|
|
54
|
+
client = PaymentClient(signer=signer)
|
|
55
|
+
|
|
56
|
+
# client.http is a requests.Session; any HTTP 402 it hits is paid and retried.
|
|
57
|
+
res = client.http.get("https://api.example.com/paid-resource")
|
|
58
|
+
print(res.json(), "paid:", bool(res.headers.get("x-payment-response")))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Manage your merchant account (REST)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client = PaymentClient(api_key=os.environ["BMA_API_KEY"], auth_token=jwt)
|
|
65
|
+
|
|
66
|
+
client.get_payout_settings()
|
|
67
|
+
client.update_payout_settings({"base_address": "0x…"})
|
|
68
|
+
client.list_payments()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Charge for your endpoints (seller)
|
|
72
|
+
|
|
73
|
+
FastAPI:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from fastapi import FastAPI
|
|
77
|
+
from billmyagent.middleware.fastapi import add_payment_middleware
|
|
78
|
+
|
|
79
|
+
app = FastAPI()
|
|
80
|
+
add_payment_middleware(
|
|
81
|
+
app,
|
|
82
|
+
pay_to="0xYourPayoutAddress",
|
|
83
|
+
routes={"GET /premium": "$0.01"},
|
|
84
|
+
network="base-sepolia", # base | base-sepolia | polygon
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@app.get("/premium")
|
|
88
|
+
def premium():
|
|
89
|
+
return {"unlocked": True}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Flask is identical via `billmyagent.middleware.flask.add_payment_middleware`.
|
|
93
|
+
|
|
94
|
+
> Mainnet (base/polygon) settlement needs a facilitator with CDP credentials —
|
|
95
|
+
> pass `facilitator_url=...`. Omit it to use the public x402.org testnet facilitator.
|
|
96
|
+
|
|
97
|
+
## Supported networks
|
|
98
|
+
|
|
99
|
+
EVM only, `exact` scheme: **base**, **base-sepolia** (testing), **polygon**.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install -e '.[dev,fastapi,flask]'
|
|
105
|
+
pytest && ruff check . && mypy billmyagent
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
Apache 2.0.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# billmyagent (Python)
|
|
2
|
+
|
|
3
|
+
x402 payment SDK for Python. Pay x402-gated APIs as a **server/agent** and gate
|
|
4
|
+
your **own endpoints** with a paywall. The Python counterpart of
|
|
5
|
+
[`@billmyagent/payments-core`](../core), built on the official
|
|
6
|
+
[`x402`](https://pypi.org/project/x402/) library + `eth-account`.
|
|
7
|
+
|
|
8
|
+
Unlike the browser-oriented JS SDK, the agent here holds its private key
|
|
9
|
+
directly — which is why this SDK matters for autonomous Python agents
|
|
10
|
+
(LangChain, CrewAI, FastAPI services).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install billmyagent # buyer client
|
|
16
|
+
pip install 'billmyagent[fastapi]' # + FastAPI seller middleware
|
|
17
|
+
pip install 'billmyagent[flask]' # + Flask seller middleware
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Pay an x402-gated API (buyer / agent)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import os
|
|
24
|
+
from billmyagent import PaymentClient, create_signer
|
|
25
|
+
|
|
26
|
+
# The private key is held in-process and never leaves it.
|
|
27
|
+
signer = create_signer("base", os.environ["PRIVATE_KEY"])
|
|
28
|
+
client = PaymentClient(signer=signer)
|
|
29
|
+
|
|
30
|
+
# client.http is a requests.Session; any HTTP 402 it hits is paid and retried.
|
|
31
|
+
res = client.http.get("https://api.example.com/paid-resource")
|
|
32
|
+
print(res.json(), "paid:", bool(res.headers.get("x-payment-response")))
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Manage your merchant account (REST)
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
client = PaymentClient(api_key=os.environ["BMA_API_KEY"], auth_token=jwt)
|
|
39
|
+
|
|
40
|
+
client.get_payout_settings()
|
|
41
|
+
client.update_payout_settings({"base_address": "0x…"})
|
|
42
|
+
client.list_payments()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Charge for your endpoints (seller)
|
|
46
|
+
|
|
47
|
+
FastAPI:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from billmyagent.middleware.fastapi import add_payment_middleware
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
add_payment_middleware(
|
|
55
|
+
app,
|
|
56
|
+
pay_to="0xYourPayoutAddress",
|
|
57
|
+
routes={"GET /premium": "$0.01"},
|
|
58
|
+
network="base-sepolia", # base | base-sepolia | polygon
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@app.get("/premium")
|
|
62
|
+
def premium():
|
|
63
|
+
return {"unlocked": True}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Flask is identical via `billmyagent.middleware.flask.add_payment_middleware`.
|
|
67
|
+
|
|
68
|
+
> Mainnet (base/polygon) settlement needs a facilitator with CDP credentials —
|
|
69
|
+
> pass `facilitator_url=...`. Omit it to use the public x402.org testnet facilitator.
|
|
70
|
+
|
|
71
|
+
## Supported networks
|
|
72
|
+
|
|
73
|
+
EVM only, `exact` scheme: **base**, **base-sepolia** (testing), **polygon**.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install -e '.[dev,fastapi,flask]'
|
|
79
|
+
pytest && ruff check . && mypy billmyagent
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
Apache 2.0.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""billmyagent — x402 payment SDK for Python.
|
|
2
|
+
|
|
3
|
+
Pay x402-gated APIs as a server/agent, and gate your own endpoints.
|
|
4
|
+
|
|
5
|
+
from billmyagent import PaymentClient, create_signer
|
|
6
|
+
|
|
7
|
+
signer = create_signer("base", PRIVATE_KEY)
|
|
8
|
+
client = PaymentClient(signer=signer)
|
|
9
|
+
res = client.http.get("https://api.example.com/paid") # auto-pays any 402
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import PaymentClient, DEFAULT_BASE_URL
|
|
13
|
+
from .signer import create_signer, SUPPORTED_NETWORKS
|
|
14
|
+
|
|
15
|
+
__all__ = ["PaymentClient", "create_signer", "SUPPORTED_NETWORKS", "DEFAULT_BASE_URL", "__version__"]
|
|
16
|
+
|
|
17
|
+
__version__ = "2.1.0"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""billmyagent payment client (Python).
|
|
2
|
+
|
|
3
|
+
Mirrors ``@billmyagent/payments-core`` for the Python AI-agent audience. Two
|
|
4
|
+
independent, optional roles:
|
|
5
|
+
|
|
6
|
+
* **Pay x402-gated APIs** (buyer/agent): pass a ``signer``. Then ``client.http``
|
|
7
|
+
auto-pays any HTTP 402 it hits — ``client.http.get("https://api.example.com/paid")``.
|
|
8
|
+
* **Manage your merchant account / payments** (REST): pass ``api_key`` (and
|
|
9
|
+
``auth_token`` for ``/merchant/*``) and call the methods below.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from eth_account.signers.local import LocalAccount
|
|
18
|
+
|
|
19
|
+
from x402 import x402ClientSync
|
|
20
|
+
from x402.mechanisms.evm.exact import register_exact_evm_client
|
|
21
|
+
from x402.http.clients.requests import x402_requests
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.billmyagent.ai/api/v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PaymentClient:
|
|
27
|
+
"""Client for the billmyagent x402 payment processor."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
signer: LocalAccount | None = None,
|
|
33
|
+
api_key: str | None = None,
|
|
34
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
35
|
+
auth_token: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Create a client.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
signer: eth_account signer (see :func:`billmyagent.create_signer`).
|
|
41
|
+
When provided, ``http`` auto-pays HTTP 402 challenges.
|
|
42
|
+
api_key: billmyagent API key, sent as ``X-API-Key`` on REST calls.
|
|
43
|
+
base_url: billmyagent API base URL.
|
|
44
|
+
auth_token: merchant JWT, required only for ``/merchant/*`` methods.
|
|
45
|
+
"""
|
|
46
|
+
self.base_url = base_url.rstrip("/")
|
|
47
|
+
self._auth_token = auth_token
|
|
48
|
+
|
|
49
|
+
if signer is not None:
|
|
50
|
+
x402_client = x402ClientSync()
|
|
51
|
+
# Registers the exact-EVM scheme for all EVM networks (eip155:*),
|
|
52
|
+
# wrapping the LocalAccount so it can sign EIP-3009 authorizations.
|
|
53
|
+
# register_exact_evm_client wraps a LocalAccount internally
|
|
54
|
+
# (_wrap_if_local_account); the static type is the wrapped interface.
|
|
55
|
+
register_exact_evm_client(x402_client, signer) # type: ignore[arg-type]
|
|
56
|
+
#: requests.Session that automatically pays HTTP 402 challenges.
|
|
57
|
+
self.http: requests.Session = x402_requests(x402_client)
|
|
58
|
+
else:
|
|
59
|
+
self.http = requests.Session()
|
|
60
|
+
|
|
61
|
+
if api_key:
|
|
62
|
+
self.http.headers["X-API-Key"] = api_key
|
|
63
|
+
self.http.headers["Content-Type"] = "application/json"
|
|
64
|
+
|
|
65
|
+
# ── Payments (REST; X-API-Key) ──────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def create_payment(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
"""Record a payment with a pre-built x402 payload/requirements pair."""
|
|
69
|
+
return self._json(self.http.post(f"{self.base_url}/payments", json=request))
|
|
70
|
+
|
|
71
|
+
def get_payment(self, payment_id: str) -> dict[str, Any]:
|
|
72
|
+
return self._json(self.http.get(f"{self.base_url}/payments/{payment_id}"))
|
|
73
|
+
|
|
74
|
+
def verify_payment(self, payment_id: str) -> dict[str, Any]:
|
|
75
|
+
return self._json(self.http.post(f"{self.base_url}/payments/{payment_id}/verify"))
|
|
76
|
+
|
|
77
|
+
def settle_payment(self, payment_id: str) -> dict[str, Any]:
|
|
78
|
+
return self._json(self.http.post(f"{self.base_url}/payments/{payment_id}/settle"))
|
|
79
|
+
|
|
80
|
+
def list_payments(self, limit: int = 20, offset: int = 0) -> dict[str, Any]:
|
|
81
|
+
return self._json(
|
|
82
|
+
self.http.get(f"{self.base_url}/payments", params={"limit": limit, "offset": offset})
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# ── Merchant account management (JWT) ───────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def get_payout_settings(self) -> dict[str, Any]:
|
|
88
|
+
"""Get your payout addresses (per network) and current fee rate."""
|
|
89
|
+
return self._json(
|
|
90
|
+
self.http.get(f"{self.base_url}/merchant/payout-settings", headers=self._bearer())
|
|
91
|
+
)["settings"]
|
|
92
|
+
|
|
93
|
+
def update_payout_settings(self, addresses: dict[str, str]) -> dict[str, Any]:
|
|
94
|
+
"""Set one or more payout wallet addresses. Only provided fields change."""
|
|
95
|
+
return self._json(
|
|
96
|
+
self.http.put(
|
|
97
|
+
f"{self.base_url}/merchant/payout-settings", json=addresses, headers=self._bearer()
|
|
98
|
+
)
|
|
99
|
+
)["settings"]
|
|
100
|
+
|
|
101
|
+
def get_fees(self) -> dict[str, Any]:
|
|
102
|
+
"""Get your accrued platform-fee ledger plus a per-currency/status summary."""
|
|
103
|
+
body = self._json(self.http.get(f"{self.base_url}/merchant/fees", headers=self._bearer()))
|
|
104
|
+
return {"summary": body.get("summary"), "fees": body.get("fees")}
|
|
105
|
+
|
|
106
|
+
def list_invoices(self) -> list[dict[str, Any]]:
|
|
107
|
+
"""List your fee invoices."""
|
|
108
|
+
return self._json(
|
|
109
|
+
self.http.get(f"{self.base_url}/merchant/invoices", headers=self._bearer())
|
|
110
|
+
)["invoices"]
|
|
111
|
+
|
|
112
|
+
def get_invoice(self, invoice_id: str) -> dict[str, Any]:
|
|
113
|
+
"""Get an invoice plus, for open/overdue invoices, the x402 payment instruction."""
|
|
114
|
+
return self._json(
|
|
115
|
+
self.http.get(f"{self.base_url}/merchant/invoices/{invoice_id}", headers=self._bearer())
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ── internals ───────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _bearer(self) -> dict[str, str]:
|
|
121
|
+
if not self._auth_token:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"auth_token (JWT) is required for merchant account methods. "
|
|
124
|
+
"Set it in PaymentClient(...)."
|
|
125
|
+
)
|
|
126
|
+
return {"Authorization": f"Bearer {self._auth_token}"}
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _json(response: requests.Response) -> Any:
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
return response.json()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Seller-side x402 middleware for Python web frameworks.
|
|
2
|
+
|
|
3
|
+
Gate your own endpoints behind an x402 paywall. A request gets HTTP 402 with the
|
|
4
|
+
official payment requirements until a valid payment is presented; the facilitator
|
|
5
|
+
verifies + settles it and your payout address receives the funds directly.
|
|
6
|
+
|
|
7
|
+
These are thin wrappers over the official ``x402`` server middleware, pre-wired
|
|
8
|
+
to billmyagent conventions (simple network names, USD/USDC pricing). Import the
|
|
9
|
+
framework you use:
|
|
10
|
+
|
|
11
|
+
from billmyagent.middleware.fastapi import add_payment_middleware # FastAPI
|
|
12
|
+
from billmyagent.middleware.flask import add_payment_middleware # Flask
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Networks the x402 facilitator can settle, mapped to CAIP-2 chain ids.
|
|
16
|
+
NETWORK_TO_CAIP2 = {
|
|
17
|
+
"base": "eip155:8453",
|
|
18
|
+
"base-sepolia": "eip155:84532",
|
|
19
|
+
"polygon": "eip155:137",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_caip2(network: str) -> str:
|
|
24
|
+
"""Translate a simple network name to its CAIP-2 id (raises on unknown)."""
|
|
25
|
+
try:
|
|
26
|
+
return NETWORK_TO_CAIP2[network]
|
|
27
|
+
except KeyError as exc:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unsupported network {network!r}. Supported: {', '.join(NETWORK_TO_CAIP2)}"
|
|
30
|
+
) from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_routes(pay_to: str, routes: dict[str, object], default_network: str) -> dict[str, object]:
|
|
34
|
+
"""Build an x402 RoutesConfig from a simple ``{"GET /premium": "$0.01"}`` spec.
|
|
35
|
+
|
|
36
|
+
Each value may be a price string (uses ``default_network``) or a dict with
|
|
37
|
+
``price`` and optional ``network`` keys.
|
|
38
|
+
"""
|
|
39
|
+
config: dict[str, object] = {}
|
|
40
|
+
for route, spec in routes.items():
|
|
41
|
+
if isinstance(spec, str):
|
|
42
|
+
price, network = spec, default_network
|
|
43
|
+
elif isinstance(spec, dict):
|
|
44
|
+
price = str(spec.get("price"))
|
|
45
|
+
network = str(spec.get("network", default_network))
|
|
46
|
+
else:
|
|
47
|
+
raise TypeError(f"route spec for {route!r} must be a price string or dict")
|
|
48
|
+
config[route] = {
|
|
49
|
+
"accepts": {
|
|
50
|
+
"scheme": "exact",
|
|
51
|
+
"payTo": pay_to,
|
|
52
|
+
"price": price,
|
|
53
|
+
"network": to_caip2(network),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return config
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""FastAPI x402 paywall middleware.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from billmyagent.middleware.fastapi import add_payment_middleware
|
|
6
|
+
|
|
7
|
+
app = FastAPI()
|
|
8
|
+
add_payment_middleware(
|
|
9
|
+
app,
|
|
10
|
+
pay_to="0xYourPayoutAddress",
|
|
11
|
+
routes={"GET /premium": "$0.01"},
|
|
12
|
+
network="base-sepolia",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
@app.get("/premium")
|
|
16
|
+
def premium():
|
|
17
|
+
return {"unlocked": True}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from x402 import x402ResourceServer
|
|
25
|
+
from x402.http import HTTPFacilitatorClient
|
|
26
|
+
from x402.mechanisms.evm.exact import register_exact_evm_server
|
|
27
|
+
from x402.http.middleware.fastapi import payment_middleware as _payment_middleware
|
|
28
|
+
|
|
29
|
+
from . import build_routes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_server(facilitator_url: str | None) -> x402ResourceServer:
|
|
33
|
+
facilitator = HTTPFacilitatorClient({"url": facilitator_url}) if facilitator_url else HTTPFacilitatorClient()
|
|
34
|
+
server = x402ResourceServer(facilitator)
|
|
35
|
+
register_exact_evm_server(server)
|
|
36
|
+
return server
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_payment_middleware(
|
|
40
|
+
app: Any,
|
|
41
|
+
*,
|
|
42
|
+
pay_to: str,
|
|
43
|
+
routes: dict[str, object],
|
|
44
|
+
network: str = "base-sepolia",
|
|
45
|
+
facilitator_url: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Register an x402 paywall on a FastAPI app.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
app: the FastAPI application.
|
|
51
|
+
pay_to: payout address that receives payments.
|
|
52
|
+
routes: ``{"GET /premium": "$0.01"}`` (or per-route ``{"price","network"}`` dicts).
|
|
53
|
+
network: default network for routes without one (base / base-sepolia / polygon).
|
|
54
|
+
facilitator_url: a facilitator you control; omit for the public x402.org
|
|
55
|
+
testnet facilitator. Mainnet settlement needs CDP credentials.
|
|
56
|
+
"""
|
|
57
|
+
server = _build_server(facilitator_url)
|
|
58
|
+
middleware = _payment_middleware(build_routes(pay_to, routes, network), server) # type: ignore[arg-type]
|
|
59
|
+
|
|
60
|
+
@app.middleware("http")
|
|
61
|
+
async def _x402(request: Any, call_next: Any) -> Any: # noqa: ANN401
|
|
62
|
+
return await middleware(request, call_next)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Flask x402 paywall middleware.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
from flask import Flask
|
|
5
|
+
from billmyagent.middleware.flask import add_payment_middleware
|
|
6
|
+
|
|
7
|
+
app = Flask(__name__)
|
|
8
|
+
add_payment_middleware(
|
|
9
|
+
app,
|
|
10
|
+
pay_to="0xYourPayoutAddress",
|
|
11
|
+
routes={"GET /premium": "$0.01"},
|
|
12
|
+
network="base-sepolia",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
@app.get("/premium")
|
|
16
|
+
def premium():
|
|
17
|
+
return {"unlocked": True}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from x402 import x402ResourceServerSync
|
|
25
|
+
from x402.http import HTTPFacilitatorClientSync
|
|
26
|
+
from x402.mechanisms.evm.exact import register_exact_evm_server
|
|
27
|
+
from x402.http.middleware.flask import payment_middleware as _payment_middleware
|
|
28
|
+
|
|
29
|
+
from . import build_routes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_server(facilitator_url: str | None) -> x402ResourceServerSync:
|
|
33
|
+
facilitator = (
|
|
34
|
+
HTTPFacilitatorClientSync({"url": facilitator_url})
|
|
35
|
+
if facilitator_url
|
|
36
|
+
else HTTPFacilitatorClientSync()
|
|
37
|
+
)
|
|
38
|
+
server = x402ResourceServerSync(facilitator)
|
|
39
|
+
register_exact_evm_server(server)
|
|
40
|
+
return server
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def add_payment_middleware(
|
|
44
|
+
app: Any,
|
|
45
|
+
*,
|
|
46
|
+
pay_to: str,
|
|
47
|
+
routes: dict[str, object],
|
|
48
|
+
network: str = "base-sepolia",
|
|
49
|
+
facilitator_url: str | None = None,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Register an x402 paywall on a Flask app. Returns the PaymentMiddleware.
|
|
52
|
+
|
|
53
|
+
Args mirror the FastAPI variant. Uses the sync x402 server + facilitator.
|
|
54
|
+
"""
|
|
55
|
+
server = _build_server(facilitator_url)
|
|
56
|
+
return _payment_middleware(app, build_routes(pay_to, routes, network), server) # type: ignore[arg-type]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Signer helpers for the billmyagent x402 client.
|
|
2
|
+
|
|
3
|
+
A "signer" is an ``eth_account`` ``LocalAccount``. The x402 exact-EVM scheme
|
|
4
|
+
signs an EIP-3009 ``transferWithAuthorization`` with it; the facilitator settles
|
|
5
|
+
on-chain. On a server/agent the private key is held directly and never leaves
|
|
6
|
+
your process — which is the main reason this SDK exists (browsers use the JS SDK
|
|
7
|
+
with an injected wallet instead).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from eth_account import Account
|
|
13
|
+
from eth_account.signers.local import LocalAccount
|
|
14
|
+
|
|
15
|
+
# EVM networks the x402 facilitator can settle. Solana is intentionally
|
|
16
|
+
# unsupported; Ethereum L1 is not offered by the facilitator.
|
|
17
|
+
SUPPORTED_NETWORKS = ("base", "base-sepolia", "polygon")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_signer(network: str, private_key: str) -> LocalAccount:
|
|
21
|
+
"""Build a signer (eth_account LocalAccount) from a private key.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
network: Target network — one of ``base``, ``base-sepolia``, ``polygon``.
|
|
25
|
+
Advisory: the exact-EVM scheme pays whatever network the 402 challenge
|
|
26
|
+
asks for, but this guards against typos / unsupported chains.
|
|
27
|
+
private_key: 0x-prefixed private key. Use a dedicated, funded key.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
An ``eth_account`` ``LocalAccount`` usable as the client's ``signer``.
|
|
31
|
+
"""
|
|
32
|
+
if network not in SUPPORTED_NETWORKS:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Unsupported network {network!r}. Supported: {', '.join(SUPPORTED_NETWORKS)}"
|
|
35
|
+
)
|
|
36
|
+
return Account.from_key(private_key)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "billmyagent"
|
|
7
|
+
version = "2.1.0"
|
|
8
|
+
description = "x402 payment SDK for billmyagent — auto-pays HTTP 402 challenges with an eth-account signer and gates your own endpoints (FastAPI/Flask)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "billmyagent" }]
|
|
13
|
+
keywords = ["x402", "payments", "ai-agents", "ethereum", "usdc", "eip-3009"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"x402[evm]>=2.13,<3",
|
|
16
|
+
"eth-account>=0.13",
|
|
17
|
+
"requests>=2.31",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://billmyagent.ai"
|
|
22
|
+
Source = "https://github.com/RealMaxPower/payment-processor"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
fastapi = ["fastapi>=0.110", "httpx>=0.27"]
|
|
26
|
+
flask = ["flask>=3.0"]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8.0",
|
|
29
|
+
"ruff>=0.6",
|
|
30
|
+
"mypy>=1.11",
|
|
31
|
+
"types-requests",
|
|
32
|
+
"responses>=0.25",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["billmyagent"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
target-version = "py310"
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import responses
|
|
3
|
+
|
|
4
|
+
from billmyagent import PaymentClient
|
|
5
|
+
|
|
6
|
+
BASE = "https://api.test/api/v1"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@responses.activate
|
|
10
|
+
def test_create_payment_sends_api_key() -> None:
|
|
11
|
+
responses.add(responses.POST, f"{BASE}/payments", json={"id": "p1", "status": "pending"})
|
|
12
|
+
client = PaymentClient(api_key="sk_test", base_url=BASE)
|
|
13
|
+
out = client.create_payment({"amount": "1", "currency": "USDC"})
|
|
14
|
+
assert out["id"] == "p1"
|
|
15
|
+
assert responses.calls[0].request.headers["X-API-Key"] == "sk_test"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@responses.activate
|
|
19
|
+
def test_list_payments() -> None:
|
|
20
|
+
responses.add(responses.GET, f"{BASE}/payments", json={"data": [], "limit": 20, "offset": 0})
|
|
21
|
+
client = PaymentClient(api_key="sk_test", base_url=BASE)
|
|
22
|
+
assert client.list_payments()["limit"] == 20
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_merchant_methods_require_auth_token() -> None:
|
|
26
|
+
client = PaymentClient(api_key="sk_test", base_url=BASE)
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
client.get_payout_settings()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@responses.activate
|
|
32
|
+
def test_get_payout_settings_sends_bearer() -> None:
|
|
33
|
+
responses.add(
|
|
34
|
+
responses.GET,
|
|
35
|
+
f"{BASE}/merchant/payout-settings",
|
|
36
|
+
json={"settings": {"base_address": "0xabc", "fee_bps": 100}},
|
|
37
|
+
)
|
|
38
|
+
client = PaymentClient(api_key="sk_test", base_url=BASE, auth_token="jwt123")
|
|
39
|
+
settings = client.get_payout_settings()
|
|
40
|
+
assert settings["base_address"] == "0xabc"
|
|
41
|
+
assert responses.calls[0].request.headers["Authorization"] == "Bearer jwt123"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from billmyagent.middleware import build_routes, to_caip2
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_to_caip2_maps_supported_networks() -> None:
|
|
7
|
+
assert to_caip2("base") == "eip155:8453"
|
|
8
|
+
assert to_caip2("base-sepolia") == "eip155:84532"
|
|
9
|
+
assert to_caip2("polygon") == "eip155:137"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_to_caip2_rejects_unsupported() -> None:
|
|
13
|
+
with pytest.raises(ValueError):
|
|
14
|
+
to_caip2("ethereum")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_build_routes_string_and_dict_specs() -> None:
|
|
18
|
+
routes = build_routes(
|
|
19
|
+
"0xPayout",
|
|
20
|
+
{"GET /a": "$0.01", "GET /b": {"price": "$1.00", "network": "polygon"}},
|
|
21
|
+
default_network="base",
|
|
22
|
+
)
|
|
23
|
+
a = routes["GET /a"]["accepts"] # type: ignore[index]
|
|
24
|
+
assert a["payTo"] == "0xPayout"
|
|
25
|
+
assert a["price"] == "$0.01"
|
|
26
|
+
assert a["network"] == "eip155:8453" # default network
|
|
27
|
+
assert a["scheme"] == "exact"
|
|
28
|
+
|
|
29
|
+
b = routes["GET /b"]["accepts"] # type: ignore[index]
|
|
30
|
+
assert b["network"] == "eip155:137" # per-route override
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from billmyagent import create_signer
|
|
4
|
+
|
|
5
|
+
# Well-known throwaway test key (never use a real key in tests).
|
|
6
|
+
TEST_KEY = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_create_signer_returns_account() -> None:
|
|
10
|
+
account = create_signer("base", TEST_KEY)
|
|
11
|
+
assert account.address.startswith("0x")
|
|
12
|
+
assert len(account.address) == 42
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_create_signer_rejects_unknown_network() -> None:
|
|
16
|
+
with pytest.raises(ValueError):
|
|
17
|
+
create_signer("ethereum", TEST_KEY)
|