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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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