uvd-x402-sdk 0.5.6__py3-none-any.whl → 0.7.0__py3-none-any.whl
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.
- uvd_x402_sdk/__init__.py +348 -241
- uvd_x402_sdk/advanced_escrow.py +633 -0
- uvd_x402_sdk/erc8004.py +663 -0
- uvd_x402_sdk/escrow.py +637 -0
- uvd_x402_sdk/networks/__init__.py +9 -9
- uvd_x402_sdk/networks/base.py +3 -0
- uvd_x402_sdk/networks/evm.py +71 -1
- {uvd_x402_sdk-0.5.6.dist-info → uvd_x402_sdk-0.7.0.dist-info}/METADATA +313 -9
- {uvd_x402_sdk-0.5.6.dist-info → uvd_x402_sdk-0.7.0.dist-info}/RECORD +12 -9
- {uvd_x402_sdk-0.5.6.dist-info → uvd_x402_sdk-0.7.0.dist-info}/WHEEL +1 -1
- {uvd_x402_sdk-0.5.6.dist-info → uvd_x402_sdk-0.7.0.dist-info}/LICENSE +0 -0
- {uvd_x402_sdk-0.5.6.dist-info → uvd_x402_sdk-0.7.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Advanced Escrow client for x402 PaymentOperator integration.
|
|
3
|
+
|
|
4
|
+
This module provides the 5 Advanced Escrow flows via the PaymentOperator contract:
|
|
5
|
+
1. AUTHORIZE - Lock funds in escrow (via facilitator)
|
|
6
|
+
2. RELEASE - Capture escrowed funds to receiver (on-chain)
|
|
7
|
+
3. REFUND IN ESCROW - Return escrowed funds to payer (on-chain)
|
|
8
|
+
4. CHARGE - Direct instant payment without escrow (on-chain)
|
|
9
|
+
5. REFUND POST ESCROW - Dispute refund after release (NOT FUNCTIONAL - tokenCollector not implemented)
|
|
10
|
+
|
|
11
|
+
Contract deposit limit: $100 USDC per deposit (enforced on-chain).
|
|
12
|
+
Dispute resolution: use refund_in_escrow() (keep funds in escrow, arbiter decides).
|
|
13
|
+
|
|
14
|
+
Contract mapping:
|
|
15
|
+
operator.authorize() -> escrow.authorize() (lock funds)
|
|
16
|
+
operator.release() -> escrow.capture() (pay receiver)
|
|
17
|
+
operator.refundInEscrow() -> escrow.partialVoid() (refund payer)
|
|
18
|
+
operator.charge() -> escrow.charge() (direct payment)
|
|
19
|
+
operator.refundPostEscrow() -> escrow.refund() (dispute refund)
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> from uvd_x402_sdk.advanced_escrow import AdvancedEscrowClient
|
|
23
|
+
>>>
|
|
24
|
+
>>> client = AdvancedEscrowClient(
|
|
25
|
+
... facilitator_url="https://facilitator.ultravioletadao.xyz",
|
|
26
|
+
... rpc_url="https://mainnet.base.org",
|
|
27
|
+
... private_key="0x...",
|
|
28
|
+
... chain_id=8453,
|
|
29
|
+
... )
|
|
30
|
+
>>>
|
|
31
|
+
>>> # Build payment info
|
|
32
|
+
>>> pi = client.build_payment_info(
|
|
33
|
+
... receiver="0xWorker...",
|
|
34
|
+
... amount=5_000_000, # $5 USDC
|
|
35
|
+
... tier=TaskTier.STANDARD,
|
|
36
|
+
... )
|
|
37
|
+
>>>
|
|
38
|
+
>>> # Lock funds in escrow
|
|
39
|
+
>>> auth = client.authorize(pi)
|
|
40
|
+
>>>
|
|
41
|
+
>>> # After work is done, release to worker
|
|
42
|
+
>>> tx = client.release(auth.payment_info)
|
|
43
|
+
>>>
|
|
44
|
+
>>> # Or cancel and refund
|
|
45
|
+
>>> tx = client.refund_in_escrow(auth.payment_info)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import secrets
|
|
49
|
+
import time
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from enum import Enum
|
|
52
|
+
from typing import Optional
|
|
53
|
+
|
|
54
|
+
import httpx
|
|
55
|
+
from eth_abi import encode
|
|
56
|
+
from eth_account import Account
|
|
57
|
+
from eth_account.messages import encode_typed_data
|
|
58
|
+
from web3 import Web3
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ============================================================
|
|
62
|
+
# Constants
|
|
63
|
+
# ============================================================
|
|
64
|
+
|
|
65
|
+
PAYMENT_INFO_TYPEHASH = bytes.fromhex(
|
|
66
|
+
"ae68ac7ce30c86ece8196b61a7c486d8f0061f575037fbd34e7fe4e2820c6591"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
70
|
+
|
|
71
|
+
# Contract deposit limit (enforced by PaymentOperator condition).
|
|
72
|
+
# As of 2026-02-03, commerce-payments contracts enforce $100 max per deposit.
|
|
73
|
+
DEPOSIT_LIMIT_USDC = 100_000_000 # $100 in atomic units (6 decimals)
|
|
74
|
+
|
|
75
|
+
# Base Mainnet contract addresses (default)
|
|
76
|
+
BASE_MAINNET_CONTRACTS = {
|
|
77
|
+
"operator": "0xa06958D93135BEd7e43893897C0d9fA931EF051C",
|
|
78
|
+
"escrow": "0x320a3c35F131E5D2Fb36af56345726B298936037",
|
|
79
|
+
"token_collector": "0x32d6AC59BCe8DFB3026F10BcaDB8D00AB218f5b6",
|
|
80
|
+
"usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# PaymentOperator ABI (minimal, for the 5 functions we need)
|
|
84
|
+
OPERATOR_ABI = [
|
|
85
|
+
{
|
|
86
|
+
"type": "function",
|
|
87
|
+
"name": "release",
|
|
88
|
+
"inputs": [
|
|
89
|
+
{
|
|
90
|
+
"name": "paymentInfo",
|
|
91
|
+
"type": "tuple",
|
|
92
|
+
"components": [
|
|
93
|
+
{"name": "operator", "type": "address"},
|
|
94
|
+
{"name": "payer", "type": "address"},
|
|
95
|
+
{"name": "receiver", "type": "address"},
|
|
96
|
+
{"name": "token", "type": "address"},
|
|
97
|
+
{"name": "maxAmount", "type": "uint120"},
|
|
98
|
+
{"name": "preApprovalExpiry", "type": "uint48"},
|
|
99
|
+
{"name": "authorizationExpiry", "type": "uint48"},
|
|
100
|
+
{"name": "refundExpiry", "type": "uint48"},
|
|
101
|
+
{"name": "minFeeBps", "type": "uint16"},
|
|
102
|
+
{"name": "maxFeeBps", "type": "uint16"},
|
|
103
|
+
{"name": "feeReceiver", "type": "address"},
|
|
104
|
+
{"name": "salt", "type": "uint256"},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
{"name": "amount", "type": "uint256"},
|
|
108
|
+
],
|
|
109
|
+
"outputs": [],
|
|
110
|
+
"stateMutability": "nonpayable",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"type": "function",
|
|
114
|
+
"name": "refundInEscrow",
|
|
115
|
+
"inputs": [
|
|
116
|
+
{
|
|
117
|
+
"name": "paymentInfo",
|
|
118
|
+
"type": "tuple",
|
|
119
|
+
"components": [
|
|
120
|
+
{"name": "operator", "type": "address"},
|
|
121
|
+
{"name": "payer", "type": "address"},
|
|
122
|
+
{"name": "receiver", "type": "address"},
|
|
123
|
+
{"name": "token", "type": "address"},
|
|
124
|
+
{"name": "maxAmount", "type": "uint120"},
|
|
125
|
+
{"name": "preApprovalExpiry", "type": "uint48"},
|
|
126
|
+
{"name": "authorizationExpiry", "type": "uint48"},
|
|
127
|
+
{"name": "refundExpiry", "type": "uint48"},
|
|
128
|
+
{"name": "minFeeBps", "type": "uint16"},
|
|
129
|
+
{"name": "maxFeeBps", "type": "uint16"},
|
|
130
|
+
{"name": "feeReceiver", "type": "address"},
|
|
131
|
+
{"name": "salt", "type": "uint256"},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{"name": "amount", "type": "uint120"},
|
|
135
|
+
],
|
|
136
|
+
"outputs": [],
|
|
137
|
+
"stateMutability": "nonpayable",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"type": "function",
|
|
141
|
+
"name": "charge",
|
|
142
|
+
"inputs": [
|
|
143
|
+
{
|
|
144
|
+
"name": "paymentInfo",
|
|
145
|
+
"type": "tuple",
|
|
146
|
+
"components": [
|
|
147
|
+
{"name": "operator", "type": "address"},
|
|
148
|
+
{"name": "payer", "type": "address"},
|
|
149
|
+
{"name": "receiver", "type": "address"},
|
|
150
|
+
{"name": "token", "type": "address"},
|
|
151
|
+
{"name": "maxAmount", "type": "uint120"},
|
|
152
|
+
{"name": "preApprovalExpiry", "type": "uint48"},
|
|
153
|
+
{"name": "authorizationExpiry", "type": "uint48"},
|
|
154
|
+
{"name": "refundExpiry", "type": "uint48"},
|
|
155
|
+
{"name": "minFeeBps", "type": "uint16"},
|
|
156
|
+
{"name": "maxFeeBps", "type": "uint16"},
|
|
157
|
+
{"name": "feeReceiver", "type": "address"},
|
|
158
|
+
{"name": "salt", "type": "uint256"},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
{"name": "amount", "type": "uint256"},
|
|
162
|
+
{"name": "tokenCollector", "type": "address"},
|
|
163
|
+
{"name": "collectorData", "type": "bytes"},
|
|
164
|
+
],
|
|
165
|
+
"outputs": [],
|
|
166
|
+
"stateMutability": "nonpayable",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"type": "function",
|
|
170
|
+
"name": "refundPostEscrow",
|
|
171
|
+
"inputs": [
|
|
172
|
+
{
|
|
173
|
+
"name": "paymentInfo",
|
|
174
|
+
"type": "tuple",
|
|
175
|
+
"components": [
|
|
176
|
+
{"name": "operator", "type": "address"},
|
|
177
|
+
{"name": "payer", "type": "address"},
|
|
178
|
+
{"name": "receiver", "type": "address"},
|
|
179
|
+
{"name": "token", "type": "address"},
|
|
180
|
+
{"name": "maxAmount", "type": "uint120"},
|
|
181
|
+
{"name": "preApprovalExpiry", "type": "uint48"},
|
|
182
|
+
{"name": "authorizationExpiry", "type": "uint48"},
|
|
183
|
+
{"name": "refundExpiry", "type": "uint48"},
|
|
184
|
+
{"name": "minFeeBps", "type": "uint16"},
|
|
185
|
+
{"name": "maxFeeBps", "type": "uint16"},
|
|
186
|
+
{"name": "feeReceiver", "type": "address"},
|
|
187
|
+
{"name": "salt", "type": "uint256"},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
{"name": "amount", "type": "uint256"},
|
|
191
|
+
{"name": "tokenCollector", "type": "address"},
|
|
192
|
+
{"name": "collectorData", "type": "bytes"},
|
|
193
|
+
],
|
|
194
|
+
"outputs": [],
|
|
195
|
+
"stateMutability": "nonpayable",
|
|
196
|
+
},
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ============================================================
|
|
201
|
+
# Types
|
|
202
|
+
# ============================================================
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TaskTier(str, Enum):
|
|
206
|
+
"""Chamba task tier determines timing parameters."""
|
|
207
|
+
|
|
208
|
+
MICRO = "micro" # $0.50-$5: 1h accept, 2h complete, 24h dispute
|
|
209
|
+
STANDARD = "standard" # $5-$50: 2h accept, 24h complete, 7d dispute
|
|
210
|
+
PREMIUM = "premium" # $50-$200: 4h accept, 48h complete, 14d dispute
|
|
211
|
+
ENTERPRISE = "enterprise" # $200+: 24h accept, 7d complete, 30d dispute
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
TIER_TIMINGS = {
|
|
215
|
+
TaskTier.MICRO: {"pre": 3600, "auth": 7200, "refund": 86400},
|
|
216
|
+
TaskTier.STANDARD: {"pre": 7200, "auth": 86400, "refund": 604800},
|
|
217
|
+
TaskTier.PREMIUM: {"pre": 14400, "auth": 172800, "refund": 1209600},
|
|
218
|
+
TaskTier.ENTERPRISE: {"pre": 86400, "auth": 604800, "refund": 2592000},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class PaymentInfo:
|
|
224
|
+
"""PaymentInfo struct matching the on-chain PaymentOperator contract."""
|
|
225
|
+
|
|
226
|
+
operator: str
|
|
227
|
+
receiver: str
|
|
228
|
+
token: str
|
|
229
|
+
max_amount: int
|
|
230
|
+
pre_approval_expiry: int
|
|
231
|
+
authorization_expiry: int
|
|
232
|
+
refund_expiry: int
|
|
233
|
+
min_fee_bps: int = 0
|
|
234
|
+
max_fee_bps: int = 800
|
|
235
|
+
fee_receiver: str = ""
|
|
236
|
+
salt: str = field(default_factory=lambda: "0x" + secrets.token_hex(32))
|
|
237
|
+
|
|
238
|
+
def __post_init__(self):
|
|
239
|
+
if not self.fee_receiver:
|
|
240
|
+
self.fee_receiver = self.operator
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass
|
|
244
|
+
class AuthorizationResult:
|
|
245
|
+
"""Result of an AUTHORIZE operation."""
|
|
246
|
+
|
|
247
|
+
success: bool
|
|
248
|
+
transaction_hash: Optional[str] = None
|
|
249
|
+
payment_info: Optional[PaymentInfo] = None
|
|
250
|
+
salt: Optional[str] = None
|
|
251
|
+
error: Optional[str] = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class TransactionResult:
|
|
256
|
+
"""Result of an on-chain transaction."""
|
|
257
|
+
|
|
258
|
+
success: bool
|
|
259
|
+
transaction_hash: Optional[str] = None
|
|
260
|
+
gas_used: Optional[int] = None
|
|
261
|
+
error: Optional[str] = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ============================================================
|
|
265
|
+
# Client
|
|
266
|
+
# ============================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class AdvancedEscrowClient:
|
|
270
|
+
"""
|
|
271
|
+
Client for x402 Advanced Escrow (PaymentOperator) operations.
|
|
272
|
+
|
|
273
|
+
Provides the 5 escrow flows:
|
|
274
|
+
- authorize(): Lock funds in escrow via facilitator
|
|
275
|
+
- release(): Capture escrowed funds to receiver
|
|
276
|
+
- refund_in_escrow(): Return escrowed funds to payer
|
|
277
|
+
- charge(): Direct instant payment (no escrow)
|
|
278
|
+
- refund_post_escrow(): Dispute refund after release
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
private_key: str,
|
|
284
|
+
*,
|
|
285
|
+
facilitator_url: str = "https://facilitator.ultravioletadao.xyz",
|
|
286
|
+
rpc_url: str = "https://mainnet.base.org",
|
|
287
|
+
chain_id: int = 8453,
|
|
288
|
+
contracts: Optional[dict] = None,
|
|
289
|
+
gas_limit: int = 300000,
|
|
290
|
+
):
|
|
291
|
+
self.private_key = private_key
|
|
292
|
+
self.facilitator_url = facilitator_url.rstrip("/")
|
|
293
|
+
self.chain_id = chain_id
|
|
294
|
+
self.gas_limit = gas_limit
|
|
295
|
+
self.contracts = contracts or BASE_MAINNET_CONTRACTS
|
|
296
|
+
self.w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
297
|
+
self.account = Account.from_key(private_key)
|
|
298
|
+
self.payer = self.account.address
|
|
299
|
+
|
|
300
|
+
self.operator_contract = self.w3.eth.contract(
|
|
301
|
+
address=Web3.to_checksum_address(self.contracts["operator"]),
|
|
302
|
+
abi=OPERATOR_ABI,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _compute_nonce(self, payment_info: PaymentInfo) -> str:
|
|
306
|
+
"""Compute the correct nonce (with PAYMENT_INFO_TYPEHASH)."""
|
|
307
|
+
salt = payment_info.salt
|
|
308
|
+
if isinstance(salt, str):
|
|
309
|
+
salt = int(salt, 16) if salt.startswith("0x") else int(salt)
|
|
310
|
+
|
|
311
|
+
pi_tuple = (
|
|
312
|
+
Web3.to_checksum_address(payment_info.operator),
|
|
313
|
+
ZERO_ADDRESS, # payer = 0 for payer-agnostic hash
|
|
314
|
+
Web3.to_checksum_address(payment_info.receiver),
|
|
315
|
+
Web3.to_checksum_address(payment_info.token),
|
|
316
|
+
payment_info.max_amount,
|
|
317
|
+
payment_info.pre_approval_expiry,
|
|
318
|
+
payment_info.authorization_expiry,
|
|
319
|
+
payment_info.refund_expiry,
|
|
320
|
+
payment_info.min_fee_bps,
|
|
321
|
+
payment_info.max_fee_bps,
|
|
322
|
+
Web3.to_checksum_address(payment_info.fee_receiver),
|
|
323
|
+
salt,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
encoded_with_typehash = encode(
|
|
327
|
+
[
|
|
328
|
+
"bytes32",
|
|
329
|
+
"(address,address,address,address,uint120,uint48,uint48,uint48,uint16,uint16,address,uint256)",
|
|
330
|
+
],
|
|
331
|
+
[PAYMENT_INFO_TYPEHASH, pi_tuple],
|
|
332
|
+
)
|
|
333
|
+
pi_hash = Web3.keccak(encoded_with_typehash)
|
|
334
|
+
|
|
335
|
+
final_encoded = encode(
|
|
336
|
+
["uint256", "address", "bytes32"],
|
|
337
|
+
[self.chain_id, Web3.to_checksum_address(self.contracts["escrow"]), pi_hash],
|
|
338
|
+
)
|
|
339
|
+
return "0x" + Web3.keccak(final_encoded).hex()
|
|
340
|
+
|
|
341
|
+
def _sign_erc3009(self, auth: dict) -> str:
|
|
342
|
+
"""Sign ReceiveWithAuthorization for ERC-3009."""
|
|
343
|
+
domain = {
|
|
344
|
+
"name": "USD Coin",
|
|
345
|
+
"version": "2",
|
|
346
|
+
"chainId": self.chain_id,
|
|
347
|
+
"verifyingContract": Web3.to_checksum_address(self.contracts["usdc"]),
|
|
348
|
+
}
|
|
349
|
+
types = {
|
|
350
|
+
"ReceiveWithAuthorization": [
|
|
351
|
+
{"name": "from", "type": "address"},
|
|
352
|
+
{"name": "to", "type": "address"},
|
|
353
|
+
{"name": "value", "type": "uint256"},
|
|
354
|
+
{"name": "validAfter", "type": "uint256"},
|
|
355
|
+
{"name": "validBefore", "type": "uint256"},
|
|
356
|
+
{"name": "nonce", "type": "bytes32"},
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
message = {
|
|
360
|
+
"from": Web3.to_checksum_address(auth["from"]),
|
|
361
|
+
"to": Web3.to_checksum_address(auth["to"]),
|
|
362
|
+
"value": int(auth["value"]),
|
|
363
|
+
"validAfter": int(auth["validAfter"]),
|
|
364
|
+
"validBefore": int(auth["validBefore"]),
|
|
365
|
+
"nonce": auth["nonce"],
|
|
366
|
+
}
|
|
367
|
+
signable = encode_typed_data(domain_data=domain, message_types=types, message_data=message)
|
|
368
|
+
signed = self.account.sign_message(signable)
|
|
369
|
+
return "0x" + signed.signature.hex()
|
|
370
|
+
|
|
371
|
+
def _build_tuple(self, pi: PaymentInfo) -> tuple:
|
|
372
|
+
"""Build the on-chain PaymentInfo tuple."""
|
|
373
|
+
if isinstance(pi.salt, int):
|
|
374
|
+
salt_int = pi.salt
|
|
375
|
+
elif isinstance(pi.salt, str):
|
|
376
|
+
salt_int = int(pi.salt, 16) if pi.salt.startswith("0x") else int(pi.salt, 16) if all(c in "0123456789abcdefABCDEF" for c in pi.salt) else int(pi.salt)
|
|
377
|
+
else:
|
|
378
|
+
salt_int = int(pi.salt)
|
|
379
|
+
return (
|
|
380
|
+
Web3.to_checksum_address(pi.operator),
|
|
381
|
+
Web3.to_checksum_address(self.payer),
|
|
382
|
+
Web3.to_checksum_address(pi.receiver),
|
|
383
|
+
Web3.to_checksum_address(pi.token),
|
|
384
|
+
pi.max_amount,
|
|
385
|
+
pi.pre_approval_expiry,
|
|
386
|
+
pi.authorization_expiry,
|
|
387
|
+
pi.refund_expiry,
|
|
388
|
+
pi.min_fee_bps,
|
|
389
|
+
pi.max_fee_bps,
|
|
390
|
+
Web3.to_checksum_address(pi.fee_receiver),
|
|
391
|
+
salt_int,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _send_tx(self, func_call) -> TransactionResult:
|
|
395
|
+
"""Build, sign, and send a transaction."""
|
|
396
|
+
try:
|
|
397
|
+
gas_price = self.w3.eth.gas_price
|
|
398
|
+
tx = func_call.build_transaction({
|
|
399
|
+
"from": self.payer,
|
|
400
|
+
"nonce": self.w3.eth.get_transaction_count(self.payer),
|
|
401
|
+
"gas": self.gas_limit,
|
|
402
|
+
"maxFeePerGas": gas_price * 2,
|
|
403
|
+
"maxPriorityFeePerGas": gas_price,
|
|
404
|
+
})
|
|
405
|
+
signed = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
|
406
|
+
tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction)
|
|
407
|
+
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
|
408
|
+
|
|
409
|
+
if receipt["status"] != 1:
|
|
410
|
+
return TransactionResult(success=False, transaction_hash=tx_hash.hex(), gas_used=receipt["gasUsed"], error="Transaction reverted")
|
|
411
|
+
|
|
412
|
+
return TransactionResult(success=True, transaction_hash=tx_hash.hex(), gas_used=receipt["gasUsed"])
|
|
413
|
+
except Exception as e:
|
|
414
|
+
return TransactionResult(success=False, error=str(e))
|
|
415
|
+
|
|
416
|
+
def build_payment_info(
|
|
417
|
+
self,
|
|
418
|
+
receiver: str,
|
|
419
|
+
amount: int,
|
|
420
|
+
*,
|
|
421
|
+
tier: TaskTier = TaskTier.STANDARD,
|
|
422
|
+
salt: Optional[str] = None,
|
|
423
|
+
min_fee_bps: int = 0,
|
|
424
|
+
max_fee_bps: int = 800,
|
|
425
|
+
) -> PaymentInfo:
|
|
426
|
+
"""
|
|
427
|
+
Build a PaymentInfo struct with appropriate timing for the task tier.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
receiver: Worker's wallet address
|
|
431
|
+
amount: Amount in token atomic units (e.g., 5_000_000 for $5 USDC)
|
|
432
|
+
tier: Task tier (determines timing parameters)
|
|
433
|
+
salt: Random salt (auto-generated if not provided)
|
|
434
|
+
min_fee_bps: Minimum fee in basis points
|
|
435
|
+
max_fee_bps: Maximum fee in basis points
|
|
436
|
+
"""
|
|
437
|
+
now = int(time.time())
|
|
438
|
+
t = TIER_TIMINGS[tier]
|
|
439
|
+
|
|
440
|
+
return PaymentInfo(
|
|
441
|
+
operator=self.contracts["operator"],
|
|
442
|
+
receiver=receiver,
|
|
443
|
+
token=self.contracts["usdc"],
|
|
444
|
+
max_amount=amount,
|
|
445
|
+
pre_approval_expiry=now + t["pre"],
|
|
446
|
+
authorization_expiry=now + t["auth"],
|
|
447
|
+
refund_expiry=now + t["refund"],
|
|
448
|
+
min_fee_bps=min_fee_bps,
|
|
449
|
+
max_fee_bps=max_fee_bps,
|
|
450
|
+
fee_receiver=self.contracts["operator"],
|
|
451
|
+
salt=salt or ("0x" + secrets.token_hex(32)),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def authorize(self, payment_info: PaymentInfo) -> AuthorizationResult:
|
|
455
|
+
"""
|
|
456
|
+
AUTHORIZE: Lock funds in escrow via the facilitator.
|
|
457
|
+
|
|
458
|
+
This sends an ERC-3009 ReceiveWithAuthorization to the facilitator,
|
|
459
|
+
which calls PaymentOperator.authorize() on-chain.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
payment_info: PaymentInfo struct with timing and amount
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
AuthorizationResult with transaction hash
|
|
466
|
+
"""
|
|
467
|
+
nonce = self._compute_nonce(payment_info)
|
|
468
|
+
|
|
469
|
+
auth = {
|
|
470
|
+
"from": self.payer,
|
|
471
|
+
"to": self.contracts["token_collector"],
|
|
472
|
+
"value": str(payment_info.max_amount),
|
|
473
|
+
"validAfter": "0",
|
|
474
|
+
"validBefore": str(payment_info.pre_approval_expiry),
|
|
475
|
+
"nonce": nonce,
|
|
476
|
+
}
|
|
477
|
+
signature = self._sign_erc3009(auth)
|
|
478
|
+
|
|
479
|
+
pi_dict = {
|
|
480
|
+
"operator": payment_info.operator,
|
|
481
|
+
"receiver": payment_info.receiver,
|
|
482
|
+
"token": payment_info.token,
|
|
483
|
+
"maxAmount": str(payment_info.max_amount),
|
|
484
|
+
"preApprovalExpiry": payment_info.pre_approval_expiry,
|
|
485
|
+
"authorizationExpiry": payment_info.authorization_expiry,
|
|
486
|
+
"refundExpiry": payment_info.refund_expiry,
|
|
487
|
+
"minFeeBps": payment_info.min_fee_bps,
|
|
488
|
+
"maxFeeBps": payment_info.max_fee_bps,
|
|
489
|
+
"feeReceiver": payment_info.fee_receiver,
|
|
490
|
+
"salt": payment_info.salt,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
payload = {
|
|
494
|
+
"x402Version": 2,
|
|
495
|
+
"scheme": "escrow",
|
|
496
|
+
"payload": {
|
|
497
|
+
"authorization": auth,
|
|
498
|
+
"signature": signature,
|
|
499
|
+
"paymentInfo": pi_dict,
|
|
500
|
+
},
|
|
501
|
+
"paymentRequirements": {
|
|
502
|
+
"scheme": "escrow",
|
|
503
|
+
"network": f"eip155:{self.chain_id}",
|
|
504
|
+
"maxAmountRequired": str(payment_info.max_amount),
|
|
505
|
+
"asset": self.contracts["usdc"],
|
|
506
|
+
"payTo": payment_info.receiver,
|
|
507
|
+
"extra": {
|
|
508
|
+
"escrowAddress": self.contracts["escrow"],
|
|
509
|
+
"operatorAddress": self.contracts["operator"],
|
|
510
|
+
"tokenCollector": self.contracts["token_collector"],
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
response = httpx.post(
|
|
517
|
+
f"{self.facilitator_url}/settle",
|
|
518
|
+
json=payload,
|
|
519
|
+
timeout=120,
|
|
520
|
+
)
|
|
521
|
+
result = response.json()
|
|
522
|
+
|
|
523
|
+
if result.get("success"):
|
|
524
|
+
return AuthorizationResult(
|
|
525
|
+
success=True,
|
|
526
|
+
transaction_hash=result.get("transaction"),
|
|
527
|
+
payment_info=payment_info,
|
|
528
|
+
salt=payment_info.salt,
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
return AuthorizationResult(success=False, error=result.get("errorReason"))
|
|
532
|
+
except Exception as e:
|
|
533
|
+
return AuthorizationResult(success=False, error=str(e))
|
|
534
|
+
|
|
535
|
+
def release(self, payment_info: PaymentInfo, amount: Optional[int] = None) -> TransactionResult:
|
|
536
|
+
"""
|
|
537
|
+
RELEASE: Capture escrowed funds to receiver (worker gets paid).
|
|
538
|
+
|
|
539
|
+
Calls PaymentOperator.release() -> escrow.capture()
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
payment_info: PaymentInfo from the authorize step
|
|
543
|
+
amount: Amount to release (defaults to max_amount)
|
|
544
|
+
"""
|
|
545
|
+
pt = self._build_tuple(payment_info)
|
|
546
|
+
amt = amount or payment_info.max_amount
|
|
547
|
+
return self._send_tx(self.operator_contract.functions.release(pt, amt))
|
|
548
|
+
|
|
549
|
+
def refund_in_escrow(self, payment_info: PaymentInfo, amount: Optional[int] = None) -> TransactionResult:
|
|
550
|
+
"""
|
|
551
|
+
REFUND IN ESCROW: Return escrowed funds to payer (cancel task).
|
|
552
|
+
|
|
553
|
+
Calls PaymentOperator.refundInEscrow() -> escrow.partialVoid()
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
payment_info: PaymentInfo from the authorize step
|
|
557
|
+
amount: Amount to refund (defaults to max_amount)
|
|
558
|
+
"""
|
|
559
|
+
pt = self._build_tuple(payment_info)
|
|
560
|
+
amt = amount or payment_info.max_amount
|
|
561
|
+
return self._send_tx(self.operator_contract.functions.refundInEscrow(pt, amt))
|
|
562
|
+
|
|
563
|
+
def charge(self, payment_info: PaymentInfo, amount: Optional[int] = None) -> TransactionResult:
|
|
564
|
+
"""
|
|
565
|
+
CHARGE: Direct instant payment (no escrow hold).
|
|
566
|
+
|
|
567
|
+
Calls PaymentOperator.charge() -> escrow.charge()
|
|
568
|
+
Funds go directly from payer to receiver.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
payment_info: PaymentInfo with receiver and amount
|
|
572
|
+
amount: Amount to charge (defaults to max_amount)
|
|
573
|
+
"""
|
|
574
|
+
nonce = self._compute_nonce(payment_info)
|
|
575
|
+
amt = amount or payment_info.max_amount
|
|
576
|
+
|
|
577
|
+
auth = {
|
|
578
|
+
"from": self.payer,
|
|
579
|
+
"to": self.contracts["token_collector"],
|
|
580
|
+
"value": str(amt),
|
|
581
|
+
"validAfter": "0",
|
|
582
|
+
"validBefore": str(payment_info.pre_approval_expiry),
|
|
583
|
+
"nonce": nonce,
|
|
584
|
+
}
|
|
585
|
+
signature = self._sign_erc3009(auth)
|
|
586
|
+
collector_data = bytes.fromhex(signature[2:])
|
|
587
|
+
|
|
588
|
+
pt = self._build_tuple(payment_info)
|
|
589
|
+
return self._send_tx(
|
|
590
|
+
self.operator_contract.functions.charge(
|
|
591
|
+
pt, amt,
|
|
592
|
+
Web3.to_checksum_address(self.contracts["token_collector"]),
|
|
593
|
+
collector_data,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def refund_post_escrow(
|
|
598
|
+
self,
|
|
599
|
+
payment_info: PaymentInfo,
|
|
600
|
+
amount: Optional[int] = None,
|
|
601
|
+
token_collector: str = ZERO_ADDRESS,
|
|
602
|
+
collector_data: bytes = b"",
|
|
603
|
+
) -> TransactionResult:
|
|
604
|
+
"""
|
|
605
|
+
REFUND POST ESCROW: Dispute refund after funds were released.
|
|
606
|
+
|
|
607
|
+
Calls PaymentOperator.refundPostEscrow() -> escrow.refund()
|
|
608
|
+
|
|
609
|
+
WARNING: NOT FUNCTIONAL IN PRODUCTION (as of 2026-02-03).
|
|
610
|
+
The protocol team has not implemented the required tokenCollector
|
|
611
|
+
contract. This call will fail on-chain.
|
|
612
|
+
|
|
613
|
+
For dispute resolution, use refund_in_escrow() instead: keep funds
|
|
614
|
+
in escrow and refund before releasing. This guarantees funds are
|
|
615
|
+
available and under arbiter control.
|
|
616
|
+
|
|
617
|
+
Kept for future use when tokenCollector is implemented.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
payment_info: PaymentInfo from the original authorization
|
|
621
|
+
amount: Amount to refund (defaults to max_amount)
|
|
622
|
+
token_collector: Address of token collector for refund sourcing
|
|
623
|
+
collector_data: Data for the token collector
|
|
624
|
+
"""
|
|
625
|
+
pt = self._build_tuple(payment_info)
|
|
626
|
+
amt = amount or payment_info.max_amount
|
|
627
|
+
return self._send_tx(
|
|
628
|
+
self.operator_contract.functions.refundPostEscrow(
|
|
629
|
+
pt, amt,
|
|
630
|
+
Web3.to_checksum_address(token_collector),
|
|
631
|
+
collector_data,
|
|
632
|
+
)
|
|
633
|
+
)
|