From b5d800aa5af6d8515945bfe55ffb8fa79de59bdb Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 10 Jun 2026 15:58:25 -0700 Subject: [PATCH 1/6] replace v1 epoch math with stateful v2 simulation for commit-reveal --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 375 +++++++++++++++++++++--------------- bindings.h | 15 -- bittensor_drand/__init__.py | 61 +++--- src/constants.rs | 56 ++++++ src/drand.rs | 141 ++++---------- src/epoch_schedule.rs | 255 ++++++++++++++++++++++++ src/ffi.rs | 215 +-------------------- src/lib.rs | 4 + src/python_bindings.rs | 63 +++--- 11 files changed, 641 insertions(+), 548 deletions(-) create mode 100644 src/constants.rs create mode 100644 src/epoch_schedule.rs diff --git a/Cargo.lock b/Cargo.lock index db4d071..c8f7d0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,7 +246,7 @@ checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bittensor-drand" -version = "1.3.0" +version = "2.0.0" dependencies = [ "ark-serialize", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index b91dd5d..537a04a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bittensor-drand" -version = "1.3.0" +version = "2.0.0" edition = "2021" [lib] diff --git a/README.md b/README.md index 3dd275d..4e271b5 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,225 @@ -# Usage -Python package `bittensor_drand` has one function. - -```python -from bittensor_drand import get_encrypted_commit -``` - -To test the function in your terminal: -1. Spin up a local subtensor branch which includes CR3 -2. Create a subnet with netuid 1 (or replace the netuid with the one you create) -```bash -mkdir test -cd test -python3 -m venv venv -. venv/bin/activate -pip install maturin bittensor ipython -cd .. - -maturin develop -ipython - -``` - -then copy-past to ipython -```python -import numpy as np -import bittensor_drand as crv3 -from bittensor.utils.weight_utils import convert_weights_and_uids_for_emit -import bittensor as bt - -uids = [1, 3] -weights = [0.3, 0.7] -version_key = 843000 -netuid = 1 - -subtensor = bt.Subtensor("local") - -subnet_reveal_period_epochs = subtensor.get_subnet_reveal_period_epochs( - netuid=netuid - ) -tempo = subtensor.get_subnet_hyperparameters(netuid).tempo -current_block = subtensor.get_current_block() - -if isinstance(uids, list): - uids = np.array(uids, dtype=np.int64) -if isinstance(weights, list): - weights = np.array(weights, dtype=np.float32) - -uids, weights = convert_weights_and_uids_for_emit(uids, weights) - -print(crv3.get_encrypted_commit(uids, weights, version_key, tempo, current_block, netuid, subnet_reveal_period_epochs)) -``` -expected result -```python -(b'\xb9\x96\xe4\xd1\xfd\xabm\x8cc\xeb\xe3W\r\xc7J\xb4\xea\xa9\xd5u}OG~\xae\xcc\x9a@\xdf\xee\x16\xa9\x0c\x8d7\xd6\xea_c\xc2<\xcb\xa6\xbe^K\x97|\x16\xc6|;\xb5Z\x97\xc9\xb4\x8em\xf1hv\x16\xcf\xea\x1e7\xbe-Z\xe7e\x1f$\n\xf8\x08\xcb\x18.\x94V\xa3\xd7\xcd\xc9\x04F::\t)Z\xc6\xbey \x00\x00\x00\x00\x00\x00\x00\xaaN\xe8\xe97\x8f\x99\xbb"\xdf\xad\xf6\\#%\xca:\xc2\xce\xf9\x96\x9d\x8f\x9d\xa2\xad\xfd\xc73j\x16\xda \x00\x00\x00\x00\x00\x00\x00\x84*\xb0\rw\xad\xdc\x02o\xf7i)\xbb^\x99e\xe2\\\xee\x02NR+-Q\xcd \xf7\x02\x83\xffV>\x00\x00\x00\x00\x00\x00\x00"\x00\x00\x00\x00\x00\x00\x00*\x13wXb\x93\xc5"F\x17F\x05\xcd\x15\xb0=\xe2d\xfco3\x16\xfd\xe9\xc6\xbc\xd1\xb3Y\x97\xf9\xb9!\x01\x0c\x00\x00\x00\x00\x00\x00\x00X\xa2\x8c\x18Wkq\xe5\xe6\x1c2\x86\x08\x00\x00\x00\x00\x00\x00\x00AES_GCM_', 13300875) -``` - -To test this in a local subnet: -1. Spin up a local node based on the subtensor branch `spiigot/add-pallet-drand` using command `./scripts/localnet.sh False` -2. Create a subnet -3. Change the following hyperparameters: - - `commit_reveal_weights_enabled` -> `True` - - `tempo` -> 10 (keep in mind that you need to provide this as `tempo` argument to `get_encrypted_commit` function. Use polkadot website for this action.) - - `weights_rate_limit` -> 0 (Reduces the limit when you can set weights.) -4. Register 1 or more wallets to the subnet -5. Create and activate python virtual environment (`python3 -m venv venv && . venv/bin/activate`) -6. Checkout bittensor `feat/roman/cr-v-3` branch. -7. Install bittensor `pip install -e .` -8. Cd to directory you cloned `https://github.com/opentensor/bittensor-commit-reveal/tree/staging` (FFI for CRv3). -9. Install the `maturin` python package and build/install `bittensor-commit-reveal` package to your env using the command `pip install maturin && maturin develop` -10. Run the following script within your python environment: -```python -import requests -import time - -from bittensor import Subtensor, logging, Wallet - -DRAND_API_BASE_URL_Q = "https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" - -logging.set_info() - - -def get_drand_info(uri): - """Fetch Drand network information.""" - url = f"{uri}/info" - response = requests.get(url) - response.raise_for_status() - return response.json() - - -def get_current_round(info): - """Calculate the current round based on genesis_time and period.""" - current_time = int(time.time()) - genesis_time = info["genesis_time"] - period = info["period"] - return (current_time - genesis_time) // period + 1 - - -def main(): - sub = Subtensor("local") - - uids = [0] - weights = [0.7] - - wallet = Wallet() # corresponds the subnet owner wallet - - result, message = sub.set_weights( - wallet=wallet, - netuid=1, - uids=uids, - weights=weights, - wait_for_inclusion=True, - wait_for_finalization=True, - ) - logging.info(f">>> Success, [blue]{result}[/blue], message: [magenta]{message}[/magenta]") - - reveal_round = int(message.split(":")[-1]) - # Fetch Drand network info - for uri in [DRAND_API_BASE_URL_Q]: - print(f"Fetching info from {uri}...") - info = get_drand_info(uri) - print("Info:", info) - - while True: - time.sleep(info["period"]) - current_round = get_current_round(info) - logging.console.info(f"Current round: [yellow]{current_round}[/yellow]") - if current_round == reveal_round: - logging.console.warning(f">>> It's time to reveal the target round: [blue]{reveal_round}[/blue]") - - break - - -if __name__ == "__main__": - main() -``` -11. Wait until your target_round comes. - -12. Check your weights with the following code: - -```python -import bittensor as bt - -sub = bt.Subtensor(network="local") - -netuid = 1 # your created subnet's netuid - -print(sub.weights(netuid=netuid)) -``` -13. You can now see the same weights which you committed earlier \ No newline at end of file +# bittensor-drand + +Drand timelock encryption for Bittensor commit-reveal weights and general-purpose commitments. Rust core with Python bindings via PyO3. + +## What's new in 2.0 + +- **`get_encrypted_commit` is replaced by `get_encrypted_commit_v2`**. The old modulo-based epoch math (`(block + netuid + 1) / (tempo + 1)`) is gone. The chain now uses a stateful epoch counter (`SubnetEpochIndex`), owner-triggered pending epochs, and configurable tempo. The new function simulates the chain's `block_step` pipeline to predict the exact reveal block. +- **Breaking change**: `get_encrypted_commit_v2` requires epoch schedule state instead of `tempo + current_block + netuid`. The SDK (`bittensor>=11.0.0`) provides this via `subtensor.get_epoch_schedule_state(netuid)`. +- Added `encrypt_mlkem768` and `mlkem_kdf_id` for ML-KEM-768 + XChaCha20Poly1305 encryption. + +## Quick start + +```python +from bittensor_drand import get_encrypted_commit_v2 +``` + +## Installation + +```bash +pip install bittensor-drand +``` + +For development (build from source): + +```bash +git clone https://github.com/opentensor/bittensor-drand.git +cd bittensor-drand +python3 -m venv venv +. venv/bin/activate +pip install maturin bittensor +maturin develop +``` + +## Usage: commit-reveal weights + +### Using the SDK (recommended) + +The Bittensor SDK handles all the epoch state plumbing internally. You just call `set_weights`: + +```python +import bittensor as bt + +sub = bt.Subtensor("local") # or "finney" for mainnet +wallet = bt.Wallet() + +result, message = sub.set_weights( + wallet=wallet, + netuid=1, + uids=[0, 1, 2], + weights=[0.5, 0.3, 0.2], + wait_for_inclusion=True, + wait_for_finalization=True, +) +print(f"Success: {result}, message: {message}") +``` + +### Using `bittensor_drand` directly + +If you need lower-level control (custom clients, miners, tooling): + +```python +import bittensor as bt +from bittensor_drand import get_encrypted_commit_v2 + +sub = bt.Subtensor("local") +netuid = 1 +current_block = sub.get_current_block() + +# Fetch epoch schedule state from chain +schedule = sub.get_epoch_schedule_state(netuid, block=current_block) +reveal_period = sub.get_subnet_reveal_period_epochs(netuid=netuid) + +uids = [1, 3] +weights = [100, 200] # u16 values after convert_weights_and_uids_for_emit +version_key = 843000 +wallet = bt.Wallet() + +commit_bytes, reveal_round = get_encrypted_commit_v2( + uids=uids, + weights=weights, + version_key=version_key, + last_epoch_block=schedule.last_epoch_block, + pending_epoch_at=schedule.pending_epoch_at, + subnet_epoch_index=schedule.subnet_epoch_index, + tempo=schedule.tempo, + blocks_since_last_step=schedule.blocks_since_last_step, + current_block=current_block, + subnet_reveal_period_epochs=reveal_period, + block_time=12.0, + hotkey=wallet.hotkey.public_key, +) + +print(f"Encrypted commit: {len(commit_bytes)} bytes, reveal round: {reveal_round}") +``` + +## General-purpose encryption + +### Encrypt a string for N blocks into the future + +```python +from bittensor_drand import get_encrypted_commitment + +encrypted, reveal_round = get_encrypted_commitment( + "my secret data", + blocks_until_reveal=10, + block_time=12.0, +) +print(f"Encrypted: {len(encrypted)} bytes, reveal at round {reveal_round}") +``` + +### Encrypt / decrypt binary data (round-trip) + +```python +from bittensor_drand import encrypt, decrypt + +encrypted, reveal_round = encrypt(b"binary payload", n_blocks=5, block_time=12.0) + +# Later, after the reveal round has passed: +decrypted = decrypt(encrypted, no_errors=False) +``` + +### Encrypt for a specific Drand round + +```python +from bittensor_drand import encrypt_at_round + +encrypted, reveal_round = encrypt_at_round(b"payload", reveal_round=17200000) +``` + +### Batch decryption with a pre-fetched signature + +When decrypting multiple ciphertexts for the same round, fetch the signature once: + +```python +from bittensor_drand import decrypt_with_signature, get_signature_for_round + +sig = get_signature_for_round(reveal_round=17200000) +plaintext = decrypt_with_signature(encrypted, sig) +``` + +## ML-KEM-768 encryption + +For post-quantum key encapsulation (used by the chain's `NextKey` rotation): + +```python +from bittensor_drand import encrypt_mlkem768, mlkem_kdf_id + +# pk_bytes: 1184-byte ML-KEM-768 public key from NextKey storage +blob = encrypt_mlkem768(pk_bytes, b"plaintext", include_key_hash=True) + +# Blob format (include_key_hash=True): +# [key_hash(16)][u16 kem_len LE][kem_ct][nonce24][aead_ct] + +kdf = mlkem_kdf_id() # b"v1" — raw shared secret, no HKDF +``` + +## Testing on a local subnet + +1. Start a local subtensor node with configurable tempo support: + +```bash +LOCALNET_IMAGE_NAME=ghcr.io/opentensor/subtensor-localnet:pr-2638 ./scripts/localnet.sh +``` + +2. Create a subnet and configure hyperparameters: + - Set `commit_reveal_weights_enabled` to `True` + - Set `tempo` to your desired value (e.g. `360`) + - Set `weights_rate_limit` to `0` (for faster testing) + +3. Register a wallet to the subnet. + +4. Run a weight-setting script: + +```python +import bittensor as bt +from bittensor import logging + +logging.set_info() + +sub = bt.Subtensor("local") +wallet = bt.Wallet() + +result, message = sub.set_weights( + wallet=wallet, + netuid=1, + uids=[0], + weights=[1.0], + wait_for_inclusion=True, + wait_for_finalization=True, +) +logging.info(f"Result: {result}, message: {message}") +``` + +5. Wait for the reveal epoch, then verify weights were applied: + +```python +import bittensor as bt + +sub = bt.Subtensor("local") +print(sub.weights(netuid=1)) +``` + +## API reference + +| Function | Description | +|---|---| +| `get_encrypted_commit_v2(...)` | Encrypt weights for commit-reveal using stateful epoch model | +| `get_encrypted_commitment(data, blocks, block_time)` | Timelock-encrypt a string | +| `encrypt(data, n_blocks, block_time)` | Timelock-encrypt binary data | +| `encrypt_at_round(data, reveal_round)` | Encrypt for a specific Drand round | +| `decrypt(data, no_errors=True)` | Decrypt (auto-fetches Drand signature) | +| `decrypt_with_signature(data, sig_hex)` | Decrypt with a pre-fetched signature | +| `get_signature_for_round(round)` | Fetch Drand BLS signature for a round | +| `get_latest_round()` | Get the latest Drand round number | +| `encrypt_mlkem768(pk, plaintext, hash)` | ML-KEM-768 + XChaCha20Poly1305 encryption | +| `mlkem_kdf_id()` | Returns KDF identifier (`b"v1"`) | + +## Build & test + +```bash +pip install maturin +maturin develop +cargo test +pytest tests/ -v +``` diff --git a/bindings.h b/bindings.h index 8e6d639..989264b 100644 --- a/bindings.h +++ b/bindings.h @@ -5,8 +5,6 @@ #include #include -#define SUBTENSOR_PULSE_DELAY 24 - #define GENESIS_TIME 1692803367 #define DRAND_PERIOD 3 @@ -48,16 +46,3 @@ struct CRByteBuffer cr_encrypt_commitment(const uint8_t *data_ptr, double block_time, uint64_t *round_out, char **err_out); - -struct CRByteBuffer cr_generate_commit(const uint16_t *uids_ptr, - uintptr_t uids_len, - const uint16_t *vals_ptr, - uintptr_t vals_len, - uint64_t version_key, - uint64_t tempo, - uint64_t current_block, - uint16_t netuid, - uint64_t subnet_reveal_epochs, - double block_time, - uint64_t *round_out, - char **err_out); diff --git a/bittensor_drand/__init__.py b/bittensor_drand/__init__.py index 5985bc5..5253d2b 100644 --- a/bittensor_drand/__init__.py +++ b/bittensor_drand/__init__.py @@ -1,7 +1,7 @@ from typing import Union, Optional from bittensor_drand.bittensor_drand import ( - get_encrypted_commit as _get_encrypted_commit, + get_encrypted_commit_v2 as _get_encrypted_commit_v2, get_encrypted_commitment as _get_encrypted_commitment, encrypt as _encrypt, encrypt_at_round as _encrypt_at_round, @@ -14,44 +14,53 @@ ) -def get_encrypted_commit( +def get_encrypted_commit_v2( uids: list[int], weights: list[int], version_key: int, + last_epoch_block: int, + pending_epoch_at: int, + subnet_epoch_index: int, tempo: int, + blocks_since_last_step: int, current_block: int, - netuid: int, subnet_reveal_period_epochs: int, block_time: Union[int, float], hotkey: bytes, ) -> tuple[bytes, int]: - """Returns encrypted commit and target round for `commit_crv3_weights` extrinsic. + """Returns encrypted commit and target round using the stateful epoch model (v2). Arguments: uids: The uids to commit. weights: The weights associated with the uids. - version_key: The version key to use for committing and revealing. Default is `bittensor.core.settings.version_as_int`. - tempo: Number of blocks in one epoch. - current_block: The current block number in the network. - netuid: The network unique identifier (NetUID) for the subnet. - subnet_reveal_period_epochs: Number of epochs after which the reveal will be performed. Corresponds to the hyperparameter `commit_reveal_weights_interval` of the subnet. In epochs. - block_time: Amount of time in seconds for one block. Defaults to 12 seconds. - hotkey: The hotkey of a neuron-committer is represented as public_key bytes (wallet.hotkey.public_key). + version_key: The version key to use for committing and revealing. + last_epoch_block: Block at which the last epoch ran for this subnet. + pending_epoch_at: Pending owner-triggered epoch block (0 if none). + subnet_epoch_index: Monotonic epoch counter for the subnet. + tempo: Epoch duration in blocks. + blocks_since_last_step: Blocks since the last step for the subnet. + current_block: Chain head block number. + subnet_reveal_period_epochs: Number of epochs before reveal. + block_time: Amount of time in seconds for one block. + hotkey: Committer hotkey public key bytes (wallet.hotkey.public_key). Returns: - commit (bytes): Raw bytes of the encrypted and compressed uids & weights values for setting weights. - target_round (int): Drand round number when weights have to be revealed. Based on Drand Quicknet network. + commit (bytes): Encrypted and compressed uids & weights payload. + target_round (int): Drand round number when weights can be revealed. Raises: ValueError: If the input parameters are invalid or encryption fails. """ - return _get_encrypted_commit( + return _get_encrypted_commit_v2( uids, weights, version_key, + last_epoch_block, + pending_epoch_at, + subnet_epoch_index, tempo, + blocks_since_last_step, current_block, - netuid, subnet_reveal_period_epochs, block_time, hotkey, @@ -134,8 +143,6 @@ def decrypt(encrypted_data: bytes, no_errors: bool = True) -> Optional[bytes]: def decrypt_with_signature(encrypted_data: bytes, signature_hex: str) -> bytes: """Decrypts data using a provided Drand signature. - This function is useful when decrypting multiple ciphertexts for the same round, - allowing you to fetch the signature once and reuse it, avoiding redundant API calls. Arguments: encrypted_data: The encrypted data to decrypt. @@ -152,8 +159,6 @@ def decrypt_with_signature(encrypted_data: bytes, signature_hex: str) -> bytes: def get_signature_for_round(reveal_round: int) -> str: """Fetches the Drand signature for a specific round. - This is useful for batch decryption scenarios where you want to decrypt - multiple ciphertexts for the same round without making redundant API calls. Arguments: reveal_round: The Drand round number to fetch the signature for. @@ -182,17 +187,10 @@ def get_latest_round() -> int: def encrypt_mlkem768(pk_bytes: bytes, plaintext: bytes, include_key_hash: bool = False) -> bytes: """Encrypts data using ML-KEM-768 + XChaCha20Poly1305. - This function encrypts plaintext using ML-KEM-768 key encapsulation followed by XChaCha20Poly1305 authenticated - encryption. The public key is rotated every block and can be queried from the NextKey storage item. - - Blob format (include_key_hash=False): [u16 kem_len LE][kem_ct][nonce24][aead_ct] - Blob format (include_key_hash=True): [key_hash(16)][u16 kem_len LE][kem_ct][nonce24][aead_ct] - Arguments: pk_bytes: ML-KEM-768 public key bytes (from NextKey storage, 1184 bytes) plaintext: Data to encrypt. include_key_hash: If True, prepends the twox_128 hash of pk_bytes (16 bytes) to the output. - Required for the MEV Shield wire format (pallet-shield v2). Returns: bytes: Encrypted blob @@ -206,17 +204,6 @@ def encrypt_mlkem768(pk_bytes: bytes, plaintext: bytes, include_key_hash: bool = def mlkem_kdf_id() -> bytes: """Returns the KDF identifier used by ML-KEM encryption. - This function returns the KDF (Key Derivation Function) identifier "v1", which indicates that the AEAD key is - derived directly from the ML-KEM shared secret without any additional HKDF or hashing steps. - - The "v1" KDF means: - - AEAD key = raw ML-KEM shared secret (32 bytes) - - No HKDF or additional hashing applied - - AAD (Additional Authenticated Data) = empty - - This identifier is used to verify compatibility between the encryption library and the decryption logic on the - blockchain node. - Returns: bytes: KDF identifier (b"v1") """ diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..efd9bda --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,56 @@ +/// Single source of truth for all compile-time constants used across the crate. + +// ---------- Chain tempo bounds ---------- + +/// Upper bound for owner-set tempo (≈ 7 days at 12 s/block). +pub const MAX_TEMPO: u16 = 50_400; + +/// `MAX_TEMPO` widened for arithmetic with `u64` epoch counters. +pub const MAX_TEMPO_U64: u64 = MAX_TEMPO as u64; + +// ---------- Drand network ---------- + +/// Drand Quicknet BLS public key (hex-encoded). +pub const DRAND_PUBLIC_KEY: &str = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"; + +/// Drand Quicknet genesis timestamp (Unix seconds). +pub const GENESIS_TIME: u64 = 1_692_803_367; + +/// Drand Quicknet round period in seconds. +pub const DRAND_PERIOD: u64 = 3; + +/// Drand Quicknet chain hash. +pub const QUICKNET_CHAIN_HASH: &str = + "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"; + +/// Public Drand HTTP endpoints (tried in order). +pub const DRAND_ENDPOINTS: [&str; 5] = [ + "https://api.drand.sh", + "https://api2.drand.sh", + "https://api3.drand.sh", + "https://drand.cloudflare.com", + "https://api.drand.secureweb3.com:6875", +]; + +// ---------- Commit simulation ---------- + +/// Additional blocks added to the predicted reveal block to target +/// a drand pulse that has already been ingested on-chain. +pub const SECURITY_BLOCK_OFFSET: u64 = 3; + +/// Offset applied to `current_block` to account for standard mempool +/// inclusion delay: the extrinsic lands in the **next** block relative +/// to the chain head queried by the SDK. +pub const COMMIT_INCLUSION_BLOCK_OFFSET: u64 = 1; + +/// Upper bound on blocks simulated when searching for the reveal block. +pub fn max_simulation_blocks(reveal_period_epochs: u64) -> u64 { + reveal_period_epochs + .saturating_mul(MAX_TEMPO_U64) + .saturating_add(MAX_TEMPO_U64) +} + +// ---------- ML-KEM ---------- + +/// XChaCha20Poly1305 nonce length used in ML-KEM seal/open. +pub const MLKEM_NONCE_LEN: usize = 24; diff --git a/src/drand.rs b/src/drand.rs index 0a20dfa..87bc982 100644 --- a/src/drand.rs +++ b/src/drand.rs @@ -14,22 +14,9 @@ use tle::{ }; use w3f_bls::EngineBLS; -const PUBLIC_KEY: &str = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"; -pub const GENESIS_TIME: u64 = 1692803367; -pub const DRAND_PERIOD: u64 = 3; - -/// the drand quicknet chain hash -pub const QUICKNET_CHAIN_HASH: &str = - "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"; - -/// endpoints for fetching round's data -const ENDPOINTS: [&str; 5] = [ - "https://api.drand.sh", - "https://api2.drand.sh", - "https://api3.drand.sh", - "https://drand.cloudflare.com", - "https://api.drand.secureweb3.com:6875", -]; +use crate::constants::{ + DRAND_ENDPOINTS, DRAND_PERIOD, DRAND_PUBLIC_KEY, GENESIS_TIME, QUICKNET_CHAIN_HASH, +}; #[derive(Encode, Decode, Debug, PartialEq)] pub struct WeightsTlockPayload { @@ -70,7 +57,7 @@ pub fn encrypt_and_compress( reveal_round: u64, ) -> Result, (std::io::Error, String)> { // Deserialize public key - let pub_key_bytes = hex::decode(PUBLIC_KEY).map_err(|e| { + let pub_key_bytes = hex::decode(DRAND_PUBLIC_KEY).map_err(|e| { ( std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}", e)), "Decoding public key failed.".to_string(), @@ -166,85 +153,45 @@ pub fn decrypt_and_decompress( Ok(decrypted_bytes) } -/// Generates a commit containing a payload to be encrypted and information about the reveal round, -/// along with the timestamped reveal round for the network. -/// -/// # Arguments -/// -/// * `uids` - A vector of unique identifiers (UIDs). -/// * `values` - A vector of associated values for the UIDs. -/// * `version_key` - A u64 value representing the version key for the commit. -/// * `tempo` - A u64 specifying the tempo (block interval) for the network. -/// * `current_block` - The current block number as u64. -/// * `netuid` - A u16 representing the network's unique identifier. -/// * `subnet_reveal_period_epochs` - A u64 indicating the number of epochs before reveal. -/// * `block_time` - Duration of each block in seconds as u64. -/// * `hotkey` - The hotkey of the committing validator -/// -/// # Returns -/// -/// A `Result` which is: -/// * `Ok((Vec, u64))` containing the encrypted commit payload and the calculated reveal round timestamp if successful. -/// * `Err((std::io::Error, String))` if an error occurs during payload serialization, encryption, or other processing steps. +/// Stateful epoch model commit (v2). /// -pub fn generate_commit( +/// Instead of legacy modulo arithmetic, uses `epoch_schedule::predict_first_reveal_block` +/// to simulate the chain's `block_step` pipeline and find the exact reveal block. +pub fn generate_commit_v2( uids: Vec, values: Vec, version_key: u64, - tempo: u64, - current_block: u64, - netuid: u16, + state: crate::epoch_schedule::EpochScheduleState, subnet_reveal_period_epochs: u64, block_time: f64, hotkey: Vec, ) -> Result<(Vec, u64), (std::io::Error, String)> { - //---------------------------------------------------------------------- - // 1 ▸ derive the first block of the reveal epoch - //---------------------------------------------------------------------- - let tempo_plus_one = tempo.saturating_add(1); - let netuid_plus_one = netuid as u64 + 1; - - // epoch index of `current_block` - let current_epoch = (current_block + netuid_plus_one) / tempo_plus_one; - - // epoch index in which the commit must be revealed - let reveal_epoch = current_epoch + subnet_reveal_period_epochs; - - // very first block *inside* the reveal epoch - let first_reveal_blk = reveal_epoch - .saturating_mul(tempo_plus_one) - .saturating_sub(netuid_plus_one); - - //---------------------------------------------------------------------- - // 2 ▸ decide in which *block* we want the pulse to be emitted - // – we aim for first_reveal_blk + 3 - //---------------------------------------------------------------------- - pub const SECURITY_BLOCK_OFFSET: u64 = 3; - let target_ingest_blk = first_reveal_blk.saturating_add(SECURITY_BLOCK_OFFSET); - let blocks_until_ingest = target_ingest_blk.saturating_sub(current_block); + let first_reveal_blk = + crate::epoch_schedule::predict_first_reveal_block(&state, subnet_reveal_period_epochs) + .map_err(|e| { + ( + std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()), + e.to_string(), + ) + })?; + + let target_ingest_blk = + first_reveal_blk.saturating_add(crate::constants::SECURITY_BLOCK_OFFSET); + let blocks_until_ingest = target_ingest_blk.saturating_sub(state.current_block); let secs_until_ingest = blocks_until_ingest as f64 * block_time; - //---------------------------------------------------------------------- - // 3 ▸ convert the desired timestamp into a DRAND round - //---------------------------------------------------------------------- let now_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs_f64(); let target_secs = now_secs + secs_until_ingest; - - // Round ***down*** so we never request a not‑yet‑ingested pulse. let mut reveal_round = ((target_secs - GENESIS_TIME as f64) / DRAND_PERIOD as f64).floor() as u64; - if reveal_round < 1 { reveal_round = 1; } - //---------------------------------------------------------------------- - // 5 ▸ build & encrypt payload - //---------------------------------------------------------------------- let payload = WeightsTlockPayload { hotkey, uids, @@ -253,9 +200,9 @@ pub fn generate_commit( }; let ct_bytes = encrypt_and_compress(&payload.encode(), reveal_round)?; - Ok((ct_bytes, reveal_round)) } + /// Encrypts a string-based commitment using Drand timelock encryption for a future reveal round. /// /// This function encodes the input `data` and calculates the corresponding Drand round number @@ -335,7 +282,7 @@ pub fn get_round_info(round: Option) -> Result { rt.block_on(async { let mut last_error = None; - for endpoint in ENDPOINTS.iter() { + for endpoint in DRAND_ENDPOINTS.iter() { let url = match round { Some(r) => format!("{}/{}/public/{}", endpoint, QUICKNET_CHAIN_HASH, r), None => format!("{}/{}/public/latest", endpoint, QUICKNET_CHAIN_HASH), @@ -438,7 +385,6 @@ pub extern "C" fn drand_endpoint(idx: usize) -> *const c_char { #[cfg(test)] mod tests { use super::*; - use codec::Decode; #[test] fn test_encrypt_and_decrypt_static_key() { @@ -481,24 +427,25 @@ mod tests { } #[test] - fn test_generate_commit_structure() { + fn test_generate_commit_v2_structure() { + let state = crate::epoch_schedule::EpochScheduleState { + last_epoch_block: 100, + pending_epoch_at: 0, + subnet_epoch_index: 0, + tempo: 50, + blocks_since_last_step: 0, + current_block: 120, + }; let uids = vec![1, 2, 3]; let values = vec![100, 200, 300]; - let version_key = 42; - let tempo = 20; - let current_block = 1000; - let netuid = 1; - let reveal_epochs = 3; let hotkey = vec![1, 2, 3]; - let (encrypted, reveal_round) = generate_commit( + let (encrypted, reveal_round) = generate_commit_v2( uids.clone(), values.clone(), - version_key, - tempo, - current_block, - netuid, - reveal_epochs, + 42, + state, + 1, 12.0, hotkey.clone(), ) @@ -506,21 +453,5 @@ mod tests { assert!(!encrypted.is_empty()); assert!(reveal_round > 0); - - let decrypted_signature = get_reveal_round_signature(Some(reveal_round), true) - .unwrap_or(None) - .unwrap_or_default(); - - if !decrypted_signature.is_empty() { - let sig_bytes = hex::decode(&decrypted_signature).unwrap(); - let plaintext = decrypt_and_decompress(&encrypted, &sig_bytes).unwrap(); - let payload = WeightsTlockPayload::decode(&mut &plaintext[..]) - .expect("Decoded payload must be valid"); - - assert_eq!(payload.uids, uids); - assert_eq!(payload.values, values); - assert_eq!(payload.version_key, version_key); - assert_eq!(payload.hotkey, hotkey) - } } } diff --git a/src/epoch_schedule.rs b/src/epoch_schedule.rs new file mode 100644 index 0000000..01d09a8 --- /dev/null +++ b/src/epoch_schedule.rs @@ -0,0 +1,255 @@ +/// Epoch scheduling state machine for the stateful tempo model. +/// +/// Ports a minimal subset of `subtensor` epoch logic required to predict +/// reveal blocks for timelock-encrypted commits. See `constants.rs` for +/// chain-verified bounds. + +use crate::constants::{max_simulation_blocks, COMMIT_INCLUSION_BLOCK_OFFSET, MAX_TEMPO_U64}; + +/// Snapshot of on-chain epoch schedule state at a given block. +/// +/// Mirrors the Python `EpochScheduleState` dataclass and contains +/// exactly the fields the SDK reads from storage before calling +/// `generate_commit_v2`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EpochScheduleState { + pub last_epoch_block: u64, + pub pending_epoch_at: u64, + pub subnet_epoch_index: u64, + pub tempo: u16, + pub blocks_since_last_step: u64, + pub current_block: u64, +} + +/// Error returned when the reveal-block simulation exceeds its budget. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EpochScheduleError { + BoundExceeded, + TempoIsZero, +} + +impl std::fmt::Display for EpochScheduleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BoundExceeded => write!(f, "reveal block simulation exceeded budget"), + Self::TempoIsZero => write!(f, "tempo is zero; subnet does not run epochs"), + } + } +} + +impl std::error::Error for EpochScheduleError {} + +/// Port of `run_coinbase.rs:1043-1057`. +pub fn should_run_epoch(state: &EpochScheduleState, block: u64) -> bool { + let tempo = state.tempo; + if tempo == 0 { + return false; + } + let pending = state.pending_epoch_at; + if pending > 0 && block >= pending { + return true; + } + if state.blocks_since_last_step > MAX_TEMPO_U64 { + return true; + } + let blocks_since = block.saturating_sub(state.last_epoch_block); + blocks_since >= tempo as u64 +} + +/// Port of `weights.rs:1275-1282` — the epoch index used by +/// `reveal_crv3_commits` (runs **before** `run_coinbase` in `block_step`). +pub fn current_epoch_pre_run_coinbase(state: &EpochScheduleState, block: u64) -> u64 { + let base = state.subnet_epoch_index; + if should_run_epoch(state, block) { + base.saturating_add(1) + } else { + base + } +} + +/// Simulate the effect of `run_coinbase` on the epoch state for one block. +/// +/// Port of `run_coinbase.rs:337-403` (subset). +/// Does **not** model `MaxEpochsPerBlock` deferral. +pub fn simulate_run_coinbase(state: &EpochScheduleState, block: u64) -> EpochScheduleState { + let mut next = state.clone(); + next.blocks_since_last_step = next.blocks_since_last_step.saturating_add(1); + next.current_block = block; + + if should_run_epoch(&next, block) { + next.last_epoch_block = block; + next.pending_epoch_at = 0; + next.subnet_epoch_index = next.subnet_epoch_index.saturating_add(1); + next.blocks_since_last_step = 0; + } + next +} + +/// Apply `simulate_run_coinbase` for each block in `start..=end`. +/// If `start > end`, returns a clone of `from`. +pub fn advance_blocks(from: &EpochScheduleState, start: u64, end: u64) -> EpochScheduleState { + let mut state = from.clone(); + if start > end { + return state; + } + for b in start..=end { + state = simulate_run_coinbase(&state, b); + } + state +} + +/// Predict the first block at which the chain will reveal a commit +/// submitted against `head_state`. +/// +/// The algorithm accounts for `COMMIT_INCLUSION_BLOCK_OFFSET` and the +/// two-phase `block_step` pipeline (reveal runs before `run_coinbase`). +pub fn predict_first_reveal_block( + head_state: &EpochScheduleState, + reveal_period_epochs: u64, +) -> Result { + if head_state.tempo == 0 { + return Err(EpochScheduleError::TempoIsZero); + } + + let head_block = head_state.current_block; + let extrinsic_block = head_block + COMMIT_INCLUSION_BLOCK_OFFSET; + + // Advance state from head to just before the extrinsic block + let post_before_extrinsic = if extrinsic_block == head_block + 1 { + head_state.clone() + } else { + advance_blocks(head_state, head_block + 1, extrinsic_block - 1) + }; + + // Commit epoch: extrinsic runs after run_coinbase at extrinsic_block + let commit_epoch = + current_epoch_pre_run_coinbase(&post_before_extrinsic, extrinsic_block); + + let target_epoch = commit_epoch + reveal_period_epochs; + + let max_sim = max_simulation_blocks(reveal_period_epochs); + + let mut post_prev = post_before_extrinsic; + for r in extrinsic_block..=extrinsic_block.saturating_add(max_sim) { + if current_epoch_pre_run_coinbase(&post_prev, r) == target_epoch { + return Ok(r); + } + post_prev = simulate_run_coinbase(&post_prev, r); + } + + Err(EpochScheduleError::BoundExceeded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_run_epoch_tempo_zero() { + let state = EpochScheduleState { + last_epoch_block: 100, + pending_epoch_at: 0, + subnet_epoch_index: 0, + tempo: 0, + blocks_since_last_step: 0, + current_block: 120, + }; + assert!(!should_run_epoch(&state, 200)); + } + + #[test] + fn should_run_epoch_auto_fire() { + let state = EpochScheduleState { + last_epoch_block: 100, + pending_epoch_at: 0, + subnet_epoch_index: 0, + tempo: 50, + blocks_since_last_step: 0, + current_block: 120, + }; + assert!(!should_run_epoch(&state, 149)); + assert!(should_run_epoch(&state, 150)); + } +} + +#[cfg(test)] +mod integration { + use super::*; + use crate::constants::COMMIT_INCLUSION_BLOCK_OFFSET; + use crate::epoch_schedule_vectors::{commit_epoch_vectors, predict_vectors}; + + #[test] + fn predict_first_reveal_block_table() { + for case in predict_vectors() { + let result = predict_first_reveal_block(&case.state, case.reveal_period_epochs); + match (&case.expected_error, &case.expected_reveal_block) { + (Some(expected_err), None) => { + assert_eq!(result, Err(expected_err.clone()), "case {}", case.name); + } + (None, Some(expected_block)) => { + assert_eq!( + result.unwrap(), + *expected_block, + "case {} ({})", + case.name, + case.source + ); + } + _ => panic!("invalid vector definition for {}", case.name), + } + } + } + + #[test] + fn commit_epoch_at_extrinsic_block_table() { + for case in commit_epoch_vectors() { + let head_block = case.state.current_block; + let extrinsic_block = head_block + COMMIT_INCLUSION_BLOCK_OFFSET; + let post_before = if extrinsic_block == head_block + 1 { + case.state.clone() + } else { + advance_blocks(&case.state, head_block + 1, extrinsic_block - 1) + }; + let commit_epoch = current_epoch_pre_run_coinbase(&post_before, extrinsic_block); + assert_eq!( + commit_epoch, case.expected_commit_epoch, + "case {}", + case.name + ); + } + } + + #[test] + fn reveal_uses_exact_equality_not_gte() { + let case = &predict_vectors()[2]; + let reveal_block = + predict_first_reveal_block(&case.state, case.reveal_period_epochs) + .expect("expected reveal block"); + assert!(reveal_block > case.state.current_block); + + let prior = reveal_block.saturating_sub(1); + let head = case.state.current_block; + let extrinsic_block = head + COMMIT_INCLUSION_BLOCK_OFFSET; + let post_before = if extrinsic_block == head + 1 { + case.state.clone() + } else { + advance_blocks(&case.state, head + 1, extrinsic_block - 1) + }; + let commit_epoch = current_epoch_pre_run_coinbase(&post_before, extrinsic_block); + let target_epoch = commit_epoch + case.reveal_period_epochs; + + let mut post_prev = post_before; + for r in extrinsic_block..prior { + if current_epoch_pre_run_coinbase(&post_prev, r) == target_epoch { + panic!( + "reveal block {prior} should not satisfy equality yet (found at {r})" + ); + } + post_prev = simulate_run_coinbase(&post_prev, r); + } + assert_eq!( + current_epoch_pre_run_coinbase(&post_prev, reveal_block), + target_epoch + ); + } +} diff --git a/src/ffi.rs b/src/ffi.rs index 6600a58..5b2b55f 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -126,8 +126,8 @@ pub extern "C" fn cr_encrypt( .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs_f64(); - let reveal_timestamp = (n_blocks as f64 * block_time + now).ceil() as u64 - drand::GENESIS_TIME; - let reveal_round = reveal_timestamp / drand::DRAND_PERIOD; + let reveal_timestamp = (n_blocks as f64 * block_time + now).ceil() as u64 - crate::constants::GENESIS_TIME; + let reveal_round = reveal_timestamp / crate::constants::DRAND_PERIOD; match drand::encrypt_and_compress(data, reveal_round) { Ok(ct) => { @@ -358,109 +358,6 @@ pub extern "C" fn cr_encrypt_commitment( } } -/// Generates a commitment for a set of UIDs and values using Drand timelock encryption. -/// -/// This function creates a commitment for voting/scoring in the Bittensor network, -/// encrypting the UIDs and their corresponding values to be revealed at a future time. -/// -/// # Parameters -/// -/// * `uids_ptr` - Pointer to an array of UIDs (u16 values) -/// * `uids_len` - Length of the UIDs array -/// * `vals_ptr` - Pointer to an array of values (u16 values) -/// * `vals_len` - Length of the values array -/// * `version_key` - Version key for the commitment -/// * `tempo` - Tempo value for the commitment -/// * `current_block` - Current block number -/// * `netuid` - Network UID -/// * `subnet_reveal_epochs` - Number of epochs to wait before revealing -/// * `block_time` - Duration of a single block in seconds -/// * `hotkey_ptr` - Pointer to a byte array representing the hotkey (Vec) -/// * `hotkey_len` - Length of the hotkey byte array -/// * `round_out` - Output parameter that will be set to the reveal round number -/// * `err_out` - Output parameter that will be set to an error message on failure -/// -/// # Returns -/// -/// A `CRByteBuffer` containing the encrypted commitment, or an empty buffer on error. -/// The caller is responsible for freeing the buffer with `cr_free`. -/// -/// # Safety -/// -/// The caller must ensure that: -/// - `uids_ptr` points to valid memory of at least `uids_len` u16 elements -/// - `vals_ptr` points to valid memory of at least `vals_len` u16 elements -/// - `round_out` and `err_out` point to valid memory locations -#[no_mangle] -pub extern "C" fn cr_generate_commit( - uids_ptr: *const u16, - uids_len: usize, - vals_ptr: *const u16, - vals_len: usize, - version_key: u64, - tempo: u64, - current_block: u64, - netuid: u16, - subnet_reveal_epochs: u64, - block_time: f64, - hotkey_ptr: *const u8, - hotkey_len: usize, - round_out: *mut u64, - err_out: *mut *mut c_char, -) -> CRByteBuffer { - unsafe { *err_out = ptr::null_mut() } - - if (uids_ptr.is_null() && uids_len > 0) - || (vals_ptr.is_null() && vals_len > 0) - || (hotkey_ptr.is_null() && hotkey_len > 0) - { - unsafe { *err_out = err_to_cstring("uids/values/hotkey ptr is null") }; - return CRByteBuffer { - ptr: ptr::null_mut(), - len: 0, - cap: 0, - }; - } - - if uids_len != vals_len { - unsafe { *err_out = err_to_cstring("uids_len != vals_len") }; - return CRByteBuffer { - ptr: ptr::null_mut(), - len: 0, - cap: 0, - }; - } - - let uids = unsafe { slice::from_raw_parts(uids_ptr, uids_len) }.to_vec(); - let values = unsafe { slice::from_raw_parts(vals_ptr, vals_len) }.to_vec(); - let hotkey = unsafe { slice::from_raw_parts(hotkey_ptr, hotkey_len) }.to_vec(); - - match drand::generate_commit( - uids, - values, - version_key, - tempo, - current_block, - netuid, - subnet_reveal_epochs, - block_time, - hotkey, - ) { - Ok((ct, rr)) => { - unsafe { *round_out = rr } - CRByteBuffer::from_vec(ct) - } - Err((ioe, msg)) => { - unsafe { *err_out = err_to_cstring(format!("{msg}: {ioe}")) }; - CRByteBuffer { - ptr: ptr::null_mut(), - len: 0, - cap: 0, - } - } - } -} - // ============================================================================ // ML-KEM-768 FFI functions (ported from mlkemffi) // ============================================================================ @@ -476,7 +373,7 @@ use ml_kem::{Encoded, EncodedSizeUser, MlKem768Params}; use rand_core::{OsRng, RngCore}; use twox_hash::XxHash64; -const MLKEM_NONCE_LEN: usize = 24; +use crate::constants::MLKEM_NONCE_LEN; /// Computes Substrate-compatible `twox_128` hash (xxHash64 with seeds 0 and 1). fn twox_128(data: &[u8]) -> [u8; 16] { @@ -919,111 +816,7 @@ mod tests { } // --------------------------------------------------------------- - // 11. generate_commit success - // --------------------------------------------------------------- - #[test] - fn test_generate_commit_success() { - let uids: [u16; 3] = [1, 2, 3]; - let vals: [u16; 3] = [10, 20, 30]; - let hotkey: [u8; 3] = [11, 22, 33]; - - let mut round: u64 = 0; - let mut err_ptr: *mut c_char = ptr::null_mut(); - - let buf = cr_generate_commit( - uids.as_ptr(), - uids.len(), - vals.as_ptr(), - vals.len(), - 42, // version_key - 20, // tempo - 10_000, // current_block - 1, // netuid - 2, // subnet_reveal_epochs - 12.0, // block_time - hotkey.as_ptr(), - hotkey.len(), - &mut round, - &mut err_ptr, - ); - - assert!(err_ptr.is_null(), "err_out should be NULL on success"); - assert!(round > 0); - assert!(!buf.ptr.is_null() && buf.len > 0); - - cr_free(buf); - } - - // --------------------------------------------------------------- - // 12. generate_commit with NULL uids pointer - // --------------------------------------------------------------- - #[test] - fn test_generate_commit_null_uids() { - let vals: [u16; 2] = [1, 2]; - let hotkey: [u8; 3] = [11, 22, 33]; - let mut round: u64 = 0; - let mut err_ptr: *mut c_char = ptr::null_mut(); - - let buf = cr_generate_commit( - ptr::null(), // NULL uids - 2, // non-zero len - vals.as_ptr(), - vals.len(), - 0, - 0, - 0, - 0, - 0, - 12.0, - hotkey.as_ptr(), - hotkey.len(), - &mut round, - &mut err_ptr, - ); - - assert!(buf.ptr.is_null()); - assert!(!err_ptr.is_null()); - unsafe { drop_cstring(err_ptr) }; - } - - // --------------------------------------------------------------- - // 13. generate_commit mismatched lengths - // --------------------------------------------------------------- - #[test] - fn test_generate_commit_mismatched_lengths() { - let uids: [u16; 2] = [1, 2]; - let vals: [u16; 3] = [10, 20, 30]; - let hotkey: [u8; 3] = [11, 22, 33]; - let mut round: u64 = 0; - let mut err_ptr: *mut c_char = ptr::null_mut(); - - let buf = cr_generate_commit( - uids.as_ptr(), - uids.len(), - vals.as_ptr(), - vals.len(), - 0, - 0, - 0, - 0, - 0, - 12.0, - hotkey.as_ptr(), - hotkey.len(), - &mut round, - &mut err_ptr, - ); - - if err_ptr.is_null() { - panic!("expected error on mismatched lengths"); - } else { - assert!(buf.ptr.is_null()); - unsafe { drop_cstring(err_ptr) }; - } - } - - // --------------------------------------------------------------- - // 14. double free on NULL buffer (should NO-OP) + // 11. double free on NULL buffer (should NO-OP) // --------------------------------------------------------------- #[test] fn test_double_free_no_crash() { diff --git a/src/lib.rs b/src/lib.rs index d78f453..465cbe5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,7 @@ +pub mod constants; mod drand; +pub mod epoch_schedule; +#[cfg(test)] +mod epoch_schedule_vectors; mod ffi; mod python_bindings; diff --git a/src/python_bindings.rs b/src/python_bindings.rs index 50f6830..3b84c09 100644 --- a/src/python_bindings.rs +++ b/src/python_bindings.rs @@ -6,54 +6,61 @@ use pyo3::types::PyBytes; use pyo3::{pyfunction, pymodule, wrap_pyfunction, Bound, PyResult, Python}; use std::time::{SystemTime, UNIX_EPOCH}; -/// Returns a timelock-encrypted commitment and its corresponding Drand reveal round. +/// Returns a timelock-encrypted commitment using the stateful epoch model (v2). /// -/// This function is used to generate an encrypted commitment based on UID weights and -/// subnet reveal parameters. The commitment will be decryptable only after a calculated -/// Drand round based on `tempo`, `current_block`, and `subnet_reveal_period_epochs`. +/// Builds an internal ``EpochScheduleState`` from the provided scalar kwargs and +/// simulates the chain's block pipeline to find the reveal block. /// /// Args: /// uids (List[int]): List of UID integers. -/// weights (List[int]): Corresponding list of weight values (same length as `uids`). +/// weights (List[int]): Corresponding list of weight values (same length as ``uids``). /// version_key (int): A version identifier for this commitment. -/// tempo (int): Block interval for the subnet (tempo parameter). -/// current_block (int): The current block number in the chain. -/// netuid (int): Subnet identifier. -/// subnet_reveal_period_epochs (int): Number of epochs to wait before decryption. -/// block_time (float, optional): Block time in seconds (default = 12.0). -/// hotkey (bytes): The hotkey of a neuron-committer is represented as public_key bytes +/// last_epoch_block (int): Block at which the last epoch ran. +/// pending_epoch_at (int): Pending owner-triggered epoch block (0 if none). +/// subnet_epoch_index (int): Monotonic epoch counter. +/// tempo (int): Epoch duration in blocks. +/// blocks_since_last_step (int): Blocks since last step for the subnet. +/// current_block (int): Chain head block number. +/// subnet_reveal_period_epochs (int): Number of epochs before reveal. +/// block_time (float): Block time in seconds. +/// hotkey (bytes): Committer hotkey public key bytes. /// /// Returns: -/// Tuple[bytes, int]: A tuple containing: -/// - the encrypted commitment (as bytes) -/// - the reveal round number (int) when it can be decrypted. +/// Tuple[bytes, int]: encrypted commitment and reveal round. #[pyfunction] -#[pyo3(signature = (uids, weights, version_key, tempo, current_block, netuid, subnet_reveal_period_epochs, block_time, hotkey))] -fn get_encrypted_commit( +#[pyo3(signature = (uids, weights, version_key, last_epoch_block, pending_epoch_at, subnet_epoch_index, tempo, blocks_since_last_step, current_block, subnet_reveal_period_epochs, block_time, hotkey))] +fn get_encrypted_commit_v2( py: Python, uids: Vec, weights: Vec, version_key: u64, - tempo: u64, + last_epoch_block: u64, + pending_epoch_at: u64, + subnet_epoch_index: u64, + tempo: u16, + blocks_since_last_step: u64, current_block: u64, - netuid: u16, subnet_reveal_period_epochs: u64, block_time: f64, hotkey: Vec, ) -> PyResult<(Py, u64)> { - // create runtime to make async call - let result = drand::generate_commit( + let state = crate::epoch_schedule::EpochScheduleState { + last_epoch_block, + pending_epoch_at, + subnet_epoch_index, + tempo, + blocks_since_last_step, + current_block, + }; + let result = drand::generate_commit_v2( uids, weights, version_key, - tempo, - current_block, - netuid, + state, subnet_reveal_period_epochs, block_time, hotkey, ); - // matching the result match result { Ok((ciphertext, target_round)) => { let py_bytes = PyBytes::new(py, &ciphertext).into(); @@ -132,8 +139,8 @@ fn encrypt( .map_err(|e| PyValueError::new_err(format!("SystemTime error: {:?}", e)))? .as_secs_f64(); - let reveal_timestamp = (n_blocks as f64 * block_time + now).ceil() as u64 - drand::GENESIS_TIME; - let reveal_round = reveal_timestamp / drand::DRAND_PERIOD; + let reveal_timestamp = (n_blocks as f64 * block_time + now).ceil() as u64 - crate::constants::GENESIS_TIME; + let reveal_round = reveal_timestamp / crate::constants::DRAND_PERIOD; let encrypted_data = drand::encrypt_and_compress(data, reveal_round) .map_err(|e| PyValueError::new_err(format!("Encryption failed: {:?}", e)))?; @@ -193,7 +200,7 @@ fn encrypt_at_round( /// fetches the corresponding Drand signature (if available), and decrypts the message. /// /// Args: -/// encrypted_data (bytes): Data previously returned from `encrypt` or `get_encrypted_commit`. +/// encrypted_data (bytes): Data previously returned from `encrypt` or `get_encrypted_commit_v2`. /// no_errors (bool, optional): If True, suppresses errors and returns None instead (default = True). /// /// Returns: @@ -362,7 +369,7 @@ fn mlkem_kdf_id(py: Python) -> PyResult> { #[pymodule] fn bittensor_drand(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(get_encrypted_commit, m)?)?; + m.add_function(wrap_pyfunction!(get_encrypted_commit_v2, m)?)?; m.add_function(wrap_pyfunction!(get_encrypted_commitment, m)?)?; m.add_function(wrap_pyfunction!(encrypt, m)?)?; m.add_function(wrap_pyfunction!(encrypt_at_round, m)?)?; From 933208711b4468ee2fdaa58de0e7bbd858320093 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 10 Jun 2026 15:58:37 -0700 Subject: [PATCH 2/6] add v2 epoch schedule vectors and python contract tests, remove v1 tests --- pyproject.toml | 2 +- src/epoch_schedule_vectors.rs | 86 ++++++++++++++ tests/test_all_functions.py | 29 +++-- tests/test_commit_reveal.py | 197 --------------------------------- tests/test_commit_reveal_v2.py | 40 +++++++ 5 files changed, 141 insertions(+), 213 deletions(-) create mode 100644 src/epoch_schedule_vectors.rs delete mode 100644 tests/test_commit_reveal.py create mode 100644 tests/test_commit_reveal_v2.py diff --git a/pyproject.toml b/pyproject.toml index 22be3af..77aa7f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bittensor-drand" -version = "1.3.0" +version = "2.0.0" description = "" readme = "README.md" license = {file = "LICENSE"} diff --git a/src/epoch_schedule_vectors.rs b/src/epoch_schedule_vectors.rs new file mode 100644 index 0000000..05dd3c9 --- /dev/null +++ b/src/epoch_schedule_vectors.rs @@ -0,0 +1,86 @@ +//! Table-driven epoch schedule test vectors. +//! +//! Numeric expectations are pinned against `bittensor_drand::epoch_schedule` and +//! cite the subtensor tests that motivated each scenario. + +use crate::epoch_schedule::{EpochScheduleError, EpochScheduleState}; + +pub struct PredictVector { + pub name: &'static str, + pub source: &'static str, + pub state: EpochScheduleState, + pub reveal_period_epochs: u64, + pub expected_reveal_block: Option, + pub expected_error: Option, +} + +pub fn predict_vectors() -> &'static [PredictVector] { + &[ + PredictVector { + name: "tempo_zero", + source: "epoch_schedule.rs tempo guard", + state: EpochScheduleState { + last_epoch_block: 10, + pending_epoch_at: 0, + subnet_epoch_index: 0, + tempo: 0, + blocks_since_last_step: 0, + current_block: 10, + }, + reveal_period_epochs: 1, + expected_reveal_block: None, + expected_error: Some(EpochScheduleError::TempoIsZero), + }, + PredictVector { + name: "cycle_reset", + source: "subtensor tempo_control.rs:195 get_next_epoch_start_block_reflects_set_tempo_cycle_reset", + state: EpochScheduleState { + last_epoch_block: 10, + pending_epoch_at: 0, + subnet_epoch_index: 0, + tempo: 50, + blocks_since_last_step: 0, + current_block: 10, + }, + reveal_period_epochs: 1, + expected_reveal_block: Some(60), + expected_error: None, + }, + PredictVector { + name: "pending_fires_before_auto", + source: "subtensor tempo_control.rs pending epoch path", + state: EpochScheduleState { + last_epoch_block: 80, + pending_epoch_at: 95, + subnet_epoch_index: 0, + tempo: 20, + blocks_since_last_step: 0, + current_block: 91, + }, + reveal_period_epochs: 1, + expected_reveal_block: Some(95), + expected_error: None, + }, + ] +} + +pub struct CommitEpochVector { + pub name: &'static str, + pub state: EpochScheduleState, + pub expected_commit_epoch: u64, +} + +pub fn commit_epoch_vectors() -> &'static [CommitEpochVector] { + &[CommitEpochVector { + name: "commit_epoch_at_extrinsic_block", + state: EpochScheduleState { + last_epoch_block: 100, + pending_epoch_at: 0, + subnet_epoch_index: 0, + tempo: 50, + blocks_since_last_step: 0, + current_block: 120, + }, + expected_commit_epoch: 0, + }] +} diff --git a/tests/test_all_functions.py b/tests/test_all_functions.py index e10e74d..dcc92ed 100644 --- a/tests/test_all_functions.py +++ b/tests/test_all_functions.py @@ -131,30 +131,29 @@ def test_get_encrypted_commitment(): assert isinstance(round_, int) -def test_get_encrypted_commit(): +def test_get_encrypted_commit_v2(): + # cycle_reset row from src/epoch_schedule_vectors.rs uids = [0, 1] weights = [100, 200] version_key = 1 - tempo = 10 - current_block = 100 - netuid = 1 - subnet_reveal_period_epochs = 2 - block_time = 12 - hotkey = bytes([1, 2, 3]) - - encrypted, round_ = btcr.get_encrypted_commit( + encrypted, round_ = btcr.get_encrypted_commit_v2( uids, weights, version_key, - tempo, - current_block, - netuid, - subnet_reveal_period_epochs, - block_time, - hotkey, + last_epoch_block=10, + pending_epoch_at=0, + subnet_epoch_index=0, + tempo=50, + blocks_since_last_step=0, + current_block=10, + subnet_reveal_period_epochs=1, + block_time=12.0, + hotkey=bytes([1, 2, 3]), ) assert isinstance(encrypted, bytes) + assert len(encrypted) > 0 assert isinstance(round_, int) + assert round_ > 0 # ML-KEM-768 test key (1184 bytes) - valid ML-KEM-768 public key diff --git a/tests/test_commit_reveal.py b/tests/test_commit_reveal.py deleted file mode 100644 index 1d3f9f1..0000000 --- a/tests/test_commit_reveal.py +++ /dev/null @@ -1,197 +0,0 @@ -import time - -from bittensor_drand import get_encrypted_commit - -SUBTENSOR_PULSE_DELAY = 24 -PERIOD = 3 # Drand period in seconds -GENESIS_TIME = 1692803367 - - -def test_get_encrypted_commits(): - uids = [1, 2] - weights = [11, 22] - version_key = 50 - tempo = 100 - current_block = 1000 - netuid = 1 - reveal_period = 2 - block_time = 12 - hotkey = bytes([1, 2, 3]) - - start_time = int(time.time()) - ct_pybytes, reveal_round = get_encrypted_commit( - uids, - weights, - version_key, - tempo, - current_block, - netuid, - reveal_period, - block_time, - hotkey, - ) - - # Basic checks - assert ct_pybytes is not None and len(ct_pybytes) > 0, ( - "Ciphertext should not be empty" - ) - assert reveal_round > 0, "Reveal round should be positive" - - expected_reveal_round, _, _ = compute_expected_reveal_round( - start_time, tempo, current_block, netuid, reveal_period, block_time - ) - - # The reveal_round should be close to what we predict - assert abs(reveal_round - expected_reveal_round) <= 1, ( - f"Reveal round {reveal_round} not close to expected {expected_reveal_round}" - ) - - -def test_generate_commit_success(): - uids = [1, 2, 3] - values = [10, 20, 30] - version_key = 42 - tempo = 50 - current_block = 500 - netuid = 100 - subnet_reveal_period_epochs = 2 - block_time = 12 - hotkey = bytes([1, 2, 3]) - - start_time = int(time.time()) - ct_pybytes, reveal_round = get_encrypted_commit( - uids, - values, - version_key, - tempo, - current_block, - netuid, - subnet_reveal_period_epochs, - block_time, - hotkey, - ) - - assert ct_pybytes is not None and len(ct_pybytes) > 0, ( - "Ciphertext should not be empty" - ) - assert reveal_round > 0, "Reveal round should be positive" - - expected_reveal_round, expected_reveal_time, time_until_reveal = ( - compute_expected_reveal_round( - start_time, - tempo, - current_block, - netuid, - subnet_reveal_period_epochs, - block_time, - ) - ) - - assert abs(reveal_round - expected_reveal_round) <= 1, ( - f"Reveal round {reveal_round} differs from expected {expected_reveal_round}" - ) - - required_lead_time = SUBTENSOR_PULSE_DELAY * PERIOD - computed_reveal_time = ( - GENESIS_TIME + (reveal_round + SUBTENSOR_PULSE_DELAY) * PERIOD - ) - assert computed_reveal_time - start_time >= required_lead_time, ( - "Not enough lead time before reveal. " - f"computed_reveal_time={computed_reveal_time}, start_time={start_time}, required={required_lead_time}" - ) - - assert time_until_reveal >= SUBTENSOR_PULSE_DELAY * PERIOD, ( - f"time_until_reveal {time_until_reveal} is less than required {SUBTENSOR_PULSE_DELAY * PERIOD}" - ) - - -def test_generate_commit_various_tempos(): - NETUID = 1 - CURRENT_BLOCK = 100_000 - SUBNET_REVEAL_PERIOD_EPOCHS = 1 - BLOCK_TIME = 12 - TEMPOS = [10, 50, 100, 250, 360, 500, 750, 1000] - - uids = [0] - values = [100] - version_key = 1 - hotkey = bytes([1, 2, 3]) - - for tempo in TEMPOS: - start_time = int(time.time()) - - ct_pybytes, reveal_round = get_encrypted_commit( - uids, - values, - version_key, - tempo, - CURRENT_BLOCK, - NETUID, - SUBNET_REVEAL_PERIOD_EPOCHS, - BLOCK_TIME, - hotkey, - ) - - assert len(ct_pybytes) > 0, f"Ciphertext is empty for tempo {tempo}" - assert reveal_round > 0, f"Reveal round is zero or negative for tempo {tempo}" - - expected_reveal_round, _, time_until_reveal = compute_expected_reveal_round( - start_time, - tempo, - CURRENT_BLOCK, - NETUID, - SUBNET_REVEAL_PERIOD_EPOCHS, - BLOCK_TIME, - ) - - assert abs(reveal_round - expected_reveal_round) <= 1, ( - f"Tempo {tempo}: reveal_round {reveal_round} not close to expected {expected_reveal_round}" - ) - - computed_reveal_time = ( - GENESIS_TIME + (reveal_round + SUBTENSOR_PULSE_DELAY) * PERIOD - ) - required_lead_time = SUBTENSOR_PULSE_DELAY * PERIOD - - if time_until_reveal >= required_lead_time: - assert computed_reveal_time - start_time >= required_lead_time, ( - f"Not enough lead time: reveal_time={computed_reveal_time}, " - f"start_time={start_time}, required={required_lead_time}" - ) - - -def compute_expected_reveal_round( - now: int, - tempo: int, - current_block: int, - netuid: int, - subnet_reveal_period_epochs: int, - block_time: int, -): - tempo_plus_one = tempo + 1 - netuid_plus_one = netuid + 1 - block_with_offset = current_block + netuid_plus_one - current_epoch = block_with_offset // tempo_plus_one - - reveal_epoch = current_epoch + subnet_reveal_period_epochs - first_reveal_blk = reveal_epoch * tempo_plus_one - netuid_plus_one - - # Rust adds SECURITY_BLOCK_OFFSET = 3 - SECURITY_BLOCK_OFFSET = 3 - target_ingest_blk = first_reveal_blk + SECURITY_BLOCK_OFFSET - - blocks_until_ingest = max(target_ingest_blk - current_block, 0) - secs_until_ingest = blocks_until_ingest * block_time - - target_secs = now + secs_until_ingest - - # Rust uses floor() and does NOT subtract SUBTENSOR_PULSE_DELAY - reveal_round = int((target_secs - GENESIS_TIME) / PERIOD) - - if reveal_round < 1: - reveal_round = 1 - - reveal_time = target_secs - time_until_reveal = secs_until_ingest - - return reveal_round, reveal_time, time_until_reveal diff --git a/tests/test_commit_reveal_v2.py b/tests/test_commit_reveal_v2.py new file mode 100644 index 0000000..dbbed76 --- /dev/null +++ b/tests/test_commit_reveal_v2.py @@ -0,0 +1,40 @@ +"""API contract tests for get_encrypted_commit_v2 (no epoch simulation in Python).""" + +import pytest +import bittensor_drand as btcr + +# cycle_reset row from src/epoch_schedule_vectors.rs (subtensor tempo_control.rs:195) +_VECTOR_KWARGS = dict( + uids=[0, 1], + weights=[100, 200], + version_key=1, + last_epoch_block=10, + pending_epoch_at=0, + subnet_epoch_index=0, + tempo=50, + blocks_since_last_step=0, + current_block=10, + subnet_reveal_period_epochs=1, + block_time=12.0, + hotkey=bytes([1, 2, 3]), +) + + +def test_get_encrypted_commit_v2_returns_bytes_and_positive_round(): + encrypted, reveal_round = btcr.get_encrypted_commit_v2(**_VECTOR_KWARGS) + assert isinstance(encrypted, bytes) + assert len(encrypted) > 0 + assert isinstance(reveal_round, int) + assert reveal_round > 0 + + +def test_get_encrypted_commit_v2_is_deterministic_for_fixed_inputs(): + first = btcr.get_encrypted_commit_v2(**_VECTOR_KWARGS) + second = btcr.get_encrypted_commit_v2(**_VECTOR_KWARGS) + assert first[1] == second[1] + + +def test_get_encrypted_commit_v2_tempo_zero_raises(): + kwargs = {**_VECTOR_KWARGS, "tempo": 0} + with pytest.raises(ValueError): + btcr.get_encrypted_commit_v2(**kwargs) From 72369c3bbe33070f250002092adb76a9b80d3964 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 10 Jun 2026 16:02:58 -0700 Subject: [PATCH 3/6] update versioning logic --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 77aa7f3..e47fe8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [project] name = "bittensor-drand" -version = "2.0.0" +# version is read from Cargo.toml by maturin +dynamic = ["version"] description = "" readme = "README.md" license = {file = "LICENSE"} @@ -13,7 +14,7 @@ authors = [ {name = "Roman Chkhaidze", email = "r@latent.to"}, ] maintainers = [ - {name = "Cortex Team", email = "cortex@opentensor.dev"}, + {name = "Cortex Team", email = "cortex@latent.to"}, ] classifiers = [ "Development Status :: 5 - Production/Stable", From 28a9f487ea16490939aed1461c322d3ec18ab8d1 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Wed, 10 Jun 2026 16:11:48 -0700 Subject: [PATCH 4/6] update ffi --- bindings.h | 18 +++++ src/ffi.rs | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/bindings.h b/bindings.h index 989264b..3b518aa 100644 --- a/bindings.h +++ b/bindings.h @@ -46,3 +46,21 @@ struct CRByteBuffer cr_encrypt_commitment(const uint8_t *data_ptr, double block_time, uint64_t *round_out, char **err_out); + +struct CRByteBuffer cr_generate_commit_v2(const uint16_t *uids_ptr, + uintptr_t uids_len, + const uint16_t *vals_ptr, + uintptr_t vals_len, + uint64_t version_key, + uint64_t last_epoch_block, + uint64_t pending_epoch_at, + uint64_t subnet_epoch_index, + uint16_t tempo, + uint64_t blocks_since_last_step, + uint64_t current_block, + uint64_t subnet_reveal_epochs, + double block_time, + const uint8_t *hotkey_ptr, + uintptr_t hotkey_len, + uint64_t *round_out, + char **err_out); diff --git a/src/ffi.rs b/src/ffi.rs index 5b2b55f..88f5f6c 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -358,6 +358,116 @@ pub extern "C" fn cr_encrypt_commitment( } } +/// Encrypts weights for commit-reveal using the stateful epoch model (v2). +/// +/// # Parameters +/// +/// * `uids_ptr` / `uids_len` - Array of neuron UIDs +/// * `vals_ptr` / `vals_len` - Array of weight values (same length as UIDs) +/// * `version_key` - Version key for this commitment +/// * `last_epoch_block` - Block at which the last epoch ran +/// * `pending_epoch_at` - Pending owner-triggered epoch block (0 if none) +/// * `subnet_epoch_index` - Monotonic epoch counter +/// * `tempo` - Epoch duration in blocks +/// * `blocks_since_last_step` - Blocks since last step for the subnet +/// * `current_block` - Chain head block number +/// * `subnet_reveal_epochs` - Number of epochs before reveal +/// * `block_time` - Block time in seconds +/// * `hotkey_ptr` / `hotkey_len` - Committer hotkey public key bytes +/// * `round_out` - Output: Drand reveal round number +/// * `err_out` - Output: error message (NULL on success) +/// +/// # Returns +/// +/// A `CRByteBuffer` containing the encrypted commitment, or empty on error. +/// +/// # Safety +/// +/// All pointer parameters must point to valid memory of the specified length. +#[no_mangle] +pub extern "C" fn cr_generate_commit_v2( + uids_ptr: *const u16, + uids_len: usize, + vals_ptr: *const u16, + vals_len: usize, + version_key: u64, + last_epoch_block: u64, + pending_epoch_at: u64, + subnet_epoch_index: u64, + tempo: u16, + blocks_since_last_step: u64, + current_block: u64, + subnet_reveal_epochs: u64, + block_time: f64, + hotkey_ptr: *const u8, + hotkey_len: usize, + round_out: *mut u64, + err_out: *mut *mut c_char, +) -> CRByteBuffer { + unsafe { *err_out = ptr::null_mut() } + + if (uids_ptr.is_null() && uids_len > 0) + || (vals_ptr.is_null() && vals_len > 0) + || (hotkey_ptr.is_null() && hotkey_len > 0) + { + unsafe { *err_out = err_to_cstring("uids/values/hotkey ptr is null") }; + return CRByteBuffer { + ptr: ptr::null_mut(), + len: 0, + cap: 0, + }; + } + + if uids_len != vals_len { + unsafe { *err_out = err_to_cstring("uids_len != vals_len") }; + return CRByteBuffer { + ptr: ptr::null_mut(), + len: 0, + cap: 0, + }; + } + + let uids = if uids_len > 0 { + unsafe { slice::from_raw_parts(uids_ptr, uids_len) }.to_vec() + } else { + vec![] + }; + let values = if vals_len > 0 { + unsafe { slice::from_raw_parts(vals_ptr, vals_len) }.to_vec() + } else { + vec![] + }; + let hotkey = if hotkey_len > 0 { + unsafe { slice::from_raw_parts(hotkey_ptr, hotkey_len) }.to_vec() + } else { + vec![] + }; + + let state = crate::epoch_schedule::EpochScheduleState { + last_epoch_block, + pending_epoch_at, + subnet_epoch_index, + tempo, + blocks_since_last_step, + current_block, + }; + + match drand::generate_commit_v2(uids, values, version_key, state, subnet_reveal_epochs, block_time, hotkey) { + Ok((ct, rr)) => { + unsafe { *round_out = rr } + CRByteBuffer::from_vec(ct) + } + Err((ioe, msg)) => { + unsafe { *err_out = err_to_cstring(format!("{msg}: {ioe}")) }; + CRByteBuffer { + ptr: ptr::null_mut(), + len: 0, + cap: 0, + } + } + } +} + // ============================================================================ // ML-KEM-768 FFI functions (ported from mlkemffi) // ============================================================================ @@ -930,4 +1040,123 @@ mod tests { unsafe { drop_cstring(dec_err) }; } + + #[test] + fn test_cr_generate_commit_v2_success() { + let uids: Vec = vec![1, 2, 3]; + let values: Vec = vec![100, 200, 300]; + let hotkey: Vec = vec![1, 2, 3]; + let mut round: u64 = 0; + let mut err_ptr: *mut c_char = ptr::null_mut(); + + let buf = cr_generate_commit_v2( + uids.as_ptr(), + uids.len(), + values.as_ptr(), + values.len(), + 42, // version_key + 100, // last_epoch_block + 0, // pending_epoch_at + 0, // subnet_epoch_index + 50, // tempo + 0, // blocks_since_last_step + 120, // current_block + 1, // subnet_reveal_epochs + 12.0, // block_time + hotkey.as_ptr(), + hotkey.len(), + &mut round, + &mut err_ptr, + ); + + assert!(err_ptr.is_null(), "err_out must be NULL on success"); + assert!(!buf.ptr.is_null(), "buffer pointer must be non-NULL"); + assert!(buf.len > 0, "ciphertext must be non-empty"); + assert!(round > 0, "round should be set"); + + cr_free(buf); + } + + #[test] + fn test_cr_generate_commit_v2_null_ptr() { + let mut round: u64 = 0; + let mut err_ptr: *mut c_char = ptr::null_mut(); + + let buf = cr_generate_commit_v2( + ptr::null(), // bad uids + 3, + ptr::null(), + 3, + 42, + 100, 0, 0, 50, 0, 120, + 1, 12.0, + ptr::null(), 3, + &mut round, + &mut err_ptr, + ); + + assert!(buf.ptr.is_null()); + assert!(!err_ptr.is_null()); + unsafe { drop_cstring(err_ptr) }; + } + + #[test] + fn test_cr_generate_commit_v2_mismatched_lengths() { + let uids: Vec = vec![1, 2]; + let values: Vec = vec![100]; + let hotkey: Vec = vec![1]; + let mut round: u64 = 0; + let mut err_ptr: *mut c_char = ptr::null_mut(); + + let buf = cr_generate_commit_v2( + uids.as_ptr(), + uids.len(), + values.as_ptr(), + values.len(), + 42, + 100, 0, 0, 50, 0, 120, + 1, 12.0, + hotkey.as_ptr(), + hotkey.len(), + &mut round, + &mut err_ptr, + ); + + assert!(buf.ptr.is_null()); + assert!(!err_ptr.is_null()); + unsafe { + let msg = CStr::from_ptr(err_ptr).to_string_lossy(); + assert!(msg.contains("uids_len != vals_len"), "got: {msg}"); + drop_cstring(err_ptr); + } + } + + #[test] + fn test_cr_generate_commit_v2_tempo_zero() { + let uids: Vec = vec![1]; + let values: Vec = vec![100]; + let hotkey: Vec = vec![1]; + let mut round: u64 = 0; + let mut err_ptr: *mut c_char = ptr::null_mut(); + + let buf = cr_generate_commit_v2( + uids.as_ptr(), + uids.len(), + values.as_ptr(), + values.len(), + 42, + 100, 0, 0, + 0, // tempo = 0 + 0, 120, + 1, 12.0, + hotkey.as_ptr(), + hotkey.len(), + &mut round, + &mut err_ptr, + ); + + assert!(buf.ptr.is_null()); + assert!(!err_ptr.is_null()); + unsafe { drop_cstring(err_ptr) }; + } } From 7992aae4e6841b1ce1f9b777f13f7dde58d63865 Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 18 Jun 2026 09:48:03 -0700 Subject: [PATCH 5/6] update CHANGELOG.MD --- CHANGELOG.MD | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 833989d..2830ea8 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,13 @@ # Changelog +## v2.0.0 /2026-18-06 + +## What's Changed +* Bump rand from 0.8.5 to 0.8.6 by @dependabot[bot] in https://github.com/latent-to/bittensor-drand/pull/70 +* Dynamic tempo support by @basfroman in https://github.com/latent-to/bittensor-drand/pull/71 + +**Full Changelog**: https://github.com/latent-to/bittensor-drand/compare/v1.3.0...v.2.0.0 + ## v1.3.0 /2026-19-02 ## What's Changed From 0dff71eb4445c5b78c243be833754d811193830c Mon Sep 17 00:00:00 2001 From: Roman Chkhaidze Date: Thu, 18 Jun 2026 10:01:57 -0700 Subject: [PATCH 6/6] add description to pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e47fe8a..13f2056 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "bittensor-drand" # version is read from Cargo.toml by maturin dynamic = ["version"] -description = "" +description = "Rust-backed Python library for generating timelock-encrypted weight commitments for Bittensor's commit-reveal mechanism using drand randomness." readme = "README.md" license = {file = "LICENSE"} keywords = ["substrate", "scale", "codec", "bittensor", "commit reveal", "drand", "TLE"]