From fab6faecba83c242806532b3b80820942668c7d8 Mon Sep 17 00:00:00 2001 From: Konrad Feldmeier Date: Tue, 5 May 2026 15:16:30 +0200 Subject: [PATCH 1/4] Remove batching from event based decryption triggers Context is issue #698 --- .../keyperimpl/shutterservice/newblock.go | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/rolling-shutter/keyperimpl/shutterservice/newblock.go b/rolling-shutter/keyperimpl/shutterservice/newblock.go index 8b8dfa46..344c0215 100644 --- a/rolling-shutter/keyperimpl/shutterservice/newblock.go +++ b/rolling-shutter/keyperimpl/shutterservice/newblock.go @@ -218,7 +218,8 @@ func (kpr *Keyper) createTriggersFromIdentityRegisteredEvents( if identityPreimages[event.Eon] == nil { identityPreimages[event.Eon] = make([]identitypreimage.IdentityPreimage, 0) } - identityPreimages[event.Eon] = append(identityPreimages[event.Eon], identitypreimage.IdentityPreimage(event.Identity)) + identityPreimages[event.Eon] = append(identityPreimages[event.Eon], + identitypreimage.IdentityPreimage(event.Identity)) if _, exists := lastEonBlock[event.Eon]; !exists { lastEonBlock[event.Eon] = eon.ActivationBlockNumber @@ -269,19 +270,15 @@ func (kpr *Keyper) prepareEventBasedTriggers(ctx context.Context) ([]epochkghand continue } - identities := []identitypreimage.IdentityPreimage{} + // issue 698: here we "unbatch" all event based decryption triggers and create one broker event per identity for _, firedTrigger := range firedTriggers { - identities = append(identities, firedTrigger.Identity) + identityPreimage := []identitypreimage.IdentityPreimage{firedTrigger.Identity} + decryptionTrigger := epochkghandler.DecryptionTrigger{ + BlockNumber: uint64(eonStruct.ActivationBlockNumber), + IdentityPreimages: identityPreimage, + } + decryptionTriggers = append(decryptionTriggers, decryptionTrigger) } - - sortedIdentityPreimages := sortIdentityPreimages(identities) - - decryptionTrigger := epochkghandler.DecryptionTrigger{ - BlockNumber: uint64(eonStruct.ActivationBlockNumber), - IdentityPreimages: sortedIdentityPreimages, - } - - decryptionTriggers = append(decryptionTriggers, decryptionTrigger) } return decryptionTriggers, nil } From b4737dcb148b3774db1f0fd63249b5a5f8c573b9 Mon Sep 17 00:00:00 2001 From: Konrad Feldmeier Date: Mon, 18 May 2026 17:15:01 +0200 Subject: [PATCH 2/4] Add integration tests for changed batching This adds (generated) integration tests and a `mise` test task for ensuring the changed batching behavior when responding to event based decryption triggers. Context is #698 --- .tool-versions | 18 +- mise-test-setup/mise-tasks/gen-keyper-configs | 3 + mise-test-setup/mise-tasks/init-keyper-dbs | 117 +++++-- .../mise-tasks/test-event-decryption | 322 ++++++++++++++++++ mise-test-setup/mise-tasks/utils.py | 38 +++ .../shutterservice/newblock_test.go | 37 +- .../keyperimpl/shutterservice/setup_test.go | 17 + rolling-shutter/p2pmsg/gossip.pb.go | 2 +- rolling-shutter/shmsg/shmsg.pb.go | 2 +- 9 files changed, 501 insertions(+), 55 deletions(-) create mode 100755 mise-test-setup/mise-tasks/test-event-decryption diff --git a/.tool-versions b/.tool-versions index a50ce8eb..ea59406b 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,10 +1,10 @@ -circleci 0.1.31425 -golang 1.24.5 +circleci 0.1.31425 +golang 1.26.3 golangci-lint 1.64.5 -nodejs 18.17.0 -postgres 14.2 -pre-commit 4.1.0 -protoc 29.3 -shfmt 3.10.0 -solidity 0.8.9 -sqlc 1.28.0 +nodejs 18.17.0 +postgres 18.3 +pre-commit 4.1.0 +protoc 29.3 +shfmt 3.10.0 +solidity 0.8.9 +sqlc latest diff --git a/mise-test-setup/mise-tasks/gen-keyper-configs b/mise-test-setup/mise-tasks/gen-keyper-configs index 27961297..a9f91a0f 100755 --- a/mise-test-setup/mise-tasks/gen-keyper-configs +++ b/mise-test-setup/mise-tasks/gen-keyper-configs @@ -67,6 +67,9 @@ for index in range(int(os.environ["NUM_KEYPERS"])): elif deployment_type == "service": utils.set_toml_path(document, ["Chain", "Node", "EthereumURL"], "ws://ethereum:8545") utils.set_toml_path(document, ["Chain", "Contracts", "ShutterRegistry"], utils.get_created_contract_address(deployment_run, "ShutterRegistry")) + event_trigger_registry = utils.get_uups_proxy_address(deployment_run, "ShutterEventTriggerRegistryV1") + if event_trigger_registry: + utils.set_toml_path(document, ["Chain", "Contracts", "ShutterEventTriggerRegistry"], event_trigger_registry) else: raise SystemExit(f"Unsupported DEPLOYMENT_TYPE for config generation: {deployment_type}") diff --git a/mise-test-setup/mise-tasks/init-keyper-dbs b/mise-test-setup/mise-tasks/init-keyper-dbs index c38453a0..df45896f 100755 --- a/mise-test-setup/mise-tasks/init-keyper-dbs +++ b/mise-test-setup/mise-tasks/init-keyper-dbs @@ -3,6 +3,7 @@ #MISE depends=["up-db", "gen-keyper-configs"] import os +from pathlib import Path import utils @@ -47,39 +48,101 @@ def public_table_count(database_name: str) -> int: return int(result.stdout.strip() or "0") +def db_ethereum_address(database_name: str) -> str: + result = utils.run( + [ + "docker", + "compose", + "exec", + "-T", + "db", + "psql", + "-U", + "postgres", + "-d", + database_name, + "-tAc", + "SELECT value FROM meta_inf WHERE key = 'ethereum address'", + ], + capture_output=True, + ) + return result.stdout.strip() + + +def drop_db(database_name: str) -> None: + utils.run( + [ + "docker", + "compose", + "exec", + "-T", + "db", + "psql", + "-U", + "postgres", + "-c", + f"DROP DATABASE IF EXISTS \"{database_name}\"", + ] + ) + + +def create_db(database_name: str) -> None: + utils.run( + [ + "docker", + "compose", + "exec", + "-T", + "db", + "createdb", + "-U", + "postgres", + database_name, + ] + ) + + +def init_db(index: int) -> None: + utils.run( + [ + "docker", + "compose", + "run", + "-T", + "--no-deps", + "--rm", + f"keyper-{index}", + utils.KEYPER_SUBCOMMANDS[deployment_type], + "initdb", + "--config", + "/config.toml", + ] + ) + + deployment_type = utils.resolve_deployment_type(os.environ.get("DEPLOYMENT_TYPE", "")) +data_dir = Path(os.environ["DATA_DIR"]) for index in range(int(os.environ["NUM_KEYPERS"])): database_name = f"keyper-{index}" + config_path = data_dir / f"keyper-{index}.toml" if not db_exists(database_name): - utils.run( - [ - "docker", - "compose", - "exec", - "-T", - "db", - "createdb", - "-U", - "postgres", - database_name, - ] - ) + create_db(database_name) + init_db(index) + continue if public_table_count(database_name) == 0: - utils.run( - [ - "docker", - "compose", - "run", - "-T", - "--no-deps", - "--rm", - f"keyper-{index}", - utils.KEYPER_SUBCOMMANDS[deployment_type], - "initdb", - "--config", - "/config.toml", - ] + init_db(index) + continue + + # DB exists with tables. Re-initialize if the stored address doesn't match the config. + config_addr = utils.keyper_address(config_path) + db_addr = db_ethereum_address(database_name) + if db_addr.lower() != config_addr.lower(): + print( + f"keyper-{index}: DB address {db_addr} != config address {config_addr}; re-initializing" ) + drop_db(database_name) + create_db(database_name) + init_db(index) diff --git a/mise-test-setup/mise-tasks/test-event-decryption b/mise-test-setup/mise-tasks/test-event-decryption new file mode 100755 index 00000000..9016d848 --- /dev/null +++ b/mise-test-setup/mise-tasks/test-event-decryption @@ -0,0 +1,322 @@ +#!/usr/bin/env -S uv run --script +#MISE description="E2E test: event-based decryption triggers without batching" +#MISE depends=["wait-for-initial-dkg", "deploy"] + +import json +import os +import secrets +import time +from pathlib import Path + +import utils + +# --------------------------------------------------------------------------- +# RLP helpers — minimal subset needed for EventTriggerDefinition encoding +# --------------------------------------------------------------------------- + +def _rlp_bytes(data: bytes) -> bytes: + if len(data) == 1 and data[0] < 0x80: + return data + if len(data) == 0: + return b"\x80" + if len(data) <= 55: + return bytes([0x80 + len(data)]) + data + lb = len(data).to_bytes((len(data).bit_length() + 7) // 8, "big") + return bytes([0xb7 + len(lb)]) + lb + data + + +def _rlp_uint(n: int) -> bytes: + if n == 0: + return b"\x80" + raw = n.to_bytes((n.bit_length() + 7) // 8, "big") + return _rlp_bytes(raw) + + +def _rlp_list(items: list[bytes]) -> bytes: + payload = b"".join(items) + if len(payload) <= 55: + return bytes([0xc0 + len(payload)]) + payload + lb = len(payload).to_bytes((len(payload).bit_length() + 7) // 8, "big") + return bytes([0xf7 + len(lb)]) + lb + payload + + +def encode_trigger_def(contract_addr: bytes, topic_index: int, topic_value: bytes) -> bytes: + """ + Encode an EventTriggerDefinition (version 0x02 + RLP) that matches logs from + `contract_addr` where topic[topic_index] == topic_value (32 bytes). + + Mirrors Go's EventTriggerDefinition.MarshalBytes(). + + ValuePredicate uses a custom EncodeRLP that produces a flat list + [Op, *int_args, *byte_args] — NOT [Op, [IntArgs], [ByteArgs]]. + For BytesEq (Op=5) there are 0 int args and 1 byte arg, so the list is [5, topic_value]. + """ + log_value_ref = _rlp_list([ + _rlp_uint(0), # Dynamic = false (bool encoded as uint 0) + _rlp_uint(topic_index), + ]) + # Flat list: [Op, byteArg] (no IntArgs for BytesEq, one ByteArg) + value_predicate = _rlp_list([ + _rlp_uint(5), # Op = BytesEq + _rlp_bytes(topic_value), # the single byte argument, directly in the list + ]) + log_predicate = _rlp_list([log_value_ref, value_predicate]) + definition = _rlp_list([ + _rlp_bytes(contract_addr), + _rlp_list([log_predicate]), + ]) + return bytes([0x02]) + definition + + +# --------------------------------------------------------------------------- +# Contract deployment + interaction via cast inside the ethereum container +# --------------------------------------------------------------------------- + +RPC = "http://127.0.0.1:8545" + + +def cast(*args: str, capture: bool = True) -> str: + """Run a cast subcommand inside the running ethereum container.""" + result = utils.run( + ["docker", "compose", "exec", "-T", "ethereum", "cast", *args], + capture_output=capture, + ) + return result.stdout.strip() if capture else "" + + +def cast_send(*args: str) -> dict: + """Broadcast a transaction and return the parsed JSON receipt.""" + raw = cast("send", "--json", "--rpc-url", RPC, + "--private-key", os.environ["DEPLOY_KEY"], *args) + return json.loads(raw) + + +def deploy_contract(bytecode_hex: str) -> str: + """Deploy a contract from hex bytecode; returns the checksummed contract address.""" + receipt = cast_send("--create", bytecode_hex) + addr = receipt.get("contractAddress") or receipt.get("creates") + if not addr: + raise SystemExit(f"Could not find contractAddress in receipt:\n{json.dumps(receipt, indent=2)}") + return addr + + +def call_register(registry_addr: str, keyper_config_index: int, + identity_prefix_hex: str, trigger_def_hex: str, ttl: int) -> None: + cast_send( + registry_addr, + "register(uint64,bytes32,bytes,uint64)", + str(keyper_config_index), + f"0x{identity_prefix_hex}", + f"0x{trigger_def_hex}", + str(ttl), + ) + + +def call_trigger(helper_addr: str, topic2_hex: str) -> None: + zero32 = "0x" + "0" * 64 + cast_send( + helper_addr, + "trigger(uint64,bytes32,bytes32,bytes32)", + "0", + f"0x{topic2_hex}", + zero32, + zero32, + ) + + +# --------------------------------------------------------------------------- +# DB helpers +# --------------------------------------------------------------------------- + + +def query_db(sql: str, keyper_index: int = 0) -> str: + return utils.query_keyper_db(keyper_index, sql) + + +def wait_for_event_decryption_key( + keyper_config_index: int, + identity_prefix_hex: str, + poll_interval: float = 1.0, + timeout: float = 120.0, +) -> str: + """ + Poll until the decryption key for the event trigger with the given identity_prefix + is available. Returns the hex-encoded identity (epoch_id). + """ + deadline = time.time() + timeout + while True: + if time.time() > deadline: + raise SystemExit( + f"Timeout waiting for decryption key for " + f"identity_prefix={identity_prefix_hex}, eon={keyper_config_index}" + ) + + identity = query_db( + f"SELECT encode(identity, 'hex') FROM event_trigger_registered_event " + f"WHERE eon = {keyper_config_index} " + f"AND encode(identity_prefix, 'hex') = '{identity_prefix_hex}' " + "LIMIT 1" + ) + if not identity: + time.sleep(poll_interval) + continue + + exists = query_db( + "SELECT EXISTS (" + " SELECT 1 FROM decryption_key " + f" WHERE eon = {keyper_config_index} " + f" AND encode(epoch_id, 'hex') = '{identity}'" + ")" + ) + if exists == "t": + print(f" Decryption key ready: identity_prefix={identity_prefix_hex} → epoch_id={identity}") + return identity + + time.sleep(poll_interval) + + +def assert_no_batching( + keyper_config_index: int, + num_triggers: int, + poll_interval: float = 1.0, + timeout: float = 30.0, +) -> None: + """ + Assert that decryption_signatures contains `num_triggers` distinct identities_hash + values for the given eon. With no batching each DecryptionTrigger carries exactly + one identity, so its identities_hash = keccak256(identity). With batching all N + identities would share one hash. + """ + deadline = time.time() + timeout + while True: + count_str = query_db( + f"SELECT COUNT(DISTINCT identities_hash) FROM decryption_signatures " + f"WHERE eon = {keyper_config_index}" + ) + count = int(count_str) if count_str else 0 + if count >= num_triggers: + print( + f"No-batching assertion passed: " + f"{count} distinct identities_hash for {num_triggers} triggers" + ) + return + if time.time() > deadline: + raise SystemExit( + f"No-batching assertion failed: expected {num_triggers} distinct " + f"identities_hash in decryption_signatures for eon={keyper_config_index}, " + f"got {count}" + ) + time.sleep(poll_interval) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def get_registry_address(data_dir: Path) -> str: + broadcast = json.loads( + (data_dir / "contracts" / "broadcast" + / utils.DEPLOYMENT_SCRIPTS["service"] + / os.environ["ETHEREUM_CHAIN_ID"] + / "run-latest.json").read_text() + ) + addr = utils.get_uups_proxy_address(broadcast, "ShutterEventTriggerRegistryV1") + if not addr: + raise SystemExit("ShutterEventTriggerRegistryV1 proxy not found in deployment artifacts") + return addr + + +def get_helper_bytecode(data_dir: Path) -> str: + artifact = json.loads( + (data_dir / "contracts" / "out" + / "EventTriggerTestHelper.sol" + / "EventTriggerTestHelper.json").read_text() + ) + return artifact["bytecode"]["object"].removeprefix("0x") + + +def main() -> None: + deployment_type = utils.resolve_deployment_type(os.environ.get("DEPLOYMENT_TYPE", "")) + if deployment_type != "service": + raise SystemExit("test-event-decryption only supports DEPLOYMENT_TYPE=service") + + data_dir = Path(os.environ["DATA_DIR"]) + poll_interval = float(os.environ.get("DECRYPTION_KEY_POLL_INTERVAL", "1")) + num_triggers = 3 + + # --- Resolve eon and keyper_config_index ----------------------------------- + # ShutterEventTriggerRegistryV1.register() takes the *keyper_config_index*, + # not the eon number. We obtain both from the eons table and use accordingly. + eon_str = query_db("SELECT max(eon) FROM eons") + if not eon_str: + raise SystemExit("No eon found in keyper DB — has DKG completed?") + eon_number = int(eon_str) + + keyper_config_index_str = query_db( + f"SELECT keyper_config_index FROM eons WHERE eon = {eon_number} LIMIT 1" + ) + if not keyper_config_index_str: + raise SystemExit(f"No keyper_config_index found for eon={eon_number}") + keyper_config_index = int(keyper_config_index_str) + + print(f"Using eon={eon_number}, keyper_config_index={keyper_config_index}") + + # --- Deploy EventTriggerTestHelper ---------------------------------------- + print("Deploying EventTriggerTestHelper…") + helper_bytecode = get_helper_bytecode(data_dir) + helper_addr = deploy_contract(helper_bytecode) + print(f" EventTriggerTestHelper deployed at {helper_addr}") + + # --- Build the trigger definition ----------------------------------------- + # Match Trigger(…, topic2=trigger_topic, …) events emitted by helper_addr. + # topic[2] is the second indexed parameter of the Trigger event (bytes32). + trigger_topic = secrets.token_bytes(32) + trigger_topic_hex = trigger_topic.hex() + + helper_addr_bytes = bytes.fromhex(helper_addr.removeprefix("0x").zfill(40)) + trigger_def = encode_trigger_def( + contract_addr=helper_addr_bytes, + topic_index=2, # topic[2] = topic2 (bytes32 indexed) + topic_value=trigger_topic, + ) + trigger_def_hex = trigger_def.hex() + print(f" EventTriggerDefinition: 0x{trigger_def_hex}") + + # --- Generate identity prefixes (one per trigger) ------------------------- + identity_prefixes = [secrets.token_bytes(32).hex() for _ in range(num_triggers)] + + # --- Register N event triggers -------------------------------------------- + registry_addr = get_registry_address(data_dir) + ttl = 10_000 # blocks until expiry + + print(f"Registering {num_triggers} event triggers on registry {registry_addr}…") + for ip_hex in identity_prefixes: + call_register(registry_addr, keyper_config_index, ip_hex, trigger_def_hex, ttl) + print(f" Registered trigger with identity_prefix=0x{ip_hex[:8]}…") + + # Give keypers time to sync the EventTriggerRegistered events before we fire. + # With ETHEREUM_BLOCK_TIME=1 a couple of seconds is sufficient. + sync_wait = max(3, int(os.environ.get("ACTIVATION_DELTA", "10"))) + print(f"Waiting {sync_wait}s for keypers to sync trigger registrations…") + time.sleep(sync_wait) + + # --- Fire one Trigger event — all 3 registered definitions will match ----- + print(f"Firing Trigger event with topic2=0x{trigger_topic_hex[:8]}…") + call_trigger(helper_addr, trigger_topic_hex) + + # --- Wait for decryption keys --------------------------------------------- + print("Waiting for decryption keys…") + for ip_hex in identity_prefixes: + wait_for_event_decryption_key(keyper_config_index, ip_hex, poll_interval) + + # --- Assert no batching --------------------------------------------------- + print("Asserting no-batching (each trigger must produce a distinct identities_hash)…") + assert_no_batching(keyper_config_index, num_triggers) + + print( + f"\nAll {num_triggers} event-based decryption triggers handled without batching. ✓" + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mise-test-setup/mise-tasks/utils.py b/mise-test-setup/mise-tasks/utils.py index 589107ea..ef07fe9e 100644 --- a/mise-test-setup/mise-tasks/utils.py +++ b/mise-test-setup/mise-tasks/utils.py @@ -115,3 +115,41 @@ def get_created_contract_address( if isinstance(address, str) and address: return address return None + + +def get_uups_proxy_address( + deployment_run: dict[str, object], impl_contract_name: str +) -> str | None: + """Return the ERC1967Proxy address deployed for the given UUPS implementation. + + The deployment script deploys the implementation first, then an ERC1967Proxy + whose constructor input encodes the implementation address. We identify the + correct proxy by searching for the ERC1967Proxy CREATE that immediately follows + the implementation and whose input data contains the implementation address. + """ + transactions = deployment_run.get("transactions") + if not isinstance(transactions, list): + return None + for i, tx in enumerate(transactions): + if not isinstance(tx, dict): + continue + if tx.get("contractName") != impl_contract_name: + continue + impl_addr = (tx.get("contractAddress") or "").lower().replace("0x", "") + if not impl_addr: + continue + for j in range(i + 1, len(transactions)): + candidate = transactions[j] + if not isinstance(candidate, dict): + continue + if candidate.get("contractName") != "ERC1967Proxy": + continue + input_data = ( + candidate.get("transaction", {}).get("input") or "" + ).lower() + if impl_addr in input_data: + proxy_addr = candidate.get("contractAddress") + if isinstance(proxy_addr, str) and proxy_addr: + return proxy_addr + break + return None diff --git a/rolling-shutter/keyperimpl/shutterservice/newblock_test.go b/rolling-shutter/keyperimpl/shutterservice/newblock_test.go index 14fbd040..3b1d4f37 100644 --- a/rolling-shutter/keyperimpl/shutterservice/newblock_test.go +++ b/rolling-shutter/keyperimpl/shutterservice/newblock_test.go @@ -1,7 +1,6 @@ package shutterservice import ( - "bytes" "context" "database/sql" "math" @@ -111,7 +110,9 @@ func TestProcessBlockSuccess(t *testing.T) { select { case ev := <-decryptionTriggerChannel: assert.Equal(t, ev.Value.BlockNumber, activationBlockNumberUint64) - assert.DeepEqual(t, ev.Value.IdentityPreimages, []identitypreimage.IdentityPreimage{identity}) + assert.DeepEqual(t, ev.Value.IdentityPreimages, []identitypreimage.IdentityPreimage{ + identity, + }) case <-time.After(2 * time.Second): t.Fatal("expected decryption trigger") } @@ -455,10 +456,7 @@ func setupEventBasedOrderingTest( ) (*Keyper, *servicedatabase.Queries, int64) { t.Helper() - const keyperIndex = uint64(1) - testsetup.InitializeEon(ctx, t, dbpool, config, keyperIndex) - - privateKey, sender, err := generateRandomAccount() + privateKey, _, err := generateRandomAccount() assert.NilError(t, err) kpr := &Keyper{ @@ -471,12 +469,16 @@ func setupEventBasedOrderingTest( }, }, } - _ = sender + + // Register kpr's address as the keyper-at-index-1 so resolveDecryptableEon + // finds it in the keyper set. + const keyperIndex = uint64(1) + testsetup.InitializeEon(ctx, t, dbpool, &kprAddressTestConfig{addr: kpr.config.GetAddress()}, keyperIndex) return kpr, servicedatabase.New(dbpool), 1 } -func TestFiredTriggersProducesOrderedShares(t *testing.T) { +func TestFiredTriggersProducesUnbatchedDecryptionTriggers(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -531,15 +533,14 @@ func TestFiredTriggersProducesOrderedShares(t *testing.T) { triggers, err := kpr.prepareEventBasedTriggers(ctx) assert.NilError(t, err) - assert.Equal(t, len(triggers), 1) - assert.Equal(t, len(triggers[0].IdentityPreimages), len(inserted)) - for i := 1; i < len(triggers[0].IdentityPreimages); i++ { - assert.Assert(t, bytes.Compare( - triggers[0].IdentityPreimages[i-1], - triggers[0].IdentityPreimages[i], - ) < 0) + + // Each fired trigger must produce its own DecryptionTrigger with exactly one identity (no batching). + assert.Equal(t, len(triggers), len(inserted)) + for _, trigger := range triggers { + assert.Equal(t, len(trigger.IdentityPreimages), 1) } + // Verify the first trigger can still be used to construct valid key shares. coreDB := corekeyperdatabase.New(dbpool) triggerBlockNumber := triggers[0].BlockNumber if triggerBlockNumber > math.MaxInt64 { @@ -551,14 +552,16 @@ func TestFiredTriggersProducesOrderedShares(t *testing.T) { keyShareHandler := &epochkghandler.KeyShareHandler{ InstanceID: config.GetInstanceID(), - KeyperAddress: config.GetAddress(), + KeyperAddress: kpr.config.GetAddress(), MaxNumKeysPerMessage: config.GetMaxNumKeysPerMessage(), DBPool: dbpool, } msg, err := keyShareHandler.ConstructDecryptionKeyShares(ctx, triggerEon, triggers[0].IdentityPreimages) assert.NilError(t, err) - validator := epochkghandler.NewDecryptionKeyShareHandler(config, dbpool) + validator := epochkghandler.NewDecryptionKeyShareHandler(&kprAddressTestConfig{ + addr: kpr.config.GetAddress(), + }, dbpool) res, err := validator.ValidateMessage(ctx, msg) assert.Equal(t, res, pubsub.ValidationAccept) assert.NilError(t, err) diff --git a/rolling-shutter/keyperimpl/shutterservice/setup_test.go b/rolling-shutter/keyperimpl/shutterservice/setup_test.go index 093ad8da..50261c54 100644 --- a/rolling-shutter/keyperimpl/shutterservice/setup_test.go +++ b/rolling-shutter/keyperimpl/shutterservice/setup_test.go @@ -33,3 +33,20 @@ func (c *TestConfig) GetMaxNumKeysPerMessage() uint64 { } var _ testsetup.TestConfig = &TestConfig{} + +// kprAddressTestConfig is a TestConfig that substitutes a specific address +// while delegating all other fields to the global config. Used to register +// a dynamically-generated keyper address in InitializeEon. +type kprAddressTestConfig struct { + addr common.Address +} + +func (c *kprAddressTestConfig) GetAddress() common.Address { return c.addr } +func (c *kprAddressTestConfig) GetInstanceID() uint64 { return config.GetInstanceID() } +func (c *kprAddressTestConfig) GetEon() uint64 { return config.GetEon() } +func (c *kprAddressTestConfig) GetCollatorKey() *ecdsa.PrivateKey { return nil } +func (c *kprAddressTestConfig) GetMaxNumKeysPerMessage() uint64 { + return config.GetMaxNumKeysPerMessage() +} + +var _ testsetup.TestConfig = &kprAddressTestConfig{} diff --git a/rolling-shutter/p2pmsg/gossip.pb.go b/rolling-shutter/p2pmsg/gossip.pb.go index 0fc0f065..978e05d2 100644 --- a/rolling-shutter/p2pmsg/gossip.pb.go +++ b/rolling-shutter/p2pmsg/gossip.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.5 -// protoc v5.29.3 +// protoc v7.34.1 // source: gossip.proto package p2pmsg diff --git a/rolling-shutter/shmsg/shmsg.pb.go b/rolling-shutter/shmsg/shmsg.pb.go index 8ffa518e..1a1f446f 100644 --- a/rolling-shutter/shmsg/shmsg.pb.go +++ b/rolling-shutter/shmsg/shmsg.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.5 -// protoc v5.29.3 +// protoc v7.34.1 // source: shmsg.proto package shmsg From 2f51c22795a0400372b502f8335e7a4e2925c576 Mon Sep 17 00:00:00 2001 From: Konrad Feldmeier Date: Mon, 18 May 2026 17:53:47 +0200 Subject: [PATCH 3/4] Fix linter error ActivationBlockNumber can not be negative, so no overflow risk here. --- rolling-shutter/keyperimpl/shutterservice/newblock.go | 2 +- rolling-shutter/p2pmsg/gossip.pb.go | 2 +- rolling-shutter/shmsg/shmsg.pb.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rolling-shutter/keyperimpl/shutterservice/newblock.go b/rolling-shutter/keyperimpl/shutterservice/newblock.go index 344c0215..296ff050 100644 --- a/rolling-shutter/keyperimpl/shutterservice/newblock.go +++ b/rolling-shutter/keyperimpl/shutterservice/newblock.go @@ -274,7 +274,7 @@ func (kpr *Keyper) prepareEventBasedTriggers(ctx context.Context) ([]epochkghand for _, firedTrigger := range firedTriggers { identityPreimage := []identitypreimage.IdentityPreimage{firedTrigger.Identity} decryptionTrigger := epochkghandler.DecryptionTrigger{ - BlockNumber: uint64(eonStruct.ActivationBlockNumber), + BlockNumber: uint64(eonStruct.ActivationBlockNumber), //nolint:gosec IdentityPreimages: identityPreimage, } decryptionTriggers = append(decryptionTriggers, decryptionTrigger) diff --git a/rolling-shutter/p2pmsg/gossip.pb.go b/rolling-shutter/p2pmsg/gossip.pb.go index 978e05d2..0fc0f065 100644 --- a/rolling-shutter/p2pmsg/gossip.pb.go +++ b/rolling-shutter/p2pmsg/gossip.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.5 -// protoc v7.34.1 +// protoc v5.29.3 // source: gossip.proto package p2pmsg diff --git a/rolling-shutter/shmsg/shmsg.pb.go b/rolling-shutter/shmsg/shmsg.pb.go index 1a1f446f..8ffa518e 100644 --- a/rolling-shutter/shmsg/shmsg.pb.go +++ b/rolling-shutter/shmsg/shmsg.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.5 -// protoc v7.34.1 +// protoc v5.29.3 // source: shmsg.proto package shmsg From 568db8539bd25f7f131debdacebe4460c604696d Mon Sep 17 00:00:00 2001 From: Konrad Feldmeier Date: Thu, 21 May 2026 11:18:52 +0200 Subject: [PATCH 4/4] Remove superfluous db helper Also apply `black` on `test-event-decryption`. --- .../mise-tasks/test-event-decryption | 142 +++++++++++------- 1 file changed, 88 insertions(+), 54 deletions(-) diff --git a/mise-test-setup/mise-tasks/test-event-decryption b/mise-test-setup/mise-tasks/test-event-decryption index 9016d848..e935a740 100755 --- a/mise-test-setup/mise-tasks/test-event-decryption +++ b/mise-test-setup/mise-tasks/test-event-decryption @@ -1,6 +1,6 @@ #!/usr/bin/env -S uv run --script -#MISE description="E2E test: event-based decryption triggers without batching" -#MISE depends=["wait-for-initial-dkg", "deploy"] +# MISE description="E2E test: event-based decryption triggers without batching" +# MISE depends=["wait-for-initial-dkg", "deploy"] import json import os @@ -14,6 +14,7 @@ import utils # RLP helpers — minimal subset needed for EventTriggerDefinition encoding # --------------------------------------------------------------------------- + def _rlp_bytes(data: bytes) -> bytes: if len(data) == 1 and data[0] < 0x80: return data @@ -22,7 +23,7 @@ def _rlp_bytes(data: bytes) -> bytes: if len(data) <= 55: return bytes([0x80 + len(data)]) + data lb = len(data).to_bytes((len(data).bit_length() + 7) // 8, "big") - return bytes([0xb7 + len(lb)]) + lb + data + return bytes([0xB7 + len(lb)]) + lb + data def _rlp_uint(n: int) -> bytes: @@ -35,12 +36,14 @@ def _rlp_uint(n: int) -> bytes: def _rlp_list(items: list[bytes]) -> bytes: payload = b"".join(items) if len(payload) <= 55: - return bytes([0xc0 + len(payload)]) + payload + return bytes([0xC0 + len(payload)]) + payload lb = len(payload).to_bytes((len(payload).bit_length() + 7) // 8, "big") - return bytes([0xf7 + len(lb)]) + lb + payload + return bytes([0xF7 + len(lb)]) + lb + payload -def encode_trigger_def(contract_addr: bytes, topic_index: int, topic_value: bytes) -> bytes: +def encode_trigger_def( + contract_addr: bytes, topic_index: int, topic_value: bytes +) -> bytes: """ Encode an EventTriggerDefinition (version 0x02 + RLP) that matches logs from `contract_addr` where topic[topic_index] == topic_value (32 bytes). @@ -51,20 +54,26 @@ def encode_trigger_def(contract_addr: bytes, topic_index: int, topic_value: byte [Op, *int_args, *byte_args] — NOT [Op, [IntArgs], [ByteArgs]]. For BytesEq (Op=5) there are 0 int args and 1 byte arg, so the list is [5, topic_value]. """ - log_value_ref = _rlp_list([ - _rlp_uint(0), # Dynamic = false (bool encoded as uint 0) - _rlp_uint(topic_index), - ]) + log_value_ref = _rlp_list( + [ + _rlp_uint(0), # Dynamic = false (bool encoded as uint 0) + _rlp_uint(topic_index), + ] + ) # Flat list: [Op, byteArg] (no IntArgs for BytesEq, one ByteArg) - value_predicate = _rlp_list([ - _rlp_uint(5), # Op = BytesEq - _rlp_bytes(topic_value), # the single byte argument, directly in the list - ]) + value_predicate = _rlp_list( + [ + _rlp_uint(5), # Op = BytesEq + _rlp_bytes(topic_value), # the single byte argument, directly in the list + ] + ) log_predicate = _rlp_list([log_value_ref, value_predicate]) - definition = _rlp_list([ - _rlp_bytes(contract_addr), - _rlp_list([log_predicate]), - ]) + definition = _rlp_list( + [ + _rlp_bytes(contract_addr), + _rlp_list([log_predicate]), + ] + ) return bytes([0x02]) + definition @@ -86,8 +95,15 @@ def cast(*args: str, capture: bool = True) -> str: def cast_send(*args: str) -> dict: """Broadcast a transaction and return the parsed JSON receipt.""" - raw = cast("send", "--json", "--rpc-url", RPC, - "--private-key", os.environ["DEPLOY_KEY"], *args) + raw = cast( + "send", + "--json", + "--rpc-url", + RPC, + "--private-key", + os.environ["DEPLOY_KEY"], + *args, + ) return json.loads(raw) @@ -96,12 +112,19 @@ def deploy_contract(bytecode_hex: str) -> str: receipt = cast_send("--create", bytecode_hex) addr = receipt.get("contractAddress") or receipt.get("creates") if not addr: - raise SystemExit(f"Could not find contractAddress in receipt:\n{json.dumps(receipt, indent=2)}") + raise SystemExit( + f"Could not find contractAddress in receipt:\n{json.dumps(receipt, indent=2)}" + ) return addr -def call_register(registry_addr: str, keyper_config_index: int, - identity_prefix_hex: str, trigger_def_hex: str, ttl: int) -> None: +def call_register( + registry_addr: str, + keyper_config_index: int, + identity_prefix_hex: str, + trigger_def_hex: str, + ttl: int, +) -> None: cast_send( registry_addr, "register(uint64,bytes32,bytes,uint64)", @@ -124,15 +147,6 @@ def call_trigger(helper_addr: str, topic2_hex: str) -> None: ) -# --------------------------------------------------------------------------- -# DB helpers -# --------------------------------------------------------------------------- - - -def query_db(sql: str, keyper_index: int = 0) -> str: - return utils.query_keyper_db(keyper_index, sql) - - def wait_for_event_decryption_key( keyper_config_index: int, identity_prefix_hex: str, @@ -151,25 +165,29 @@ def wait_for_event_decryption_key( f"identity_prefix={identity_prefix_hex}, eon={keyper_config_index}" ) - identity = query_db( + identity = utils.query_keyper_db( + 0, f"SELECT encode(identity, 'hex') FROM event_trigger_registered_event " f"WHERE eon = {keyper_config_index} " f"AND encode(identity_prefix, 'hex') = '{identity_prefix_hex}' " - "LIMIT 1" + "LIMIT 1", ) if not identity: time.sleep(poll_interval) continue - exists = query_db( + exists = utils.query_keyper_db( + 0, "SELECT EXISTS (" " SELECT 1 FROM decryption_key " f" WHERE eon = {keyper_config_index} " f" AND encode(epoch_id, 'hex') = '{identity}'" - ")" + ")", ) if exists == "t": - print(f" Decryption key ready: identity_prefix={identity_prefix_hex} → epoch_id={identity}") + print( + f" Decryption key ready: identity_prefix={identity_prefix_hex} → epoch_id={identity}" + ) return identity time.sleep(poll_interval) @@ -189,9 +207,10 @@ def assert_no_batching( """ deadline = time.time() + timeout while True: - count_str = query_db( + count_str = utils.query_keyper_db( + 0, f"SELECT COUNT(DISTINCT identities_hash) FROM decryption_signatures " - f"WHERE eon = {keyper_config_index}" + f"WHERE eon = {keyper_config_index}", ) count = int(count_str) if count_str else 0 if count >= num_triggers: @@ -213,30 +232,43 @@ def assert_no_batching( # Main # --------------------------------------------------------------------------- + def get_registry_address(data_dir: Path) -> str: broadcast = json.loads( - (data_dir / "contracts" / "broadcast" - / utils.DEPLOYMENT_SCRIPTS["service"] - / os.environ["ETHEREUM_CHAIN_ID"] - / "run-latest.json").read_text() + ( + data_dir + / "contracts" + / "broadcast" + / utils.DEPLOYMENT_SCRIPTS["service"] + / os.environ["ETHEREUM_CHAIN_ID"] + / "run-latest.json" + ).read_text() ) addr = utils.get_uups_proxy_address(broadcast, "ShutterEventTriggerRegistryV1") if not addr: - raise SystemExit("ShutterEventTriggerRegistryV1 proxy not found in deployment artifacts") + raise SystemExit( + "ShutterEventTriggerRegistryV1 proxy not found in deployment artifacts" + ) return addr def get_helper_bytecode(data_dir: Path) -> str: artifact = json.loads( - (data_dir / "contracts" / "out" - / "EventTriggerTestHelper.sol" - / "EventTriggerTestHelper.json").read_text() + ( + data_dir + / "contracts" + / "out" + / "EventTriggerTestHelper.sol" + / "EventTriggerTestHelper.json" + ).read_text() ) return artifact["bytecode"]["object"].removeprefix("0x") def main() -> None: - deployment_type = utils.resolve_deployment_type(os.environ.get("DEPLOYMENT_TYPE", "")) + deployment_type = utils.resolve_deployment_type( + os.environ.get("DEPLOYMENT_TYPE", "") + ) if deployment_type != "service": raise SystemExit("test-event-decryption only supports DEPLOYMENT_TYPE=service") @@ -247,13 +279,13 @@ def main() -> None: # --- Resolve eon and keyper_config_index ----------------------------------- # ShutterEventTriggerRegistryV1.register() takes the *keyper_config_index*, # not the eon number. We obtain both from the eons table and use accordingly. - eon_str = query_db("SELECT max(eon) FROM eons") + eon_str = utils.query_keyper_db(0, "SELECT max(eon) FROM eons") if not eon_str: raise SystemExit("No eon found in keyper DB — has DKG completed?") eon_number = int(eon_str) - keyper_config_index_str = query_db( - f"SELECT keyper_config_index FROM eons WHERE eon = {eon_number} LIMIT 1" + keyper_config_index_str = utils.query_keyper_db( + 0, f"SELECT keyper_config_index FROM eons WHERE eon = {eon_number} LIMIT 1" ) if not keyper_config_index_str: raise SystemExit(f"No keyper_config_index found for eon={eon_number}") @@ -276,7 +308,7 @@ def main() -> None: helper_addr_bytes = bytes.fromhex(helper_addr.removeprefix("0x").zfill(40)) trigger_def = encode_trigger_def( contract_addr=helper_addr_bytes, - topic_index=2, # topic[2] = topic2 (bytes32 indexed) + topic_index=2, # topic[2] = topic2 (bytes32 indexed) topic_value=trigger_topic, ) trigger_def_hex = trigger_def.hex() @@ -310,7 +342,9 @@ def main() -> None: wait_for_event_decryption_key(keyper_config_index, ip_hex, poll_interval) # --- Assert no batching --------------------------------------------------- - print("Asserting no-batching (each trigger must produce a distinct identities_hash)…") + print( + "Asserting no-batching (each trigger must produce a distinct identities_hash)…" + ) assert_no_batching(keyper_config_index, num_triggers) print( @@ -319,4 +353,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main()