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.
@@ -0,0 +1,10 @@
1
+ # Python build + cache artifacts — never committed.
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
@@ -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.
@@ -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