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.
- okxweb3_app_mpp-0.1.0/.gitignore +12 -0
- okxweb3_app_mpp-0.1.0/PKG-INFO +31 -0
- okxweb3_app_mpp-0.1.0/demo/fastapi/main.py +289 -0
- okxweb3_app_mpp-0.1.0/demo/pyproject.toml +14 -0
- okxweb3_app_mpp-0.1.0/demo/tools/main.py +143 -0
- okxweb3_app_mpp-0.1.0/demo/uv.lock +906 -0
- okxweb3_app_mpp-0.1.0/pyproject.toml +59 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/__init__.py +90 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/_defaults.py +30 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/_patch_session_respond.py +237 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/adapters/__init__.py +5 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/adapters/mpp_adapter.py +179 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/__init__.py +6 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/intent.py +186 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/method.py +95 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/charge/schemas.py +88 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/errors.py +218 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/method.py +31 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/nonce.py +31 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/saclient/__init__.py +27 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/saclient/client.py +246 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/saclient/types.py +251 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/session/__init__.py +12 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/session/channel.py +168 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/session/intent.py +763 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/session/voucher.py +244 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/signer.py +54 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/store.py +96 -0
- okxweb3_app_mpp-0.1.0/src/mpp_evm/types.py +238 -0
- okxweb3_app_mpp-0.1.0/tests/__init__.py +0 -0
- okxweb3_app_mpp-0.1.0/tests/conftest.py +16 -0
- okxweb3_app_mpp-0.1.0/tests/test_charge.py +540 -0
- okxweb3_app_mpp-0.1.0/tests/test_errors.py +151 -0
- okxweb3_app_mpp-0.1.0/tests/test_patch_session_respond.py +251 -0
- okxweb3_app_mpp-0.1.0/tests/test_saclient.py +390 -0
- okxweb3_app_mpp-0.1.0/tests/test_session.py +842 -0
- okxweb3_app_mpp-0.1.0/tests/test_types.py +238 -0
- okxweb3_app_mpp-0.1.0/uv.lock +1151 -0
|
@@ -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()
|