okxweb3-app-mpp 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.
Files changed (38) hide show
  1. okxweb3_app_mpp-0.1.0/.gitignore +12 -0
  2. okxweb3_app_mpp-0.1.0/PKG-INFO +31 -0
  3. okxweb3_app_mpp-0.1.0/demo/fastapi/main.py +289 -0
  4. okxweb3_app_mpp-0.1.0/demo/pyproject.toml +14 -0
  5. okxweb3_app_mpp-0.1.0/demo/tools/main.py +143 -0
  6. okxweb3_app_mpp-0.1.0/demo/uv.lock +906 -0
  7. okxweb3_app_mpp-0.1.0/pyproject.toml +59 -0
  8. okxweb3_app_mpp-0.1.0/src/mpp_evm/__init__.py +90 -0
  9. okxweb3_app_mpp-0.1.0/src/mpp_evm/_defaults.py +30 -0
  10. okxweb3_app_mpp-0.1.0/src/mpp_evm/_patch_session_respond.py +237 -0
  11. okxweb3_app_mpp-0.1.0/src/mpp_evm/adapters/__init__.py +5 -0
  12. okxweb3_app_mpp-0.1.0/src/mpp_evm/adapters/mpp_adapter.py +179 -0
  13. okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/__init__.py +6 -0
  14. okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/intent.py +186 -0
  15. okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/method.py +95 -0
  16. okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/schemas.py +88 -0
  17. okxweb3_app_mpp-0.1.0/src/mpp_evm/errors.py +218 -0
  18. okxweb3_app_mpp-0.1.0/src/mpp_evm/method.py +31 -0
  19. okxweb3_app_mpp-0.1.0/src/mpp_evm/nonce.py +31 -0
  20. okxweb3_app_mpp-0.1.0/src/mpp_evm/saclient/__init__.py +27 -0
  21. okxweb3_app_mpp-0.1.0/src/mpp_evm/saclient/client.py +246 -0
  22. okxweb3_app_mpp-0.1.0/src/mpp_evm/saclient/types.py +251 -0
  23. okxweb3_app_mpp-0.1.0/src/mpp_evm/session/__init__.py +12 -0
  24. okxweb3_app_mpp-0.1.0/src/mpp_evm/session/channel.py +168 -0
  25. okxweb3_app_mpp-0.1.0/src/mpp_evm/session/intent.py +763 -0
  26. okxweb3_app_mpp-0.1.0/src/mpp_evm/session/voucher.py +244 -0
  27. okxweb3_app_mpp-0.1.0/src/mpp_evm/signer.py +54 -0
  28. okxweb3_app_mpp-0.1.0/src/mpp_evm/store.py +96 -0
  29. okxweb3_app_mpp-0.1.0/src/mpp_evm/types.py +238 -0
  30. okxweb3_app_mpp-0.1.0/tests/__init__.py +0 -0
  31. okxweb3_app_mpp-0.1.0/tests/conftest.py +16 -0
  32. okxweb3_app_mpp-0.1.0/tests/test_charge.py +540 -0
  33. okxweb3_app_mpp-0.1.0/tests/test_errors.py +151 -0
  34. okxweb3_app_mpp-0.1.0/tests/test_patch_session_respond.py +251 -0
  35. okxweb3_app_mpp-0.1.0/tests/test_saclient.py +390 -0
  36. okxweb3_app_mpp-0.1.0/tests/test_session.py +842 -0
  37. okxweb3_app_mpp-0.1.0/tests/test_types.py +238 -0
  38. okxweb3_app_mpp-0.1.0/uv.lock +1151 -0
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .pytest_cache/
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .mypy_cache/
10
+ .oli-run/
11
+ *.so
12
+ .env
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: okxweb3-app-mpp
3
+ Version: 0.1.0
4
+ Summary: EVM payment method for MPP (Micropayment Protocol)
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: eth-abi>=5.0
7
+ Requires-Dist: eth-account>=0.11
8
+ Requires-Dist: eth-utils>=4.0
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pydantic>=2.0
11
+ Requires-Dist: pympp>=0.7.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # okxweb3-app-mpp
15
+
16
+ EVM mechanism for the [MPP (Micropayment Protocol)](https://github.com/tempoxyz/pympp) — adds on-chain charge and session-channel intents for EVM networks (X Layer, Base, etc.) on top of `pympp`.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install okxweb3-app-mpp
22
+ ```
23
+
24
+ ## What it provides
25
+
26
+ - **`mpp_evm.charge`** — one-shot EIP-3009 payments settled via OKX Smart Account.
27
+ - **`mpp_evm.session`** — open/topUp/voucher/close session channels with off-chain vouchers and on-chain settlement.
28
+ - **`mpp_evm.signer`** — local private-key signer; pluggable for KMS/TEE backends.
29
+ - **`mpp_evm.saclient`** — OKX SA API client for relayed transactions.
30
+
31
+ Import as `mpp_evm`; works alongside `pympp` which exposes the protocol-agnostic `mpp` namespace.
@@ -0,0 +1,289 @@
1
+ """MPP EVM Demo Server — mirrors go/demo/mpp/gin/main.go.
2
+
3
+ Uses the @mpp.pay() decorator for verify-or-challenge flow.
4
+
5
+ Endpoints:
6
+ GET /free — Free endpoint (no payment)
7
+ GET /charge/tx/primary — Charge: 0.00001 USDT, tx mode, feePayer=true
8
+ GET /charge/tx/split — Charge: 0.00003 USDT (10+20 split), tx mode, feePayer=true
9
+ GET /charge/hash/primary — Charge: 0.00001 USDT, hash mode, feePayer=false
10
+ GET /charge/hash/split — Charge: 0.00003 USDT (10+20 split), hash mode, feePayer=false
11
+ GET /session/tx — Session: 0.00001 USDT per req, feePayer=true
12
+ GET /session/hash — Session: 0.00001 USDT per req, feePayer=false
13
+
14
+ Config via env vars (mirrors Go demo):
15
+ PRIVATE_KEY — Payee hex private key (required)
16
+ PAY_TO_ADDRESS — Payment recipient (defaults to signer address)
17
+ PAY_TO_ADDRESS_SPLIT — Split recipient (defaults to PAY_TO_ADDRESS)
18
+ MPP_SECRET_KEY — Server challenge secret (default: "demo-secret-key")
19
+ ESCROW_CONTRACT — Escrow contract address (default: DEFAULT_ESCROW_CONTRACT)
20
+ OKX_BASE_URL — SA API base URL (default: "https://web3.okx.com")
21
+ OKX_API_KEY — SA API key
22
+ OKX_SECRET_KEY — SA API secret key
23
+ OKX_PASSPHRASE — SA API passphrase
24
+ CHANNEL_STORE_DIR — Channel state dir (default: "./mpp-data/channels")
25
+ ADDR — Listen address (default: ":8080")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import signal
32
+ import sys
33
+
34
+ from fastapi import FastAPI, Request
35
+ from mpp import Credential, Receipt
36
+ from mpp.server.mpp import Mpp
37
+
38
+ from mpp_evm import (
39
+ X_LAYER_CHAIN_ID,
40
+ DEFAULT_ESCROW_CONTRACT,
41
+ FileStore,
42
+ )
43
+ from mpp_evm.charge.intent import ChargeIntent
44
+ from mpp_evm.method import EvmMethod
45
+ from mpp_evm.saclient.client import OKXSAClient
46
+ from mpp_evm.session.intent import SessionIntent
47
+ from mpp_evm.signer import PrivateKeySigner
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Configuration (mirrors Go envOrDefault pattern)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def _env(key: str, default: str = "") -> str:
55
+ return os.environ.get(key, default)
56
+
57
+
58
+ # USDT on X Layer
59
+ TOKEN_ADDRESS = "0x779ded0c9e1022225f8e0630b35a9b54be713736"
60
+ DECIMALS = 6
61
+
62
+ # Amounts (human-readable for decorator; 0.00001 USDT with 6 decimals = 10 base units)
63
+ AMOUNT_PRIMARY = "0.00001"
64
+ AMOUNT_SPLIT_TOTAL = "0.00003"
65
+ AMOUNT_SESSION_PER_REQUEST = "0.00001"
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # App factory
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ def create_app() -> FastAPI:
74
+ """Create and configure the FastAPI demo server."""
75
+ # --- Payee signer (from PRIVATE_KEY env var) ---
76
+ private_key_hex = _env("PRIVATE_KEY")
77
+ if not private_key_hex:
78
+ print("FATAL: PRIVATE_KEY environment variable is required", file=sys.stderr)
79
+ sys.exit(1)
80
+
81
+ signer = PrivateKeySigner.from_hex(private_key_hex)
82
+ pay_to_address = _env("PAY_TO_ADDRESS", signer.address)
83
+ pay_to_address_split = _env("PAY_TO_ADDRESS_SPLIT", pay_to_address)
84
+ mpp_secret_key = _env("MPP_SECRET_KEY", "demo-secret-key")
85
+ escrow_contract = _env("ESCROW_CONTRACT", DEFAULT_ESCROW_CONTRACT)
86
+
87
+ sa_client = OKXSAClient(
88
+ base_url=_env("OKX_BASE_URL", "https://web3.okx.com"),
89
+ api_key=_env("OKX_API_KEY", ""),
90
+ secret_key=_env("OKX_SECRET_KEY", ""),
91
+ passphrase=_env("OKX_PASSPHRASE", ""),
92
+ )
93
+
94
+ # --- Channel store (file-backed, compatible with Go mpp-tools) ---
95
+ channel_dir = _env("CHANNEL_STORE_DIR", "./mpp-data/channels")
96
+ store = FileStore(channel_dir)
97
+ print(f"Channel store: {channel_dir}")
98
+
99
+ # --- Charge methods ---
100
+ # Each request = one EIP-3009 authorization, settled via SA API.
101
+ charge_intent_tx = ChargeIntent(
102
+ sa_client=sa_client,
103
+ chain_id=X_LAYER_CHAIN_ID,
104
+ recipient=pay_to_address,
105
+ fee_payer=True,
106
+ )
107
+ charge_intent_hash = ChargeIntent(
108
+ sa_client=sa_client,
109
+ chain_id=X_LAYER_CHAIN_ID,
110
+ recipient=pay_to_address,
111
+ fee_payer=False,
112
+ )
113
+
114
+ # --- Session methods ---
115
+ # Client opens a payment channel, then sends vouchers authorizing cumulative spend.
116
+ # Server deducts perRequestCost (10 base units) from the voucher balance on each request.
117
+ print(f"Escrow contract: {escrow_contract}")
118
+
119
+ session_intent_tx = SessionIntent(
120
+ sa_client=sa_client,
121
+ recipient=pay_to_address,
122
+ signer=signer,
123
+ store=store,
124
+ chain_id=X_LAYER_CHAIN_ID,
125
+ escrow_contract=escrow_contract,
126
+ per_request_cost=10,
127
+ min_voucher_delta=30,
128
+ fee_payer=True,
129
+ )
130
+ session_intent_hash = SessionIntent(
131
+ sa_client=sa_client,
132
+ recipient=pay_to_address,
133
+ signer=signer,
134
+ store=store,
135
+ chain_id=X_LAYER_CHAIN_ID,
136
+ escrow_contract=escrow_contract,
137
+ per_request_cost=10,
138
+ min_voucher_delta=30,
139
+ fee_payer=False,
140
+ )
141
+
142
+ session_intent_tx_other = SessionIntent(
143
+ sa_client=sa_client,
144
+ recipient=pay_to_address,
145
+ signer=signer,
146
+ store=store,
147
+ chain_id=X_LAYER_CHAIN_ID,
148
+ escrow_contract=escrow_contract,
149
+ per_request_cost=15,
150
+ min_voucher_delta=30,
151
+ fee_payer=True,
152
+ )
153
+
154
+ # --- EVM methods (one per fee_payer mode, with both charge + session intents) ---
155
+ method_tx = EvmMethod(intents={"charge": charge_intent_tx, "session": session_intent_tx})
156
+ method_tx.currency = TOKEN_ADDRESS # type: ignore[attr-defined]
157
+ method_tx.recipient = pay_to_address # type: ignore[attr-defined]
158
+ method_tx.decimals = DECIMALS # type: ignore[attr-defined]
159
+ method_tx.chain_id = X_LAYER_CHAIN_ID # type: ignore[attr-defined]
160
+
161
+ method_hash = EvmMethod(intents={"charge": charge_intent_hash, "session": session_intent_hash})
162
+ method_hash.currency = TOKEN_ADDRESS # type: ignore[attr-defined]
163
+ method_hash.recipient = pay_to_address # type: ignore[attr-defined]
164
+ method_hash.decimals = DECIMALS # type: ignore[attr-defined]
165
+ method_hash.chain_id = X_LAYER_CHAIN_ID # type: ignore[attr-defined]
166
+
167
+ method_tx_other = EvmMethod(intents={"session": session_intent_tx_other})
168
+ method_tx_other.currency = TOKEN_ADDRESS # type: ignore[attr-defined]
169
+ method_tx_other.recipient = pay_to_address # type: ignore[attr-defined]
170
+ method_tx_other.decimals = DECIMALS # type: ignore[attr-defined]
171
+ method_tx_other.chain_id = X_LAYER_CHAIN_ID # type: ignore[attr-defined]
172
+
173
+ # --- Mpp server instances ---
174
+ cfg_realm = "mpp"
175
+ mpp_tx = Mpp(method=method_tx, realm=cfg_realm, secret_key=mpp_secret_key)
176
+ mpp_hash = Mpp(method=method_hash, realm=cfg_realm, secret_key=mpp_secret_key)
177
+ mpp_tx_other = Mpp(method=method_tx_other, realm=cfg_realm, secret_key=mpp_secret_key)
178
+
179
+ app = FastAPI(title="MPP EVM Demo Server")
180
+
181
+ # ------------------------------------------------------------------
182
+ # Free endpoint
183
+ # ------------------------------------------------------------------
184
+
185
+ @app.get("/free")
186
+ async def free_endpoint() -> dict:
187
+ return {"message": "This endpoint is free!"}
188
+
189
+ # ------------------------------------------------------------------
190
+ # Charge endpoints
191
+ # ------------------------------------------------------------------
192
+
193
+ @app.get("/charge/tx/primary")
194
+ @mpp_tx.pay(amount=AMOUNT_PRIMARY, intent="charge", description="Charge tx/primary: 0.00001 USDT")
195
+ async def charge_tx_primary(request: Request, credential: Credential, receipt: Receipt) -> dict:
196
+ return {"message": "Charge payment received!", "receipt": _receipt_to_dict(receipt)}
197
+
198
+ @app.get("/charge/tx/split")
199
+ @mpp_tx.pay(amount=AMOUNT_SPLIT_TOTAL, intent="charge", description="Charge tx/split: 0.00003 USDT (10 primary + 20 split)")
200
+ async def charge_tx_split(request: Request, credential: Credential, receipt: Receipt) -> dict:
201
+ return {"message": "Charge payment received!", "receipt": _receipt_to_dict(receipt)}
202
+
203
+ @app.get("/charge/hash/primary")
204
+ @mpp_hash.pay(amount=AMOUNT_PRIMARY, intent="charge", description="Charge hash/primary: 0.00001 USDT")
205
+ async def charge_hash_primary(request: Request, credential: Credential, receipt: Receipt) -> dict:
206
+ return {"message": "Charge payment received!", "receipt": _receipt_to_dict(receipt)}
207
+
208
+ @app.get("/charge/hash/split")
209
+ @mpp_hash.pay(amount=AMOUNT_SPLIT_TOTAL, intent="charge", description="Charge hash/split: 0.00003 USDT (10 primary + 20 split)")
210
+ async def charge_hash_split(request: Request, credential: Credential, receipt: Receipt) -> dict:
211
+ return {"message": "Charge payment received!", "receipt": _receipt_to_dict(receipt)}
212
+
213
+ # ------------------------------------------------------------------
214
+ # Session endpoints
215
+ # ------------------------------------------------------------------
216
+
217
+ @app.get("/session/tx")
218
+ @mpp_tx.pay(amount=AMOUNT_SESSION_PER_REQUEST, intent="session", description="Session tx: 0.00001 USDT per request", unit_type="request", suggested_deposit="60")
219
+ async def session_tx(request: Request, credential: Credential, receipt: Receipt) -> dict:
220
+ return {"message": "Session request served!", "receipt": _receipt_to_dict(receipt)}
221
+
222
+ @app.get("/session/tx-other")
223
+ @mpp_tx_other.pay(amount="0.000015", intent="session", description="Session tx-other: 0.000015 USDT per request (cost=15)", unit_type="request", suggested_deposit="60")
224
+ async def session_tx_other(request: Request, credential: Credential, receipt: Receipt) -> dict:
225
+ return {"message": "Session request served!", "receipt": _receipt_to_dict(receipt)}
226
+
227
+ @app.get("/session/hash")
228
+ @mpp_hash.pay(amount=AMOUNT_SESSION_PER_REQUEST, intent="session", description="Session hash: 0.00001 USDT per request", unit_type="request", suggested_deposit="60")
229
+ async def session_hash(request: Request, credential: Credential, receipt: Receipt) -> dict:
230
+ return {"message": "Session request served!", "receipt": _receipt_to_dict(receipt)}
231
+
232
+ return app
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Helpers
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def _receipt_to_dict(receipt: object) -> dict:
241
+ """Convert a Receipt to a JSON-safe dict."""
242
+ if hasattr(receipt, "model_dump"):
243
+ return receipt.model_dump()
244
+ if isinstance(receipt, dict):
245
+ return receipt
246
+ return {
247
+ "reference": getattr(receipt, "reference", ""),
248
+ "method": getattr(receipt, "method", ""),
249
+ "status": getattr(receipt, "status", ""),
250
+ "external_id": getattr(receipt, "external_id", None),
251
+ }
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Entrypoint
256
+ # ---------------------------------------------------------------------------
257
+
258
+ app = create_app() if _env("PRIVATE_KEY") else None
259
+
260
+
261
+ def main() -> None:
262
+ """Run the demo server."""
263
+ import uvicorn
264
+
265
+ addr = _env("ADDR", ":8080")
266
+ host = "0.0.0.0"
267
+ port = 8080
268
+ if addr.startswith(":"):
269
+ port = int(addr[1:])
270
+ elif ":" in addr:
271
+ host, port_str = addr.rsplit(":", 1)
272
+ port = int(port_str)
273
+
274
+ application = create_app()
275
+
276
+ print(f"Demo server listening on {addr}")
277
+ print(f" GET {addr}/free — no payment")
278
+ print(f" GET {addr}/charge/tx/primary — charge, tx mode")
279
+ print(f" GET {addr}/charge/tx/split — charge with splits, tx mode")
280
+ print(f" GET {addr}/charge/hash/primary — charge, hash mode")
281
+ print(f" GET {addr}/charge/hash/split — charge with splits, hash mode")
282
+ print(f" GET {addr}/session/tx — session, feePayer=true")
283
+ print(f" GET {addr}/session/hash — session, feePayer=false")
284
+
285
+ uvicorn.run(application, host=host, port=port)
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "okxweb3-app-mpp-demo"
3
+ version = "0.0.0"
4
+ description = "Demo servers for okxweb3-app-mpp"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "okxweb3-app-mpp",
8
+ "fastapi>=0.100.0",
9
+ "uvicorn>=0.20.0",
10
+ ]
11
+ classifiers = ["Private :: Do Not Upload"]
12
+
13
+ [tool.uv.sources]
14
+ okxweb3-app-mpp = { path = "..", editable = true }
@@ -0,0 +1,143 @@
1
+ """MPP EVM tools server (Python) — payee-initiated settle/close + channel inspection.
2
+
3
+ Python counterpart to go/mpp/demo/tools/main.go. Exercises the Python SDK's
4
+ merchant-side server functions (SessionIntent.settle_channel / close_channel)
5
+ directly, against the SAME cross-language FileStore the demo server writes.
6
+
7
+ Hash-mode on-chain calldata (open/topUp/approve) is intentionally NOT here —
8
+ use the Go tools server for that.
9
+
10
+ Endpoints:
11
+ GET /channels — list channel IDs in the store
12
+ GET /channel?id=<channelId> — get channel state (raw store JSON)
13
+ POST /settle?channel=<channelId> — SessionIntent.settle_channel (intermediate settle)
14
+ POST /close?channel=<channelId> — SessionIntent.close_channel (final settle + close)
15
+
16
+ Config via env (shares the demo server's vars; point at the SAME store):
17
+ PRIVATE_KEY — payee hex key (required; signs settle/close authorizations)
18
+ PAY_TO_ADDRESS — recipient (default: signer address)
19
+ ESCROW_CONTRACT — escrow address (default: DEFAULT_ESCROW_CONTRACT)
20
+ OKX_BASE_URL / OKX_API_KEY / OKX_SECRET_KEY / OKX_PASSPHRASE — SA API
21
+ CHANNEL_STORE_DIR — channel state dir (default: "./mpp-data/channels")
22
+ TOOLS_ADDR — listen address (default: ":8083")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sys
29
+
30
+ from fastapi import FastAPI
31
+ from fastapi.responses import JSONResponse
32
+
33
+ from mpp_evm import DEFAULT_ESCROW_CONTRACT, FileStore, X_LAYER_CHAIN_ID
34
+ from mpp_evm.errors import EvmPaymentError
35
+ from mpp_evm.saclient.client import OKXSAClient
36
+ from mpp_evm.session.intent import SessionIntent
37
+ from mpp_evm.signer import PrivateKeySigner
38
+
39
+
40
+ def _env(key: str, default: str = "") -> str:
41
+ return os.environ.get(key, default)
42
+
43
+
44
+ def create_app() -> FastAPI:
45
+ private_key_hex = _env("PRIVATE_KEY")
46
+ if not private_key_hex:
47
+ print("FATAL: PRIVATE_KEY environment variable is required", file=sys.stderr)
48
+ sys.exit(1)
49
+
50
+ signer = PrivateKeySigner.from_hex(private_key_hex)
51
+ pay_to = _env("PAY_TO_ADDRESS", signer.address)
52
+ escrow_contract = _env("ESCROW_CONTRACT", DEFAULT_ESCROW_CONTRACT)
53
+ store_dir = _env("CHANNEL_STORE_DIR", "./mpp-data/channels")
54
+
55
+ sa_client = OKXSAClient(
56
+ base_url=_env("OKX_BASE_URL", "https://web3.okx.com"),
57
+ api_key=_env("OKX_API_KEY", ""),
58
+ secret_key=_env("OKX_SECRET_KEY", ""),
59
+ passphrase=_env("OKX_PASSPHRASE", ""),
60
+ )
61
+ store = FileStore(store_dir)
62
+
63
+ session = SessionIntent(
64
+ sa_client=sa_client,
65
+ recipient=pay_to,
66
+ signer=signer,
67
+ store=store,
68
+ chain_id=X_LAYER_CHAIN_ID,
69
+ escrow_contract=escrow_contract,
70
+ per_request_cost=10,
71
+ min_voucher_delta=30,
72
+ fee_payer=True,
73
+ )
74
+
75
+ print(f"Channel store: {store_dir}")
76
+ print(f"Payee (signer): {signer.address}")
77
+
78
+ app = FastAPI(title="MPP EVM Tools (Python)")
79
+
80
+ def _err(exc: Exception) -> JSONResponse:
81
+ if isinstance(exc, EvmPaymentError):
82
+ return JSONResponse(status_code=exc.status_code, content=exc.to_problem_details())
83
+ return JSONResponse(status_code=500, content={"error": str(exc)})
84
+
85
+ @app.get("/channels")
86
+ async def list_channels() -> JSONResponse:
87
+ if not os.path.isdir(store_dir):
88
+ return JSONResponse({"channels": []})
89
+ ids = [f[:-5] for f in os.listdir(store_dir) if f.endswith(".json")]
90
+ return JSONResponse({"channels": sorted(ids)})
91
+
92
+ @app.get("/channel")
93
+ async def get_channel(id: str = "") -> JSONResponse:
94
+ if not id:
95
+ return JSONResponse(status_code=400, content={"error": "missing ?id="})
96
+ state = await store.get(id)
97
+ if state is None:
98
+ return JSONResponse(status_code=404, content={"error": f"channel {id!r} not found"})
99
+ return JSONResponse({"channel": state})
100
+
101
+ @app.post("/settle")
102
+ async def settle(channel: str = "") -> JSONResponse:
103
+ if not channel:
104
+ return JSONResponse(status_code=400, content={"error": "missing ?channel="})
105
+ try:
106
+ return JSONResponse(await session.settle_channel(channel))
107
+ except Exception as exc: # noqa: BLE001 — surface SA/validation errors to caller
108
+ return _err(exc)
109
+
110
+ @app.post("/close")
111
+ async def close(channel: str = "") -> JSONResponse:
112
+ if not channel:
113
+ return JSONResponse(status_code=400, content={"error": "missing ?channel="})
114
+ try:
115
+ return JSONResponse(await session.close_channel(channel))
116
+ except Exception as exc: # noqa: BLE001
117
+ return _err(exc)
118
+
119
+ return app
120
+
121
+
122
+ def main() -> None:
123
+ import uvicorn
124
+
125
+ addr = _env("TOOLS_ADDR", ":8083")
126
+ host, port = "0.0.0.0", 8083
127
+ if addr.startswith(":"):
128
+ port = int(addr[1:])
129
+ elif ":" in addr:
130
+ host, port_str = addr.rsplit(":", 1)
131
+ port = int(port_str)
132
+
133
+ app = create_app()
134
+ print(f"MPP Python tools listening on {addr}")
135
+ print(f" GET {addr}/channels")
136
+ print(f" GET {addr}/channel?id=<channelId>")
137
+ print(f" POST {addr}/settle?channel=<channelId>")
138
+ print(f" POST {addr}/close?channel=<channelId>")
139
+ uvicorn.run(app, host=host, port=port)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()