mersennet-sdk 0.1.1__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.
- mersennet_sdk-0.1.1/LICENSE +21 -0
- mersennet_sdk-0.1.1/PKG-INFO +177 -0
- mersennet_sdk-0.1.1/README.md +149 -0
- mersennet_sdk-0.1.1/mersennet/__init__.py +83 -0
- mersennet_sdk-0.1.1/mersennet/attestation.py +236 -0
- mersennet_sdk-0.1.1/mersennet/orders.py +183 -0
- mersennet_sdk-0.1.1/mersennet/positions.py +179 -0
- mersennet_sdk-0.1.1/mersennet/provider.py +376 -0
- mersennet_sdk-0.1.1/mersennet/reconstruction.py +245 -0
- mersennet_sdk-0.1.1/mersennet/shielded.py +295 -0
- mersennet_sdk-0.1.1/mersennet/subscriber.py +134 -0
- mersennet_sdk-0.1.1/mersennet/types.py +214 -0
- mersennet_sdk-0.1.1/mersennet_sdk.egg-info/PKG-INFO +177 -0
- mersennet_sdk-0.1.1/mersennet_sdk.egg-info/SOURCES.txt +19 -0
- mersennet_sdk-0.1.1/mersennet_sdk.egg-info/dependency_links.txt +1 -0
- mersennet_sdk-0.1.1/mersennet_sdk.egg-info/requires.txt +6 -0
- mersennet_sdk-0.1.1/mersennet_sdk.egg-info/top_level.txt +1 -0
- mersennet_sdk-0.1.1/pyproject.toml +42 -0
- mersennet_sdk-0.1.1/setup.cfg +4 -0
- mersennet_sdk-0.1.1/tests/test_reconstruction.py +200 -0
- mersennet_sdk-0.1.1/tests/test_shielded.py +94 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mersennet Foundation
|
|
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,177 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mersennet-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for Mersennet: JSON-RPC, the on-chain order book (MersennetOrders precompile), shielded notes and viewing keys
|
|
5
|
+
Author-email: Mersennet Foundation <dev@mersennet.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://mersennet.com
|
|
8
|
+
Project-URL: Documentation, https://docs.mersennet.com/developers/sdks/python/
|
|
9
|
+
Project-URL: Repository, https://github.com/mersennet/sdk-python
|
|
10
|
+
Project-URL: Issues, https://github.com/mersennet/sdk-python/issues
|
|
11
|
+
Keywords: mersennet,blockchain,evm,order-book,perpetuals,zk,json-rpc,sdk
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: requests>=2.28.0
|
|
23
|
+
Requires-Dist: websocket-client>=1.5.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
<p align="center"><a href="https://mersennet.com"><img src="https://raw.githubusercontent.com/mersennet/.github/main/profile/mark.svg" width="72" alt="Mersennet"></a></p>
|
|
30
|
+
<h1 align="center">Mersennet Python SDK</h1>
|
|
31
|
+
<p align="center">
|
|
32
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-7dff9b?style=flat-square" alt="MIT license"></a>
|
|
33
|
+
<a href="https://github.com/mersennet/sdk-python/actions/workflows/ci.yml"><img src="https://github.com/mersennet/sdk-python/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
|
34
|
+
<a href="https://docs.mersennet.com/developers/sdks/python/"><img src="https://img.shields.io/badge/docs-mersennet-1c1c1c?style=flat-square" alt="Docs"></a>
|
|
35
|
+
<a href="https://t.me/Mersennet"><img src="https://img.shields.io/badge/telegram-%40Mersennet-26A5E4?style=flat-square" alt="Telegram"></a>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
Python client for Mersennet - JSON-RPC, CLOB (order book), and WebSocket subscriptions.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install "git+https://github.com/mersennet/sdk-python.git@v0.1.0" # PyPI publication pending
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or from source:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd sdk-python
|
|
50
|
+
pip install -e .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from mersennet import MersennetProvider, MersennetOrders
|
|
57
|
+
|
|
58
|
+
provider = MersennetProvider("http://localhost:8545")
|
|
59
|
+
|
|
60
|
+
# Chain info
|
|
61
|
+
print("Chain ID:", provider.chain_id())
|
|
62
|
+
print("Block:", provider.block_number())
|
|
63
|
+
print("Gas price:", provider.gas_price())
|
|
64
|
+
|
|
65
|
+
# Account
|
|
66
|
+
balance = provider.get_balance("0xYourAddress")
|
|
67
|
+
print("Balance:", balance)
|
|
68
|
+
|
|
69
|
+
# Order book
|
|
70
|
+
orders = MersennetOrders(provider)
|
|
71
|
+
book = orders.get_order_book(1)
|
|
72
|
+
print("Bids:", book.bids)
|
|
73
|
+
print("Asks:", book.asks)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API Reference
|
|
77
|
+
|
|
78
|
+
### MersennetProvider
|
|
79
|
+
|
|
80
|
+
| Method | Description |
|
|
81
|
+
|--------|-------------|
|
|
82
|
+
| `get_block(number, include_txs)` | Get block by number or "latest" |
|
|
83
|
+
| `get_block_by_hash(hash, include_txs)` | Get block by hash |
|
|
84
|
+
| `get_transaction(hash)` | Get transaction by hash |
|
|
85
|
+
| `get_balance(address)` | Get balance (hex string) |
|
|
86
|
+
| `get_nonce(address)` | Get nonce |
|
|
87
|
+
| `send_raw_transaction(raw_tx)` | Send signed transaction |
|
|
88
|
+
| `call(tx_object)` | Simulate call (eth_call) |
|
|
89
|
+
| `chain_id()` | Chain ID |
|
|
90
|
+
| `block_number()` | Latest block number |
|
|
91
|
+
| `view_notes(grant_id_hex, limit, cursor_hex)` | Grant-gated encrypted note export |
|
|
92
|
+
| `gas_price()` | Current gas price |
|
|
93
|
+
|
|
94
|
+
### MersennetOrders
|
|
95
|
+
|
|
96
|
+
| Method | Description |
|
|
97
|
+
|--------|-------------|
|
|
98
|
+
| `add_market(base, quote, lot, tick)` | Add market (admin) |
|
|
99
|
+
| `place_order(market, side, price, amount, tif, owner)` | Place order |
|
|
100
|
+
| `cancel_order(order_id)` | Cancel order |
|
|
101
|
+
| `get_order_book(market)` | Get order book |
|
|
102
|
+
| `get_trades(market)` | Get recent trades |
|
|
103
|
+
| `get_positions(address, market)` | Get positions |
|
|
104
|
+
|
|
105
|
+
### MersennetSubscriber (WebSocket)
|
|
106
|
+
|
|
107
|
+
| Method | Description |
|
|
108
|
+
|--------|-------------|
|
|
109
|
+
| `connect()` | Connect to WebSocket |
|
|
110
|
+
| `disconnect()` | Disconnect |
|
|
111
|
+
| `subscribe_blocks(callback)` | Subscribe to new blocks |
|
|
112
|
+
| `subscribe_trades(market, callback)` | Subscribe to trades |
|
|
113
|
+
| `subscribe_logs(callback, topics, address)` | Subscribe to logs |
|
|
114
|
+
| `unsubscribe(id)` | Unsubscribe |
|
|
115
|
+
|
|
116
|
+
### Shielded Notes
|
|
117
|
+
|
|
118
|
+
Use `view_notes` to fetch encrypted note envelopes, then call
|
|
119
|
+
`scan_granted_notes` with a decrypt function that applies your granted
|
|
120
|
+
viewing material locally.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from mersennet import GrantedViewingMaterial, MersennetProvider, make_mock_note_decryptor, scan_granted_notes
|
|
124
|
+
|
|
125
|
+
provider = MersennetProvider("http://localhost:8545")
|
|
126
|
+
|
|
127
|
+
material = GrantedViewingMaterial(
|
|
128
|
+
grant_id_hex="0x...",
|
|
129
|
+
recipient_public_key="0x...",
|
|
130
|
+
decrypt_note_ciphertext=make_mock_note_decryptor("0x..."),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
result = scan_granted_notes(provider, material, limit=64)
|
|
134
|
+
print("Decrypted notes:", len(result.notes))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
See the runnable end-to-end example in [examples/view_notes_end_to_end.py](examples/view_notes_end_to_end.py).
|
|
138
|
+
|
|
139
|
+
## WebSocket Example
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from mersennet import MersennetSubscriber
|
|
143
|
+
|
|
144
|
+
sub = MersennetSubscriber("ws://localhost:8545")
|
|
145
|
+
sub.connect()
|
|
146
|
+
|
|
147
|
+
def on_block(block):
|
|
148
|
+
print("New block:", block)
|
|
149
|
+
|
|
150
|
+
sub_id = sub.subscribe_blocks(on_block)
|
|
151
|
+
# ...
|
|
152
|
+
sub.unsubscribe(sub_id)
|
|
153
|
+
sub.disconnect()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Error Handling
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from mersennet.provider import MersennetError
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
balance = provider.get_balance("0x...")
|
|
163
|
+
except MersennetError as e:
|
|
164
|
+
print(f"Error {e.code}: {e}")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
<p align="center">
|
|
170
|
+
Part of the <a href="https://github.com/mersennet">Mersennet</a> ecosystem —
|
|
171
|
+
<a href="https://trade.mersennet.com">trade</a> ·
|
|
172
|
+
<a href="https://explorer.mersennet.com">explorer</a> ·
|
|
173
|
+
<a href="https://docs.mersennet.com">docs</a> ·
|
|
174
|
+
<a href="https://mersennet.com/downloads/">run a node</a> ·
|
|
175
|
+
<a href="https://t.me/Mersennet">Telegram</a><br>
|
|
176
|
+
<sub>© 2026 Mersennet Foundation · MIT License · security@mersennet.com</sub>
|
|
177
|
+
</p>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<p align="center"><a href="https://mersennet.com"><img src="https://raw.githubusercontent.com/mersennet/.github/main/profile/mark.svg" width="72" alt="Mersennet"></a></p>
|
|
2
|
+
<h1 align="center">Mersennet Python SDK</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-7dff9b?style=flat-square" alt="MIT license"></a>
|
|
5
|
+
<a href="https://github.com/mersennet/sdk-python/actions/workflows/ci.yml"><img src="https://github.com/mersennet/sdk-python/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
|
|
6
|
+
<a href="https://docs.mersennet.com/developers/sdks/python/"><img src="https://img.shields.io/badge/docs-mersennet-1c1c1c?style=flat-square" alt="Docs"></a>
|
|
7
|
+
<a href="https://t.me/Mersennet"><img src="https://img.shields.io/badge/telegram-%40Mersennet-26A5E4?style=flat-square" alt="Telegram"></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
Python client for Mersennet - JSON-RPC, CLOB (order book), and WebSocket subscriptions.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install "git+https://github.com/mersennet/sdk-python.git@v0.1.0" # PyPI publication pending
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or from source:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd sdk-python
|
|
22
|
+
pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from mersennet import MersennetProvider, MersennetOrders
|
|
29
|
+
|
|
30
|
+
provider = MersennetProvider("http://localhost:8545")
|
|
31
|
+
|
|
32
|
+
# Chain info
|
|
33
|
+
print("Chain ID:", provider.chain_id())
|
|
34
|
+
print("Block:", provider.block_number())
|
|
35
|
+
print("Gas price:", provider.gas_price())
|
|
36
|
+
|
|
37
|
+
# Account
|
|
38
|
+
balance = provider.get_balance("0xYourAddress")
|
|
39
|
+
print("Balance:", balance)
|
|
40
|
+
|
|
41
|
+
# Order book
|
|
42
|
+
orders = MersennetOrders(provider)
|
|
43
|
+
book = orders.get_order_book(1)
|
|
44
|
+
print("Bids:", book.bids)
|
|
45
|
+
print("Asks:", book.asks)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API Reference
|
|
49
|
+
|
|
50
|
+
### MersennetProvider
|
|
51
|
+
|
|
52
|
+
| Method | Description |
|
|
53
|
+
|--------|-------------|
|
|
54
|
+
| `get_block(number, include_txs)` | Get block by number or "latest" |
|
|
55
|
+
| `get_block_by_hash(hash, include_txs)` | Get block by hash |
|
|
56
|
+
| `get_transaction(hash)` | Get transaction by hash |
|
|
57
|
+
| `get_balance(address)` | Get balance (hex string) |
|
|
58
|
+
| `get_nonce(address)` | Get nonce |
|
|
59
|
+
| `send_raw_transaction(raw_tx)` | Send signed transaction |
|
|
60
|
+
| `call(tx_object)` | Simulate call (eth_call) |
|
|
61
|
+
| `chain_id()` | Chain ID |
|
|
62
|
+
| `block_number()` | Latest block number |
|
|
63
|
+
| `view_notes(grant_id_hex, limit, cursor_hex)` | Grant-gated encrypted note export |
|
|
64
|
+
| `gas_price()` | Current gas price |
|
|
65
|
+
|
|
66
|
+
### MersennetOrders
|
|
67
|
+
|
|
68
|
+
| Method | Description |
|
|
69
|
+
|--------|-------------|
|
|
70
|
+
| `add_market(base, quote, lot, tick)` | Add market (admin) |
|
|
71
|
+
| `place_order(market, side, price, amount, tif, owner)` | Place order |
|
|
72
|
+
| `cancel_order(order_id)` | Cancel order |
|
|
73
|
+
| `get_order_book(market)` | Get order book |
|
|
74
|
+
| `get_trades(market)` | Get recent trades |
|
|
75
|
+
| `get_positions(address, market)` | Get positions |
|
|
76
|
+
|
|
77
|
+
### MersennetSubscriber (WebSocket)
|
|
78
|
+
|
|
79
|
+
| Method | Description |
|
|
80
|
+
|--------|-------------|
|
|
81
|
+
| `connect()` | Connect to WebSocket |
|
|
82
|
+
| `disconnect()` | Disconnect |
|
|
83
|
+
| `subscribe_blocks(callback)` | Subscribe to new blocks |
|
|
84
|
+
| `subscribe_trades(market, callback)` | Subscribe to trades |
|
|
85
|
+
| `subscribe_logs(callback, topics, address)` | Subscribe to logs |
|
|
86
|
+
| `unsubscribe(id)` | Unsubscribe |
|
|
87
|
+
|
|
88
|
+
### Shielded Notes
|
|
89
|
+
|
|
90
|
+
Use `view_notes` to fetch encrypted note envelopes, then call
|
|
91
|
+
`scan_granted_notes` with a decrypt function that applies your granted
|
|
92
|
+
viewing material locally.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from mersennet import GrantedViewingMaterial, MersennetProvider, make_mock_note_decryptor, scan_granted_notes
|
|
96
|
+
|
|
97
|
+
provider = MersennetProvider("http://localhost:8545")
|
|
98
|
+
|
|
99
|
+
material = GrantedViewingMaterial(
|
|
100
|
+
grant_id_hex="0x...",
|
|
101
|
+
recipient_public_key="0x...",
|
|
102
|
+
decrypt_note_ciphertext=make_mock_note_decryptor("0x..."),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
result = scan_granted_notes(provider, material, limit=64)
|
|
106
|
+
print("Decrypted notes:", len(result.notes))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
See the runnable end-to-end example in [examples/view_notes_end_to_end.py](examples/view_notes_end_to_end.py).
|
|
110
|
+
|
|
111
|
+
## WebSocket Example
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from mersennet import MersennetSubscriber
|
|
115
|
+
|
|
116
|
+
sub = MersennetSubscriber("ws://localhost:8545")
|
|
117
|
+
sub.connect()
|
|
118
|
+
|
|
119
|
+
def on_block(block):
|
|
120
|
+
print("New block:", block)
|
|
121
|
+
|
|
122
|
+
sub_id = sub.subscribe_blocks(on_block)
|
|
123
|
+
# ...
|
|
124
|
+
sub.unsubscribe(sub_id)
|
|
125
|
+
sub.disconnect()
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Error Handling
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from mersennet.provider import MersennetError
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
balance = provider.get_balance("0x...")
|
|
135
|
+
except MersennetError as e:
|
|
136
|
+
print(f"Error {e.code}: {e}")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
<p align="center">
|
|
142
|
+
Part of the <a href="https://github.com/mersennet">Mersennet</a> ecosystem —
|
|
143
|
+
<a href="https://trade.mersennet.com">trade</a> ·
|
|
144
|
+
<a href="https://explorer.mersennet.com">explorer</a> ·
|
|
145
|
+
<a href="https://docs.mersennet.com">docs</a> ·
|
|
146
|
+
<a href="https://mersennet.com/downloads/">run a node</a> ·
|
|
147
|
+
<a href="https://t.me/Mersennet">Telegram</a><br>
|
|
148
|
+
<sub>© 2026 Mersennet Foundation · MIT License · security@mersennet.com</sub>
|
|
149
|
+
</p>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Mersennet Python SDK - JSON-RPC, CLOB, shielded privacy, and WebSocket client."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
|
+
|
|
5
|
+
from .provider import MersennetProvider
|
|
6
|
+
from .orders import MersennetOrders
|
|
7
|
+
from .shielded import (
|
|
8
|
+
EncryptedNoteEnvelope,
|
|
9
|
+
GrantedDecryptedNote,
|
|
10
|
+
GrantedNoteDecryptInput,
|
|
11
|
+
GrantedNoteScanResult,
|
|
12
|
+
GrantedViewingMaterial,
|
|
13
|
+
ShieldedNote,
|
|
14
|
+
ViewingKey,
|
|
15
|
+
create_owner_viewing_material,
|
|
16
|
+
delegate_view_token,
|
|
17
|
+
make_mock_note_decryptor,
|
|
18
|
+
parse_encrypted_note_payload,
|
|
19
|
+
parse_shielded_note_plaintext,
|
|
20
|
+
scan_granted_notes,
|
|
21
|
+
viewing_key_from_seed,
|
|
22
|
+
)
|
|
23
|
+
from .reconstruction import (
|
|
24
|
+
BalanceReconstructionResult,
|
|
25
|
+
PortfolioNote,
|
|
26
|
+
ReconstructedPortfolio,
|
|
27
|
+
default_nullifier_deriver,
|
|
28
|
+
reconstruct_portfolio,
|
|
29
|
+
scan_and_reconstruct_balances,
|
|
30
|
+
)
|
|
31
|
+
from .positions import (
|
|
32
|
+
FillRecord,
|
|
33
|
+
OpenOrder,
|
|
34
|
+
OrderRecord,
|
|
35
|
+
ReconstructedPosition,
|
|
36
|
+
reconstruct_open_orders,
|
|
37
|
+
reconstruct_positions,
|
|
38
|
+
)
|
|
39
|
+
from .attestation import (
|
|
40
|
+
ComplianceAttestation,
|
|
41
|
+
build_portfolio_attestation,
|
|
42
|
+
verify_attestation,
|
|
43
|
+
)
|
|
44
|
+
from .subscriber import MersennetSubscriber
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"MersennetProvider",
|
|
48
|
+
"MersennetOrders",
|
|
49
|
+
"MersennetSubscriber",
|
|
50
|
+
# shielded note scanning + viewing keys
|
|
51
|
+
"EncryptedNoteEnvelope",
|
|
52
|
+
"GrantedDecryptedNote",
|
|
53
|
+
"GrantedNoteDecryptInput",
|
|
54
|
+
"GrantedNoteScanResult",
|
|
55
|
+
"GrantedViewingMaterial",
|
|
56
|
+
"ShieldedNote",
|
|
57
|
+
"ViewingKey",
|
|
58
|
+
"create_owner_viewing_material",
|
|
59
|
+
"delegate_view_token",
|
|
60
|
+
"make_mock_note_decryptor",
|
|
61
|
+
"parse_encrypted_note_payload",
|
|
62
|
+
"parse_shielded_note_plaintext",
|
|
63
|
+
"scan_granted_notes",
|
|
64
|
+
"viewing_key_from_seed",
|
|
65
|
+
# balance reconstruction
|
|
66
|
+
"BalanceReconstructionResult",
|
|
67
|
+
"PortfolioNote",
|
|
68
|
+
"ReconstructedPortfolio",
|
|
69
|
+
"default_nullifier_deriver",
|
|
70
|
+
"reconstruct_portfolio",
|
|
71
|
+
"scan_and_reconstruct_balances",
|
|
72
|
+
# order/position reconstruction
|
|
73
|
+
"FillRecord",
|
|
74
|
+
"OpenOrder",
|
|
75
|
+
"OrderRecord",
|
|
76
|
+
"ReconstructedPosition",
|
|
77
|
+
"reconstruct_open_orders",
|
|
78
|
+
"reconstruct_positions",
|
|
79
|
+
# compliance attestation
|
|
80
|
+
"ComplianceAttestation",
|
|
81
|
+
"build_portfolio_attestation",
|
|
82
|
+
"verify_attestation",
|
|
83
|
+
]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Compliance / view-key attestation (selective disclosure, ADR-019).
|
|
2
|
+
|
|
3
|
+
A grantee holding a scoped viewing grant (e.g. an exchange, auditor, or
|
|
4
|
+
regulated counterparty) reconstructs a grantor's shielded portfolio locally
|
|
5
|
+
and then produces a **portable, tamper-evident attestation**: a compact
|
|
6
|
+
document asserting the balances observed as of a specific block and shielded
|
|
7
|
+
state root, bound to the grant.
|
|
8
|
+
|
|
9
|
+
The attestation is the selective-disclosure output: the grantor reveals
|
|
10
|
+
exactly the scope the grant permits, to exactly the party the grant names,
|
|
11
|
+
and the recipient can hand the signed artifact to a third party (an auditor,
|
|
12
|
+
a compliance desk) who can verify integrity without any chain access.
|
|
13
|
+
|
|
14
|
+
Design goals:
|
|
15
|
+
* **Deterministic digest** - a canonical serialization hashed with SHA-256,
|
|
16
|
+
so the same portfolio always yields the same digest across SDKs.
|
|
17
|
+
* **Cross-checkable** - carries the node's ``mersennet_viewPortfolioDigest``
|
|
18
|
+
when available, and flags whether the locally-reconstructed digest matches.
|
|
19
|
+
* **Dependency-free signing** - signing/verification are injected callbacks,
|
|
20
|
+
so a wallet can plug in secp256k1/ed25519 without the SDK forcing a dep.
|
|
21
|
+
|
|
22
|
+
This is a faithful cross-language design shared with the TypeScript and Go
|
|
23
|
+
SDKs (``attestation.ts`` / ``attestation.go``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
import hashlib
|
|
30
|
+
import time
|
|
31
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from .reconstruction import BalanceReconstructionResult, ReconstructedPortfolio
|
|
34
|
+
|
|
35
|
+
ATTESTATION_VERSION = "mersennet-attestation-v1"
|
|
36
|
+
|
|
37
|
+
# Signer: (digest_hex) -> signature_hex. Verifier: (digest_hex, signature_hex, attester) -> bool.
|
|
38
|
+
Signer = Callable[[str], str]
|
|
39
|
+
Verifier = Callable[[str, str, Optional[str]], bool]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ComplianceAttestation:
|
|
44
|
+
version: str
|
|
45
|
+
grant_id: str
|
|
46
|
+
grantor_commitment: str
|
|
47
|
+
block_number: int
|
|
48
|
+
shielded_state_root: str
|
|
49
|
+
per_asset: Dict[int, int]
|
|
50
|
+
unspent_note_count: int
|
|
51
|
+
spent_nullifier_count: int
|
|
52
|
+
portfolio_digest: str
|
|
53
|
+
issued_at: int
|
|
54
|
+
scope: str = "balances:read"
|
|
55
|
+
onchain_portfolio_digest: Optional[str] = None
|
|
56
|
+
digest_matches_onchain: Optional[bool] = None
|
|
57
|
+
attester: Optional[str] = None
|
|
58
|
+
signature: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> Dict:
|
|
61
|
+
"""JSON-serializable dict with camelCase keys (wire parity with TS/Go)."""
|
|
62
|
+
return {
|
|
63
|
+
"version": self.version,
|
|
64
|
+
"grantId": self.grant_id,
|
|
65
|
+
"grantorCommitment": self.grantor_commitment,
|
|
66
|
+
"blockNumber": self.block_number,
|
|
67
|
+
"shieldedStateRoot": self.shielded_state_root,
|
|
68
|
+
"perAsset": {str(k): str(v) for k, v in sorted(self.per_asset.items())},
|
|
69
|
+
"unspentNoteCount": self.unspent_note_count,
|
|
70
|
+
"spentNullifierCount": self.spent_nullifier_count,
|
|
71
|
+
"portfolioDigest": self.portfolio_digest,
|
|
72
|
+
"issuedAt": self.issued_at,
|
|
73
|
+
"scope": self.scope,
|
|
74
|
+
"onchainPortfolioDigest": self.onchain_portfolio_digest,
|
|
75
|
+
"digestMatchesOnchain": self.digest_matches_onchain,
|
|
76
|
+
"attester": self.attester,
|
|
77
|
+
"signature": self.signature,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def from_dict(obj: Dict) -> "ComplianceAttestation":
|
|
82
|
+
per_asset = {int(k): int(v) for k, v in (obj.get("perAsset", {}) or {}).items()}
|
|
83
|
+
return ComplianceAttestation(
|
|
84
|
+
version=obj.get("version", ATTESTATION_VERSION),
|
|
85
|
+
grant_id=obj.get("grantId", "0x"),
|
|
86
|
+
grantor_commitment=obj.get("grantorCommitment", "0x"),
|
|
87
|
+
block_number=obj.get("blockNumber", 0),
|
|
88
|
+
shielded_state_root=obj.get("shieldedStateRoot", "0x"),
|
|
89
|
+
per_asset=per_asset,
|
|
90
|
+
unspent_note_count=obj.get("unspentNoteCount", 0),
|
|
91
|
+
spent_nullifier_count=obj.get("spentNullifierCount", 0),
|
|
92
|
+
portfolio_digest=obj.get("portfolioDigest", "0x"),
|
|
93
|
+
issued_at=obj.get("issuedAt", 0),
|
|
94
|
+
scope=obj.get("scope", "balances:read"),
|
|
95
|
+
onchain_portfolio_digest=obj.get("onchainPortfolioDigest"),
|
|
96
|
+
digest_matches_onchain=obj.get("digestMatchesOnchain"),
|
|
97
|
+
attester=obj.get("attester"),
|
|
98
|
+
signature=obj.get("signature"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _canonical_preimage(
|
|
103
|
+
grant_id: str,
|
|
104
|
+
grantor_commitment: str,
|
|
105
|
+
block_number: int,
|
|
106
|
+
shielded_state_root: str,
|
|
107
|
+
per_asset: Dict[int, int],
|
|
108
|
+
unspent_note_count: int,
|
|
109
|
+
spent_nullifier_count: int,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Deterministic string over the disclosed facts. Asset ids are sorted so
|
|
112
|
+
dict ordering never affects the digest; this string is identical across
|
|
113
|
+
the TS/Python/Go SDKs."""
|
|
114
|
+
assets = "|".join(f"{aid}:{per_asset[aid]}" for aid in sorted(per_asset))
|
|
115
|
+
return "\n".join(
|
|
116
|
+
[
|
|
117
|
+
ATTESTATION_VERSION,
|
|
118
|
+
grant_id.lower(),
|
|
119
|
+
grantor_commitment.lower(),
|
|
120
|
+
str(block_number),
|
|
121
|
+
shielded_state_root.lower(),
|
|
122
|
+
assets,
|
|
123
|
+
str(unspent_note_count),
|
|
124
|
+
str(spent_nullifier_count),
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def compute_portfolio_digest(
|
|
130
|
+
grant_id: str,
|
|
131
|
+
grantor_commitment: str,
|
|
132
|
+
block_number: int,
|
|
133
|
+
shielded_state_root: str,
|
|
134
|
+
per_asset: Dict[int, int],
|
|
135
|
+
unspent_note_count: int,
|
|
136
|
+
spent_nullifier_count: int,
|
|
137
|
+
) -> str:
|
|
138
|
+
preimage = _canonical_preimage(
|
|
139
|
+
grant_id,
|
|
140
|
+
grantor_commitment,
|
|
141
|
+
block_number,
|
|
142
|
+
shielded_state_root,
|
|
143
|
+
per_asset,
|
|
144
|
+
unspent_note_count,
|
|
145
|
+
spent_nullifier_count,
|
|
146
|
+
)
|
|
147
|
+
return "0x" + hashlib.sha256(preimage.encode("utf-8")).hexdigest()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_portfolio_attestation(
|
|
151
|
+
result: BalanceReconstructionResult,
|
|
152
|
+
*,
|
|
153
|
+
grantor_commitment: str = "0x",
|
|
154
|
+
scope: str = "balances:read",
|
|
155
|
+
onchain_portfolio_digest: Optional[str] = None,
|
|
156
|
+
attester: Optional[str] = None,
|
|
157
|
+
sign: Optional[Signer] = None,
|
|
158
|
+
issued_at: Optional[int] = None,
|
|
159
|
+
) -> ComplianceAttestation:
|
|
160
|
+
"""Build a compliance attestation from a reconstructed balance result.
|
|
161
|
+
|
|
162
|
+
``result`` typically comes from :func:`scan_and_reconstruct_balances`.
|
|
163
|
+
Pass ``onchain_portfolio_digest`` (from
|
|
164
|
+
``provider`` ``mersennet_viewPortfolioDigest``) to cross-check the local
|
|
165
|
+
reconstruction against the node's independent digest. Pass a ``sign``
|
|
166
|
+
callback to bind the attester's signature over the portfolio digest.
|
|
167
|
+
"""
|
|
168
|
+
digest = compute_portfolio_digest(
|
|
169
|
+
result.grant_id,
|
|
170
|
+
grantor_commitment,
|
|
171
|
+
result.block_number,
|
|
172
|
+
result.shielded_state_root,
|
|
173
|
+
result.per_asset,
|
|
174
|
+
result.unspent_note_count,
|
|
175
|
+
result.spent_nullifier_count,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
digest_matches: Optional[bool] = None
|
|
179
|
+
if onchain_portfolio_digest is not None:
|
|
180
|
+
digest_matches = onchain_portfolio_digest.lower() == digest.lower()
|
|
181
|
+
|
|
182
|
+
signature = sign(digest) if sign is not None else None
|
|
183
|
+
|
|
184
|
+
return ComplianceAttestation(
|
|
185
|
+
version=ATTESTATION_VERSION,
|
|
186
|
+
grant_id=result.grant_id,
|
|
187
|
+
grantor_commitment=grantor_commitment,
|
|
188
|
+
block_number=result.block_number,
|
|
189
|
+
shielded_state_root=result.shielded_state_root,
|
|
190
|
+
per_asset=dict(result.per_asset),
|
|
191
|
+
unspent_note_count=result.unspent_note_count,
|
|
192
|
+
spent_nullifier_count=result.spent_nullifier_count,
|
|
193
|
+
portfolio_digest=digest,
|
|
194
|
+
issued_at=issued_at if issued_at is not None else int(time.time()),
|
|
195
|
+
scope=scope,
|
|
196
|
+
onchain_portfolio_digest=onchain_portfolio_digest,
|
|
197
|
+
digest_matches_onchain=digest_matches,
|
|
198
|
+
attester=attester,
|
|
199
|
+
signature=signature,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def verify_attestation(
|
|
204
|
+
attestation: ComplianceAttestation,
|
|
205
|
+
*,
|
|
206
|
+
verify_sig: Optional[Verifier] = None,
|
|
207
|
+
) -> Tuple[bool, str]:
|
|
208
|
+
"""Verify an attestation's integrity (and optionally its signature).
|
|
209
|
+
|
|
210
|
+
Returns ``(ok, reason)``. Integrity: recompute the digest from the
|
|
211
|
+
attestation's own disclosed fields and require it to equal the embedded
|
|
212
|
+
``portfolio_digest``. If ``verify_sig`` is supplied and a signature is
|
|
213
|
+
present, the signature must also verify over the digest.
|
|
214
|
+
"""
|
|
215
|
+
recomputed = compute_portfolio_digest(
|
|
216
|
+
attestation.grant_id,
|
|
217
|
+
attestation.grantor_commitment,
|
|
218
|
+
attestation.block_number,
|
|
219
|
+
attestation.shielded_state_root,
|
|
220
|
+
attestation.per_asset,
|
|
221
|
+
attestation.unspent_note_count,
|
|
222
|
+
attestation.spent_nullifier_count,
|
|
223
|
+
)
|
|
224
|
+
if recomputed.lower() != attestation.portfolio_digest.lower():
|
|
225
|
+
return False, "digest mismatch: attestation fields do not hash to the embedded digest"
|
|
226
|
+
|
|
227
|
+
if attestation.digest_matches_onchain is False:
|
|
228
|
+
return False, "reconstructed digest did not match the on-chain portfolio digest"
|
|
229
|
+
|
|
230
|
+
if verify_sig is not None:
|
|
231
|
+
if not attestation.signature:
|
|
232
|
+
return False, "signature required but missing"
|
|
233
|
+
if not verify_sig(attestation.portfolio_digest, attestation.signature, attestation.attester):
|
|
234
|
+
return False, "signature verification failed"
|
|
235
|
+
|
|
236
|
+
return True, "ok"
|