pinely-sdk 0.1.0b1__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.
- pinely_sdk/__init__.py +42 -0
- pinely_sdk/client.py +408 -0
- pinely_sdk/eip712.py +108 -0
- pinely_sdk/keystore.py +55 -0
- pinely_sdk/py.typed +0 -0
- pinely_sdk/userop.py +203 -0
- pinely_sdk-0.1.0b1.dist-info/METADATA +105 -0
- pinely_sdk-0.1.0b1.dist-info/RECORD +11 -0
- pinely_sdk-0.1.0b1.dist-info/WHEEL +5 -0
- pinely_sdk-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- pinely_sdk-0.1.0b1.dist-info/top_level.txt +1 -0
pinely_sdk/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PINELY Python SDK — autonomous machine finance on Robinhood Chain."""
|
|
2
|
+
|
|
3
|
+
from .client import SUPPORTED_CHAIN_IDS, MachineClient, Pinely
|
|
4
|
+
from .eip712 import (
|
|
5
|
+
PAYMENT_402_TYPES,
|
|
6
|
+
SWAP_INTENT_TYPES,
|
|
7
|
+
compute_machine_id,
|
|
8
|
+
compute_pool_id,
|
|
9
|
+
encode_hook_data,
|
|
10
|
+
get_pinely_domain,
|
|
11
|
+
get_pinely_facilitator_domain,
|
|
12
|
+
)
|
|
13
|
+
from .keystore import DevKeyStore, KeyStore
|
|
14
|
+
from .userop import (
|
|
15
|
+
ENTRY_POINT_V07,
|
|
16
|
+
build_settlement_call_data,
|
|
17
|
+
build_settlement_user_op,
|
|
18
|
+
get_user_op_hash,
|
|
19
|
+
verify_prepared_settlement,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Pinely",
|
|
24
|
+
"MachineClient",
|
|
25
|
+
"SUPPORTED_CHAIN_IDS",
|
|
26
|
+
"KeyStore",
|
|
27
|
+
"DevKeyStore",
|
|
28
|
+
"compute_machine_id",
|
|
29
|
+
"compute_pool_id",
|
|
30
|
+
"encode_hook_data",
|
|
31
|
+
"get_pinely_domain",
|
|
32
|
+
"get_pinely_facilitator_domain",
|
|
33
|
+
"SWAP_INTENT_TYPES",
|
|
34
|
+
"PAYMENT_402_TYPES",
|
|
35
|
+
"ENTRY_POINT_V07",
|
|
36
|
+
"build_settlement_call_data",
|
|
37
|
+
"build_settlement_user_op",
|
|
38
|
+
"get_user_op_hash",
|
|
39
|
+
"verify_prepared_settlement",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0b1"
|
pinely_sdk/client.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Pinely and MachineClient classes for the Python SDK (parity with the TypeScript SDK)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
import requests
|
|
6
|
+
from web3 import Web3
|
|
7
|
+
|
|
8
|
+
from .eip712 import (
|
|
9
|
+
PAYMENT_402_TYPES,
|
|
10
|
+
SWAP_INTENT_TYPES,
|
|
11
|
+
compute_machine_id,
|
|
12
|
+
compute_pool_id,
|
|
13
|
+
encode_hook_data,
|
|
14
|
+
get_pinely_domain,
|
|
15
|
+
get_pinely_facilitator_domain,
|
|
16
|
+
)
|
|
17
|
+
from .keystore import DevKeyStore, KeyStore
|
|
18
|
+
from .userop import ENTRY_POINT_V07, verify_prepared_settlement
|
|
19
|
+
|
|
20
|
+
#: Robinhood Chain testnet and mainnet. Anything else is refused: the SDK never guesses a network.
|
|
21
|
+
SUPPORTED_CHAIN_IDS = (46630, 4663)
|
|
22
|
+
|
|
23
|
+
_nonce_counter = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _generate_monotonic_nonce() -> int:
|
|
27
|
+
"""Generates a microsecond-resolution monotonic nonce preventing collisions."""
|
|
28
|
+
global _nonce_counter
|
|
29
|
+
_nonce_counter = (_nonce_counter + 1) % 1000
|
|
30
|
+
return int(time.time() * 1000000) + _nonce_counter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_supported_chain(chain_id: int) -> int:
|
|
34
|
+
if chain_id not in SUPPORTED_CHAIN_IDS:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Unsupported chain id {chain_id} (expected one of {SUPPORTED_CHAIN_IDS})."
|
|
37
|
+
)
|
|
38
|
+
return chain_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MachineClient:
|
|
42
|
+
"""Public interface for a specific PINELY machine device."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
machine_id: str,
|
|
47
|
+
name: str,
|
|
48
|
+
operator: str,
|
|
49
|
+
account: str,
|
|
50
|
+
keystore: KeyStore,
|
|
51
|
+
registry_address: str,
|
|
52
|
+
stablecoin_address: str,
|
|
53
|
+
chain_id: int,
|
|
54
|
+
hook_address: Optional[str] = None,
|
|
55
|
+
router_address: Optional[str] = None,
|
|
56
|
+
paymaster_address: Optional[str] = None,
|
|
57
|
+
entry_point_address: str = ENTRY_POINT_V07,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.machine_id = machine_id
|
|
60
|
+
self.name = name
|
|
61
|
+
self.operator = Web3.to_checksum_address(operator)
|
|
62
|
+
self.account = Web3.to_checksum_address(account)
|
|
63
|
+
self.keystore = keystore
|
|
64
|
+
self.registry_address = Web3.to_checksum_address(registry_address)
|
|
65
|
+
self.stablecoin_address = Web3.to_checksum_address(stablecoin_address)
|
|
66
|
+
# PinelyHook and PinelyRouter only exist from Stage 2.
|
|
67
|
+
self.hook_address = Web3.to_checksum_address(hook_address) if hook_address else None
|
|
68
|
+
self.router_address = Web3.to_checksum_address(router_address) if router_address else None
|
|
69
|
+
self.paymaster_address = (
|
|
70
|
+
Web3.to_checksum_address(paymaster_address) if paymaster_address else None
|
|
71
|
+
)
|
|
72
|
+
self.entry_point_address = Web3.to_checksum_address(entry_point_address)
|
|
73
|
+
self.chain_id = _require_supported_chain(chain_id)
|
|
74
|
+
|
|
75
|
+
def _require_hook(self) -> str:
|
|
76
|
+
if not self.hook_address:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"PinelyHook is not deployed on chain {self.chain_id} (Stage 2). Swaps are unavailable."
|
|
79
|
+
)
|
|
80
|
+
return self.hook_address
|
|
81
|
+
|
|
82
|
+
def quote_swap(self, token_in: str, amount_in: int) -> Dict[str, Any]:
|
|
83
|
+
"""Estimates the swap output with the Bronze fee tier.
|
|
84
|
+
|
|
85
|
+
This is a local estimate: the on-chain tier is not read by the Python SDK.
|
|
86
|
+
"""
|
|
87
|
+
lp_fee = (amount_in * 3000) // 1_000_000 # Bronze 0.30%
|
|
88
|
+
protocol_fee = (amount_in * 500) // 1_000_000 # Protocol 0.05%
|
|
89
|
+
amount_out = amount_in - lp_fee - protocol_fee
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"amountIn": amount_in,
|
|
93
|
+
"amountOut": amount_out,
|
|
94
|
+
"lpFee": lp_fee,
|
|
95
|
+
"protocolFee": protocol_fee,
|
|
96
|
+
"estimatedTier": 0,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def prepare_swap(
|
|
100
|
+
self,
|
|
101
|
+
token_in: str,
|
|
102
|
+
amount_in: int,
|
|
103
|
+
min_amount_out: int,
|
|
104
|
+
token_out: Optional[str] = None,
|
|
105
|
+
deadline_seconds: int = 300,
|
|
106
|
+
nonce: Optional[int] = None,
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
"""Prepares a signed EIP-712 SwapIntent and the encoded hookData."""
|
|
109
|
+
if min_amount_out <= 0:
|
|
110
|
+
raise ValueError("min_amount_out must be greater than 0 for slippage protection.")
|
|
111
|
+
hook_address = self._require_hook()
|
|
112
|
+
|
|
113
|
+
out_token = token_out or self.stablecoin_address
|
|
114
|
+
resolved_nonce = nonce if nonce is not None else _generate_monotonic_nonce()
|
|
115
|
+
deadline = int(time.time()) + deadline_seconds
|
|
116
|
+
|
|
117
|
+
token_a = Web3.to_checksum_address(token_in)
|
|
118
|
+
token_b = Web3.to_checksum_address(out_token)
|
|
119
|
+
|
|
120
|
+
c0, c1 = (token_a, token_b) if token_a.lower() < token_b.lower() else (token_b, token_a)
|
|
121
|
+
pool_id = compute_pool_id(c0, c1, 0x800000, 60, hook_address)
|
|
122
|
+
|
|
123
|
+
intent = {
|
|
124
|
+
"machineId": self.machine_id,
|
|
125
|
+
"poolId": pool_id,
|
|
126
|
+
"tokenIn": token_a,
|
|
127
|
+
"tokenOut": token_b,
|
|
128
|
+
"amountIn": amount_in,
|
|
129
|
+
"minAmountOut": min_amount_out,
|
|
130
|
+
"nonce": resolved_nonce,
|
|
131
|
+
"deadline": deadline,
|
|
132
|
+
"chainId": self.chain_id,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
full_message = {
|
|
136
|
+
"types": SWAP_INTENT_TYPES,
|
|
137
|
+
"domain": get_pinely_domain(hook_address, self.chain_id),
|
|
138
|
+
"primaryType": "SwapIntent",
|
|
139
|
+
"message": intent,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
signature = self.keystore.sign_typed_data(full_message)
|
|
143
|
+
hook_data = encode_hook_data(intent, signature)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"intent": intent,
|
|
147
|
+
"signature": signature,
|
|
148
|
+
"hookData": hook_data,
|
|
149
|
+
"poolKey": {
|
|
150
|
+
"currency0": c0,
|
|
151
|
+
"currency1": c1,
|
|
152
|
+
"fee": 0x800000,
|
|
153
|
+
"tickSpacing": 60,
|
|
154
|
+
"hooks": hook_address,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def pay(
|
|
159
|
+
self,
|
|
160
|
+
url: str,
|
|
161
|
+
max_amount: int,
|
|
162
|
+
facilitator_url: Optional[str] = None,
|
|
163
|
+
max_fee_bps: int = 100,
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""Executes the HTTP 402 (x402) payment protocol against a Facilitator.
|
|
166
|
+
|
|
167
|
+
1. Reads the payment requirements from the resource's HTTP 402 challenge.
|
|
168
|
+
2. Validates them against what this machine is willing to pay: the asset must be the configured
|
|
169
|
+
stablecoin, the network must match, and the amount must not exceed `max_amount`.
|
|
170
|
+
3. Signs an EIP-712 Payment402Intent, asks the Facilitator to prepare the sponsored UserOperation,
|
|
171
|
+
verifies it field by field, signs its hash with the machine session key and submits it. A
|
|
172
|
+
UserOperation that pays another recipient, a larger amount, uses another paymaster or inflated gas
|
|
173
|
+
is never signed.
|
|
174
|
+
|
|
175
|
+
Fail-closed: without a Facilitator URL, with an unexpected challenge, or without a Facilitator
|
|
176
|
+
confirmation carrying both a transaction reference and an access token, the result is `failed`.
|
|
177
|
+
Payments are never simulated.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def fail(error: str, amount: Optional[int] = None) -> Dict[str, Any]:
|
|
181
|
+
return {"status": "failed", "resource": url, "amountStable": amount, "error": error}
|
|
182
|
+
|
|
183
|
+
if not facilitator_url:
|
|
184
|
+
return fail("facilitator_url is required: payments are never simulated.")
|
|
185
|
+
|
|
186
|
+
# 1. HTTP 402 challenge
|
|
187
|
+
try:
|
|
188
|
+
challenge = requests.get(url, timeout=30)
|
|
189
|
+
except Exception as e: # network failure, timeout, ...
|
|
190
|
+
return fail(str(e))
|
|
191
|
+
if challenge.status_code != 402:
|
|
192
|
+
return fail(f"Resource did not return HTTP 402 (got {challenge.status_code}).")
|
|
193
|
+
try:
|
|
194
|
+
body = challenge.json()
|
|
195
|
+
except ValueError:
|
|
196
|
+
body = None
|
|
197
|
+
# A malformed challenge (a JSON list, string or null, or entries that are not objects) must come back
|
|
198
|
+
# as a failed payment, never as an exception that takes the machine process down.
|
|
199
|
+
accepts = body.get("accepts") if isinstance(body, dict) else None
|
|
200
|
+
if not isinstance(accepts, list) or not accepts or not isinstance(accepts[0], dict):
|
|
201
|
+
return fail("The 402 challenge contains no payment requirements.")
|
|
202
|
+
accept = accepts[0]
|
|
203
|
+
|
|
204
|
+
# 2. Validate the requirements before signing anything
|
|
205
|
+
asset = str(accept.get("asset") or "")
|
|
206
|
+
if not asset or asset.lower() != self.stablecoin_address.lower():
|
|
207
|
+
return fail("The 402 challenge asks for an asset other than the configured stablecoin.")
|
|
208
|
+
extra = accept.get("extra")
|
|
209
|
+
raw_chain = extra.get("chainId") if isinstance(extra, dict) else None
|
|
210
|
+
try:
|
|
211
|
+
challenge_chain = int(raw_chain) if raw_chain is not None else None
|
|
212
|
+
except (TypeError, ValueError):
|
|
213
|
+
challenge_chain = None
|
|
214
|
+
if challenge_chain != self.chain_id:
|
|
215
|
+
return fail("The 402 challenge is for a different network.")
|
|
216
|
+
pay_to = accept.get("payTo")
|
|
217
|
+
if not pay_to or not Web3.is_address(pay_to):
|
|
218
|
+
return fail("The 402 challenge has no valid payTo address.")
|
|
219
|
+
try:
|
|
220
|
+
amount = int(accept["maxAmountRequired"])
|
|
221
|
+
except (KeyError, TypeError, ValueError):
|
|
222
|
+
return fail("The 402 challenge has no valid amount.")
|
|
223
|
+
if amount <= 0 or amount > max_amount:
|
|
224
|
+
return fail("The 402 challenge asks for more than max_amount.", amount)
|
|
225
|
+
|
|
226
|
+
# 3. What the machine independently expects for the sponsored UserOperation
|
|
227
|
+
if not self.paymaster_address:
|
|
228
|
+
return fail("PinelyPaymaster address is required to verify the sponsored UserOperation.", amount)
|
|
229
|
+
|
|
230
|
+
# 4. Sign the payment intent
|
|
231
|
+
intent = {
|
|
232
|
+
"machineId": self.machine_id,
|
|
233
|
+
"resource": accept.get("resource") or url,
|
|
234
|
+
"amountStable": amount,
|
|
235
|
+
"nonce": _generate_monotonic_nonce(),
|
|
236
|
+
"deadline": int(time.time()) + 300,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
full_message = {
|
|
240
|
+
"types": PAYMENT_402_TYPES,
|
|
241
|
+
# The registry is deployed in Stage 1, so it is always available as the verifying contract.
|
|
242
|
+
"domain": get_pinely_facilitator_domain(self.registry_address, self.chain_id),
|
|
243
|
+
"primaryType": "Payment402Intent",
|
|
244
|
+
"message": intent,
|
|
245
|
+
}
|
|
246
|
+
signature = self.keystore.sign_typed_data(full_message)
|
|
247
|
+
|
|
248
|
+
base = facilitator_url.rstrip("/")
|
|
249
|
+
request = {
|
|
250
|
+
"intent": {
|
|
251
|
+
"machineId": intent["machineId"],
|
|
252
|
+
"resource": intent["resource"],
|
|
253
|
+
"amountStable": str(amount),
|
|
254
|
+
"nonce": str(intent["nonce"]),
|
|
255
|
+
"deadline": str(intent["deadline"]),
|
|
256
|
+
},
|
|
257
|
+
"signature": signature,
|
|
258
|
+
"machineAccount": self.account,
|
|
259
|
+
"merchantAddress": Web3.to_checksum_address(pay_to),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
# 5. The facilitator builds the unsigned UserOperation
|
|
264
|
+
prepare_resp = requests.post(f"{base}/v1/settle/prepare", json=request, timeout=30)
|
|
265
|
+
if prepare_resp.status_code != 200:
|
|
266
|
+
return fail(
|
|
267
|
+
f"Facilitator returned HTTP {prepare_resp.status_code} while preparing", amount
|
|
268
|
+
)
|
|
269
|
+
prepared = prepare_resp.json()
|
|
270
|
+
|
|
271
|
+
# 6. Never sign blindly: rebuild what the UserOperation must contain and compare
|
|
272
|
+
try:
|
|
273
|
+
verify_prepared_settlement(
|
|
274
|
+
prepared,
|
|
275
|
+
sender=self.account,
|
|
276
|
+
stablecoin=self.stablecoin_address,
|
|
277
|
+
merchant=pay_to,
|
|
278
|
+
gross_amount=amount,
|
|
279
|
+
paymaster=self.paymaster_address,
|
|
280
|
+
entry_point=self.entry_point_address,
|
|
281
|
+
chain_id=self.chain_id,
|
|
282
|
+
max_fee_bps=max_fee_bps,
|
|
283
|
+
)
|
|
284
|
+
except ValueError as e:
|
|
285
|
+
return fail(f"Refused to sign the UserOperation: {e}", amount)
|
|
286
|
+
|
|
287
|
+
# 7. PinelyAccount.validateUserOp recovers the signer from personal_sign over the raw hash
|
|
288
|
+
user_op_signature = self.keystore.sign_message(bytes.fromhex(prepared["userOpHash"][2:]))
|
|
289
|
+
|
|
290
|
+
# 8. Submit; the facilitator confirms on-chain before it answers
|
|
291
|
+
resp = requests.post(
|
|
292
|
+
f"{base}/v1/settle",
|
|
293
|
+
json={**request, "userOp": prepared["userOp"], "userOpSignature": user_op_signature},
|
|
294
|
+
timeout=60,
|
|
295
|
+
)
|
|
296
|
+
except Exception as e: # network failure, timeout, malformed JSON ...
|
|
297
|
+
return fail(str(e), amount)
|
|
298
|
+
|
|
299
|
+
if resp.status_code != 200:
|
|
300
|
+
return fail(f"Facilitator returned HTTP {resp.status_code}", amount)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
data = resp.json()
|
|
304
|
+
except ValueError:
|
|
305
|
+
data = {}
|
|
306
|
+
if not isinstance(data, dict):
|
|
307
|
+
data = {}
|
|
308
|
+
if data.get("status") != "settled" or not data.get("txHash") or not data.get("accessToken"):
|
|
309
|
+
return fail("Facilitator response did not confirm a settled payment.", amount)
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"status": "settled",
|
|
313
|
+
"resource": url,
|
|
314
|
+
"amountStable": amount,
|
|
315
|
+
"txHash": data["txHash"],
|
|
316
|
+
"accessToken": data["accessToken"],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class MachineManager:
|
|
321
|
+
"""Factory helper for managing Pinely machines."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, client: "Pinely") -> None:
|
|
324
|
+
self._client = client
|
|
325
|
+
|
|
326
|
+
def create(
|
|
327
|
+
self,
|
|
328
|
+
name: str,
|
|
329
|
+
operator: str,
|
|
330
|
+
account: Optional[str] = None,
|
|
331
|
+
) -> MachineClient:
|
|
332
|
+
"""Creates a MachineClient.
|
|
333
|
+
|
|
334
|
+
When `account` is omitted the signer address is used as the account, which is a development
|
|
335
|
+
shortcut. Production machines pass the smart-account address predicted by the factory.
|
|
336
|
+
"""
|
|
337
|
+
account_addr = account or self._client.keystore.address
|
|
338
|
+
machine_id = compute_machine_id(account_addr)
|
|
339
|
+
|
|
340
|
+
return MachineClient(
|
|
341
|
+
machine_id=machine_id,
|
|
342
|
+
name=name,
|
|
343
|
+
operator=operator,
|
|
344
|
+
account=account_addr,
|
|
345
|
+
keystore=self._client.keystore,
|
|
346
|
+
registry_address=self._client.registry_address,
|
|
347
|
+
stablecoin_address=self._client.stablecoin_address,
|
|
348
|
+
chain_id=self._client.chain_id,
|
|
349
|
+
hook_address=self._client.hook_address,
|
|
350
|
+
router_address=self._client.router_address,
|
|
351
|
+
paymaster_address=self._client.paymaster_address,
|
|
352
|
+
entry_point_address=self._client.entry_point_address,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class Pinely:
|
|
357
|
+
"""Main client class for PINELY on Robinhood Chain.
|
|
358
|
+
|
|
359
|
+
There are no default addresses: the registry and stablecoin addresses must be passed explicitly, taken
|
|
360
|
+
from the deployment address book (`shared/contracts`) and the official Robinhood Chain documentation.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
def __init__(
|
|
364
|
+
self,
|
|
365
|
+
keystore: KeyStore,
|
|
366
|
+
chain_id: int,
|
|
367
|
+
registry_address: str,
|
|
368
|
+
stablecoin_address: str,
|
|
369
|
+
hook_address: Optional[str] = None,
|
|
370
|
+
router_address: Optional[str] = None,
|
|
371
|
+
paymaster_address: Optional[str] = None,
|
|
372
|
+
entry_point_address: str = ENTRY_POINT_V07,
|
|
373
|
+
) -> None:
|
|
374
|
+
self.keystore = keystore
|
|
375
|
+
self.chain_id = _require_supported_chain(chain_id)
|
|
376
|
+
self.registry_address = Web3.to_checksum_address(registry_address)
|
|
377
|
+
self.stablecoin_address = Web3.to_checksum_address(stablecoin_address)
|
|
378
|
+
self.hook_address = Web3.to_checksum_address(hook_address) if hook_address else None
|
|
379
|
+
self.router_address = Web3.to_checksum_address(router_address) if router_address else None
|
|
380
|
+
self.paymaster_address = (
|
|
381
|
+
Web3.to_checksum_address(paymaster_address) if paymaster_address else None
|
|
382
|
+
)
|
|
383
|
+
self.entry_point_address = Web3.to_checksum_address(entry_point_address)
|
|
384
|
+
self.machines = MachineManager(self)
|
|
385
|
+
|
|
386
|
+
@classmethod
|
|
387
|
+
def connect(
|
|
388
|
+
cls,
|
|
389
|
+
private_key: str,
|
|
390
|
+
chain_id: int,
|
|
391
|
+
registry_address: str,
|
|
392
|
+
stablecoin_address: str,
|
|
393
|
+
hook_address: Optional[str] = None,
|
|
394
|
+
router_address: Optional[str] = None,
|
|
395
|
+
paymaster_address: Optional[str] = None,
|
|
396
|
+
entry_point_address: str = ENTRY_POINT_V07,
|
|
397
|
+
) -> "Pinely":
|
|
398
|
+
keystore = DevKeyStore(private_key)
|
|
399
|
+
return cls(
|
|
400
|
+
keystore=keystore,
|
|
401
|
+
chain_id=chain_id,
|
|
402
|
+
registry_address=registry_address,
|
|
403
|
+
stablecoin_address=stablecoin_address,
|
|
404
|
+
hook_address=hook_address,
|
|
405
|
+
router_address=router_address,
|
|
406
|
+
paymaster_address=paymaster_address,
|
|
407
|
+
entry_point_address=entry_point_address,
|
|
408
|
+
)
|
pinely_sdk/eip712.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""EIP-712 typing, hashing, and encoding utilities for PINELY on Robinhood Chain.
|
|
2
|
+
|
|
3
|
+
The type definitions here must stay identical to the TypeScript SDK (`sdk/src/utils/eip712.ts`) and to
|
|
4
|
+
the on-chain typehash in `PinelyHook.sol`. A golden-digest test in `tests/` enforces that parity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
from eth_abi import encode
|
|
9
|
+
from web3 import Web3
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_EIP712_DOMAIN = [
|
|
13
|
+
{"name": "name", "type": "string"},
|
|
14
|
+
{"name": "version", "type": "string"},
|
|
15
|
+
{"name": "chainId", "type": "uint256"},
|
|
16
|
+
{"name": "verifyingContract", "type": "address"},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
SWAP_INTENT_TYPES = {
|
|
20
|
+
"EIP712Domain": _EIP712_DOMAIN,
|
|
21
|
+
"SwapIntent": [
|
|
22
|
+
{"name": "machineId", "type": "bytes32"},
|
|
23
|
+
{"name": "poolId", "type": "bytes32"},
|
|
24
|
+
{"name": "tokenIn", "type": "address"},
|
|
25
|
+
{"name": "tokenOut", "type": "address"},
|
|
26
|
+
{"name": "amountIn", "type": "uint256"},
|
|
27
|
+
{"name": "minAmountOut", "type": "uint256"},
|
|
28
|
+
{"name": "nonce", "type": "uint256"},
|
|
29
|
+
# uint64 like the contract's SWAP_INTENT_TYPEHASH. A uint256 here would produce a different
|
|
30
|
+
# type hash and the on-chain signature check would always fail.
|
|
31
|
+
{"name": "deadline", "type": "uint64"},
|
|
32
|
+
{"name": "chainId", "type": "uint256"},
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
PAYMENT_402_TYPES = {
|
|
37
|
+
"EIP712Domain": _EIP712_DOMAIN,
|
|
38
|
+
"Payment402Intent": [
|
|
39
|
+
{"name": "machineId", "type": "bytes32"},
|
|
40
|
+
{"name": "resource", "type": "string"},
|
|
41
|
+
{"name": "amountStable", "type": "uint256"},
|
|
42
|
+
{"name": "nonce", "type": "uint256"},
|
|
43
|
+
{"name": "deadline", "type": "uint64"},
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_pinely_domain(verifying_contract: str, chain_id: int) -> Dict[str, Any]:
|
|
49
|
+
"""Returns the canonical PINELY Hook EIP-712 domain."""
|
|
50
|
+
return {
|
|
51
|
+
"name": "PINELY",
|
|
52
|
+
"version": "1",
|
|
53
|
+
"chainId": chain_id,
|
|
54
|
+
"verifyingContract": Web3.to_checksum_address(verifying_contract),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_pinely_facilitator_domain(verifying_contract: str, chain_id: int) -> Dict[str, Any]:
|
|
59
|
+
"""Returns the canonical PINELY Facilitator (HTTP 402) EIP-712 domain."""
|
|
60
|
+
return {
|
|
61
|
+
"name": "PINELYFacilitator",
|
|
62
|
+
"version": "1",
|
|
63
|
+
"chainId": chain_id,
|
|
64
|
+
"verifyingContract": Web3.to_checksum_address(verifying_contract),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compute_machine_id(account: str) -> str:
|
|
69
|
+
"""Computes the canonical machineId: keccak256(abi.encode(account))."""
|
|
70
|
+
encoded = encode(["address"], [Web3.to_checksum_address(account)])
|
|
71
|
+
return Web3.to_hex(Web3.keccak(encoded))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_pool_id(currency0: str, currency1: str, fee: int, tick_spacing: int, hooks: str) -> str:
|
|
75
|
+
"""Computes the canonical Uniswap v4 PoolId: keccak256(abi.encode(PoolKey))."""
|
|
76
|
+
encoded = encode(
|
|
77
|
+
["address", "address", "uint24", "int24", "address"],
|
|
78
|
+
[
|
|
79
|
+
Web3.to_checksum_address(currency0),
|
|
80
|
+
Web3.to_checksum_address(currency1),
|
|
81
|
+
fee,
|
|
82
|
+
tick_spacing,
|
|
83
|
+
Web3.to_checksum_address(hooks),
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
return Web3.to_hex(Web3.keccak(encoded))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def encode_hook_data(intent: Dict[str, Any], signature: str) -> str:
|
|
90
|
+
"""Encodes hookData: abi.encode(SwapIntent intent, bytes signature)."""
|
|
91
|
+
intent_tuple = (
|
|
92
|
+
bytes.fromhex(intent["machineId"][2:] if intent["machineId"].startswith("0x") else intent["machineId"]),
|
|
93
|
+
bytes.fromhex(intent["poolId"][2:] if intent["poolId"].startswith("0x") else intent["poolId"]),
|
|
94
|
+
Web3.to_checksum_address(intent["tokenIn"]),
|
|
95
|
+
Web3.to_checksum_address(intent["tokenOut"]),
|
|
96
|
+
int(intent["amountIn"]),
|
|
97
|
+
int(intent["minAmountOut"]),
|
|
98
|
+
int(intent["nonce"]),
|
|
99
|
+
int(intent["deadline"]),
|
|
100
|
+
int(intent["chainId"]),
|
|
101
|
+
)
|
|
102
|
+
sig_bytes = bytes.fromhex(signature[2:] if signature.startswith("0x") else signature)
|
|
103
|
+
|
|
104
|
+
# deadline is uint64 in the Solidity struct; the ABI encoding pads it to 32 bytes, so the encoded
|
|
105
|
+
# bytes are identical to a uint256 here. The tuple type still mirrors the struct exactly.
|
|
106
|
+
tuple_type = "(bytes32,bytes32,address,address,uint256,uint256,uint256,uint64,uint256)"
|
|
107
|
+
encoded = encode([tuple_type, "bytes"], [intent_tuple, sig_bytes])
|
|
108
|
+
return Web3.to_hex(encoded)
|
pinely_sdk/keystore.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Keystore abstraction for PINELY hardware & autonomous machines."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
from eth_account import Account
|
|
6
|
+
from eth_account.messages import encode_defunct, encode_typed_data
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KeyStore(ABC):
|
|
10
|
+
"""Abstract keystore interface for machine private keys."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def address(self) -> str:
|
|
15
|
+
"""Returns the checksum Ethereum address of the signer."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def sign_message(self, message: bytes) -> str:
|
|
20
|
+
"""Signs an arbitrary message using EIP-191 personal sign."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def sign_typed_data(self, full_message: Dict[str, Any]) -> str:
|
|
25
|
+
"""Signs an EIP-712 structured typed data payload."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DevKeyStore(KeyStore):
|
|
30
|
+
"""
|
|
31
|
+
In-memory developer keystore.
|
|
32
|
+
WARNING: For local development and testing only. Do not use on production hardware!
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, private_key: str) -> None:
|
|
36
|
+
if private_key.startswith("0x"):
|
|
37
|
+
self._account = Account.from_key(private_key)
|
|
38
|
+
else:
|
|
39
|
+
self._account = Account.from_key(f"0x{private_key}")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def address(self) -> str:
|
|
43
|
+
return self._account.address
|
|
44
|
+
|
|
45
|
+
def sign_message(self, message: bytes) -> str:
|
|
46
|
+
encoded = encode_defunct(message)
|
|
47
|
+
signed = self._account.sign_message(encoded)
|
|
48
|
+
sig = signed.signature.hex()
|
|
49
|
+
return f"0x{sig}" if not sig.startswith("0x") else sig
|
|
50
|
+
|
|
51
|
+
def sign_typed_data(self, full_message: Dict[str, Any]) -> str:
|
|
52
|
+
encoded = encode_typed_data(full_message=full_message)
|
|
53
|
+
signed = self._account.sign_message(encoded)
|
|
54
|
+
sig = signed.signature.hex()
|
|
55
|
+
return f"0x{sig}" if not sig.startswith("0x") else sig
|
pinely_sdk/py.typed
ADDED
|
File without changes
|
pinely_sdk/userop.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""ERC-4337 v0.7 UserOperation helpers (parity with `sdk/src/utils/userop.ts`).
|
|
2
|
+
|
|
3
|
+
The machine must never sign a UserOperation it has not checked. `verify_prepared_settlement` rebuilds the
|
|
4
|
+
expected call data from the payment requirements the machine itself validated and recomputes the
|
|
5
|
+
`userOpHash`, so a facilitator cannot slip in another recipient, a larger amount, a different paymaster or
|
|
6
|
+
inflated gas. Golden hashes in `tests/` pin this module to the TypeScript SDK byte for byte.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from eth_abi import encode
|
|
12
|
+
from web3 import Web3
|
|
13
|
+
|
|
14
|
+
#: Canonical ERC-4337 EntryPoint v0.7 (deterministic address). Present on Robinhood Chain testnet and mainnet.
|
|
15
|
+
ENTRY_POINT_V07 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
|
|
16
|
+
|
|
17
|
+
# Static gas parameters (placeholders): production must use the bundler's estimate. Both SDKs pin the same
|
|
18
|
+
# values, so any deviation proposed by a facilitator is refused.
|
|
19
|
+
# verificationGasLimit (150,000) and callGasLimit (100,000), packed
|
|
20
|
+
SETTLEMENT_ACCOUNT_GAS_LIMITS = "0x000000000000000000000000000249f0000000000000000000000000000186a0"
|
|
21
|
+
# maxPriorityFeePerGas (0) and maxFeePerGas (0.1 gwei), packed. Robinhood Chain reports a priority fee of 0 and a
|
|
22
|
+
# base fee of 0.01 to 0.02 gwei, so the ceiling is 5 to 10 times the base fee. The previous 2 gwei made the
|
|
23
|
+
# prefund (450,000 gas) about $2.70 at an ETH price of $3,000, above the Paymaster's default cost cap, so every
|
|
24
|
+
# UserOperation would have been refused. Replace these with the bundler's estimate once a bundler is chosen.
|
|
25
|
+
SETTLEMENT_GAS_FEES = "0x0000000000000000000000000000000000000000000000000000000005f5e100"
|
|
26
|
+
SETTLEMENT_PRE_VERIFICATION_GAS = 50_000
|
|
27
|
+
# paymaster verificationGasLimit (100,000) and postOpGasLimit (50,000), 16 bytes each
|
|
28
|
+
_PAYMASTER_GAS_SUFFIX = "000000000000000000000000000186a0" + "0000000000000000000000000000c350"
|
|
29
|
+
|
|
30
|
+
#: Facilitator fee cap a machine accepts by default: 1.00% of the payment.
|
|
31
|
+
DEFAULT_MAX_FEE_BPS = 100
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _selector(signature: str) -> bytes:
|
|
35
|
+
return Web3.keccak(text=signature)[:4]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _transfer(to: str, amount: int) -> bytes:
|
|
39
|
+
return _selector("transfer(address,uint256)") + encode(
|
|
40
|
+
["address", "uint256"], [Web3.to_checksum_address(to), amount]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hex(data: bytes) -> str:
|
|
45
|
+
return "0x" + data.hex()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bytes(value: str) -> bytes:
|
|
49
|
+
return bytes.fromhex(value[2:] if value.startswith("0x") else value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _paymaster_and_data(paymaster: str) -> str:
|
|
53
|
+
return "0x" + _bytes(Web3.to_checksum_address(paymaster)).hex() + _PAYMASTER_GAS_SUFFIX
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_settlement_call_data(
|
|
57
|
+
stablecoin: str,
|
|
58
|
+
merchant: str,
|
|
59
|
+
amount: int,
|
|
60
|
+
fee_recipient: Optional[str] = None,
|
|
61
|
+
fee_amount: int = 0,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""`PinelyAccount.execute` for one stablecoin transfer, or `executeBatch` when a fee is paid in the same
|
|
64
|
+
operation. `amount` is what the merchant receives."""
|
|
65
|
+
token = Web3.to_checksum_address(stablecoin)
|
|
66
|
+
if fee_recipient and fee_amount > 0:
|
|
67
|
+
body = encode(
|
|
68
|
+
["address[]", "uint256[]", "bytes[]"],
|
|
69
|
+
[
|
|
70
|
+
[token, token],
|
|
71
|
+
[0, 0],
|
|
72
|
+
[_transfer(merchant, amount), _transfer(fee_recipient, fee_amount)],
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
return _hex(_selector("executeBatch(address[],uint256[],bytes[])") + body)
|
|
76
|
+
|
|
77
|
+
body = encode(["address", "uint256", "bytes"], [token, 0, _transfer(merchant, amount)])
|
|
78
|
+
return _hex(_selector("execute(address,uint256,bytes)") + body)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_settlement_user_op(
|
|
82
|
+
sender: str,
|
|
83
|
+
nonce: int,
|
|
84
|
+
stablecoin: str,
|
|
85
|
+
merchant: str,
|
|
86
|
+
amount: int,
|
|
87
|
+
paymaster: str,
|
|
88
|
+
fee_recipient: Optional[str] = None,
|
|
89
|
+
fee_amount: int = 0,
|
|
90
|
+
) -> Dict[str, Any]:
|
|
91
|
+
"""Builds the (unsigned) UserOperation that settles one x402 payment, in serialized form."""
|
|
92
|
+
return {
|
|
93
|
+
"sender": Web3.to_checksum_address(sender),
|
|
94
|
+
"nonce": hex(nonce),
|
|
95
|
+
"initCode": "0x",
|
|
96
|
+
"callData": build_settlement_call_data(stablecoin, merchant, amount, fee_recipient, fee_amount),
|
|
97
|
+
"accountGasLimits": SETTLEMENT_ACCOUNT_GAS_LIMITS,
|
|
98
|
+
"preVerificationGas": hex(SETTLEMENT_PRE_VERIFICATION_GAS),
|
|
99
|
+
"gasFees": SETTLEMENT_GAS_FEES,
|
|
100
|
+
"paymasterAndData": _paymaster_and_data(paymaster),
|
|
101
|
+
"signature": "0x",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_user_op_hash(op: Dict[str, Any], entry_point: str, chain_id: int) -> str:
|
|
106
|
+
"""EntryPoint v0.7 `getUserOpHash`. The signature is not part of the hash."""
|
|
107
|
+
packed = Web3.keccak(
|
|
108
|
+
encode(
|
|
109
|
+
["address", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "bytes32", "bytes32"],
|
|
110
|
+
[
|
|
111
|
+
Web3.to_checksum_address(op["sender"]),
|
|
112
|
+
int(op["nonce"], 16),
|
|
113
|
+
Web3.keccak(_bytes(op["initCode"])),
|
|
114
|
+
Web3.keccak(_bytes(op["callData"])),
|
|
115
|
+
_bytes(op["accountGasLimits"]),
|
|
116
|
+
int(op["preVerificationGas"], 16),
|
|
117
|
+
_bytes(op["gasFees"]),
|
|
118
|
+
Web3.keccak(_bytes(op["paymasterAndData"])),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return Web3.to_hex(
|
|
123
|
+
Web3.keccak(
|
|
124
|
+
encode(
|
|
125
|
+
["bytes32", "address", "uint256"],
|
|
126
|
+
[packed, Web3.to_checksum_address(entry_point), chain_id],
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _same(a: Any, b: Any) -> bool:
|
|
133
|
+
return str(a).lower() == str(b).lower()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def verify_prepared_settlement(
|
|
137
|
+
prepared: Dict[str, Any],
|
|
138
|
+
*,
|
|
139
|
+
sender: str,
|
|
140
|
+
stablecoin: str,
|
|
141
|
+
merchant: str,
|
|
142
|
+
gross_amount: int,
|
|
143
|
+
paymaster: str,
|
|
144
|
+
entry_point: str,
|
|
145
|
+
chain_id: int,
|
|
146
|
+
max_fee_bps: int = DEFAULT_MAX_FEE_BPS,
|
|
147
|
+
) -> Dict[str, Any]:
|
|
148
|
+
"""Checks a prepared UserOperation field by field and returns it only if the machine can safely sign
|
|
149
|
+
it. Raises `ValueError` with a descriptive message otherwise. Nothing here trusts the facilitator."""
|
|
150
|
+
try:
|
|
151
|
+
op = prepared["userOp"]
|
|
152
|
+
for key in (
|
|
153
|
+
"sender", "nonce", "initCode", "callData", "accountGasLimits",
|
|
154
|
+
"preVerificationGas", "gasFees", "paymasterAndData", "signature",
|
|
155
|
+
):
|
|
156
|
+
if not isinstance(op.get(key), str):
|
|
157
|
+
raise ValueError(f"UserOperation.{key} is missing")
|
|
158
|
+
int(op["nonce"], 16)
|
|
159
|
+
int(op["preVerificationGas"], 16)
|
|
160
|
+
declared_hash = str(prepared["userOpHash"])
|
|
161
|
+
declared_entry_point = str(prepared["entryPoint"])
|
|
162
|
+
declared_chain = int(prepared["chainId"])
|
|
163
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
164
|
+
raise ValueError(f"Malformed prepared settlement ({e})") from e
|
|
165
|
+
|
|
166
|
+
if not _same(declared_entry_point, entry_point):
|
|
167
|
+
raise ValueError("EntryPoint differs from the expected canonical EntryPoint")
|
|
168
|
+
if declared_chain != chain_id:
|
|
169
|
+
raise ValueError("UserOperation is for another network")
|
|
170
|
+
if not _same(op["sender"], sender):
|
|
171
|
+
raise ValueError("UserOperation is not sent from the machine account")
|
|
172
|
+
if op["initCode"] != "0x":
|
|
173
|
+
raise ValueError("UserOperation deploys code (initCode must be empty)")
|
|
174
|
+
if op["signature"] != "0x":
|
|
175
|
+
raise ValueError("UserOperation must be unsigned")
|
|
176
|
+
if not _same(op["paymasterAndData"], _paymaster_and_data(paymaster)):
|
|
177
|
+
raise ValueError("UserOperation uses an unexpected paymaster or paymaster gas")
|
|
178
|
+
if (
|
|
179
|
+
not _same(op["accountGasLimits"], SETTLEMENT_ACCOUNT_GAS_LIMITS)
|
|
180
|
+
or not _same(op["gasFees"], SETTLEMENT_GAS_FEES)
|
|
181
|
+
or int(op["preVerificationGas"], 16) != SETTLEMENT_PRE_VERIFICATION_GAS
|
|
182
|
+
):
|
|
183
|
+
raise ValueError("UserOperation gas parameters differ from the expected values")
|
|
184
|
+
|
|
185
|
+
# Facilitator fee: bounded, and the machine still pays exactly the agreed gross amount.
|
|
186
|
+
fee = int(prepared.get("facilitatorFee") or 0)
|
|
187
|
+
if fee < 0 or fee >= gross_amount:
|
|
188
|
+
raise ValueError("Facilitator fee is out of range")
|
|
189
|
+
if fee * 10_000 > gross_amount * max_fee_bps:
|
|
190
|
+
raise ValueError("Facilitator fee exceeds the accepted maximum")
|
|
191
|
+
fee_recipient = prepared.get("feeRecipient")
|
|
192
|
+
if fee > 0 and not (fee_recipient and Web3.is_address(fee_recipient)):
|
|
193
|
+
raise ValueError("Facilitator fee has no valid recipient")
|
|
194
|
+
|
|
195
|
+
expected_call_data = build_settlement_call_data(
|
|
196
|
+
stablecoin, merchant, gross_amount - fee, fee_recipient if fee > 0 else None, fee
|
|
197
|
+
)
|
|
198
|
+
if not _same(op["callData"], expected_call_data):
|
|
199
|
+
raise ValueError("UserOperation call data does not match the agreed payment")
|
|
200
|
+
|
|
201
|
+
if not _same(get_user_op_hash(op, entry_point, chain_id), declared_hash):
|
|
202
|
+
raise ValueError("userOpHash does not match the UserOperation")
|
|
203
|
+
return op
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pinely-sdk
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: PINELY Python SDK: EIP-712 signing and HTTP 402 payments for autonomous machines on Robinhood Chain
|
|
5
|
+
Author: PINELY Engineering
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pinely-dev/pinely/tree/main/sdk-python#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/pinely-dev/pinely/issues
|
|
9
|
+
Project-URL: Source, https://github.com/pinely-dev/pinely
|
|
10
|
+
Keywords: pinely,depin,robinhood-chain,erc-4337,x402,eip-712,web3
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: web3<9,>=7.0.0
|
|
21
|
+
Requires-Dist: eth-account<1,>=0.13.1
|
|
22
|
+
Requires-Dist: requests<3,>=2.31.0
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# PINELY Python SDK
|
|
26
|
+
|
|
27
|
+
Python package for autonomous machine finance on Robinhood Chain (testnet `46630`, mainnet `4663`). It implements
|
|
28
|
+
the same signing and payment flow as the TypeScript SDK (`@pinely-protocol/sdk`), and produces identical machine
|
|
29
|
+
IDs and signatures for identical inputs. It is a **subset** of the TypeScript API, see [Limitations](#limitations).
|
|
30
|
+
|
|
31
|
+
- Machine key store abstraction (`DevKeyStore`, `KeyStore`).
|
|
32
|
+
- EIP-712 signing for revenue swaps (`SwapIntent`) and HTTP 402 payments (`Payment402Intent`).
|
|
33
|
+
- `hookData` encoding for the PinelyHook (Stage 2).
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
This is a beta release (`0.1.0b1`). While it is the only release, a plain install picks it up:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install pinely-sdk
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Once a stable release exists, pip skips pre-releases unless you pass `--pre`, so use `pip install --pre pinely-sdk`
|
|
44
|
+
to keep following the beta channel.
|
|
45
|
+
|
|
46
|
+
Supported dependency ranges: `web3` 7.0 to 8.x, `eth-account` 0.13.1 or newer, `requests` 2.31 or newer. The
|
|
47
|
+
test suite was run on Python 3.12 with both the lowest and the highest of these combinations.
|
|
48
|
+
|
|
49
|
+
From a clone of the repository (development):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e sdk-python
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pinely_sdk import Pinely
|
|
59
|
+
|
|
60
|
+
pinely = Pinely.connect(
|
|
61
|
+
private_key="0x...",
|
|
62
|
+
chain_id=46630,
|
|
63
|
+
registry_address="0x...", # MachineRegistry, from the deployment address book
|
|
64
|
+
stablecoin_address="0x...", # canonical stablecoin, from the official Robinhood Chain documentation
|
|
65
|
+
paymaster_address="0x...", # PinelyPaymaster, required for payments
|
|
66
|
+
)
|
|
67
|
+
machine = pinely.machines.create(name="autonomous-delivery-01", operator="0x...")
|
|
68
|
+
|
|
69
|
+
result = machine.pay(
|
|
70
|
+
"https://facilitator.example/v1/charge/dock-001",
|
|
71
|
+
max_amount=250_000, # 0.25 stablecoin (6 decimals): an upper bound, the challenge amount is what is paid
|
|
72
|
+
facilitator_url="https://facilitator.example",
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Behaviour you can rely on
|
|
77
|
+
|
|
78
|
+
- **No default addresses and no default network.** A missing address or an unsupported chain raises.
|
|
79
|
+
- **Payments are never simulated.** Without `facilitator_url`, with a 402 challenge for another asset,
|
|
80
|
+
network or a larger amount, or without a Facilitator confirmation carrying a transaction hash and an
|
|
81
|
+
access token, the result is `failed`.
|
|
82
|
+
- **Never signs blindly.** The prepared UserOperation is rebuilt and compared field by field (recipient,
|
|
83
|
+
amount, token, paymaster, gas, fee cap) before its hash is signed.
|
|
84
|
+
- **Swaps need Stage 2.** `prepare_swap` raises until a `hook_address` is configured.
|
|
85
|
+
- **Replay isolation.** The domains are `PINELY` and `PINELYFacilitator`, bound to the chain id. Tests assert
|
|
86
|
+
digests identical to the TypeScript SDK and the contract's type hash, so signatures from any earlier
|
|
87
|
+
deployment never verify.
|
|
88
|
+
|
|
89
|
+
## Limitations
|
|
90
|
+
|
|
91
|
+
This beta covers signing, hashing and the HTTP 402 payment flow. Compared with the TypeScript SDK it does **not**
|
|
92
|
+
provide:
|
|
93
|
+
|
|
94
|
+
- **A hardware key store.** `Pinely.connect()` takes a private key and always uses `DevKeyStore`, an in-memory key
|
|
95
|
+
for development. Do not use it for a key that controls real funds on production hardware.
|
|
96
|
+
- **On-chain reads.** There is no `rpc_url`, so there is no machine info lookup and no kill-switch (`isFrozen`) check.
|
|
97
|
+
- **Transaction builders** for freeze, unfreeze, policy changes and surplus sweeping.
|
|
98
|
+
|
|
99
|
+
The SDK is not externally audited.
|
|
100
|
+
|
|
101
|
+
## Tests
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python -m unittest discover -s sdk-python/tests -t sdk-python
|
|
105
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pinely_sdk/__init__.py,sha256=6Chn2jOjGsRH4S8ICT5Zhh78yUKOXA2nmmw7frY-mhA,1050
|
|
2
|
+
pinely_sdk/client.py,sha256=M_DtUnGOMPK4N1UgE0mhfyfixrKnldyo2aPxQujGo1E,16620
|
|
3
|
+
pinely_sdk/eip712.py,sha256=qj_Lyj2_MVQurExFVJnMGnQy9ppArXxmtdz2tXBQDY0,4302
|
|
4
|
+
pinely_sdk/keystore.py,sha256=L1hUBlOfFtAli3EEWSAC004U16hMeI4BHLYcdAJMtuA,1853
|
|
5
|
+
pinely_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pinely_sdk/userop.py,sha256=fe9G7S3zZ57efvHcHWad7kDWa_Nd09XWYCg5LgpUoR0,8763
|
|
7
|
+
pinely_sdk-0.1.0b1.dist-info/licenses/LICENSE,sha256=TL6tlUPS0w4H8fyy2wMaPc0sZYppA8e-9GamjXqh_kU,1097
|
|
8
|
+
pinely_sdk-0.1.0b1.dist-info/METADATA,sha256=zd52noYEWdwfcYtzj11SU1KrV532eji2_p0uzz8BZn0,4520
|
|
9
|
+
pinely_sdk-0.1.0b1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
pinely_sdk-0.1.0b1.dist-info/top_level.txt,sha256=Q8mkrAdPPD0qPQ7WKJ9ELV1ayjRtxY2LQCEcjMlspqs,11
|
|
11
|
+
pinely_sdk-0.1.0b1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PINELY contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pinely_sdk
|