omne-sdk 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.
- omne_sdk-0.1.0/.gitignore +10 -0
- omne_sdk-0.1.0/PKG-INFO +100 -0
- omne_sdk-0.1.0/README.md +87 -0
- omne_sdk-0.1.0/examples/live_mint.py +111 -0
- omne_sdk-0.1.0/pyproject.toml +29 -0
- omne_sdk-0.1.0/src/omne_sdk/__init__.py +42 -0
- omne_sdk-0.1.0/src/omne_sdk/abi.py +113 -0
- omne_sdk-0.1.0/src/omne_sdk/address.py +129 -0
- omne_sdk-0.1.0/src/omne_sdk/errors.py +23 -0
- omne_sdk-0.1.0/src/omne_sdk/rpc.py +166 -0
- omne_sdk-0.1.0/src/omne_sdk/transaction.py +80 -0
- omne_sdk-0.1.0/src/omne_sdk/wallet.py +144 -0
- omne_sdk-0.1.0/tests/test_parity.py +65 -0
omne_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omne-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Omne Python SDK — post-quantum (ML-DSA-44) wallet, ABI, and JSON-RPC client
|
|
5
|
+
Author: OmneDAO
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: blockchain,fips-204,ml-dsa,omne,post-quantum
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: dilithium-py==1.4.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Omne Python SDK (`omne-sdk`)
|
|
15
|
+
|
|
16
|
+
Post-quantum Python SDK for the Omne L1 — ML-DSA-44 (FIPS 204) wallet, ABI
|
|
17
|
+
encoding, and JSON-RPC client. Parity-matched with the
|
|
18
|
+
[TypeScript SDK](../typescript): the same mnemonic produces the same `om1z`
|
|
19
|
+
addresses, and Python-produced signatures are accepted by the node's verify
|
|
20
|
+
path.
|
|
21
|
+
|
|
22
|
+
> **Status:** validated. Offline crypto/address parity is covered by
|
|
23
|
+
> `tests/test_parity.py` (6/6). Validated against a live 4-validator mesh on
|
|
24
|
+
> 2026-06-17: a Python account (parity-matched, genesis-funded address) built →
|
|
25
|
+
> ML-DSA-44 signed → submitted `mint_permission` to the live `cinchor_permissions`
|
|
26
|
+
> contract; the node accepted the Python signature and the reference-typed
|
|
27
|
+
> `get_principal` return decoded back to the principal address. See
|
|
28
|
+
> [`examples/live_mint.py`](examples/live_mint.py).
|
|
29
|
+
|
|
30
|
+
> **Calling pysub contracts:** the ABI method name is the contract-qualified
|
|
31
|
+
> selector `"<contract>::<method>"` (e.g. `cinchor_permissions::get_status`).
|
|
32
|
+
> The SDK passes the selector through verbatim — qualify it at the call site.
|
|
33
|
+
> Reference (address/bytes) returns come back as a `0x`-hex `returnValue`;
|
|
34
|
+
> decode addresses with `to_omne_address(bytes.fromhex(rv[2:]))`.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e ".[dev]" # from sdk/python/
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The only runtime dependency is [`dilithium-py`](https://pypi.org/project/dilithium-py/)
|
|
43
|
+
(pure-Python FIPS 204), **pinned exact (`==1.4.0`)**. Its public `key_derive(ξ)`
|
|
44
|
+
is byte-identical to the TS SDK's `@noble/post-quantum` `ml_dsa44.keygen(seed)`
|
|
45
|
+
— verified across multiple seeds, which is why addresses match. Because keygen
|
|
46
|
+
output determines `om1z` addresses, the pin is load-bearing: re-run the parity
|
|
47
|
+
tests before bumping it. Everything else (BIP39, the HD KDF, bech32m, the tx
|
|
48
|
+
hash, the ABI codec) is stdlib.
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from omne_sdk import Wallet, OmneClient, AbiEncode
|
|
54
|
+
|
|
55
|
+
wallet = Wallet.from_mnemonic("abandon abandon ... about")
|
|
56
|
+
account = wallet.get_account(0)
|
|
57
|
+
print(account.address) # om1z…
|
|
58
|
+
|
|
59
|
+
client = OmneClient("http://127.0.0.1:26657", chain_id=3)
|
|
60
|
+
|
|
61
|
+
# read-only query (reference-typed returns come back as 0x-hex)
|
|
62
|
+
res = client.query_contract(
|
|
63
|
+
"om1z<contract>", "get_principal",
|
|
64
|
+
[AbiEncode.address("om1z<capabilityId>")],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# state-modifying call: build → sign (ML-DSA-44) → submit
|
|
68
|
+
tx_hash = client.send_contract_call(
|
|
69
|
+
account, "om1z<contract>", "mint_permission",
|
|
70
|
+
[AbiEncode.address("om1z<id>"), AbiEncode.u128(100)],
|
|
71
|
+
)
|
|
72
|
+
receipt = client.wait_for_receipt(tx_hash)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Layout
|
|
76
|
+
|
|
77
|
+
| Module | Mirrors (TS) | Responsibility |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `address.py` | `utils.ts` | bech32m `om1z` codec, `derive_address_from_public_key`, hex |
|
|
80
|
+
| `wallet.py` | `wallet.ts` | BIP39 + hardened HMAC-SHA512 HD KDF, ML-DSA-44 keygen/sign |
|
|
81
|
+
| `transaction.py` | `wallet.ts` (hash) | tx build + canonical **little-endian** signing preimage |
|
|
82
|
+
| `abi.py` | `contract.ts` | `ArgType` / `AbiEncode` / `encode_contract_call` (**big-endian** wire) |
|
|
83
|
+
| `rpc.py` | `client.ts` | JSON-RPC: `omne_sendTransaction` wire, `omne_call`, nonce, receipt |
|
|
84
|
+
|
|
85
|
+
## Parity vectors
|
|
86
|
+
|
|
87
|
+
`tests/test_parity.py` pins:
|
|
88
|
+
- ML-DSA-44 keygen public-key SHA-256 for fixed seeds (vs `@noble`),
|
|
89
|
+
- mnemonic → HD seed → pubkey → `om1z` address for the canonical BIP39 test
|
|
90
|
+
mnemonic,
|
|
91
|
+
- address bech32m round-trip, sign→verify, and ABI call shape.
|
|
92
|
+
|
|
93
|
+
Run: `pytest -q` (offline; no node required).
|
|
94
|
+
|
|
95
|
+
## Notes / follow-ups
|
|
96
|
+
- BIP39 mnemonic **checksum validation** against the English wordlist is a
|
|
97
|
+
follow-up; seed derivation (the parity-critical path) is exact.
|
|
98
|
+
- `dilithium-py` is pure-Python (keygen ~8 ms, sign ~46 ms). Fine for a client
|
|
99
|
+
SDK; a native backend (liboqs/PQClean) is a drop-in optimization behind the
|
|
100
|
+
same keygen/sign interface if high-throughput signing is ever needed.
|
omne_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Omne Python SDK (`omne-sdk`)
|
|
2
|
+
|
|
3
|
+
Post-quantum Python SDK for the Omne L1 — ML-DSA-44 (FIPS 204) wallet, ABI
|
|
4
|
+
encoding, and JSON-RPC client. Parity-matched with the
|
|
5
|
+
[TypeScript SDK](../typescript): the same mnemonic produces the same `om1z`
|
|
6
|
+
addresses, and Python-produced signatures are accepted by the node's verify
|
|
7
|
+
path.
|
|
8
|
+
|
|
9
|
+
> **Status:** validated. Offline crypto/address parity is covered by
|
|
10
|
+
> `tests/test_parity.py` (6/6). Validated against a live 4-validator mesh on
|
|
11
|
+
> 2026-06-17: a Python account (parity-matched, genesis-funded address) built →
|
|
12
|
+
> ML-DSA-44 signed → submitted `mint_permission` to the live `cinchor_permissions`
|
|
13
|
+
> contract; the node accepted the Python signature and the reference-typed
|
|
14
|
+
> `get_principal` return decoded back to the principal address. See
|
|
15
|
+
> [`examples/live_mint.py`](examples/live_mint.py).
|
|
16
|
+
|
|
17
|
+
> **Calling pysub contracts:** the ABI method name is the contract-qualified
|
|
18
|
+
> selector `"<contract>::<method>"` (e.g. `cinchor_permissions::get_status`).
|
|
19
|
+
> The SDK passes the selector through verbatim — qualify it at the call site.
|
|
20
|
+
> Reference (address/bytes) returns come back as a `0x`-hex `returnValue`;
|
|
21
|
+
> decode addresses with `to_omne_address(bytes.fromhex(rv[2:]))`.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[dev]" # from sdk/python/
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The only runtime dependency is [`dilithium-py`](https://pypi.org/project/dilithium-py/)
|
|
30
|
+
(pure-Python FIPS 204), **pinned exact (`==1.4.0`)**. Its public `key_derive(ξ)`
|
|
31
|
+
is byte-identical to the TS SDK's `@noble/post-quantum` `ml_dsa44.keygen(seed)`
|
|
32
|
+
— verified across multiple seeds, which is why addresses match. Because keygen
|
|
33
|
+
output determines `om1z` addresses, the pin is load-bearing: re-run the parity
|
|
34
|
+
tests before bumping it. Everything else (BIP39, the HD KDF, bech32m, the tx
|
|
35
|
+
hash, the ABI codec) is stdlib.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from omne_sdk import Wallet, OmneClient, AbiEncode
|
|
41
|
+
|
|
42
|
+
wallet = Wallet.from_mnemonic("abandon abandon ... about")
|
|
43
|
+
account = wallet.get_account(0)
|
|
44
|
+
print(account.address) # om1z…
|
|
45
|
+
|
|
46
|
+
client = OmneClient("http://127.0.0.1:26657", chain_id=3)
|
|
47
|
+
|
|
48
|
+
# read-only query (reference-typed returns come back as 0x-hex)
|
|
49
|
+
res = client.query_contract(
|
|
50
|
+
"om1z<contract>", "get_principal",
|
|
51
|
+
[AbiEncode.address("om1z<capabilityId>")],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# state-modifying call: build → sign (ML-DSA-44) → submit
|
|
55
|
+
tx_hash = client.send_contract_call(
|
|
56
|
+
account, "om1z<contract>", "mint_permission",
|
|
57
|
+
[AbiEncode.address("om1z<id>"), AbiEncode.u128(100)],
|
|
58
|
+
)
|
|
59
|
+
receipt = client.wait_for_receipt(tx_hash)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Layout
|
|
63
|
+
|
|
64
|
+
| Module | Mirrors (TS) | Responsibility |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `address.py` | `utils.ts` | bech32m `om1z` codec, `derive_address_from_public_key`, hex |
|
|
67
|
+
| `wallet.py` | `wallet.ts` | BIP39 + hardened HMAC-SHA512 HD KDF, ML-DSA-44 keygen/sign |
|
|
68
|
+
| `transaction.py` | `wallet.ts` (hash) | tx build + canonical **little-endian** signing preimage |
|
|
69
|
+
| `abi.py` | `contract.ts` | `ArgType` / `AbiEncode` / `encode_contract_call` (**big-endian** wire) |
|
|
70
|
+
| `rpc.py` | `client.ts` | JSON-RPC: `omne_sendTransaction` wire, `omne_call`, nonce, receipt |
|
|
71
|
+
|
|
72
|
+
## Parity vectors
|
|
73
|
+
|
|
74
|
+
`tests/test_parity.py` pins:
|
|
75
|
+
- ML-DSA-44 keygen public-key SHA-256 for fixed seeds (vs `@noble`),
|
|
76
|
+
- mnemonic → HD seed → pubkey → `om1z` address for the canonical BIP39 test
|
|
77
|
+
mnemonic,
|
|
78
|
+
- address bech32m round-trip, sign→verify, and ABI call shape.
|
|
79
|
+
|
|
80
|
+
Run: `pytest -q` (offline; no node required).
|
|
81
|
+
|
|
82
|
+
## Notes / follow-ups
|
|
83
|
+
- BIP39 mnemonic **checksum validation** against the English wordlist is a
|
|
84
|
+
follow-up; seed derivation (the parity-critical path) is exact.
|
|
85
|
+
- `dilithium-py` is pure-Python (keygen ~8 ms, sign ~46 ms). Fine for a client
|
|
86
|
+
SDK; a native backend (liboqs/PQClean) is a drop-in optimization behind the
|
|
87
|
+
same keygen/sign interface if high-throughput signing is ever needed.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Live-mesh integration proof for the Omne Python SDK.
|
|
2
|
+
|
|
3
|
+
A Python-derived account (same demo mnemonic as the TS smoke -> parity-matched,
|
|
4
|
+
genesis-funded address) builds -> ML-DSA-44 signs -> submits a mint_permission
|
|
5
|
+
call to the live cinchor_permissions contract, then reads it back. Proves the
|
|
6
|
+
node accepts a Python signature and reference returns decode in Python.
|
|
7
|
+
|
|
8
|
+
python live_mint.py <rpc_url> <contract_address> <wallets_json>
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from omne_sdk import Wallet, OmneClient, AbiEncode
|
|
17
|
+
from omne_sdk.address import from_omne_address, to_omne_address
|
|
18
|
+
|
|
19
|
+
RPC = sys.argv[1]
|
|
20
|
+
CONTRACT = sys.argv[2]
|
|
21
|
+
WALLETS = sys.argv[3]
|
|
22
|
+
CHAIN_ID = 3
|
|
23
|
+
# pysub contracts export contract-qualified selectors; the ABI method name is
|
|
24
|
+
# "<contract>::<method>" (the @omne SDK passes the selector through verbatim).
|
|
25
|
+
SEL = "cinchor_permissions::"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def derive_capability_id(principal: str, agent: str, nonce: int, created_at: int) -> str:
|
|
29
|
+
pre = (
|
|
30
|
+
from_omne_address(principal)
|
|
31
|
+
+ from_omne_address(agent)
|
|
32
|
+
+ nonce.to_bytes(8, "big")
|
|
33
|
+
+ created_at.to_bytes(8, "big")
|
|
34
|
+
)
|
|
35
|
+
return to_omne_address(hashlib.sha256(pre).digest())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decode_ref(rv) -> str:
|
|
39
|
+
"""A reference (address) return comes back as 0x-hex; decode to om1z."""
|
|
40
|
+
if isinstance(rv, str) and rv.startswith("0x"):
|
|
41
|
+
return to_omne_address(bytes.fromhex(rv[2:]))
|
|
42
|
+
return str(rv)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> int:
|
|
46
|
+
w = json.load(open(WALLETS))
|
|
47
|
+
principal = Wallet.from_mnemonic(w["principal"]["mnemonic"]).get_account(0)
|
|
48
|
+
agent = Wallet.from_mnemonic(w["agent"]["mnemonic"]).get_account(0)
|
|
49
|
+
client = OmneClient(RPC, CHAIN_ID)
|
|
50
|
+
print(f"principal: {principal.address}")
|
|
51
|
+
print(f"agent: {agent.address}")
|
|
52
|
+
|
|
53
|
+
# wait until the freshly-deployed contract is callable on this node
|
|
54
|
+
probe = AbiEncode.address(principal.address)
|
|
55
|
+
for _ in range(24):
|
|
56
|
+
try:
|
|
57
|
+
client.query_contract(CONTRACT, SEL + "get_status", [probe])
|
|
58
|
+
break
|
|
59
|
+
except Exception:
|
|
60
|
+
time.sleep(3)
|
|
61
|
+
else:
|
|
62
|
+
print("FAIL: contract not callable")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
now = int(time.time())
|
|
66
|
+
cap = derive_capability_id(principal.address, agent.address, 777, now)
|
|
67
|
+
print(f"capabilityId: {cap}")
|
|
68
|
+
|
|
69
|
+
# build -> ML-DSA-44 sign -> submit (the node verifies the Python signature)
|
|
70
|
+
tx = client.send_contract_call(
|
|
71
|
+
principal,
|
|
72
|
+
CONTRACT,
|
|
73
|
+
SEL + "mint_permission",
|
|
74
|
+
[
|
|
75
|
+
AbiEncode.address(cap),
|
|
76
|
+
AbiEncode.address(principal.address),
|
|
77
|
+
AbiEncode.address(agent.address),
|
|
78
|
+
AbiEncode.u128(100), # max_spend
|
|
79
|
+
AbiEncode.u128(now + 3600), # valid_until
|
|
80
|
+
AbiEncode.u128(0), # allowlist_enabled
|
|
81
|
+
AbiEncode.u128(now), # current_time
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
print(f"mint tx: {tx}")
|
|
85
|
+
client.wait_for_receipt(tx, timeout=60)
|
|
86
|
+
|
|
87
|
+
# assert the capability is active
|
|
88
|
+
status = None
|
|
89
|
+
for _ in range(15):
|
|
90
|
+
status = client.query_contract(CONTRACT, SEL + "get_status", [AbiEncode.address(cap)]).get("returnValue")
|
|
91
|
+
if str(status) == "1":
|
|
92
|
+
print("PASS: get_status == 1 (active) — node accepted the Python-signed mint")
|
|
93
|
+
break
|
|
94
|
+
time.sleep(2)
|
|
95
|
+
else:
|
|
96
|
+
print(f"FAIL: get_status not active (got {status!r})")
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
# full reference-return path in Python: get_principal should decode to the principal
|
|
100
|
+
rv = client.query_contract(CONTRACT, SEL + "get_principal", [AbiEncode.address(cap)]).get("returnValue")
|
|
101
|
+
got = decode_ref(rv)
|
|
102
|
+
print(f"get_principal returnValue={rv!r} -> {got}")
|
|
103
|
+
if got == principal.address:
|
|
104
|
+
print("PASS: get_principal decodes to the principal address (reference-return path)")
|
|
105
|
+
return 0
|
|
106
|
+
print("FAIL: get_principal mismatch")
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "omne-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Omne Python SDK — post-quantum (ML-DSA-44) wallet, ABI, and JSON-RPC client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "OmneDAO" }]
|
|
13
|
+
keywords = ["omne", "blockchain", "post-quantum", "ml-dsa", "fips-204"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
# FIPS 204 ML-DSA-44. key_derive(xi) is byte-identical to the TS SDK's
|
|
16
|
+
# @noble/post-quantum ml_dsa44.keygen(seed) (parity-verified on 1.4.0).
|
|
17
|
+
# PINNED EXACT: keygen output determines om1z addresses, so an upstream
|
|
18
|
+
# release must not silently change it — re-verify parity before bumping.
|
|
19
|
+
"dilithium-py==1.4.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=8.0"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/omne_sdk"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Omne Python SDK — post-quantum (ML-DSA-44) wallet, ABI, and RPC client.
|
|
2
|
+
|
|
3
|
+
Parity-matched with the TypeScript SDK (sdk/typescript): the same mnemonic
|
|
4
|
+
yields the same om1z addresses, and Python-produced signatures are accepted by
|
|
5
|
+
the node's verify path. See tests/test_parity.py for the proof vectors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .address import (
|
|
9
|
+
derive_address_from_public_key,
|
|
10
|
+
from_omne_address,
|
|
11
|
+
parse_address,
|
|
12
|
+
to_omne_address,
|
|
13
|
+
)
|
|
14
|
+
from .abi import AbiArgument, AbiEncode, ArgType, encode_contract_call
|
|
15
|
+
from .errors import OmneError, RpcError, ValidationError, WalletError
|
|
16
|
+
from .rpc import OmneClient
|
|
17
|
+
from .transaction import build_transaction, hash_transaction
|
|
18
|
+
from .wallet import Wallet, WalletAccount, mnemonic_to_seed
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Wallet",
|
|
24
|
+
"WalletAccount",
|
|
25
|
+
"mnemonic_to_seed",
|
|
26
|
+
"OmneClient",
|
|
27
|
+
"AbiEncode",
|
|
28
|
+
"AbiArgument",
|
|
29
|
+
"ArgType",
|
|
30
|
+
"encode_contract_call",
|
|
31
|
+
"build_transaction",
|
|
32
|
+
"hash_transaction",
|
|
33
|
+
"to_omne_address",
|
|
34
|
+
"from_omne_address",
|
|
35
|
+
"parse_address",
|
|
36
|
+
"derive_address_from_public_key",
|
|
37
|
+
"OmneError",
|
|
38
|
+
"WalletError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"RpcError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Omne ABI wire encoding for contract calls.
|
|
2
|
+
|
|
3
|
+
Port of sdk/typescript/src/contract.ts. Encodes a method call into the binary
|
|
4
|
+
wire format the runtime decodes, placed (hex-encoded) in a transaction's `data`
|
|
5
|
+
field or an omne_call `data` field:
|
|
6
|
+
|
|
7
|
+
MAGIC "OMNE" (4) | VERSION 0x01 (1) | method_len u16 BE | method_utf8
|
|
8
|
+
| arg_count u16 BE | [ type u8 | data_len u32 BE | data ]*
|
|
9
|
+
|
|
10
|
+
NOTE: ABI integers are BIG-endian here (the codec), distinct from the
|
|
11
|
+
LITTLE-endian transaction-hash preimage in transaction.py. Keep them separate.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import IntEnum
|
|
18
|
+
|
|
19
|
+
from .address import parse_address
|
|
20
|
+
|
|
21
|
+
_ABI_MAGIC = b"OMNE"
|
|
22
|
+
_ABI_VERSION = 0x01
|
|
23
|
+
_MAX_METHOD_NAME_LEN = 256
|
|
24
|
+
_MAX_ARG_COUNT = 64
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ArgType(IntEnum):
|
|
28
|
+
U32 = 0x01
|
|
29
|
+
U64 = 0x02
|
|
30
|
+
I32 = 0x03
|
|
31
|
+
I64 = 0x04
|
|
32
|
+
STRING = 0x05
|
|
33
|
+
BYTES = 0x06
|
|
34
|
+
BOOL = 0x07
|
|
35
|
+
ADDRESS = 0x08
|
|
36
|
+
U128 = 0x09
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AbiArgument:
|
|
41
|
+
type: ArgType
|
|
42
|
+
data: bytes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AbiEncode:
|
|
46
|
+
"""Convenience builders for typed ABI arguments (big-endian numerics)."""
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def u32(value: int) -> AbiArgument:
|
|
50
|
+
return AbiArgument(ArgType.U32, int(value).to_bytes(4, "big"))
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def u64(value: int) -> AbiArgument:
|
|
54
|
+
return AbiArgument(ArgType.U64, int(value).to_bytes(8, "big"))
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def i32(value: int) -> AbiArgument:
|
|
58
|
+
return AbiArgument(ArgType.I32, int(value).to_bytes(4, "big", signed=True))
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def i64(value: int) -> AbiArgument:
|
|
62
|
+
return AbiArgument(ArgType.I64, int(value).to_bytes(8, "big", signed=True))
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def u128(value: int) -> AbiArgument:
|
|
66
|
+
return AbiArgument(ArgType.U128, int(value).to_bytes(16, "big"))
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def string(value: str) -> AbiArgument:
|
|
70
|
+
return AbiArgument(ArgType.STRING, value.encode("utf-8"))
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def bytes_(value: bytes) -> AbiArgument:
|
|
74
|
+
return AbiArgument(ArgType.BYTES, bytes(value))
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def bool_(value: bool) -> AbiArgument:
|
|
78
|
+
return AbiArgument(ArgType.BOOL, b"\x01" if value else b"\x00")
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def address(omne_address: str) -> AbiArgument:
|
|
82
|
+
payload = parse_address(omne_address)
|
|
83
|
+
if len(payload) != 32:
|
|
84
|
+
raise ValueError(f"Address must be 32 bytes, got {len(payload)}")
|
|
85
|
+
return AbiArgument(ArgType.ADDRESS, payload)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def address_bytes(payload: bytes) -> AbiArgument:
|
|
89
|
+
if len(payload) != 32:
|
|
90
|
+
raise ValueError(f"Address must be 32 bytes, got {len(payload)}")
|
|
91
|
+
return AbiArgument(ArgType.ADDRESS, bytes(payload))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def encode_contract_call(method: str, args: list[AbiArgument] | None = None) -> str:
|
|
95
|
+
"""Encode a method call into the Omne ABI wire format; returns bare hex."""
|
|
96
|
+
args = args or []
|
|
97
|
+
method_bytes = method.encode("utf-8")
|
|
98
|
+
if len(method_bytes) > _MAX_METHOD_NAME_LEN:
|
|
99
|
+
raise ValueError(f"Method name exceeds {_MAX_METHOD_NAME_LEN} byte limit")
|
|
100
|
+
if len(args) > _MAX_ARG_COUNT:
|
|
101
|
+
raise ValueError(f"Argument count {len(args)} exceeds maximum of {_MAX_ARG_COUNT}")
|
|
102
|
+
|
|
103
|
+
out = bytearray()
|
|
104
|
+
out += _ABI_MAGIC
|
|
105
|
+
out.append(_ABI_VERSION)
|
|
106
|
+
out += len(method_bytes).to_bytes(2, "big")
|
|
107
|
+
out += method_bytes
|
|
108
|
+
out += len(args).to_bytes(2, "big")
|
|
109
|
+
for arg in args:
|
|
110
|
+
out.append(int(arg.type))
|
|
111
|
+
out += len(arg.data).to_bytes(4, "big")
|
|
112
|
+
out += arg.data
|
|
113
|
+
return out.hex()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Omne address + identifier encoding.
|
|
2
|
+
|
|
3
|
+
The Omne chain is uniformly 32-byte (post-quantum). Addresses are bech32m
|
|
4
|
+
encodings of a 32-byte payload under witness version 2 (``om1z…``), matching
|
|
5
|
+
the TypeScript SDK (sdk/typescript/src/utils.ts) and the Rust-side
|
|
6
|
+
``PqcAccountAddress`` exactly:
|
|
7
|
+
|
|
8
|
+
address = bech32m("om", [2, ...toWords(SHA-256("OMNE_PQC_ADDRESS_V1" || pubkey))])
|
|
9
|
+
|
|
10
|
+
This module is pure stdlib (hashlib) + an inline bech32m (BIP-350). Every
|
|
11
|
+
function here is parity-verified against the TS SDK ground truth (see
|
|
12
|
+
tests/test_parity.py).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
|
|
19
|
+
ADDRESS_HRP = "om"
|
|
20
|
+
ADDRESS_WITNESS_VERSION = 2 # bech32 alphabet index 2 = 'z' -> "om1z…"
|
|
21
|
+
ADDRESS_DOMAIN_TAG = b"OMNE_PQC_ADDRESS_V1"
|
|
22
|
+
ADDRESS_PAYLOAD_BYTES = 32
|
|
23
|
+
|
|
24
|
+
_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
25
|
+
_CHARSET_REV = {c: i for i, c in enumerate(_CHARSET)}
|
|
26
|
+
_BECH32M_CONST = 0x2BC830A3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── hex helpers ─────────────────────────────────────────────────────
|
|
30
|
+
def to_hex(data: bytes) -> str:
|
|
31
|
+
return data.hex()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def from_hex(value: str) -> bytes:
|
|
35
|
+
return bytes.fromhex(value[2:] if value.startswith("0x") else value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── bech32m (BIP-350) ───────────────────────────────────────────────
|
|
39
|
+
def _polymod(values: list[int]) -> int:
|
|
40
|
+
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
|
41
|
+
chk = 1
|
|
42
|
+
for v in values:
|
|
43
|
+
top = chk >> 25
|
|
44
|
+
chk = ((chk & 0x1FFFFFF) << 5) ^ v
|
|
45
|
+
for i in range(5):
|
|
46
|
+
chk ^= gen[i] if ((top >> i) & 1) else 0
|
|
47
|
+
return chk
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _hrp_expand(hrp: str) -> list[int]:
|
|
51
|
+
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _create_checksum(hrp: str, data: list[int]) -> list[int]:
|
|
55
|
+
values = _hrp_expand(hrp) + data
|
|
56
|
+
polymod = _polymod(values + [0] * 6) ^ _BECH32M_CONST
|
|
57
|
+
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _verify_checksum(hrp: str, data: list[int]) -> bool:
|
|
61
|
+
return _polymod(_hrp_expand(hrp) + data) == _BECH32M_CONST
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _convert_bits(data: list[int], from_bits: int, to_bits: int, pad: bool) -> list[int]:
|
|
65
|
+
acc = 0
|
|
66
|
+
bits = 0
|
|
67
|
+
out: list[int] = []
|
|
68
|
+
maxv = (1 << to_bits) - 1
|
|
69
|
+
for value in data:
|
|
70
|
+
if value < 0 or value >> from_bits:
|
|
71
|
+
raise ValueError("invalid value in convert_bits")
|
|
72
|
+
acc = (acc << from_bits) | value
|
|
73
|
+
bits += from_bits
|
|
74
|
+
while bits >= to_bits:
|
|
75
|
+
bits -= to_bits
|
|
76
|
+
out.append((acc >> bits) & maxv)
|
|
77
|
+
if pad:
|
|
78
|
+
if bits:
|
|
79
|
+
out.append((acc << (to_bits - bits)) & maxv)
|
|
80
|
+
elif bits >= from_bits or ((acc << (to_bits - bits)) & maxv):
|
|
81
|
+
raise ValueError("invalid padding in convert_bits")
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── om1z address codec ──────────────────────────────────────────────
|
|
86
|
+
def to_omne_address(address_bytes: bytes) -> str:
|
|
87
|
+
"""Encode a 32-byte payload as a canonical ``om1z…`` bech32m address."""
|
|
88
|
+
if len(address_bytes) != ADDRESS_PAYLOAD_BYTES:
|
|
89
|
+
raise ValueError(f"Address must be {ADDRESS_PAYLOAD_BYTES} bytes, got {len(address_bytes)}")
|
|
90
|
+
data = [ADDRESS_WITNESS_VERSION] + _convert_bits(list(address_bytes), 8, 5, True)
|
|
91
|
+
combined = data + _create_checksum(ADDRESS_HRP, data)
|
|
92
|
+
return ADDRESS_HRP + "1" + "".join(_CHARSET[d] for d in combined)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def from_omne_address(address: str) -> bytes:
|
|
96
|
+
"""Decode an ``om1z…`` address to its raw 32-byte payload."""
|
|
97
|
+
lowered = address.lower()
|
|
98
|
+
pos = lowered.rfind("1")
|
|
99
|
+
if pos < 1:
|
|
100
|
+
raise ValueError(f"Invalid address (no separator): {address}")
|
|
101
|
+
hrp = lowered[:pos]
|
|
102
|
+
if hrp != ADDRESS_HRP:
|
|
103
|
+
raise ValueError(f"Invalid address HRP: expected '{ADDRESS_HRP}', got '{hrp}'")
|
|
104
|
+
try:
|
|
105
|
+
data = [_CHARSET_REV[c] for c in lowered[pos + 1:]]
|
|
106
|
+
except KeyError as exc:
|
|
107
|
+
raise ValueError(f"Invalid bech32 character in address: {address}") from exc
|
|
108
|
+
if not _verify_checksum(hrp, data):
|
|
109
|
+
raise ValueError(f"Invalid bech32m checksum: {address}")
|
|
110
|
+
payload_words = data[:-6]
|
|
111
|
+
if not payload_words or payload_words[0] != ADDRESS_WITNESS_VERSION:
|
|
112
|
+
raise ValueError("Invalid witness version")
|
|
113
|
+
payload = bytes(_convert_bits(payload_words[1:], 5, 8, False))
|
|
114
|
+
if len(payload) != ADDRESS_PAYLOAD_BYTES:
|
|
115
|
+
raise ValueError(f"Address payload must be {ADDRESS_PAYLOAD_BYTES} bytes, got {len(payload)}")
|
|
116
|
+
return payload
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_address(address: str) -> bytes:
|
|
120
|
+
"""Alias for :func:`from_omne_address` — returns the 32-byte payload."""
|
|
121
|
+
return from_omne_address(address)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def derive_address_from_public_key(public_key: bytes) -> str:
|
|
125
|
+
"""Canonical address derivation, shared with the Rust-side PqcAccountAddress:
|
|
126
|
+
``to_omne_address(SHA-256("OMNE_PQC_ADDRESS_V1" || pubkey))``.
|
|
127
|
+
"""
|
|
128
|
+
digest = hashlib.sha256(ADDRESS_DOMAIN_TAG + public_key).digest()
|
|
129
|
+
return to_omne_address(digest)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Error types for the Omne Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OmneError(Exception):
|
|
7
|
+
"""Base class for all Omne SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WalletError(OmneError):
|
|
11
|
+
"""Wallet / key-derivation / signing error."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationError(OmneError):
|
|
15
|
+
"""Invalid argument or input."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RpcError(OmneError):
|
|
19
|
+
"""JSON-RPC transport or node-returned error."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, code: int | None = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Minimal JSON-RPC client for an Omne node (stdlib urllib — no extra deps).
|
|
2
|
+
|
|
3
|
+
Mirrors the request/wire shapes the TS SDK (sdk/typescript/src/client.ts) and
|
|
4
|
+
the Cinchor SDK use:
|
|
5
|
+
|
|
6
|
+
* omne_sendTransaction([wire]) — wire carries om1z addresses + a nested
|
|
7
|
+
{ signature: { signature, publicKey } }; the node rebuilds the canonical
|
|
8
|
+
hash preimage and verifies the ML-DSA-44 signature.
|
|
9
|
+
* omne_call([{ to, data, from? }]) — read-only; reference-typed returns come
|
|
10
|
+
back as a 0x-hex `returnValue` (decode addresses with address.from_omne_*).
|
|
11
|
+
* omne_blockNumber, omne_getNonce, omne_getTransactionReceipt.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from .abi import AbiArgument, encode_contract_call
|
|
21
|
+
from .errors import RpcError
|
|
22
|
+
from .transaction import DEFAULT_GAS_LIMIT, DEFAULT_GAS_PRICE, build_transaction
|
|
23
|
+
from .wallet import WalletAccount
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OmneClient:
|
|
27
|
+
def __init__(self, rpc_url: str, chain_id: int, *, timeout: float = 10.0):
|
|
28
|
+
self.rpc_url = rpc_url
|
|
29
|
+
self.chain_id = chain_id
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self._id = 0
|
|
32
|
+
# Per-signer client-side nonce cache. The node reports real nonces but
|
|
33
|
+
# only advances them on block commit, so rapid back-to-back sends within
|
|
34
|
+
# a process must be numbered locally to stay unique (identical payload +
|
|
35
|
+
# nonce => identical tx hash => dropped by gossip dedup). Seeded once per
|
|
36
|
+
# address from the node, then incremented locally; a fresh process
|
|
37
|
+
# re-seeds from the committed nonce, so uniqueness holds across restarts.
|
|
38
|
+
self._nonces: dict[str, int] = {}
|
|
39
|
+
|
|
40
|
+
# ── transport ───────────────────────────────────────────────────
|
|
41
|
+
def request(self, method: str, params: list | None = None):
|
|
42
|
+
self._id += 1
|
|
43
|
+
payload = json.dumps({"jsonrpc": "2.0", "method": method, "params": params or [], "id": self._id})
|
|
44
|
+
req = urllib.request.Request(
|
|
45
|
+
self.rpc_url,
|
|
46
|
+
data=payload.encode("utf-8"),
|
|
47
|
+
headers={"Content-Type": "application/json"},
|
|
48
|
+
method="POST",
|
|
49
|
+
)
|
|
50
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
51
|
+
body = json.loads(resp.read().decode("utf-8"))
|
|
52
|
+
if body.get("error"):
|
|
53
|
+
err = body["error"]
|
|
54
|
+
raise RpcError(str(err.get("message", err)), code=err.get("code"))
|
|
55
|
+
return body.get("result")
|
|
56
|
+
|
|
57
|
+
# ── reads ────────────────────────────────────────────────────────
|
|
58
|
+
def block_number(self) -> int:
|
|
59
|
+
return int(self.request("omne_blockNumber", []))
|
|
60
|
+
|
|
61
|
+
def get_nonce(self, address: str) -> int:
|
|
62
|
+
# Devnet note: the node does not yet enforce per-account nonces;
|
|
63
|
+
# omne_getNonce reports 0 even for a busy signer (any nonce accepted).
|
|
64
|
+
try:
|
|
65
|
+
return int(self.request("omne_getNonce", [address]) or 0)
|
|
66
|
+
except RpcError:
|
|
67
|
+
acct = self.request("omne_getAccount", [address]) or {}
|
|
68
|
+
return int(acct.get("nonce", 0))
|
|
69
|
+
|
|
70
|
+
def next_nonce(self, address: str) -> int:
|
|
71
|
+
"""Allocate the next unique nonce for an address: seeded from the node's
|
|
72
|
+
committed nonce on first use, then incremented locally per call so
|
|
73
|
+
rapid sends never collide on the tx hash."""
|
|
74
|
+
if address not in self._nonces:
|
|
75
|
+
self._nonces[address] = self.get_nonce(address)
|
|
76
|
+
n = self._nonces[address]
|
|
77
|
+
self._nonces[address] = n + 1
|
|
78
|
+
return n
|
|
79
|
+
|
|
80
|
+
def call(self, to: str, data: str, sender: str | None = None) -> dict:
|
|
81
|
+
call_obj = {"to": to, "data": data}
|
|
82
|
+
if sender:
|
|
83
|
+
call_obj["from"] = sender
|
|
84
|
+
return self.request("omne_call", [call_obj])
|
|
85
|
+
|
|
86
|
+
def query_contract(self, contract: str, method: str, args: list[AbiArgument] | None = None,
|
|
87
|
+
sender: str | None = None) -> dict:
|
|
88
|
+
"""Read-only contract call. `method` is the ABI selector — for pysub
|
|
89
|
+
contracts the contract-qualified form `"<contract>::<method>"` (e.g.
|
|
90
|
+
`cinchor_permissions::get_status`). Reference (address/bytes) returns
|
|
91
|
+
come back as a `0x`-hex `returnValue`.
|
|
92
|
+
"""
|
|
93
|
+
return self.call(contract, encode_contract_call(method, args or []), sender)
|
|
94
|
+
|
|
95
|
+
def get_transaction_receipt(self, tx_hash: str):
|
|
96
|
+
return self.request("omne_getTransactionReceipt", [tx_hash])
|
|
97
|
+
|
|
98
|
+
# ── writes ───────────────────────────────────────────────────────
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _to_wire(signed: dict) -> dict:
|
|
101
|
+
wire = {
|
|
102
|
+
"from": signed["from"],
|
|
103
|
+
"to": signed["to"],
|
|
104
|
+
"value": signed["value"],
|
|
105
|
+
"gasLimit": signed["gasLimit"],
|
|
106
|
+
"gasPrice": signed["gasPrice"],
|
|
107
|
+
"nonce": signed["nonce"],
|
|
108
|
+
"chainId": signed["chainId"],
|
|
109
|
+
"data": signed.get("data") or "",
|
|
110
|
+
"signature": {"signature": signed["signature"], "publicKey": signed["publicKey"]},
|
|
111
|
+
}
|
|
112
|
+
if signed.get("priority"):
|
|
113
|
+
wire["priority"] = signed["priority"]
|
|
114
|
+
if signed.get("layer"):
|
|
115
|
+
wire["layer"] = signed["layer"]
|
|
116
|
+
return wire
|
|
117
|
+
|
|
118
|
+
def send_signed(self, signed: dict) -> str | None:
|
|
119
|
+
result = self.request("omne_sendTransaction", [self._to_wire(signed)])
|
|
120
|
+
if isinstance(result, str):
|
|
121
|
+
return result
|
|
122
|
+
if isinstance(result, dict):
|
|
123
|
+
return result.get("transactionHash")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def send_contract_call(
|
|
127
|
+
self,
|
|
128
|
+
account: WalletAccount,
|
|
129
|
+
contract: str,
|
|
130
|
+
method: str,
|
|
131
|
+
args: list[AbiArgument] | None = None,
|
|
132
|
+
*,
|
|
133
|
+
value: int | str = 0,
|
|
134
|
+
gas_limit: int = DEFAULT_GAS_LIMIT,
|
|
135
|
+
gas_price: str = DEFAULT_GAS_PRICE,
|
|
136
|
+
nonce: int | None = None,
|
|
137
|
+
) -> str | None:
|
|
138
|
+
"""Build → sign → submit a state-modifying contract call."""
|
|
139
|
+
resolved_nonce = self.next_nonce(account.address) if nonce is None else nonce
|
|
140
|
+
tx = build_transaction(
|
|
141
|
+
sender=account.address,
|
|
142
|
+
to=contract,
|
|
143
|
+
data=encode_contract_call(method, args or []),
|
|
144
|
+
value=value,
|
|
145
|
+
gas_limit=gas_limit,
|
|
146
|
+
gas_price=gas_price,
|
|
147
|
+
nonce=resolved_nonce,
|
|
148
|
+
chain_id=self.chain_id,
|
|
149
|
+
)
|
|
150
|
+
signed = account.sign_transaction(tx, chain_id=self.chain_id)
|
|
151
|
+
return self.send_signed(signed)
|
|
152
|
+
|
|
153
|
+
def wait_for_receipt(self, tx_hash: str, timeout: float = 60.0, interval: float = 1.0):
|
|
154
|
+
"""Poll for a receipt, tolerating 'not found' while the tx is in mempool."""
|
|
155
|
+
if not tx_hash:
|
|
156
|
+
return None
|
|
157
|
+
deadline = time.monotonic() + timeout
|
|
158
|
+
while time.monotonic() < deadline:
|
|
159
|
+
try:
|
|
160
|
+
receipt = self.get_transaction_receipt(tx_hash)
|
|
161
|
+
if receipt:
|
|
162
|
+
return receipt
|
|
163
|
+
except RpcError:
|
|
164
|
+
pass # receipt not yet available — keep polling
|
|
165
|
+
time.sleep(interval)
|
|
166
|
+
return None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Transaction building + canonical hashing.
|
|
2
|
+
|
|
3
|
+
The signing preimage matches the Rust-side hash_transaction() in
|
|
4
|
+
omne-blockchain/src/rpc/wallet.rs and the TS wallet: fields are concatenated in
|
|
5
|
+
a fixed order with little-endian numeric encoding, then SHA-256'd. The node
|
|
6
|
+
reconstructs this exact preimage from the wire payload to verify the signature,
|
|
7
|
+
so every signed field must round-trip byte-for-byte.
|
|
8
|
+
|
|
9
|
+
SHA-256( from(32) ‖ to(32) ‖ value(LE128) ‖ gasLimit(LE64) ‖ gasPrice(LE64)
|
|
10
|
+
‖ nonce(LE64) ‖ chainId(LE64) ‖ data )
|
|
11
|
+
|
|
12
|
+
Addresses are decoded from their om1z form to 32 raw bytes for the preimage.
|
|
13
|
+
`data` is the hex-encoded ABI calldata (see abi.encode_contract_call).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
|
|
20
|
+
from .address import from_omne_address, from_hex
|
|
21
|
+
|
|
22
|
+
DEFAULT_GAS_LIMIT = 200_000
|
|
23
|
+
DEFAULT_GAS_PRICE = "5000"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _le(value: int, num_bytes: int) -> bytes:
|
|
27
|
+
return int(value).to_bytes(num_bytes, "little")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def hash_transaction(transaction: dict) -> bytes:
|
|
31
|
+
"""Canonical 32-byte transaction hash used as the ML-DSA-44 signing message."""
|
|
32
|
+
if transaction.get("chainId") is None:
|
|
33
|
+
raise ValueError("chainId must be set on the transaction before hashing")
|
|
34
|
+
|
|
35
|
+
from_bytes = from_omne_address(transaction["from"])
|
|
36
|
+
to = transaction.get("to")
|
|
37
|
+
to_bytes = from_omne_address(to) if to else b""
|
|
38
|
+
|
|
39
|
+
data = transaction.get("data") or ""
|
|
40
|
+
data_bytes = from_hex(data) if data else b""
|
|
41
|
+
|
|
42
|
+
preimage = b"".join(
|
|
43
|
+
[
|
|
44
|
+
from_bytes,
|
|
45
|
+
to_bytes,
|
|
46
|
+
_le(int(transaction["value"]), 16),
|
|
47
|
+
_le(int(transaction["gasLimit"]), 8),
|
|
48
|
+
_le(int(transaction["gasPrice"]), 8),
|
|
49
|
+
_le(int(transaction["nonce"]), 8),
|
|
50
|
+
_le(int(transaction["chainId"]), 8),
|
|
51
|
+
data_bytes,
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
return hashlib.sha256(preimage).digest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_transaction(
|
|
58
|
+
*,
|
|
59
|
+
sender: str,
|
|
60
|
+
to: str,
|
|
61
|
+
data: str = "",
|
|
62
|
+
value: int | str = 0,
|
|
63
|
+
gas_limit: int = DEFAULT_GAS_LIMIT,
|
|
64
|
+
gas_price: str = DEFAULT_GAS_PRICE,
|
|
65
|
+
nonce: int = 0,
|
|
66
|
+
chain_id: int | None = None,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Build an unsigned transaction dict ready for WalletAccount.sign_transaction."""
|
|
69
|
+
tx: dict = {
|
|
70
|
+
"from": sender,
|
|
71
|
+
"to": to,
|
|
72
|
+
"value": str(value),
|
|
73
|
+
"gasLimit": gas_limit,
|
|
74
|
+
"gasPrice": gas_price,
|
|
75
|
+
"nonce": nonce,
|
|
76
|
+
"data": data,
|
|
77
|
+
}
|
|
78
|
+
if chain_id is not None:
|
|
79
|
+
tx["chainId"] = chain_id
|
|
80
|
+
return tx
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Mnemonic-based HD wallet + ML-DSA-44 (FIPS 204) signing for Omne.
|
|
2
|
+
|
|
3
|
+
Port of sdk/typescript/src/wallet.ts. Omne is post-quantum from the ground up:
|
|
4
|
+
all signing is ML-DSA-44 (FIPS 204). A 32-byte seed is the portable secret; the
|
|
5
|
+
keypair (1312-byte public key, 2560-byte secret key) is deterministically
|
|
6
|
+
expanded from it via ML-DSA-44 KeyGen_internal.
|
|
7
|
+
|
|
8
|
+
Key derivation is an HMAC-SHA512 hierarchical KDF (hardened-only) over the
|
|
9
|
+
BIP39 seed, identical to the TS wallet — so the same mnemonic yields the same
|
|
10
|
+
addresses across both SDKs (parity-verified in tests/test_parity.py).
|
|
11
|
+
|
|
12
|
+
ML-DSA-44 keygen/sign come from `dilithium-py` (pinned), whose public
|
|
13
|
+
`key_derive(xi)` is byte-identical to the TS SDK's `@noble/post-quantum`
|
|
14
|
+
`ml_dsa44.keygen(seed)` (verified). Signatures cross-verify in both directions
|
|
15
|
+
and are accepted by the node's verify path.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import unicodedata
|
|
23
|
+
|
|
24
|
+
from dilithium_py.ml_dsa import ML_DSA_44
|
|
25
|
+
|
|
26
|
+
from .address import derive_address_from_public_key, from_omne_address
|
|
27
|
+
from .errors import WalletError
|
|
28
|
+
from .transaction import hash_transaction
|
|
29
|
+
|
|
30
|
+
_BASE_PATH = "m/44'/60'/0'/0"
|
|
31
|
+
_HD_MASTER_KEY = b"omne ml-dsa44 seed"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── BIP39 mnemonic -> 64-byte seed ──────────────────────────────────
|
|
35
|
+
def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
|
|
36
|
+
"""BIP39 seed: PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic"+passphrase, 2048)."""
|
|
37
|
+
m = unicodedata.normalize("NFKD", mnemonic).encode("utf-8")
|
|
38
|
+
salt = unicodedata.normalize("NFKD", "mnemonic" + passphrase).encode("utf-8")
|
|
39
|
+
return hashlib.pbkdf2_hmac("sha512", m, salt, 2048, dklen=64)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Omne HD KDF (hardened-only HMAC-SHA512) ─────────────────────────
|
|
43
|
+
def _hd_master(seed: bytes) -> tuple[bytes, bytes]:
|
|
44
|
+
i = hmac.new(_HD_MASTER_KEY, seed, hashlib.sha512).digest()
|
|
45
|
+
return i[:32], i[32:]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hd_child(parent_seed: bytes, parent_chain: bytes, index: int) -> tuple[bytes, bytes]:
|
|
49
|
+
hardened = (index | 0x80000000) & 0xFFFFFFFF
|
|
50
|
+
data = b"\x00" + parent_seed + hardened.to_bytes(4, "big")
|
|
51
|
+
i = hmac.new(parent_chain, data, hashlib.sha512).digest()
|
|
52
|
+
return i[:32], i[32:]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _hd_derive_path(seed: bytes, path: str) -> bytes:
|
|
56
|
+
segments = [s for s in path.replace("m/", "").split("/") if s]
|
|
57
|
+
cur_seed, cur_chain = _hd_master(seed)
|
|
58
|
+
for seg in segments:
|
|
59
|
+
try:
|
|
60
|
+
idx = int(seg.replace("'", ""))
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
raise WalletError(f"Invalid derivation path segment: {seg}") from exc
|
|
63
|
+
cur_seed, cur_chain = _hd_child(cur_seed, cur_chain, idx)
|
|
64
|
+
return cur_seed
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WalletAccount:
|
|
68
|
+
"""A single account: a 32-byte seed expanded to an ML-DSA-44 keypair."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, private_key: str, path: str | None = None):
|
|
71
|
+
if len(private_key) != 64 or any(c not in "0123456789abcdef" for c in private_key.lower()):
|
|
72
|
+
raise WalletError("Invalid private key: expected 64 hex chars (32-byte ML-DSA-44 seed)")
|
|
73
|
+
self.private_key = private_key.lower()
|
|
74
|
+
self.path = path
|
|
75
|
+
seed = bytes.fromhex(self.private_key)
|
|
76
|
+
# key_derive(xi) is dilithium-py's PUBLIC, documented (FIPS 204 §6.1)
|
|
77
|
+
# deterministic keygen from a 32-byte seed — byte-identical to the TS
|
|
78
|
+
# SDK's @noble ml_dsa44.keygen(seed). Public API (not the _keygen_internal
|
|
79
|
+
# private method) so it is part of the lib's stability contract.
|
|
80
|
+
public_key, secret_key = ML_DSA_44.key_derive(seed)
|
|
81
|
+
self._secret_key = secret_key # 2560 bytes, never exported
|
|
82
|
+
self.public_key = public_key.hex()
|
|
83
|
+
self.address = derive_address_from_public_key(public_key)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_private_key(cls, private_key: str) -> "WalletAccount":
|
|
87
|
+
return cls(private_key)
|
|
88
|
+
|
|
89
|
+
def sign_hash(self, digest: bytes) -> bytes:
|
|
90
|
+
"""ML-DSA-44 signature (2420 bytes, empty context — matches the node)."""
|
|
91
|
+
return ML_DSA_44.sign(self._secret_key, digest, ctx=b"")
|
|
92
|
+
|
|
93
|
+
def sign_message(self, message: str) -> str:
|
|
94
|
+
"""Sign SHA-256(message); appends the 1312-byte pubkey (ML-DSA has no recovery)."""
|
|
95
|
+
digest = hashlib.sha256(message.encode("utf-8")).digest()
|
|
96
|
+
signature = self.sign_hash(digest)
|
|
97
|
+
return (signature + bytes.fromhex(self.public_key)).hex()
|
|
98
|
+
|
|
99
|
+
def sign_transaction(self, transaction: dict, chain_id: int | None = None) -> dict:
|
|
100
|
+
"""Sign a transaction. Returns the tx augmented with chainId, signature, publicKey.
|
|
101
|
+
|
|
102
|
+
The hash preimage matches the Rust-side hash_transaction() and the TS
|
|
103
|
+
wallet: canonical field concat with little-endian numbers.
|
|
104
|
+
"""
|
|
105
|
+
resolved = chain_id if chain_id is not None else transaction.get("chainId")
|
|
106
|
+
if resolved is None:
|
|
107
|
+
raise WalletError("chainId required for signing (e.g. 3 for Ignis)")
|
|
108
|
+
normalized = {**transaction, "chainId": resolved}
|
|
109
|
+
tx_hash = hash_transaction(normalized)
|
|
110
|
+
signature = self.sign_hash(tx_hash)
|
|
111
|
+
return {
|
|
112
|
+
**normalized,
|
|
113
|
+
"signature": signature.hex(),
|
|
114
|
+
"publicKey": self.public_key,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Wallet:
|
|
119
|
+
"""HD wallet with BIP39 mnemonic support (hardened-only HMAC-SHA512 KDF)."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, mnemonic: str, passphrase: str = ""):
|
|
122
|
+
# NOTE: mnemonic checksum validation against the BIP39 wordlist is a
|
|
123
|
+
# follow-up; seed derivation (the parity-critical path) is exact.
|
|
124
|
+
self.mnemonic = mnemonic
|
|
125
|
+
self._seed = mnemonic_to_seed(mnemonic, passphrase)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_mnemonic(cls, mnemonic: str, passphrase: str = "") -> "Wallet":
|
|
129
|
+
return cls(mnemonic, passphrase)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_private_key(cls, private_key: str) -> WalletAccount:
|
|
133
|
+
return WalletAccount.from_private_key(private_key)
|
|
134
|
+
|
|
135
|
+
def get_account(self, index: int = 0) -> WalletAccount:
|
|
136
|
+
if index < 0:
|
|
137
|
+
raise WalletError(f"Account index must be non-negative: {index}")
|
|
138
|
+
path = f"{_BASE_PATH}/{index}"
|
|
139
|
+
seed = _hd_derive_path(self._seed, path)
|
|
140
|
+
return WalletAccount(seed.hex(), path)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def address(self) -> str:
|
|
144
|
+
return self.get_account(0).address
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Cross-SDK parity tests.
|
|
2
|
+
|
|
3
|
+
These pin the Python SDK to byte-for-byte agreement with the TypeScript SDK
|
|
4
|
+
(@omne/sdk + @noble/post-quantum). Vectors were captured from the TS SDK:
|
|
5
|
+
* keygen vectors: ml_dsa44.keygen(seed) public-key SHA-256 for fixed seeds.
|
|
6
|
+
* chain vector: Wallet.fromMnemonic(<canonical BIP39 test mnemonic>)
|
|
7
|
+
.getAccount(0) -> HD seed, pubkey SHA-256, om1z address.
|
|
8
|
+
|
|
9
|
+
No node required — pure crypto/address parity. The live-mesh proof (a Python
|
|
10
|
+
account minting/enforcing against Cinchor) is the integration test run on a mesh.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
from dilithium_py.ml_dsa import ML_DSA_44
|
|
16
|
+
|
|
17
|
+
from omne_sdk import Wallet, AbiEncode, encode_contract_call
|
|
18
|
+
from omne_sdk.address import from_omne_address, to_omne_address
|
|
19
|
+
|
|
20
|
+
# Canonical BIP39 test mnemonic (public, not a real key) — vector from the TS SDK.
|
|
21
|
+
TEST_MNEMONIC = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
22
|
+
EXPECTED_HD_SEED = "d6f8deee4da4c94e81c8e0e53a61f584bf15a540b48516273fc1bfe27006612d"
|
|
23
|
+
EXPECTED_PUBKEY_SHA256 = "a875fccc8fd28539d6249741acef4e3c6333822707e39ed019c49d0fc1fcc5fc"
|
|
24
|
+
EXPECTED_ADDRESS = "om1z6n2ydj89l7e6wq3eravk35er4jx66r63q48wfgh4ql6x0p566rvsj22jgp"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pk_sha256(seed: bytes) -> str:
|
|
28
|
+
pk, _ = ML_DSA_44.key_derive(seed) # public seed-keygen the SDK uses
|
|
29
|
+
return hashlib.sha256(pk).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_keygen_zero_seed_matches_noble():
|
|
33
|
+
assert _pk_sha256(bytes(32)) == "eb4e7302842153b0fa19e8620739ad258af4929c26dd89079a7ec7d4282208e1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_keygen_iota_seed_matches_noble():
|
|
37
|
+
assert _pk_sha256(bytes(range(32))) == "9f107644c1084526af3bc8098680b05499a2325a644e388fb4f970e058d19d46"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_full_chain_mnemonic_to_address():
|
|
41
|
+
account = Wallet.from_mnemonic(TEST_MNEMONIC).get_account(0)
|
|
42
|
+
assert account.private_key == EXPECTED_HD_SEED
|
|
43
|
+
assert hashlib.sha256(bytes.fromhex(account.public_key)).hexdigest() == EXPECTED_PUBKEY_SHA256
|
|
44
|
+
assert account.address == EXPECTED_ADDRESS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_address_roundtrip():
|
|
48
|
+
payload = from_omne_address(EXPECTED_ADDRESS)
|
|
49
|
+
assert len(payload) == 32
|
|
50
|
+
assert to_omne_address(payload) == EXPECTED_ADDRESS
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_sign_then_verify():
|
|
54
|
+
account = Wallet.from_mnemonic(TEST_MNEMONIC).get_account(0)
|
|
55
|
+
digest = hashlib.sha256(b"omne-parity-test").digest()
|
|
56
|
+
sig = account.sign_hash(digest)
|
|
57
|
+
assert len(sig) == 2420 # ML-DSA-44 signature length
|
|
58
|
+
pk = bytes.fromhex(account.public_key)
|
|
59
|
+
assert ML_DSA_44.verify(pk, digest, sig, ctx=b"") is True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_abi_encode_contract_call_shape():
|
|
63
|
+
data = encode_contract_call("get_principal", [AbiEncode.address(EXPECTED_ADDRESS)])
|
|
64
|
+
assert data.startswith("4f4d4e45") # "OMNE" magic
|
|
65
|
+
assert bytes.fromhex(data)[4] == 0x01 # version
|