pinely-sdk 0.1.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,81 @@
1
+ # PINELY Python SDK
2
+
3
+ Python package for autonomous machine finance on Robinhood Chain (testnet `46630`, mainnet `4663`). It implements
4
+ the same signing and payment flow as the TypeScript SDK (`@pinely-protocol/sdk`), and produces identical machine
5
+ IDs and signatures for identical inputs. It is a **subset** of the TypeScript API, see [Limitations](#limitations).
6
+
7
+ - Machine key store abstraction (`DevKeyStore`, `KeyStore`).
8
+ - EIP-712 signing for revenue swaps (`SwapIntent`) and HTTP 402 payments (`Payment402Intent`).
9
+ - `hookData` encoding for the PinelyHook (Stage 2).
10
+
11
+ ## Install
12
+
13
+ This is a beta release (`0.1.0b1`). While it is the only release, a plain install picks it up:
14
+
15
+ ```bash
16
+ pip install pinely-sdk
17
+ ```
18
+
19
+ Once a stable release exists, pip skips pre-releases unless you pass `--pre`, so use `pip install --pre pinely-sdk`
20
+ to keep following the beta channel.
21
+
22
+ Supported dependency ranges: `web3` 7.0 to 8.x, `eth-account` 0.13.1 or newer, `requests` 2.31 or newer. The
23
+ test suite was run on Python 3.12 with both the lowest and the highest of these combinations.
24
+
25
+ From a clone of the repository (development):
26
+
27
+ ```bash
28
+ pip install -e sdk-python
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ from pinely_sdk import Pinely
35
+
36
+ pinely = Pinely.connect(
37
+ private_key="0x...",
38
+ chain_id=46630,
39
+ registry_address="0x...", # MachineRegistry, from the deployment address book
40
+ stablecoin_address="0x...", # canonical stablecoin, from the official Robinhood Chain documentation
41
+ paymaster_address="0x...", # PinelyPaymaster, required for payments
42
+ )
43
+ machine = pinely.machines.create(name="autonomous-delivery-01", operator="0x...")
44
+
45
+ result = machine.pay(
46
+ "https://facilitator.example/v1/charge/dock-001",
47
+ max_amount=250_000, # 0.25 stablecoin (6 decimals): an upper bound, the challenge amount is what is paid
48
+ facilitator_url="https://facilitator.example",
49
+ )
50
+ ```
51
+
52
+ ## Behaviour you can rely on
53
+
54
+ - **No default addresses and no default network.** A missing address or an unsupported chain raises.
55
+ - **Payments are never simulated.** Without `facilitator_url`, with a 402 challenge for another asset,
56
+ network or a larger amount, or without a Facilitator confirmation carrying a transaction hash and an
57
+ access token, the result is `failed`.
58
+ - **Never signs blindly.** The prepared UserOperation is rebuilt and compared field by field (recipient,
59
+ amount, token, paymaster, gas, fee cap) before its hash is signed.
60
+ - **Swaps need Stage 2.** `prepare_swap` raises until a `hook_address` is configured.
61
+ - **Replay isolation.** The domains are `PINELY` and `PINELYFacilitator`, bound to the chain id. Tests assert
62
+ digests identical to the TypeScript SDK and the contract's type hash, so signatures from any earlier
63
+ deployment never verify.
64
+
65
+ ## Limitations
66
+
67
+ This beta covers signing, hashing and the HTTP 402 payment flow. Compared with the TypeScript SDK it does **not**
68
+ provide:
69
+
70
+ - **A hardware key store.** `Pinely.connect()` takes a private key and always uses `DevKeyStore`, an in-memory key
71
+ for development. Do not use it for a key that controls real funds on production hardware.
72
+ - **On-chain reads.** There is no `rpc_url`, so there is no machine info lookup and no kill-switch (`isFrozen`) check.
73
+ - **Transaction builders** for freeze, unfreeze, policy changes and surplus sweeping.
74
+
75
+ The SDK is not externally audited.
76
+
77
+ ## Tests
78
+
79
+ ```bash
80
+ python -m unittest discover -s sdk-python/tests -t sdk-python
81
+ ```
@@ -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"
@@ -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
+ )