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.
- pinely_sdk-0.1.0b1/LICENSE +21 -0
- pinely_sdk-0.1.0b1/PKG-INFO +105 -0
- pinely_sdk-0.1.0b1/README.md +81 -0
- pinely_sdk-0.1.0b1/pinely_sdk/__init__.py +42 -0
- pinely_sdk-0.1.0b1/pinely_sdk/client.py +408 -0
- pinely_sdk-0.1.0b1/pinely_sdk/eip712.py +108 -0
- pinely_sdk-0.1.0b1/pinely_sdk/keystore.py +55 -0
- pinely_sdk-0.1.0b1/pinely_sdk/py.typed +0 -0
- pinely_sdk-0.1.0b1/pinely_sdk/userop.py +203 -0
- pinely_sdk-0.1.0b1/pinely_sdk.egg-info/PKG-INFO +105 -0
- pinely_sdk-0.1.0b1/pinely_sdk.egg-info/SOURCES.txt +16 -0
- pinely_sdk-0.1.0b1/pinely_sdk.egg-info/dependency_links.txt +1 -0
- pinely_sdk-0.1.0b1/pinely_sdk.egg-info/requires.txt +3 -0
- pinely_sdk-0.1.0b1/pinely_sdk.egg-info/top_level.txt +1 -0
- pinely_sdk-0.1.0b1/pyproject.toml +45 -0
- pinely_sdk-0.1.0b1/setup.cfg +4 -0
- pinely_sdk-0.1.0b1/tests/test_pinely_sdk.py +487 -0
- pinely_sdk-0.1.0b1/tests/test_ros2_node.py +139 -0
|
@@ -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
|
+
)
|