locivault-client 0.1.0__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.
- locivault_client-0.1.0/PKG-INFO +164 -0
- locivault_client-0.1.0/README.md +136 -0
- locivault_client-0.1.0/locivault_client/__init__.py +30 -0
- locivault_client-0.1.0/locivault_client/client.py +315 -0
- locivault_client-0.1.0/locivault_client/crypto.py +159 -0
- locivault_client-0.1.0/locivault_client/payment.py +77 -0
- locivault_client-0.1.0/locivault_client/signing.py +121 -0
- locivault_client-0.1.0/locivault_client.egg-info/PKG-INFO +164 -0
- locivault_client-0.1.0/locivault_client.egg-info/SOURCES.txt +12 -0
- locivault_client-0.1.0/locivault_client.egg-info/dependency_links.txt +1 -0
- locivault_client-0.1.0/locivault_client.egg-info/requires.txt +9 -0
- locivault_client-0.1.0/locivault_client.egg-info/top_level.txt +1 -0
- locivault_client-0.1.0/pyproject.toml +45 -0
- locivault_client-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: locivault-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for LocIVault — encrypted, agent-owned memory with x402 micropayments
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://locivault.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/locivault/locivault-python
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/locivault/locivault-python/issues
|
|
9
|
+
Keywords: ai,agent,memory,encryption,x402,web3,locivault
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Requires-Dist: cryptography>=41
|
|
23
|
+
Requires-Dist: eth-account>=0.10
|
|
24
|
+
Provides-Extra: payments
|
|
25
|
+
Requires-Dist: x402[evm]>=2.5; extra == "payments"
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: x402[evm]>=2.5; extra == "all"
|
|
28
|
+
|
|
29
|
+
# locivault-client
|
|
30
|
+
|
|
31
|
+
Python client SDK for [LocIVault](https://locivault.dev) — encrypted, agent-owned memory storage with x402 micropayments.
|
|
32
|
+
|
|
33
|
+
## What it does
|
|
34
|
+
|
|
35
|
+
AI agents need memory that persists across sessions and travels with them across platforms. LocIVault stores encrypted blobs identified by wallet address. The agent holds the key. The server never sees plaintext.
|
|
36
|
+
|
|
37
|
+
- **Encrypted at rest** — AES-256-GCM, key derived from your wallet's private key
|
|
38
|
+
- **x402 micropayments** — first 50 ops free, then $0.001/op in USDC on Base
|
|
39
|
+
- **Payments are automatic** — the client detects 402, signs off-chain, retries transparently
|
|
40
|
+
- **No subscriptions** — pay per op, pay as you go
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Core (read/write with your own encrypted blobs)
|
|
46
|
+
pip install locivault-client
|
|
47
|
+
|
|
48
|
+
# With built-in payment support (recommended for agents)
|
|
49
|
+
pip install "locivault-client[payments]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from eth_account import Account
|
|
56
|
+
from locivault_client import LocIVaultClient
|
|
57
|
+
|
|
58
|
+
# Load your wallet (or generate one: Account.create())
|
|
59
|
+
account = Account.from_key("0x<your-private-key>")
|
|
60
|
+
|
|
61
|
+
# Create the client — payments are automatic
|
|
62
|
+
client = LocIVaultClient(account)
|
|
63
|
+
|
|
64
|
+
# Write encrypted memory (you encrypt it; LocIVault stores the blob)
|
|
65
|
+
import os
|
|
66
|
+
blob = os.urandom(64) # your AES-encrypted data
|
|
67
|
+
result = client.write(blob)
|
|
68
|
+
print(result)
|
|
69
|
+
# {'ok': True, 'sha256': '...', 'size': 64, 'ops_used': 1, 'free_ops_remaining': 49}
|
|
70
|
+
|
|
71
|
+
# Read it back
|
|
72
|
+
retrieved = client.read()
|
|
73
|
+
assert retrieved == blob
|
|
74
|
+
|
|
75
|
+
# Check status
|
|
76
|
+
print(client.status())
|
|
77
|
+
# {'wallet': '0x...', 'ops_used': 2, 'free_ops_remaining': 48, 'tier': 'free', ...}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Built-in encryption helper
|
|
81
|
+
|
|
82
|
+
If you want LocIVault to handle encryption for you using your wallet's private key:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
client = LocIVaultClient(account, auto_encrypt=True)
|
|
86
|
+
|
|
87
|
+
# Encrypt + store in one call
|
|
88
|
+
client.write_plaintext(b"my agent's memory goes here")
|
|
89
|
+
|
|
90
|
+
# Retrieve + decrypt in one call
|
|
91
|
+
text = client.read_plaintext()
|
|
92
|
+
print(text) # b"my agent's memory goes here"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The encryption uses AES-256-GCM with a key derived from your wallet's private key via scrypt. The encrypted blob is what gets stored on LocIVault's servers.
|
|
96
|
+
|
|
97
|
+
## Manual encryption
|
|
98
|
+
|
|
99
|
+
Use the `encrypt` / `decrypt` functions directly if you want more control:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from locivault_client import encrypt, decrypt
|
|
103
|
+
from locivault_client.crypto import derive_key, _extract_private_key
|
|
104
|
+
|
|
105
|
+
# Derive a stable key from your wallet
|
|
106
|
+
raw_key = _extract_private_key(account)
|
|
107
|
+
|
|
108
|
+
# Encrypt your data
|
|
109
|
+
blob = encrypt(b"plaintext memory", raw_key)
|
|
110
|
+
|
|
111
|
+
# Store it
|
|
112
|
+
client.write(blob)
|
|
113
|
+
|
|
114
|
+
# Later: retrieve and decrypt
|
|
115
|
+
retrieved_blob = client.read()
|
|
116
|
+
plaintext = decrypt(retrieved_blob, raw_key)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Payment behaviour
|
|
120
|
+
|
|
121
|
+
LocIVault uses [x402](https://x402.org) for micropayments:
|
|
122
|
+
|
|
123
|
+
- Every wallet gets **50 free ops** (reads + writes combined)
|
|
124
|
+
- After that: **$0.001 per op** in USDC on Base
|
|
125
|
+
- Payments are signed off-chain (EIP-3009) — **no gas needed**
|
|
126
|
+
- The server settles on-chain; the agent just signs an authorization
|
|
127
|
+
|
|
128
|
+
With `auto_pay=True` (the default), this is invisible to your code. Set `auto_pay=False` if you want to handle payment explicitly:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from locivault_client import LocIVaultClient, PaymentRequired
|
|
132
|
+
|
|
133
|
+
client = LocIVaultClient(account, auto_pay=False)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
client.write(blob)
|
|
137
|
+
except PaymentRequired as e:
|
|
138
|
+
print("Free tier exhausted. Payment required.")
|
|
139
|
+
print("Requirements:", e.accepts)
|
|
140
|
+
# sign manually and retry with the x_payment header
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Network
|
|
144
|
+
|
|
145
|
+
Default: **Base Sepolia** (`eip155:84532`) — testnet USDC, no real money.
|
|
146
|
+
|
|
147
|
+
To use Base mainnet:
|
|
148
|
+
```python
|
|
149
|
+
client = LocIVaultClient(account, network="eip155:8453")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Note: you'll need real USDC on Base mainnet for paid ops.
|
|
153
|
+
|
|
154
|
+
## Requirements
|
|
155
|
+
|
|
156
|
+
- Python 3.10+
|
|
157
|
+
- `eth-account` — wallet operations
|
|
158
|
+
- `cryptography` — AES-256-GCM
|
|
159
|
+
- `requests` — HTTP
|
|
160
|
+
- `x402[evm]` — payment signing (optional, required for auto_pay)
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# locivault-client
|
|
2
|
+
|
|
3
|
+
Python client SDK for [LocIVault](https://locivault.dev) — encrypted, agent-owned memory storage with x402 micropayments.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
AI agents need memory that persists across sessions and travels with them across platforms. LocIVault stores encrypted blobs identified by wallet address. The agent holds the key. The server never sees plaintext.
|
|
8
|
+
|
|
9
|
+
- **Encrypted at rest** — AES-256-GCM, key derived from your wallet's private key
|
|
10
|
+
- **x402 micropayments** — first 50 ops free, then $0.001/op in USDC on Base
|
|
11
|
+
- **Payments are automatic** — the client detects 402, signs off-chain, retries transparently
|
|
12
|
+
- **No subscriptions** — pay per op, pay as you go
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Core (read/write with your own encrypted blobs)
|
|
18
|
+
pip install locivault-client
|
|
19
|
+
|
|
20
|
+
# With built-in payment support (recommended for agents)
|
|
21
|
+
pip install "locivault-client[payments]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from eth_account import Account
|
|
28
|
+
from locivault_client import LocIVaultClient
|
|
29
|
+
|
|
30
|
+
# Load your wallet (or generate one: Account.create())
|
|
31
|
+
account = Account.from_key("0x<your-private-key>")
|
|
32
|
+
|
|
33
|
+
# Create the client — payments are automatic
|
|
34
|
+
client = LocIVaultClient(account)
|
|
35
|
+
|
|
36
|
+
# Write encrypted memory (you encrypt it; LocIVault stores the blob)
|
|
37
|
+
import os
|
|
38
|
+
blob = os.urandom(64) # your AES-encrypted data
|
|
39
|
+
result = client.write(blob)
|
|
40
|
+
print(result)
|
|
41
|
+
# {'ok': True, 'sha256': '...', 'size': 64, 'ops_used': 1, 'free_ops_remaining': 49}
|
|
42
|
+
|
|
43
|
+
# Read it back
|
|
44
|
+
retrieved = client.read()
|
|
45
|
+
assert retrieved == blob
|
|
46
|
+
|
|
47
|
+
# Check status
|
|
48
|
+
print(client.status())
|
|
49
|
+
# {'wallet': '0x...', 'ops_used': 2, 'free_ops_remaining': 48, 'tier': 'free', ...}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Built-in encryption helper
|
|
53
|
+
|
|
54
|
+
If you want LocIVault to handle encryption for you using your wallet's private key:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
client = LocIVaultClient(account, auto_encrypt=True)
|
|
58
|
+
|
|
59
|
+
# Encrypt + store in one call
|
|
60
|
+
client.write_plaintext(b"my agent's memory goes here")
|
|
61
|
+
|
|
62
|
+
# Retrieve + decrypt in one call
|
|
63
|
+
text = client.read_plaintext()
|
|
64
|
+
print(text) # b"my agent's memory goes here"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The encryption uses AES-256-GCM with a key derived from your wallet's private key via scrypt. The encrypted blob is what gets stored on LocIVault's servers.
|
|
68
|
+
|
|
69
|
+
## Manual encryption
|
|
70
|
+
|
|
71
|
+
Use the `encrypt` / `decrypt` functions directly if you want more control:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from locivault_client import encrypt, decrypt
|
|
75
|
+
from locivault_client.crypto import derive_key, _extract_private_key
|
|
76
|
+
|
|
77
|
+
# Derive a stable key from your wallet
|
|
78
|
+
raw_key = _extract_private_key(account)
|
|
79
|
+
|
|
80
|
+
# Encrypt your data
|
|
81
|
+
blob = encrypt(b"plaintext memory", raw_key)
|
|
82
|
+
|
|
83
|
+
# Store it
|
|
84
|
+
client.write(blob)
|
|
85
|
+
|
|
86
|
+
# Later: retrieve and decrypt
|
|
87
|
+
retrieved_blob = client.read()
|
|
88
|
+
plaintext = decrypt(retrieved_blob, raw_key)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Payment behaviour
|
|
92
|
+
|
|
93
|
+
LocIVault uses [x402](https://x402.org) for micropayments:
|
|
94
|
+
|
|
95
|
+
- Every wallet gets **50 free ops** (reads + writes combined)
|
|
96
|
+
- After that: **$0.001 per op** in USDC on Base
|
|
97
|
+
- Payments are signed off-chain (EIP-3009) — **no gas needed**
|
|
98
|
+
- The server settles on-chain; the agent just signs an authorization
|
|
99
|
+
|
|
100
|
+
With `auto_pay=True` (the default), this is invisible to your code. Set `auto_pay=False` if you want to handle payment explicitly:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from locivault_client import LocIVaultClient, PaymentRequired
|
|
104
|
+
|
|
105
|
+
client = LocIVaultClient(account, auto_pay=False)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
client.write(blob)
|
|
109
|
+
except PaymentRequired as e:
|
|
110
|
+
print("Free tier exhausted. Payment required.")
|
|
111
|
+
print("Requirements:", e.accepts)
|
|
112
|
+
# sign manually and retry with the x_payment header
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Network
|
|
116
|
+
|
|
117
|
+
Default: **Base Sepolia** (`eip155:84532`) — testnet USDC, no real money.
|
|
118
|
+
|
|
119
|
+
To use Base mainnet:
|
|
120
|
+
```python
|
|
121
|
+
client = LocIVaultClient(account, network="eip155:8453")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Note: you'll need real USDC on Base mainnet for paid ops.
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- Python 3.10+
|
|
129
|
+
- `eth-account` — wallet operations
|
|
130
|
+
- `cryptography` — AES-256-GCM
|
|
131
|
+
- `requests` — HTTP
|
|
132
|
+
- `x402[evm]` — payment signing (optional, required for auto_pay)
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LocIVault Python Client SDK
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
Encrypted, agent-owned memory storage with x402 micropayments.
|
|
6
|
+
|
|
7
|
+
Quickstart
|
|
8
|
+
----------
|
|
9
|
+
from locivault_client import LocIVaultClient
|
|
10
|
+
from eth_account import Account
|
|
11
|
+
|
|
12
|
+
account = Account.from_key("0x...")
|
|
13
|
+
client = LocIVaultClient(account)
|
|
14
|
+
|
|
15
|
+
client.write(b"\\x00\\x01\\x02...") # your encrypted blob
|
|
16
|
+
blob = client.read() # get it back
|
|
17
|
+
print(client.status()) # ops used, tier, etc.
|
|
18
|
+
|
|
19
|
+
Key custody
|
|
20
|
+
-----------
|
|
21
|
+
LocIVault stores encrypted blobs — it never sees plaintext.
|
|
22
|
+
Encrypt/decrypt your data before passing it to this client.
|
|
23
|
+
Use locivault_client.crypto for a bundled AES-256-GCM helper.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .client import LocIVaultClient
|
|
27
|
+
from .crypto import encrypt, decrypt
|
|
28
|
+
|
|
29
|
+
__all__ = ["LocIVaultClient", "encrypt", "decrypt"]
|
|
30
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LocIVaultClient — main client class.
|
|
3
|
+
|
|
4
|
+
Usage (simple):
|
|
5
|
+
from locivault_client import LocIVaultClient
|
|
6
|
+
from eth_account import Account
|
|
7
|
+
|
|
8
|
+
account = Account.from_key("0x...")
|
|
9
|
+
client = LocIVaultClient(account)
|
|
10
|
+
|
|
11
|
+
# Write encrypted memory (encrypt it yourself or use the auto_encrypt option)
|
|
12
|
+
import os
|
|
13
|
+
blob = os.urandom(64) # your encrypted bytes
|
|
14
|
+
result = client.write(blob)
|
|
15
|
+
print(result) # {"ok": True, "sha256": "...", "ops_used": 1, ...}
|
|
16
|
+
|
|
17
|
+
# Read it back
|
|
18
|
+
data = client.read()
|
|
19
|
+
|
|
20
|
+
# Check account status
|
|
21
|
+
print(client.status())
|
|
22
|
+
|
|
23
|
+
Usage (with built-in encryption):
|
|
24
|
+
from locivault_client import LocIVaultClient
|
|
25
|
+
from eth_account import Account
|
|
26
|
+
|
|
27
|
+
account = Account.from_key("0x...")
|
|
28
|
+
client = LocIVaultClient(account, auto_encrypt=True)
|
|
29
|
+
|
|
30
|
+
client.write_plaintext(b"my secret memory")
|
|
31
|
+
text = client.read_plaintext()
|
|
32
|
+
|
|
33
|
+
Payment behaviour:
|
|
34
|
+
- First 50 ops per wallet are free (no payment needed)
|
|
35
|
+
- After that, each op costs $0.001 USDC on Base
|
|
36
|
+
- Payment is automatic when an account is provided — the client
|
|
37
|
+
detects the 402, signs a payment, and retries transparently
|
|
38
|
+
- Set auto_pay=False to disable (you'll get PaymentRequired exceptions instead)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import base64
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import time
|
|
46
|
+
from typing import Optional
|
|
47
|
+
|
|
48
|
+
import requests
|
|
49
|
+
|
|
50
|
+
from .crypto import encrypt_with_account, decrypt_with_account
|
|
51
|
+
from .signing import sign_request
|
|
52
|
+
|
|
53
|
+
log = logging.getLogger("locivault.client")
|
|
54
|
+
|
|
55
|
+
# Public server
|
|
56
|
+
DEFAULT_BASE_URL = "https://locivault.fly.dev"
|
|
57
|
+
DEFAULT_NETWORK = "eip155:84532" # Base Sepolia (testnet)
|
|
58
|
+
|
|
59
|
+
# Throttle: don't fire two payments within this many seconds.
|
|
60
|
+
# The x402.org facilitator needs ~3s between settlements to avoid simulation failures.
|
|
61
|
+
PAY_THROTTLE_SEC = 3.5
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PaymentRequired(Exception):
|
|
65
|
+
"""
|
|
66
|
+
Raised when the server returns 402 and auto_pay=False (or no account provided).
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
accepts (list): the "accepts" list from the 402 body — pass to PaymentSigner.sign()
|
|
70
|
+
body (dict): full 402 response body
|
|
71
|
+
"""
|
|
72
|
+
def __init__(self, accepts: list, body: dict):
|
|
73
|
+
self.accepts = accepts
|
|
74
|
+
self.body = body
|
|
75
|
+
super().__init__(f"Payment required. Use auto_pay=True or sign manually. Accepts: {accepts}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocIVaultError(Exception):
|
|
79
|
+
"""Raised for non-payment API errors (4xx/5xx)."""
|
|
80
|
+
def __init__(self, status_code: int, detail: str):
|
|
81
|
+
self.status_code = status_code
|
|
82
|
+
self.detail = detail
|
|
83
|
+
super().__init__(f"LocIVault error {status_code}: {detail}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LocIVaultClient:
|
|
87
|
+
"""
|
|
88
|
+
Python client for the LocIVault API.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
account: eth_account LocalAccount (your wallet). Used for:
|
|
92
|
+
- Wallet address (sent as X-Wallet-Address header)
|
|
93
|
+
- Payment signing (when auto_pay=True)
|
|
94
|
+
- Encryption key derivation (when auto_encrypt=True)
|
|
95
|
+
base_url: LocIVault server URL. Default: https://locivault.fly.dev
|
|
96
|
+
network: EIP-155 chain ID string. Default: "eip155:84532" (Base Sepolia)
|
|
97
|
+
auto_pay: If True (default), automatically sign and send x402 payments
|
|
98
|
+
when the free tier is exhausted. Requires x402[evm] to be installed.
|
|
99
|
+
auto_encrypt: If True, write_plaintext/read_plaintext will encrypt/decrypt
|
|
100
|
+
using the account's private key (AES-256-GCM). Default: False.
|
|
101
|
+
timeout: HTTP request timeout in seconds. Default: 30.
|
|
102
|
+
session: Optional requests.Session to reuse. If None, a fresh one is created.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
account,
|
|
108
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
109
|
+
network: str = DEFAULT_NETWORK,
|
|
110
|
+
auto_pay: bool = True,
|
|
111
|
+
auto_encrypt: bool = False,
|
|
112
|
+
timeout: int = 30,
|
|
113
|
+
session: Optional[requests.Session] = None,
|
|
114
|
+
):
|
|
115
|
+
self.account = account
|
|
116
|
+
self.base_url = base_url.rstrip("/")
|
|
117
|
+
self.network = network
|
|
118
|
+
self.auto_pay = auto_pay
|
|
119
|
+
self.auto_encrypt = auto_encrypt
|
|
120
|
+
self.timeout = timeout
|
|
121
|
+
self._session = session or requests.Session()
|
|
122
|
+
self._signer = None # lazy — only built if payment needed
|
|
123
|
+
self._last_pay_ts = 0.0 # for payment throttling
|
|
124
|
+
|
|
125
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def write(self, data: bytes) -> dict:
|
|
128
|
+
"""
|
|
129
|
+
Store an encrypted blob for this wallet.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
data: arbitrary bytes (client-side encrypted — LocIVault never sees plaintext)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
dict with keys: ok, sha256, size, ops_used, free_ops_remaining
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
PaymentRequired: if auto_pay=False and free tier is exhausted
|
|
139
|
+
LocIVaultError: on 4xx/5xx errors
|
|
140
|
+
"""
|
|
141
|
+
return self._request_with_payment(
|
|
142
|
+
method="POST",
|
|
143
|
+
path="/write",
|
|
144
|
+
json_body={"data": base64.b64encode(data).decode()},
|
|
145
|
+
resource_path="/write",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def read(self) -> bytes:
|
|
149
|
+
"""
|
|
150
|
+
Retrieve the stored encrypted blob for this wallet.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
bytes: the blob previously stored with write()
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
PaymentRequired: if auto_pay=False and free tier is exhausted
|
|
157
|
+
LocIVaultError: on 4xx/5xx errors (404 if nothing stored yet)
|
|
158
|
+
"""
|
|
159
|
+
result = self._request_with_payment(
|
|
160
|
+
method="GET",
|
|
161
|
+
path="/read",
|
|
162
|
+
resource_path="/read",
|
|
163
|
+
)
|
|
164
|
+
return base64.b64decode(result["data"])
|
|
165
|
+
|
|
166
|
+
def write_plaintext(self, plaintext: bytes) -> dict:
|
|
167
|
+
"""
|
|
168
|
+
Encrypt plaintext with this wallet's key and store it.
|
|
169
|
+
|
|
170
|
+
Requires auto_encrypt=True (or just call this directly — it will work
|
|
171
|
+
regardless of the auto_encrypt flag, using the account's private key).
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
plaintext: bytes to encrypt and store
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
dict: same as write()
|
|
178
|
+
"""
|
|
179
|
+
blob = encrypt_with_account(plaintext, self.account)
|
|
180
|
+
return self.write(blob)
|
|
181
|
+
|
|
182
|
+
def read_plaintext(self) -> bytes:
|
|
183
|
+
"""
|
|
184
|
+
Read and decrypt stored memory using this wallet's key.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
bytes: original plaintext
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: if decryption fails (wrong key or tampered blob)
|
|
191
|
+
"""
|
|
192
|
+
blob = self.read()
|
|
193
|
+
return decrypt_with_account(blob, self.account)
|
|
194
|
+
|
|
195
|
+
def status(self, wallet: Optional[str] = None) -> dict:
|
|
196
|
+
"""
|
|
197
|
+
Check ops usage and tier for a wallet address.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
wallet: wallet address to check. Defaults to this client's account address.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
dict with keys: wallet, ops_used, free_ops_remaining, tier, price_per_op, ...
|
|
204
|
+
"""
|
|
205
|
+
addr = wallet or self.account.address
|
|
206
|
+
resp = self._session.get(
|
|
207
|
+
f"{self.base_url}/status",
|
|
208
|
+
headers={"X-Wallet-Address": addr},
|
|
209
|
+
timeout=self.timeout,
|
|
210
|
+
)
|
|
211
|
+
return resp.json()
|
|
212
|
+
|
|
213
|
+
def health(self) -> dict:
|
|
214
|
+
"""Liveness check. Returns {"status": "ok", "ts": "..."}."""
|
|
215
|
+
resp = self._session.get(f"{self.base_url}/health", timeout=self.timeout)
|
|
216
|
+
resp.raise_for_status()
|
|
217
|
+
return resp.json()
|
|
218
|
+
|
|
219
|
+
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def _request_with_payment(
|
|
222
|
+
self,
|
|
223
|
+
method: str,
|
|
224
|
+
path: str,
|
|
225
|
+
resource_path: str,
|
|
226
|
+
json_body: Optional[dict] = None,
|
|
227
|
+
) -> dict:
|
|
228
|
+
"""
|
|
229
|
+
Make an API request. If the server returns 402 and auto_pay=True,
|
|
230
|
+
sign a payment and retry exactly once.
|
|
231
|
+
Every request is signed with the wallet key for authentication.
|
|
232
|
+
"""
|
|
233
|
+
sig_headers = sign_request(self.account, method, path)
|
|
234
|
+
headers = {"X-Wallet-Address": self.account.address, **sig_headers}
|
|
235
|
+
|
|
236
|
+
# First attempt — no payment header
|
|
237
|
+
resp = self._do_request(method, path, headers, json_body)
|
|
238
|
+
|
|
239
|
+
if resp.status_code == 200:
|
|
240
|
+
return resp.json()
|
|
241
|
+
|
|
242
|
+
if resp.status_code == 402:
|
|
243
|
+
body_402 = resp.json()
|
|
244
|
+
accepts = body_402.get("accepts", [])
|
|
245
|
+
|
|
246
|
+
if not self.auto_pay:
|
|
247
|
+
raise PaymentRequired(accepts=accepts, body=body_402)
|
|
248
|
+
|
|
249
|
+
if not accepts:
|
|
250
|
+
raise LocIVaultError(402, "Server returned 402 with no payment requirements")
|
|
251
|
+
|
|
252
|
+
# Re-sign with fresh timestamp + add payment header
|
|
253
|
+
sig_headers = sign_request(self.account, method, path)
|
|
254
|
+
payment_header = self._sign_payment(
|
|
255
|
+
resource_url=f"{self.base_url}{resource_path}",
|
|
256
|
+
accepts=accepts,
|
|
257
|
+
)
|
|
258
|
+
headers = {
|
|
259
|
+
"X-Wallet-Address": self.account.address,
|
|
260
|
+
**sig_headers,
|
|
261
|
+
"X-Payment": payment_header,
|
|
262
|
+
}
|
|
263
|
+
resp = self._do_request(method, path, headers, json_body)
|
|
264
|
+
|
|
265
|
+
if resp.status_code == 200:
|
|
266
|
+
return resp.json()
|
|
267
|
+
elif resp.status_code == 402:
|
|
268
|
+
# Payment was rejected — surface the error
|
|
269
|
+
detail = resp.json().get("error_detail", resp.text[:200])
|
|
270
|
+
raise LocIVaultError(402, f"Payment rejected by server: {detail}")
|
|
271
|
+
else:
|
|
272
|
+
self._raise_for_status(resp)
|
|
273
|
+
|
|
274
|
+
else:
|
|
275
|
+
self._raise_for_status(resp)
|
|
276
|
+
|
|
277
|
+
def _do_request(
|
|
278
|
+
self,
|
|
279
|
+
method: str,
|
|
280
|
+
path: str,
|
|
281
|
+
headers: dict,
|
|
282
|
+
json_body: Optional[dict],
|
|
283
|
+
) -> requests.Response:
|
|
284
|
+
url = f"{self.base_url}{path}"
|
|
285
|
+
if method == "POST":
|
|
286
|
+
return self._session.post(url, json=json_body, headers=headers, timeout=self.timeout)
|
|
287
|
+
elif method == "GET":
|
|
288
|
+
return self._session.get(url, headers=headers, timeout=self.timeout)
|
|
289
|
+
else:
|
|
290
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
291
|
+
|
|
292
|
+
def _sign_payment(self, resource_url: str, accepts: list) -> str:
|
|
293
|
+
"""Build signer lazily, throttle, sign, return X-Payment header value."""
|
|
294
|
+
if self._signer is None:
|
|
295
|
+
from .payment import PaymentSigner
|
|
296
|
+
self._signer = PaymentSigner(self.account, network=self.network)
|
|
297
|
+
|
|
298
|
+
# Throttle: ensure >= PAY_THROTTLE_SEC between payments
|
|
299
|
+
elapsed = time.time() - self._last_pay_ts
|
|
300
|
+
if elapsed < PAY_THROTTLE_SEC:
|
|
301
|
+
wait = PAY_THROTTLE_SEC - elapsed
|
|
302
|
+
log.debug(f"Throttling payment: sleeping {wait:.1f}s")
|
|
303
|
+
time.sleep(wait)
|
|
304
|
+
|
|
305
|
+
header = self._signer.sign(resource_url, accepts)
|
|
306
|
+
self._last_pay_ts = time.time()
|
|
307
|
+
return header
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _raise_for_status(resp: requests.Response):
|
|
311
|
+
try:
|
|
312
|
+
detail = resp.json().get("detail", resp.text[:300])
|
|
313
|
+
except Exception:
|
|
314
|
+
detail = resp.text[:300]
|
|
315
|
+
raise LocIVaultError(resp.status_code, detail)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AES-256-GCM encryption helpers for LocIVault clients.
|
|
3
|
+
|
|
4
|
+
Key derivation: scrypt(wallet_private_key, salt) → 32-byte AES key
|
|
5
|
+
Blob format (v1): 4-byte magic | 1-byte version | 32-byte salt | 12-byte nonce | ciphertext+GCM-tag
|
|
6
|
+
|
|
7
|
+
This matches the format used by agentmem_core.py (Mnemis's own memory layer),
|
|
8
|
+
so a LocIVault client using these helpers is byte-for-byte compatible with the
|
|
9
|
+
agentmem stack.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from eth_account import Account
|
|
13
|
+
from locivault_client.crypto import derive_key, encrypt, decrypt
|
|
14
|
+
|
|
15
|
+
account = Account.from_key("0x...")
|
|
16
|
+
key = derive_key(account)
|
|
17
|
+
|
|
18
|
+
blob = encrypt(b"plaintext", key)
|
|
19
|
+
text = decrypt(blob, key)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import struct
|
|
24
|
+
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
26
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
27
|
+
from cryptography.hazmat.backends import default_backend
|
|
28
|
+
|
|
29
|
+
# Binary format constants
|
|
30
|
+
MAGIC = b'\xA6\xE1\x4D\x00'
|
|
31
|
+
VERSION = b'\x01'
|
|
32
|
+
HEADER = MAGIC + VERSION # 5 bytes
|
|
33
|
+
|
|
34
|
+
# scrypt params — same as agentmem_core
|
|
35
|
+
SCRYPT_N = 2**17
|
|
36
|
+
SCRYPT_R = 8
|
|
37
|
+
SCRYPT_P = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def derive_key(private_key_bytes: bytes, salt: bytes) -> bytes:
|
|
41
|
+
"""
|
|
42
|
+
Derive a 32-byte AES key from a wallet private key + salt via scrypt.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
private_key_bytes: raw 32-byte wallet private key
|
|
46
|
+
salt: 32-byte random salt (generated fresh per encrypt call)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
32-byte AES-256 key
|
|
50
|
+
"""
|
|
51
|
+
kdf = Scrypt(
|
|
52
|
+
salt=salt,
|
|
53
|
+
length=32,
|
|
54
|
+
n=SCRYPT_N,
|
|
55
|
+
r=SCRYPT_R,
|
|
56
|
+
p=SCRYPT_P,
|
|
57
|
+
backend=default_backend(),
|
|
58
|
+
)
|
|
59
|
+
return kdf.derive(private_key_bytes)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encrypt(plaintext: bytes, key: bytes) -> bytes:
|
|
63
|
+
"""
|
|
64
|
+
Encrypt plaintext with AES-256-GCM. Returns a self-contained binary blob.
|
|
65
|
+
|
|
66
|
+
The blob includes the salt and nonce — no external state needed to decrypt.
|
|
67
|
+
Fresh salt per call = different derived key per call (forward secrecy).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
plaintext: arbitrary bytes to encrypt
|
|
71
|
+
key: 32-byte AES key (e.g. from derive_key())
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
bytes: encrypted blob in LocIVault v1 format
|
|
75
|
+
"""
|
|
76
|
+
salt = os.urandom(32)
|
|
77
|
+
nonce = os.urandom(12)
|
|
78
|
+
derived = derive_key(key, salt) # note: key here = private key seed
|
|
79
|
+
ciphertext = AESGCM(derived).encrypt(nonce, plaintext, None)
|
|
80
|
+
return HEADER + salt + nonce + ciphertext
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def decrypt(blob: bytes, key: bytes) -> bytes:
|
|
84
|
+
"""
|
|
85
|
+
Decrypt a LocIVault v1 encrypted blob.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
blob: bytes from encrypt() or from the /read endpoint
|
|
89
|
+
key: 32-byte AES key seed (same value passed to encrypt)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
bytes: original plaintext
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: if magic/version don't match, or AESGCM authentication fails
|
|
96
|
+
"""
|
|
97
|
+
if len(blob) < 5 + 32 + 12 + 16:
|
|
98
|
+
raise ValueError(f"Blob too short ({len(blob)} bytes)")
|
|
99
|
+
if blob[:4] != MAGIC:
|
|
100
|
+
raise ValueError("Not a LocIVault blob (bad magic)")
|
|
101
|
+
if blob[4:5] != VERSION:
|
|
102
|
+
raise ValueError(f"Unknown blob version: {blob[4]:#x}")
|
|
103
|
+
|
|
104
|
+
salt = blob[5:37]
|
|
105
|
+
nonce = blob[37:49]
|
|
106
|
+
ciphertext = blob[49:]
|
|
107
|
+
|
|
108
|
+
derived = derive_key(key, salt)
|
|
109
|
+
try:
|
|
110
|
+
return AESGCM(derived).decrypt(nonce, ciphertext, None)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise ValueError(f"Decryption failed (wrong key or tampered blob): {e}") from e
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── convenience wrappers that take an eth_account.Account directly ────────────
|
|
116
|
+
|
|
117
|
+
def encrypt_with_account(plaintext: bytes, account) -> bytes:
|
|
118
|
+
"""
|
|
119
|
+
Encrypt using an eth_account Account's private key.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
plaintext: bytes to encrypt
|
|
123
|
+
account: eth_account.Account instance (must have ._private_key)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
encrypted blob bytes
|
|
127
|
+
"""
|
|
128
|
+
raw_key = _extract_private_key(account)
|
|
129
|
+
return encrypt(plaintext, raw_key)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def decrypt_with_account(blob: bytes, account) -> bytes:
|
|
133
|
+
"""
|
|
134
|
+
Decrypt using an eth_account Account's private key.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
blob: encrypted blob bytes
|
|
138
|
+
account: eth_account.Account instance
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
plaintext bytes
|
|
142
|
+
"""
|
|
143
|
+
raw_key = _extract_private_key(account)
|
|
144
|
+
return decrypt(blob, raw_key)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_private_key(account) -> bytes:
|
|
148
|
+
"""Extract raw 32-byte private key from an eth_account LocalAccount."""
|
|
149
|
+
# eth_account stores it as _private_key (HexBytes or bytes)
|
|
150
|
+
pk = getattr(account, '_private_key', None) or getattr(account, 'key', None)
|
|
151
|
+
if pk is None:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Could not extract private key from account object. "
|
|
154
|
+
"Expected eth_account.LocalAccount with ._private_key attribute."
|
|
155
|
+
)
|
|
156
|
+
if isinstance(pk, (bytes, bytearray)):
|
|
157
|
+
return bytes(pk)
|
|
158
|
+
# HexBytes or other hex-string type
|
|
159
|
+
return bytes.fromhex(str(pk).removeprefix("0x"))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
x402 payment signing for LocIVault.
|
|
3
|
+
|
|
4
|
+
This module handles the client side of x402:
|
|
5
|
+
1. Parse the 402 response body to get payment requirements
|
|
6
|
+
2. Sign an EIP-3009 authorization off-chain (no gas needed)
|
|
7
|
+
3. Return a JSON string suitable for the X-Payment header
|
|
8
|
+
|
|
9
|
+
The server verifies + settles on its end. The agent never submits a transaction.
|
|
10
|
+
Network: Base Sepolia (eip155:84532) for testnet, Base mainnet (eip155:8453) for prod.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("locivault.payment")
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from x402.client import x402ClientSync
|
|
21
|
+
from x402.mechanisms.evm.exact import ExactEvmScheme
|
|
22
|
+
from x402.mechanisms.evm.signers import EthAccountSigner
|
|
23
|
+
from x402.schemas.payments import PaymentRequired, PaymentRequirements
|
|
24
|
+
_X402_AVAILABLE = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
_X402_AVAILABLE = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PaymentSigner:
|
|
30
|
+
"""
|
|
31
|
+
Signs x402 payment authorizations for LocIVault ops.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
account: eth_account LocalAccount (the payer)
|
|
35
|
+
network: EIP-155 chain ID string, e.g. "eip155:84532" (Base Sepolia)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, account, network: str = "eip155:84532"):
|
|
39
|
+
if not _X402_AVAILABLE:
|
|
40
|
+
raise ImportError(
|
|
41
|
+
"x402 is required for payment signing. "
|
|
42
|
+
"Install it with: pip install 'x402[evm]'"
|
|
43
|
+
)
|
|
44
|
+
self.network = network
|
|
45
|
+
signer = EthAccountSigner(account)
|
|
46
|
+
self._client = x402ClientSync()
|
|
47
|
+
self._client.register(network, ExactEvmScheme(signer=signer))
|
|
48
|
+
|
|
49
|
+
def sign(self, resource_url: str, accepts: list) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Sign a payment for the given resource.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
resource_url: full URL of the resource being paid for (e.g. https://locivault.fly.dev/write)
|
|
55
|
+
accepts: the "accepts" list from the 402 response body
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
JSON string to use as the X-Payment header value
|
|
59
|
+
"""
|
|
60
|
+
acc = accepts[0]
|
|
61
|
+
requirements = PaymentRequirements(
|
|
62
|
+
scheme=acc["scheme"],
|
|
63
|
+
network=acc["network"],
|
|
64
|
+
asset=acc["asset"],
|
|
65
|
+
amount=str(acc["amount"]),
|
|
66
|
+
pay_to=acc["payTo"],
|
|
67
|
+
max_timeout_seconds=acc.get("maxTimeoutSeconds", 300),
|
|
68
|
+
extra=acc.get("extra"),
|
|
69
|
+
resource=resource_url,
|
|
70
|
+
description="LocIVault op",
|
|
71
|
+
mime_type="application/json",
|
|
72
|
+
output_schema=None,
|
|
73
|
+
request_hash="",
|
|
74
|
+
)
|
|
75
|
+
payment_required = PaymentRequired(x402_version=2, accepts=[requirements])
|
|
76
|
+
payload = self._client.create_payment_payload(payment_required)
|
|
77
|
+
return payload.model_dump_json()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request signing for LocIVault.
|
|
3
|
+
|
|
4
|
+
Every write/read request is signed with the agent's wallet key.
|
|
5
|
+
The server verifies the signature matches the X-Wallet-Address header.
|
|
6
|
+
|
|
7
|
+
WHY: Without signing, anyone who knows a wallet address can overwrite that
|
|
8
|
+
agent's memory (address is not secret). With signing, only the holder of
|
|
9
|
+
the private key can write/read — the address IS a verifiable identity.
|
|
10
|
+
|
|
11
|
+
Signature scheme:
|
|
12
|
+
message = SHA256( wallet_address_lower + ":" + timestamp_str + ":" + method_upper + ":" + path )
|
|
13
|
+
signature = eth_account sign_message(Ethereum signed message prefix + message)
|
|
14
|
+
|
|
15
|
+
Headers added to every authenticated request:
|
|
16
|
+
X-Wallet-Address: 0x... (already present)
|
|
17
|
+
X-Timestamp: 1234567890 (Unix seconds, UTC)
|
|
18
|
+
X-Signature: 0x... (65-byte hex ECDSA sig)
|
|
19
|
+
|
|
20
|
+
Server verification:
|
|
21
|
+
1. Parse X-Wallet-Address, X-Timestamp, X-Signature
|
|
22
|
+
2. Reject if abs(now - timestamp) > TIMESTAMP_TOLERANCE_SEC (60s default) — replay protection
|
|
23
|
+
3. Reconstruct the same message, recover signer address from signature
|
|
24
|
+
4. Reject if recovered address != X-Wallet-Address (case-insensitive)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import time
|
|
29
|
+
from eth_account import Account
|
|
30
|
+
from eth_account.messages import encode_defunct
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TIMESTAMP_TOLERANCE_SEC = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_message(wallet: str, timestamp: int, method: str, path: str) -> bytes:
|
|
37
|
+
"""Build the canonical message bytes to sign/verify."""
|
|
38
|
+
raw = f"{wallet.lower()}:{timestamp}:{method.upper()}:{path}"
|
|
39
|
+
return hashlib.sha256(raw.encode()).digest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sign_request(account, method: str, path: str) -> dict:
|
|
43
|
+
"""
|
|
44
|
+
Sign a request. Returns headers dict with X-Timestamp and X-Signature.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
account: eth_account LocalAccount
|
|
48
|
+
method: HTTP method string ("GET", "POST", etc.)
|
|
49
|
+
path: URL path being requested (e.g. "/write")
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
dict of extra headers to merge into the request:
|
|
53
|
+
{"X-Timestamp": "...", "X-Signature": "0x..."}
|
|
54
|
+
"""
|
|
55
|
+
ts = int(time.time())
|
|
56
|
+
msg_bytes = _build_message(account.address, ts, method, path)
|
|
57
|
+
signable = encode_defunct(primitive=msg_bytes)
|
|
58
|
+
signed = account.sign_message(signable)
|
|
59
|
+
return {
|
|
60
|
+
"X-Timestamp": str(ts),
|
|
61
|
+
"X-Signature": signed.signature.hex(),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def verify_request_signature(
|
|
66
|
+
wallet_address: str,
|
|
67
|
+
timestamp_str: str,
|
|
68
|
+
signature_hex: str,
|
|
69
|
+
method: str,
|
|
70
|
+
path: str,
|
|
71
|
+
tolerance_sec: int = TIMESTAMP_TOLERANCE_SEC,
|
|
72
|
+
) -> tuple[bool, str]:
|
|
73
|
+
"""
|
|
74
|
+
Verify a signed request on the server side.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
wallet_address: from X-Wallet-Address header
|
|
78
|
+
timestamp_str: from X-Timestamp header
|
|
79
|
+
signature_hex: from X-Signature header (hex with or without 0x)
|
|
80
|
+
method: HTTP method
|
|
81
|
+
path: URL path
|
|
82
|
+
tolerance_sec: max age of timestamp before rejection
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
(True, "") if valid
|
|
86
|
+
(False, reason_string) if invalid
|
|
87
|
+
"""
|
|
88
|
+
# --- parse timestamp ---
|
|
89
|
+
try:
|
|
90
|
+
ts = int(timestamp_str)
|
|
91
|
+
except (ValueError, TypeError):
|
|
92
|
+
return False, "X-Timestamp is not a valid integer"
|
|
93
|
+
|
|
94
|
+
age = abs(time.time() - ts)
|
|
95
|
+
if age > tolerance_sec:
|
|
96
|
+
return False, f"X-Timestamp too old or in future (age={age:.0f}s, tolerance={tolerance_sec}s)"
|
|
97
|
+
|
|
98
|
+
# --- parse signature ---
|
|
99
|
+
sig = signature_hex
|
|
100
|
+
if sig.startswith("0x"):
|
|
101
|
+
sig = sig[2:]
|
|
102
|
+
try:
|
|
103
|
+
sig_bytes = bytes.fromhex(sig)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False, "X-Signature is not valid hex"
|
|
106
|
+
|
|
107
|
+
if len(sig_bytes) != 65:
|
|
108
|
+
return False, f"X-Signature must be 65 bytes, got {len(sig_bytes)}"
|
|
109
|
+
|
|
110
|
+
# --- recover signer ---
|
|
111
|
+
msg_bytes = _build_message(wallet_address, ts, method, path)
|
|
112
|
+
signable = encode_defunct(primitive=msg_bytes)
|
|
113
|
+
try:
|
|
114
|
+
recovered = Account.recover_message(signable, signature=sig_bytes)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return False, f"Signature recovery failed: {e}"
|
|
117
|
+
|
|
118
|
+
if recovered.lower() != wallet_address.lower():
|
|
119
|
+
return False, f"Signature mismatch: signed by {recovered}, expected {wallet_address}"
|
|
120
|
+
|
|
121
|
+
return True, ""
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: locivault-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for LocIVault — encrypted, agent-owned memory with x402 micropayments
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://locivault.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/locivault/locivault-python
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/locivault/locivault-python/issues
|
|
9
|
+
Keywords: ai,agent,memory,encryption,x402,web3,locivault
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Requires-Dist: cryptography>=41
|
|
23
|
+
Requires-Dist: eth-account>=0.10
|
|
24
|
+
Provides-Extra: payments
|
|
25
|
+
Requires-Dist: x402[evm]>=2.5; extra == "payments"
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: x402[evm]>=2.5; extra == "all"
|
|
28
|
+
|
|
29
|
+
# locivault-client
|
|
30
|
+
|
|
31
|
+
Python client SDK for [LocIVault](https://locivault.dev) — encrypted, agent-owned memory storage with x402 micropayments.
|
|
32
|
+
|
|
33
|
+
## What it does
|
|
34
|
+
|
|
35
|
+
AI agents need memory that persists across sessions and travels with them across platforms. LocIVault stores encrypted blobs identified by wallet address. The agent holds the key. The server never sees plaintext.
|
|
36
|
+
|
|
37
|
+
- **Encrypted at rest** — AES-256-GCM, key derived from your wallet's private key
|
|
38
|
+
- **x402 micropayments** — first 50 ops free, then $0.001/op in USDC on Base
|
|
39
|
+
- **Payments are automatic** — the client detects 402, signs off-chain, retries transparently
|
|
40
|
+
- **No subscriptions** — pay per op, pay as you go
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Core (read/write with your own encrypted blobs)
|
|
46
|
+
pip install locivault-client
|
|
47
|
+
|
|
48
|
+
# With built-in payment support (recommended for agents)
|
|
49
|
+
pip install "locivault-client[payments]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from eth_account import Account
|
|
56
|
+
from locivault_client import LocIVaultClient
|
|
57
|
+
|
|
58
|
+
# Load your wallet (or generate one: Account.create())
|
|
59
|
+
account = Account.from_key("0x<your-private-key>")
|
|
60
|
+
|
|
61
|
+
# Create the client — payments are automatic
|
|
62
|
+
client = LocIVaultClient(account)
|
|
63
|
+
|
|
64
|
+
# Write encrypted memory (you encrypt it; LocIVault stores the blob)
|
|
65
|
+
import os
|
|
66
|
+
blob = os.urandom(64) # your AES-encrypted data
|
|
67
|
+
result = client.write(blob)
|
|
68
|
+
print(result)
|
|
69
|
+
# {'ok': True, 'sha256': '...', 'size': 64, 'ops_used': 1, 'free_ops_remaining': 49}
|
|
70
|
+
|
|
71
|
+
# Read it back
|
|
72
|
+
retrieved = client.read()
|
|
73
|
+
assert retrieved == blob
|
|
74
|
+
|
|
75
|
+
# Check status
|
|
76
|
+
print(client.status())
|
|
77
|
+
# {'wallet': '0x...', 'ops_used': 2, 'free_ops_remaining': 48, 'tier': 'free', ...}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Built-in encryption helper
|
|
81
|
+
|
|
82
|
+
If you want LocIVault to handle encryption for you using your wallet's private key:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
client = LocIVaultClient(account, auto_encrypt=True)
|
|
86
|
+
|
|
87
|
+
# Encrypt + store in one call
|
|
88
|
+
client.write_plaintext(b"my agent's memory goes here")
|
|
89
|
+
|
|
90
|
+
# Retrieve + decrypt in one call
|
|
91
|
+
text = client.read_plaintext()
|
|
92
|
+
print(text) # b"my agent's memory goes here"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The encryption uses AES-256-GCM with a key derived from your wallet's private key via scrypt. The encrypted blob is what gets stored on LocIVault's servers.
|
|
96
|
+
|
|
97
|
+
## Manual encryption
|
|
98
|
+
|
|
99
|
+
Use the `encrypt` / `decrypt` functions directly if you want more control:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from locivault_client import encrypt, decrypt
|
|
103
|
+
from locivault_client.crypto import derive_key, _extract_private_key
|
|
104
|
+
|
|
105
|
+
# Derive a stable key from your wallet
|
|
106
|
+
raw_key = _extract_private_key(account)
|
|
107
|
+
|
|
108
|
+
# Encrypt your data
|
|
109
|
+
blob = encrypt(b"plaintext memory", raw_key)
|
|
110
|
+
|
|
111
|
+
# Store it
|
|
112
|
+
client.write(blob)
|
|
113
|
+
|
|
114
|
+
# Later: retrieve and decrypt
|
|
115
|
+
retrieved_blob = client.read()
|
|
116
|
+
plaintext = decrypt(retrieved_blob, raw_key)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Payment behaviour
|
|
120
|
+
|
|
121
|
+
LocIVault uses [x402](https://x402.org) for micropayments:
|
|
122
|
+
|
|
123
|
+
- Every wallet gets **50 free ops** (reads + writes combined)
|
|
124
|
+
- After that: **$0.001 per op** in USDC on Base
|
|
125
|
+
- Payments are signed off-chain (EIP-3009) — **no gas needed**
|
|
126
|
+
- The server settles on-chain; the agent just signs an authorization
|
|
127
|
+
|
|
128
|
+
With `auto_pay=True` (the default), this is invisible to your code. Set `auto_pay=False` if you want to handle payment explicitly:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from locivault_client import LocIVaultClient, PaymentRequired
|
|
132
|
+
|
|
133
|
+
client = LocIVaultClient(account, auto_pay=False)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
client.write(blob)
|
|
137
|
+
except PaymentRequired as e:
|
|
138
|
+
print("Free tier exhausted. Payment required.")
|
|
139
|
+
print("Requirements:", e.accepts)
|
|
140
|
+
# sign manually and retry with the x_payment header
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Network
|
|
144
|
+
|
|
145
|
+
Default: **Base Sepolia** (`eip155:84532`) — testnet USDC, no real money.
|
|
146
|
+
|
|
147
|
+
To use Base mainnet:
|
|
148
|
+
```python
|
|
149
|
+
client = LocIVaultClient(account, network="eip155:8453")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Note: you'll need real USDC on Base mainnet for paid ops.
|
|
153
|
+
|
|
154
|
+
## Requirements
|
|
155
|
+
|
|
156
|
+
- Python 3.10+
|
|
157
|
+
- `eth-account` — wallet operations
|
|
158
|
+
- `cryptography` — AES-256-GCM
|
|
159
|
+
- `requests` — HTTP
|
|
160
|
+
- `x402[evm]` — payment signing (optional, required for auto_pay)
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
locivault_client/__init__.py
|
|
4
|
+
locivault_client/client.py
|
|
5
|
+
locivault_client/crypto.py
|
|
6
|
+
locivault_client/payment.py
|
|
7
|
+
locivault_client/signing.py
|
|
8
|
+
locivault_client.egg-info/PKG-INFO
|
|
9
|
+
locivault_client.egg-info/SOURCES.txt
|
|
10
|
+
locivault_client.egg-info/dependency_links.txt
|
|
11
|
+
locivault_client.egg-info/requires.txt
|
|
12
|
+
locivault_client.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
locivault_client
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "locivault-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for LocIVault — encrypted, agent-owned memory with x402 micropayments"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["ai", "agent", "memory", "encryption", "x402", "web3", "locivault"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Security :: Cryptography",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"requests>=2.28",
|
|
26
|
+
"cryptography>=41",
|
|
27
|
+
"eth-account>=0.10",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
payments = [
|
|
32
|
+
"x402[evm]>=2.5",
|
|
33
|
+
]
|
|
34
|
+
all = [
|
|
35
|
+
"x402[evm]>=2.5",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://locivault.dev"
|
|
40
|
+
Repository = "https://github.com/locivault/locivault-python"
|
|
41
|
+
"Bug Tracker" = "https://github.com/locivault/locivault-python/issues"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["."]
|
|
45
|
+
include = ["locivault_client*"]
|