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.
@@ -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"