diff --git a/crates/dkg-cli/README.md b/crates/dkg-cli/README.md index de3080ec..fc58d4d9 100644 --- a/crates/dkg-cli/README.md +++ b/crates/dkg-cli/README.md @@ -2,116 +2,130 @@ ** WARNING: This is WIP. Do not use. ** -Command-line tool for Distributed Key Generation (DKG) and key rotation protocols. A DKG process involves a coordinator and a set of participating members. Here we describe the processes for both a fresh DKG and a DKG key rotation. +Command-line tool for Distributed Key Generation (DKG) and key rotation protocols. A DKG process involves a coordinator and a set of participating members. Here we describe the processes for both a fresh DKG and a key rotation. -### Fresh DKG Process +Both fresh DKG and key rotation has 3 phases. -#### Coordinator Runbook +- **Phase 1 (Registration)**: Members generate and register their encryption keys onchain.- **Phase 2 (Message Creation)**: Members create and share DKG messages offchain. +- **Phase 3 (Finalization)**: Members process messages, propose committee onchain. + +The coordinator signals when to proceed from one phase to the next. -1. Deploy the `seal_committee` package in the Seal repo. Make sure you are on the right network with wallet with enough gas. Find the package ID in output, set it to env var `COMMITTEE_PKG`. Share this with members later. +## Prerequisites + +1. Install Sui (See [more](https://docs.sui.io/guides/developer/getting-started/sui-install)). +2. Clone the [Seal](https://github.com/MystenLabs/seal) repo locally and set to working directory. +3. Install python and the dependencies in `/seal`. ```bash -NETWORK=testnet -sui client switch --env $NETWORK -cd move/committee -sui client publish +brew install python # if needed +cd seal/ -COMMITTEE_PKG=0x3358b7f7150efe9a0487ad354e5959771c56556737605848231b09cca5b791c6 +# for the first time +python -m venv .venv +source .venv/bin/activate +pip install -r crates/dkg-cli/scripts/requirements.txt ``` -2. Gather all members' addresses. -3. Initialize the committee onchain. Notify members: +### Fresh DKG Process -- Committee package ID (`COMMITTEE_PKG`) -- Committee object ID (`COMMITTEE_ID`) +#### Coordinator Runbook -Then announce phase 1. +1. Gather all members' addresses and create a `dkg.yaml`. + +```yaml +NETWORK: Testnet # expected network +THRESHOLD: 2 # expected threshold +# your address, make sure you have enough gas for subsequent onchain calls for the network +COORDINATOR_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +MEMBERS: # gathered from participating members + - 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d + - 0xe6a37ff5cd968b6a666fb033d85eabc674449f44f9fc2b600e55e27354211ed6 + - 0x223762117ab21a439f0f3f3b0577e838b8b26a37d9a1723a4be311243f4461b9 +``` + +2. Run the publish-and-init script. ```bash -NETWORK=testnet -THRESHOLD=2 # Replace this with your threshold. -ADDRESS_0=0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d # Replace these with the members' addresses. -ADDRESS_1=0xe6a37ff5cd968b6a666fb033d85eabc674449f44f9fc2b600e55e27354211ed6 -ADDRESS_2=0x223762117ab21a439f0f3f3b0577e838b8b26a37d9a1723a4be311243f4461b9 - -sui client call --package $COMMITTEE_PKG --module seal_committee \ - --function init_committee \ - --args $THRESHOLD "[\"$ADDRESS_0\", \"$ADDRESS_1\", \"$ADDRESS_2\"]" - -# Find the created committee object in output and share this with members. -COMMITTEE_ID=0x46540663327da161b688786cbebbafbd32e0f344c85f8dc3bfe874c65a613418 +python crates/dkg-cli/scripts/dkg-scripts.py publish-and-init -c crates/dkg-cli/scripts/dkg.yaml ``` -4. Watch the onchain state until all members registered. Check the committee object state members on Explorer containing entries of all members' addresses. -5. Notify all members to run phase 2. -6. Watch the offchain storage until all members upload their messages. -7. Make a directory containing all messages and share it. Notify all members to run phase 3 with this directory. -8. Monitor the committee onchain object for finalized state when all members approve. Notify the members the DKG process is completed and the created key server object ID. +This publishes the `seal_committee` package and initializes the committee onchain. The script also appends the following to `dkg.yaml`. -#### Member Runbook +```yaml +COMMITTEE_PKG: 0x3358b7f7150efe9a0487ad354e5959771c56556737605848231b09cca5b791c6 +COMMITTEE_ID: 0x46540663327da161b688786cbebbafbd32e0f344c85f8dc3bfe874c65a613418 +``` + +3. Share the `dkg.yaml` file with all members. Announce to all members to begin Phase 1. -1. Share with the coordinator your address (`MY_ADDRESS`). This is the wallet used for the rest of the onchain commands. -2. Receive from coordinator the committee package ID and committee object ID. Verify its parameters (members addresses and threshold) on Sui Explorer. Set environment variables. +4. Monitor onchain state until all members are registered using the check-committee script. ```bash -COMMITTEE_PKG=0x3358b7f7150efe9a0487ad354e5959771c56556737605848231b09cca5b791c6 -COMMITTEE_ID=0x46540663327da161b688786cbebbafbd32e0f344c85f8dc3bfe874c65a613418 +python crates/dkg-cli/scripts/dkg-scripts.py check-committee -c crates/dkg-cli/scripts/dkg.yaml ``` -3. Wait for the coordinator to announce phase 1. Run the CLI below to generate keys locally and register the public keys onchain. Make sure you are on the right network with wallet with enough gas. +This will show which members have registered and which are still missing. + +5. Announce to all members to begin Phase 2 when all members registered. Monitor offchain storage until all members upload their messages. + +6. Collect all messages into a directory (e.g., `./dkg-messages`) and share it. Announce to all members to begin Phase 3. + +7. Monitor the committee onchain object for finalized state when all members had proposed. Run the check-committee command to get the key server object ID. ```bash -# A directory (default to `./dkg-state/`) containing sensitive private keys is created. Keep it secure till DKG is completed. -cargo run --bin dkg-cli generate-keys +python crates/dkg-cli/scripts/dkg-scripts.py check-committee -c crates/dkg-cli/scripts/dkg.yaml +``` + +Share `KEY_SERVER_OBJ_ID` from output all members to configure their key servers. -export DKG_ENC_PK=$(jq -r '.enc_pk' dkg-state/dkg.key) -export DKG_SIGNING_PK=$(jq -r '.signing_pk' dkg-state/dkg.key) +#### Member Runbook -# Register onchain. -sui client switch --env $NETWORK -YOUR_SERVER_URL="replace your url here" -MY_ADDRESS=$ADDRESS_0 # Replace your address here. +1. Share with the coordinator your address (`MY_ADDRESS`). This is the wallet used for the rest of the onchain commands. Make sure you are on the right network with wallet with enough gas. -sui client switch --address $MY_ADDRESS -sui client call --package $COMMITTEE_PKG --module seal_committee \ - --function register \ - --args $COMMITTEE_ID x"$DKG_ENC_PK" x"$DKG_SIGNING_PK" "$YOUR_SERVER_URL" +2. Wait till the coordinator annouces Phase 1 and receive the `dkg.yaml` file containing `COMMITTEE_PKG` and `COMMITTEE_ID`. Verify its parameters (members addresses and threshold) on Sui Explorer. Add the following member specific fields to `dkg.yaml`: + +```yaml +MY_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +MY_SERVER_URL: https://myserver.example.com ``` -4. Wait for the coordinator to announce phase 2. Initialize the DKG state locally and create your message file. Share the output file with the coordinator. +And run the following to generate keys locally and register onchain. ```bash -cargo run --bin dkg-cli create-message --my-address $MY_ADDRESS --committee-id $COMMITTEE_ID --network $NETWORK - -# This creates a file: ./message_P.json (where P is your party ID). +python crates/dkg-cli/scripts/dkg-scripts.py genkey-and-register -c crates/dkg-cli/scripts/dkg.yaml ``` -5. Wait for the coordinator to announce phase 3 and share a directory `./dkg-messages` containing all messages. Process the directory locally. +This script: +- Generates DKG keys (creates `./dkg-state/` directory with sensitive private keys - keep it secure!). +- Appends `DKG_ENC_PK` and `DKG_SIGNING_PK` to your yaml. +- Registers your public keys onchain. + +3. Wait till the coordinator annouces Phase 2. Run the following to initialize DKG state and create your message. ```bash -cargo run --bin dkg-cli process-all --messages-dir ./dkg-messages - -# Outputs key server public key and partial public keys, used for onchain proposal. -============KEY SERVER PK AND PARTIAL PKS===================== -KEY_SERVER_PK=0xb43c7a03bae03685d6411083d34d2cc3efd997274ac9ca1fdee37d592bb9c8e6ed4576c68031477d2f19296f8ce1590d022c60d0a82a56c0c1018551648978f193c5fa5737a50415a3391311decafc6224d7632253a92d142dcd62c85fcc09f7 -PARTY_0_PARTIAL_PK=0x8ef79f15defb1ea58b5644aa1fccc79f6235d3fff425ebe9140c3fda8e493d23ea6575bbe63af0204a14343f04f7d3d70d0bb51e044d87b03e251ee388f3837d87c6973e53af50602805110b2ec0f365de51bd046c38ce6e433e663cd8aaff1e -PARTY_1_PARTIAL_PK=0x810b3577cb1e6dd011f1f8e2561f0e4f3c05eb0918f388817156de1a87a00b2b43f1e892da1efd09192fa85d62f83c1308b04beba3ed4d42ce01865bbd4eed24942a9504df90dce40575b05014a7b953ca4ec17530fe4367c1815cb7aca10261 -PARTY_2_PARTIAL_PK=0x92bb786ec791646fe63e99917b88c33966c9380b61dac70e4518d4a95834b42cc9163eb2cb6d067279525400bc59d91b05e52d19846bdd55a143e2d7cc7365355563a0a4d2004c6d5511da2d102d64bf0b4a518597b01af1984bfe69e2f13da5 - -# Outputs new share, use it for MASTER_SHARE_V0 environment variable for step 7. -============YOUR PARTIAL KEY SHARE, KEEP SECRET===================== -MASTER_SHARE_V0=0x208cd48a92430eb9f90482291e5552e07aebc335d84b7b6371a58ebedd6ed036 +python crates/dkg-cli/scripts/dkg-scripts.py create-message \ + -c crates/dkg-cli/scripts/dkg.yaml + +# This creates a file: message_P.json (where P is your party ID). ``` -6. Propose the committee onchain with locally finalized key server public key and partial public keys. +Share the output `message_P.json` file with the coordinator. + +4. Wait for the coordinator to announce Phase 3 and receive the `./dkg-messages` directory containing all messages from the coordinator. Run the following: ```bash -sui client call --package $COMMITTEE_PKG --module seal_committee \ - --function propose \ - --args $COMMITTEE_ID "[x\"$PARTY_0_PARTIAL_PK\", x\"$PARTY_1_PARTIAL_PK\", x\"$PARTY_2_PARTIAL_PK\"]" x"$KEY_SERVER_PK" +python crates/dkg-cli/scripts/dkg-scripts.py process-all-and-propose \ + -c crates/dkg-cli/scripts/dkg.yaml \ + -m ./dkg-messages ``` -7. Wait for the coordinator to announce that the DKG process is completed and share the created key server object ID `KEY_SERVER_OBJ_ID`. Create `key-server-config.yaml` with `MY_ADDRESS` and `KEY_SERVER_OBJ_ID` and set Active mode. Start the server with `MASTER_SHARE_V0` (version 0 for fresh DKG). +This script: +- Processes all messages from `./dkg-messages` directory. +- Appends to `dkg.yaml`: `KEY_SERVER_PK` (new key server public key), `PARTIAL_PKS_V0` (partial public keys for all members), and `MASTER_SHARE_V0` (your secret master share - keep secure!). +- Proposes the committee onchain by calling the `propose` function. + +5. Wait for the coordinator to announce that the DKG is completed and receive `KEY_SERVER_OBJ_ID`. Create `key-server-config.yaml` with `MY_ADDRESS` and `KEY_SERVER_OBJ_ID` and set Active mode. Start the server with `MASTER_SHARE_V0` (version 0 for fresh DKG). Example config file: ```yaml @@ -128,103 +142,93 @@ CONFIG_PATH=crates/key-server/key-server-config.yaml MASTER_SHARE_V0=0x208cd48a9 ### Key Rotation Process -A key rotation process is needed when a committee wants to rotate a portion of its members. The continuing members (in both current and next committee) must meet the threshold of the current committee. +A key rotation process is needed when a committee wants to rotate a portion of its members. The continuing members (in both current and next committee) must meet the threshold of the current committee. -Assuming the key server committee mode version onchain is currently X and it is being rotated to X+1. +Assuming the key server committee mode version onchain is currently X and it is being rotated to X+1. #### Coordinator Runbook -All steps are the same as the runbook for fresh DKG. Except: -- Modified step 2: Instead of calling `init_committee`, call `init_rotation`, where `CURRENT_COMMITTEE_ID` is the object ID of the current committee (e.g., `CURRENT_COMMITTEE_ID=0xaf2962d702d718f7b968eddc262da28418a33c296786cd356a43728a858faf80`). +The process follows the same three phases as fresh DKG, with the following differences: -```bash -# Example new members for rotation, along with ADDRESS_1, ADDRESS_0. Replace with your own. -ADDRESS_3=0x2aaadc85d1013bde04e7bff32aceaa03201627e43e3e3dd0b30521486b5c34cb -ADDRESS_4=0x8b4a608c002d969d29f1dd84bc8ac13e6c2481d6de45718e606cfc4450723ec2 -THRESHOLD=3 # New committee threshold, replace with your own. +1. Create `dkg-rotation.yaml` with `CURRENT_COMMITTEE_ID` specified: -sui client call --package $COMMITTEE_PKG --module seal_committee \ - --function init_rotation \ - --args $CURRENT_COMMITTEE_ID $THRESHOLD "[\"$ADDRESS_1\", \"$ADDRESS_0\", \"$ADDRESS_3\", \"$ADDRESS_4\"]" - -# New committee ID, share with all members. -COMMITTEE_ID=0x82283c1056bb18832428034d20e0af5ed098bc58f8815363c33eb3a9b3fba867 +```yaml +NETWORK: Testnet +THRESHOLD: 3 # New threshold for rotated committee +COORDINATOR_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +CURRENT_COMMITTEE_ID: 0x984d6edd224af9b67c1abd806aee5f7f85e7f5b33f37851c3daa3949f1bb5d3c # Current committee +COMMITTEE_PKG: 0x3d7fbd0db6b200970c438dfc9ec6d61d0d5b0d8f318fd9cdae7c204597ca88e4 # Reuse existing package +MEMBERS: # New committee members (can include continuing members) + - 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d + - 0xe6a37ff5cd968b6a666fb033d85eabc674449f44f9fc2b600e55e27354211ed6 + - 0x2aaadc85d1013bde04e7bff32aceaa03201627e43e3e3dd0b30521486b5c34cb + - 0x8b4a608c002d969d29f1dd84bc8ac13e6c2481d6de45718e606cfc4450723ec2 ``` -- Added step 9: Monitor onchain that the new committee is finalized. Then announce DKG rotation is completed, so members can restart with Active mode server. - -#### Member Runbook - -1. Share with the coordinator your address (`MY_ADDRESS`). This is the wallet used for the rest of the onchain commands. -2. Receive from the coordinator the next committee ID. Verify its parameters (members addresses, threshold, the current committee ID) on Sui Explorer. Set environment variable. +Instead of `publish-and-init`, run `init-rotation`. ```bash -# Next committee ID -COMMITTEE_ID=0x1614a8a2597e4ce6db9e8887386957b1d47fd36d58114034b511260f62fe539b -``` +python crates/dkg-cli/scripts/dkg-scripts.py init-rotation -c crates/dkg-cli/scripts/dkg-rotation.yaml +``` -3. Wait for the coordinator to announce phase 1. Run the CLI below to generate keys locally and register the public keys onchain. Make sure you are on the right network with wallet with enough gas. +This will initialize rotation and append the new `COMMITTEE_ID` to your config. Share this file with all members. -```bash -# A directory (default to `./dkg-state/`) containing sensitive private keys is created. Keep it secure till DKG is completed. -cargo run --bin dkg-cli generate-keys +2. Phase 1, 2, 3: Follow the same steps as fresh DKG. Announce each phase to members and monitor progress. Also announce key rotation completion. + +#### Member Runbook -export DKG_ENC_PK=$(jq -r '.enc_pk' dkg-state/dkg.key) -export DKG_SIGNING_PK=$(jq -r '.signing_pk' dkg-state/dkg.key) +1. Share with the coordinator your address (`MY_ADDRESS`). This is the wallet used for the rest of the onchain commands. Make sure you are on the right network with wallet with enough gas. -# Register onchain. -sui client switch --env $NETWORK -YOUR_SERVER_URL="replace your url here" -MY_ADDRESS=$ADDRESS_0 # Replace your address here. +2. Wait till the coordinator annouces Phase 1 and receive the `dkg-rotation.yaml` file containing `COMMITTEE_PKG`, `CURRENT_COMMITTEE_ID` and `COMMITTEE_ID` (next committee ID). Verify its parameters (members addresses, threshold, the current committee ID) on Sui Explorer. Add the following member specific fields to `dkg-rotation.yaml`: -sui client switch --address $MY_ADDRESS -sui client call --package $COMMITTEE_PKG --module seal_committee \ - --function register \ - --args $COMMITTEE_ID x"$DKG_ENC_PK" x"$DKG_SIGNING_PK" "$YOUR_SERVER_URL" +```yaml +MY_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +MY_SERVER_URL: https://myserver.example.com ``` -4. Wait for the coordinator to announce phase 2. - -a. For continuing members, run the CLI below to initialize the local state and create your message file. Must provide `--old-share` arg with your current version `X` master share. Share the output file with the coordinator. +And run the script to generate keys locally and register onchain. ```bash -cargo run --bin dkg-cli create-message --my-address $MY_ADDRESS --committee-id $COMMITTEE_ID --network $NETWORK --old-share $MASTER_SHARE_VX - -# This creates a file: ./message_P.json (where P is your party ID). +python crates/dkg-cli/scripts/dkg-scripts.py genkey-and-register -c crates/dkg-cli/scripts/dkg-rotation.yaml ``` -b. For new members, run the CLI below that initializes the local state. Do not provide old share. +3. Wait for the coordinator to announce Phase 2 and run the following: + +**For continuing members**: Required to pass your old master share as an argument. ```bash -cargo run --bin dkg-cli create-message --my-address $MY_ADDRESS --committee-id $COMMITTEE_ID --network $NETWORK +python crates/dkg-cli/scripts/dkg-scripts.py create-message \ + -c crates/dkg-cli/scripts/dkg-rotation.yaml \ + --old-share -# No file is created or needed to be shared with the coordinator. +# This creates a file: message_P.json (where P is your party ID). ``` -5. Wait for the coordinator to announce phase 3 and share a directory `./dkg-messages` containing all messages. Process the directory locally. +Share the output `message_P.json` file with the coordinator. -```bash -cargo run --bin dkg-cli process-all --messages-dir ./dkg-messages +**For new members**: Just run the command without old share. -# Outputs partial public keys, used for onchain proposal. -PARTY_0_PARTIAL_PK=<...> -PARTY_1_PARTIAL_PK=<...> -PARTY_2_PARTIAL_PK=<...> -PARTY_3_PARTIAL_PK=<...> +```bash +python crates/dkg-cli/scripts/dkg-scripts.py create-message \ + -c crates/dkg-cli/scripts/dkg-rotation.yaml -# Outputs new share (version X+1), to be used to start server at step 7. -MASTER_SHARE_VX+1=0x03899294f5e6551631fcbaea5583367fb565471adeccb220b769879c55e66ed9 +# No message file is created for new members. ``` -6. Propose the committee onchain with locally finalized partial public keys. +4. Wait for the coordinator to announce Phase 3 and receive the messages directory `./dkg-rotation-messages`. Process the directory locally and propose onchain: ```bash -sui client call --package $COMMITTEE_PKG --module seal_committee \ - --function propose_for_rotation \ - --args $COMMITTEE_ID "[x\"$PARTY_0_PARTIAL_PK\", x\"$PARTY_1_PARTIAL_PK\", x\"$PARTY_2_PARTIAL_PK\", x\"$PARTY_3_PARTIAL_PK\"]" $CURRENT_COMMITTEE_ID +python crates/dkg-cli/scripts/dkg-scripts.py process-all-and-propose \ + -c crates/dkg-cli/scripts/dkg-rotation.yaml \ + -m ./dkg-rotation-messages ``` -7. Update `key-server-config.yaml` to Rotation mode with target version `X+1`. +This script: +- Processes all messages from `./dkg-rotation-messages` directory. +- Appends to your config: `PARTIAL_PKS_VX+1` (new partial public keys for all members) and `MASTER_SHARE_VX+1` (your new secret master share - keep secure!). +- Proposes the rotation onchain by calling the `propose_for_rotation` function. + +5. Update `key-server-config.yaml` to Rotation mode with target version `X+1`. Example config file: ```yaml @@ -262,7 +266,7 @@ CONFIG_PATH=crates/key-server/key-server-config.yaml \ cargo run --bin key-server ``` -TODO: Discuss what to do with old share. +Store your old master share securely. It is needed for future rotation. b. For new members, since `X+1` is the first known key, so just need to start the server with it: diff --git a/crates/dkg-cli/scripts/dkg-rotation.example.yaml b/crates/dkg-cli/scripts/dkg-rotation.example.yaml new file mode 100644 index 00000000..1703a039 --- /dev/null +++ b/crates/dkg-cli/scripts/dkg-rotation.example.yaml @@ -0,0 +1,38 @@ +# DKG Configuration File +# This file is used by both coordinator and member scripts for DKG operations. + +# ============================================================================ +# COORDINATOR SECTION - Created initially by coordinator +# ============================================================================ + +NETWORK: Testnet +THRESHOLD: 3 # New threshold for rotated committee +COORDINATOR_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +CURRENT_COMMITTEE_ID: 0x984d6edd224af9b67c1abd806aee5f7f85e7f5b33f37851c3daa3949f1bb5d3c # Current committee +COMMITTEE_PKG: 0x3d7fbd0db6b200970c438dfc9ec6d61d0d5b0d8f318fd9cdae7c204597ca88e4 # Reuse existing package +MEMBERS: # New committee members (can include continuing members) + - 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d + - 0xe6a37ff5cd968b6a666fb033d85eabc674449f44f9fc2b600e55e27354211ed6 + - 0x2aaadc85d1013bde04e7bff32aceaa03201627e43e3e3dd0b30521486b5c34cb + - 0x8b4a608c002d969d29f1dd84bc8ac13e6c2481d6de45718e606cfc4450723ec2 + +# ============================================================================ +# AUTO-GENERATED by 'publish-and-init' script (coordinator) +# ============================================================================ + +# COMMITTEE_ID: 0x... +# COMMITTEE_PKG: 0x... + +# ============================================================================ +# MEMBER SECTION - Added manually by each member +# ============================================================================ + +# MY_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +# MY_SERVER_URL: https://myserver.example.com + +# ============================================================================ +# AUTO-GENERATED by 'genkey-and-register' script (member) +# ============================================================================ + +# DKG_ENC_PK: 0x... +# DKG_SIGNING_PK: 0x... \ No newline at end of file diff --git a/crates/dkg-cli/scripts/dkg-scripts.py b/crates/dkg-cli/scripts/dkg-scripts.py new file mode 100755 index 00000000..b36d8c77 --- /dev/null +++ b/crates/dkg-cli/scripts/dkg-scripts.py @@ -0,0 +1,837 @@ +#!/usr/bin/env python +# Copyright (c), Mysten Labs, Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +DKG scripts for both coordinator and member operations. + +Coordinator usage: + python dkg-scripts.py publish-and-init -c dkg.yaml + python dkg-scripts.py check-committee -c dkg.yaml + python dkg-scripts.py process-all-and-propose -c dkg.yaml -m ./dkg-messages + python dkg-scripts.py init-rotation -c dkg-rotation.yaml + +Member usage: + python dkg-scripts.py genkey-and-register -c dkg.yaml -k ./dkg-state/dkg.key + python dkg-scripts.py create-message -c dkg.yaml -k ./dkg-state/dkg.key -s ./dkg-state + python dkg-scripts.py process-all-and-propose -c dkg.yaml -m ./dkg-messages +""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml + + +def normalize_sui_address(value: Any) -> str: + """Normalize a Sui address / object ID from YAML into a 0x-prefixed hex string.""" + if value is None: + return None + + # PyYAML may parse `0x...` as int; convert back to 0xNN..NN (64 nybbles) + if isinstance(value, int): + return "0x" + format(value, "064x") + + # Already string: just return as-is (caller may ensure correct format) + return str(value) + + +def normalize_sui_address_list(values: list[Any]) -> list[str]: + """Normalize a list of Sui addresses / object IDs.""" + return [normalize_sui_address(v) for v in values] + + +def run_sui_command(args: list[str]) -> dict: + """Run a sui CLI command and return parsed JSON output.""" + cmd = ["sui", "client", "--json"] + args + print(f"Running: {' '.join(cmd)}") + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + print(f"Error: {result.stderr}", file=sys.stderr) + sys.exit(1) + + try: + return json.loads(result.stdout) + except json.JSONDecodeError as e: + print(f"Failed to parse JSON output: {e}", file=sys.stderr) + print(f"Output: {result.stdout}", file=sys.stderr) + sys.exit(1) + + +def extract_published_package_id(output: dict) -> str: + """Extract published package ID from sui publish output.""" + effects = output.get("effects", {}) + created = effects.get("created", []) + + for obj in created: + owner = obj.get("owner", {}) + if owner == "Immutable": + return obj["reference"]["objectId"] + + raise ValueError("Could not find published package ID in output") + + +def extract_object_id_by_type(output: dict, type_suffix: str) -> str: + """Extract object ID by type suffix from sui call output.""" + effects = output.get("effects", {}) + created = effects.get("created", []) + + # Get object changes for type info + object_changes = output.get("objectChanges", []) + type_map = {} + for change in object_changes: + if change.get("type") == "created": + obj_id = change.get("objectId") + obj_type = change.get("objectType", "") + type_map[obj_id] = obj_type + + for obj in created: + obj_id = obj["reference"]["objectId"] + obj_type = type_map.get(obj_id, "") + if type_suffix in obj_type: + return obj_id + + raise ValueError(f"Could not find object with type '{type_suffix}' in output") + + +def load_config(config_path: str) -> dict: + """Load YAML configuration file.""" + try: + with open(config_path, "r") as f: + return yaml.safe_load(f) + except FileNotFoundError: + print(f"Error: Config file not found: {config_path}", file=sys.stderr) + print(f"Please create a config file or check the path.", file=sys.stderr) + sys.exit(1) + except yaml.YAMLError as e: + print(f"Error: Invalid YAML in config file: {e}", file=sys.stderr) + sys.exit(1) + + +def validate_required_fields(config: dict, required_fields: list[str], command_name: str, suggestion: str = None): + """Validate that required fields exist in config with helpful error messages.""" + missing = [f for f in required_fields if not config.get(f)] + if missing: + print(f"\n[ERROR] Missing required fields for '{command_name}':", file=sys.stderr) + for field in missing: + print(f" - {field}", file=sys.stderr) + + if suggestion: + print(f"\nSuggestion: {suggestion}", file=sys.stderr) + + sys.exit(1) + + +def update_config_fields(config_path: str, updates: dict): + """Update specific fields in YAML config without rewriting the entire file.""" + with open(config_path, "r") as f: + content = f.read() + + # Define markers for where to insert specific fields + field_markers = { + "COMMITTEE_PKG": r"# AUTO-GENERATED by 'publish-and-init' script \(coordinator\)\n# ============================================================================\n", + "COMMITTEE_ID": r"# AUTO-GENERATED by 'publish-and-init' script \(coordinator\)\n# ============================================================================\n", + "DKG_ENC_PK": r"# AUTO-GENERATED by 'genkey-and-register' script \(member\)\n# ============================================================================\n", + "DKG_SIGNING_PK": r"# AUTO-GENERATED by 'genkey-and-register' script \(member\)\n# ============================================================================\n", + "KEY_SERVER_PK": r"# AUTO-GENERATED by 'process-all-and-propose' script\n# ============================================================================\n", + "PARTIAL_PKS": r"# AUTO-GENERATED by 'process-all-and-propose' script\n# ============================================================================\n", + "MASTER_SHARE": r"# AUTO-GENERATED by 'process-all-and-propose' script\n# ============================================================================\n", + } + + for key, value in updates.items(): + # Match the key and replace its value, preserving formatting + # For list fields (PARTIAL_PKS_VX), we need to match the entire list block + if key.startswith('PARTIAL_PKS_V'): + # Pattern to match: KEY:\n- item1\n- item2\n... + # Match from the key line until we hit a non-list line or end + pattern = rf'^{re.escape(key)}:(?:\n- .+)*' + replacement = f'{key}: {value}' + + if re.search(rf'^{re.escape(key)}:', content, flags=re.MULTILINE): + # Field exists, replace the entire block + content = re.sub(pattern, replacement, content, flags=re.MULTILINE) + else: + # Field doesn't exist, insert it + base_key = key.rsplit('_V', 1)[0] + marker = field_markers.get(base_key) + + if marker and re.search(marker, content): + # Insert after the marker + marker_match = re.search(marker, content) + insert_pos = marker_match.end() + content = content[:insert_pos] + f'{replacement}\n' + content[insert_pos:] + else: + # No marker found, append at end + if not content.endswith('\n'): + content += '\n' + content += f'{replacement}\n' + else: + # Single-line field + pattern = rf'^{re.escape(key)}:.*$' + replacement = f'{key}: {value}' + + if re.search(pattern, content, flags=re.MULTILINE): + # Field exists, update it + content = re.sub(pattern, replacement, content, flags=re.MULTILINE) + else: + # Field doesn't exist, need to insert it + # For versioned fields like MASTER_SHARE_V1, use the base field marker + base_key = key + if re.match(r'^MASTER_SHARE_V\d+$', key): + base_key = key.rsplit('_V', 1)[0] + + marker = field_markers.get(base_key) + + if marker and re.search(marker, content): + # Insert after the marker + marker_match = re.search(marker, content) + insert_pos = marker_match.end() + content = content[:insert_pos] + f'{replacement}\n' + content[insert_pos:] + else: + # No marker found, append at end + if not content.endswith('\n'): + content += '\n' + content += f'{replacement}\n' + + with open(config_path, "w") as f: + f.write(content) + + +def get_network_env(network: str) -> str: + """Convert network config to sui env name.""" + if isinstance(network, dict): + # Handle tagged enum like !Testnet + return list(network.keys())[0].lower() + return str(network).lower() + + +def switch_sui_context(network_env: str, address: str | None = None): + """Switch sui client to the specified network and optionally address.""" + print(f"Switching to network: {network_env}") + subprocess.run(["sui", "client", "switch", "--env", str(network_env)], check=True) + + if address is not None: + addr_str = str(address) + print(f"Switching to address: {addr_str}") + subprocess.run( + ["sui", "client", "switch", "--address", addr_str], + check=True, + ) + + +def publish_and_init(config_path: str): + """Publish committee package and initialize committee.""" + config = load_config(config_path) + + # Check if already initialized + if config.get("COMMITTEE_PKG") and config.get("COMMITTEE_ID"): + print("Committee already initialized:") + print(f" COMMITTEE_PKG: {normalize_sui_address(config.get('COMMITTEE_PKG'))}") + print(f" COMMITTEE_ID: {normalize_sui_address(config.get('COMMITTEE_ID'))}") + print("\nSkipping publish and init. Remove these fields from config to reinitialize.") + return + + network = config.get("NETWORK", "testnet") + network_env = get_network_env(network) + + # Normalize addresses from YAML + coordinator_address = normalize_sui_address(config.get("COORDINATOR_ADDRESS")) + members = normalize_sui_address_list(config.get("MEMBERS", [])) + threshold = config.get("THRESHOLD", 2) + + if not members: + print("Error: MEMBERS list is empty", file=sys.stderr) + sys.exit(1) + + # Switch to correct network and address + switch_sui_context(network_env, coordinator_address) + + # Publish the package + print("\nPublishing seal_committee package...") + committee_path = ( + Path(__file__).parent.parent.parent.parent / "move" / "committee" + ) + + publish_output = run_sui_command( + ["publish", str(committee_path)] + ) + + package_id = extract_published_package_id(publish_output) + print(f"Published package: {package_id}") + + # Initialize the committee + print("\nInitializing committee...") + members_json = json.dumps(members) + + init_output = run_sui_command( + [ + "call", + "--package", + package_id, + "--module", + "seal_committee", + "--function", + "init_committee", + "--args", + str(threshold), + members_json, + ] + ) + + committee_id = extract_object_id_by_type(init_output, "Committee") + print(f"Created committee: {committee_id}") + + # Update config (only specific fields to preserve formatting) + update_config_fields( + config_path, + { + "COMMITTEE_PKG": package_id, + "COMMITTEE_ID": committee_id, + }, + ) + + print(f"\nUpdated {config_path} with:") + print(f" COMMITTEE_PKG: {package_id}") + print(f" COMMITTEE_ID: {committee_id}") + print("\nShare this file with committee members.") + + +def init_rotation(config_path: str): + """Initialize committee rotation.""" + config = load_config(config_path) + + # Check if already initialized + if config.get("COMMITTEE_ID"): + print("Committee rotation already initialized:") + print(f" COMMITTEE_ID: {normalize_sui_address(config.get('COMMITTEE_ID'))}") + print("\nSkipping init-rotation. Remove COMMITTEE_ID from config to re-initialize.") + return + + network = config.get("NETWORK", "testnet") + network_env = get_network_env(network) + + coordinator_address = normalize_sui_address(config.get("COORDINATOR_ADDRESS")) + package_id = normalize_sui_address(config.get("COMMITTEE_PKG")) + current_committee_id = normalize_sui_address(config.get("CURRENT_COMMITTEE_ID")) + members = normalize_sui_address_list(config.get("MEMBERS", [])) + threshold = config.get("THRESHOLD", 2) + + if not package_id: + print("Error: COMMITTEE_PKG not found in config", file=sys.stderr) + sys.exit(1) + + if not current_committee_id: + print("Error: CURRENT_COMMITTEE_ID not found in config", file=sys.stderr) + sys.exit(1) + + if not members: + print("Error: MEMBERS list is empty", file=sys.stderr) + sys.exit(1) + + # Switch to correct network and address + switch_sui_context(network_env, coordinator_address) + + # Call init_rotation + print("\nInitializing rotation...") + members_json = json.dumps(members) + + rotation_output = run_sui_command( + [ + "call", + "--package", + package_id, + "--module", + "seal_committee", + "--function", + "init_rotation", + "--args", + current_committee_id, + str(threshold), + members_json, + ] + ) + + new_committee_id = extract_object_id_by_type(rotation_output, "Committee") + print(f"Created new committee for rotation: {new_committee_id}") + + # Update config (only specific fields to preserve formatting) + update_config_fields( + config_path, + { + "COMMITTEE_ID": new_committee_id, + }, + ) + + print(f"\nUpdated {config_path} with:") + print(f" COMMITTEE_ID: {new_committee_id}") + print("\nShare this file with committee members.") + + +def genkey_and_register(config_path: str, keys_file: str = "./dkg-state/dkg.key"): + """Generate DKG keys and register onchain (member operation).""" + config = load_config(config_path) + + # Check if already generated keys + if config.get("DKG_ENC_PK") and config.get("DKG_SIGNING_PK"): + print("Keys already generated:") + print(f" DKG_ENC_PK: {config.get('DKG_ENC_PK')}") + print(f" DKG_SIGNING_PK: {config.get('DKG_SIGNING_PK')}") + print("\nSkipping key generation and registration.") + print("WARNING: If these keys were already registered onchain, this operation cannot be redone.") + print("Remove these fields from config only if you need to regenerate for a different committee.") + return + + # Validate required fields + validate_required_fields( + config, + ["MY_ADDRESS", "MY_SERVER_URL", "COMMITTEE_PKG", "COMMITTEE_ID", "NETWORK"], + "genkey-and-register", + "Make sure you have received the config file from the coordinator with COMMITTEE_PKG and COMMITTEE_ID, and added your MY_ADDRESS and MY_SERVER_URL." + ) + + # Normalize addresses from YAML + my_address = normalize_sui_address(config["MY_ADDRESS"]) + my_server_url = config["MY_SERVER_URL"] + committee_pkg = normalize_sui_address(config["COMMITTEE_PKG"]) + committee_id = normalize_sui_address(config["COMMITTEE_ID"]) + network = config["NETWORK"] + network_env = get_network_env(network) + + # Step 1: Generate keys using cargo + print("\n=== Generating DKG keys ===") + print(f"Running: cargo run --bin dkg-cli generate-keys --keys-file {keys_file}") + result = subprocess.run([ + "cargo", "run", "--bin", "dkg-cli", "generate-keys", + "--keys-file", keys_file + ]) + if result.returncode != 0: + sys.exit(1) + + # Step 2: Read generated keys + key_file = Path(keys_file) + if not key_file.exists(): + print(f"Error: Key file not found at {key_file}", file=sys.stderr) + sys.exit(1) + + with open(key_file, "r") as f: + keys = json.load(f) + + enc_pk = keys.get("enc_pk") + signing_pk = keys.get("signing_pk") + + if not enc_pk or not signing_pk: + print("Error: Failed to read enc_pk or signing_pk from key file", file=sys.stderr) + sys.exit(1) + + print(f"Generated keys:") + print(f" DKG_ENC_PK: {enc_pk}") + print(f" DKG_SIGNING_PK: {signing_pk}") + + # Step 3: Update config with public keys + print(f"\n=== Updating {config_path} ===") + update_config_fields( + config_path, + { + "DKG_ENC_PK": enc_pk, + "DKG_SIGNING_PK": signing_pk, + }, + ) + print("Config updated with DKG_ENC_PK and DKG_SIGNING_PK") + + # Step 4: Switch network and address + print(f"\n=== Switching to network: {network_env} and address: {my_address} ===") + switch_sui_context(network_env, my_address) + + # Step 5: Register onchain + print(f"\n=== Registering onchain ===") + subprocess.run([ + "sui", "client", "call", + "--package", committee_pkg, + "--module", "seal_committee", + "--function", "register", + "--args", committee_id, f'x"{enc_pk}"', f'x"{signing_pk}"', my_server_url + ], check=True) + + print("\n[SUCCESS] Keys generated and registered onchain!") + print(f" Your address: {my_address}") + print(f" Server URL: {my_server_url}") + print(f" Committee ID: {committee_id}") + print(f"\nIMPORTANT: Your private keys are stored in: {keys_file}") + + +def check_committee(config_path: str): + """Check committee status and member registration (coordinator operation).""" + config = load_config(config_path) + + # Validate required fields + validate_required_fields( + config, + ["COMMITTEE_ID", "NETWORK"], + "check-committee", + "Make sure your config has COMMITTEE_ID and NETWORK." + ) + + # Normalize addresses from YAML + committee_id = normalize_sui_address(config["COMMITTEE_ID"]) + network = config["NETWORK"] + network_env = get_network_env(network) + + # Call the Rust CLI + print(f"Checking committee status for {committee_id}...\n") + subprocess.run([ + "cargo", "run", "--bin", "dkg-cli", "check-committee", + "--committee-id", committee_id, + "--network", network_env + ], check=True) + + +def create_message(config_path: str, keys_file: str = "./dkg-state/dkg.key", state_dir: str = "./dkg-state", old_share: str = None): + """Create DKG message for phase 2 (member operation).""" + config = load_config(config_path) + + # Validate required fields + validate_required_fields( + config, + ["MY_ADDRESS", "COMMITTEE_ID", "NETWORK"], + "create-message", + "Make sure your config has MY_ADDRESS, COMMITTEE_ID (from coordinator), and NETWORK." + ) + + # Normalize addresses from YAML + my_address = normalize_sui_address(config["MY_ADDRESS"]) + committee_id = normalize_sui_address(config["COMMITTEE_ID"]) + network = config["NETWORK"] + network_env = get_network_env(network) + + # Call the Rust CLI + print(f"\n=== Creating DKG message ===") + print(f" My address: {my_address}") + print(f" Committee ID: {committee_id}") + print(f" Network: {network_env}") + print(f" Keys file: {keys_file}") + print(f" State directory: {state_dir}") + if old_share: + print(f" Old share: {old_share} (rotation mode)") + print() + + cmd = [ + "cargo", "run", "--bin", "dkg-cli", "create-message", + "--my-address", my_address, + "--committee-id", committee_id, + "--network", network_env, + "--keys-file", keys_file, + "--state-dir", state_dir + ] + + # Add old-share argument if present (for rotation) + if old_share: + cmd.extend(["--old-share", old_share]) + + result = subprocess.run(cmd) + + if result.returncode != 0: + sys.exit(1) + + +def process_all_and_propose(config_path: str, messages_dir: str, keys_file: str = "./dkg-state/dkg.key", state_dir: str = "./dkg-state"): + """Process all DKG messages and propose committee onchain (coordinator operation).""" + config = load_config(config_path) + + # Validate required fields + validate_required_fields( + config, + ["COMMITTEE_PKG", "COMMITTEE_ID", "NETWORK", "MY_ADDRESS"], + "process-all-and-propose", + "Make sure your config has COMMITTEE_PKG, COMMITTEE_ID, MY_ADDRESS, and NETWORK." + ) + + # Normalize addresses from YAML + committee_pkg = normalize_sui_address(config["COMMITTEE_PKG"]) + committee_id = normalize_sui_address(config["COMMITTEE_ID"]) + my_address = normalize_sui_address(config["MY_ADDRESS"]) + network = config["NETWORK"] + network_env = get_network_env(network) + + # Check if this is a rotation (CURRENT_COMMITTEE_ID present) + current_committee_id = config.get("CURRENT_COMMITTEE_ID") + is_rotation = current_committee_id is not None + if is_rotation: + current_committee_id = normalize_sui_address(current_committee_id) + + # Step 1: Run process-all command + print(f"\n=== Processing DKG messages ===") + print(f" Messages directory: {messages_dir}") + print(f" State directory: {state_dir}") + print(f" Keys file: {keys_file}\n") + + result = subprocess.run([ + "cargo", "run", "--bin", "dkg-cli", "process-all", + "--messages-dir", messages_dir, + "--state-dir", state_dir, + "--keys-file", keys_file, + "--network", network_env + ], capture_output=True, text=True) + + if result.returncode != 0: + print(f"Error processing messages: {result.stderr}", file=sys.stderr) + sys.exit(1) + + # Step 2: Parse the output (suppress verbose cargo output) + output = result.stdout + + # Extract values from output + key_server_pk = None + partial_pks = [] + master_share = None + committee_version = None + + for line in output.split('\n'): + if line.startswith('KEY_SERVER_PK='): + key_server_pk = line.split('=', 1)[1] + elif line.startswith('PARTY_') and '_PARTIAL_PK=' in line: + partial_pk = line.split('=', 1)[1] + partial_pks.append(partial_pk) + elif line.startswith('MASTER_SHARE_V'): + master_share = line.split('=', 1)[1] + elif line.startswith('COMMITTEE_VERSION='): + committee_version = line.split('=', 1)[1] + + if not key_server_pk or not partial_pks or not master_share or not committee_version: + print("Error: Failed to parse all required values from process-all output", file=sys.stderr) + sys.exit(1) + + # Step 3: Append parsed values to config file + print(f"\n=== Updating {config_path} ===") + + # Read content to check if marker exists + with open(config_path, 'r') as f: + content = f.read() + + marker = "# AUTO-GENERATED by 'process-all-and-propose' script" + + # Only append if marker doesn't exist + if marker not in content: + with open(config_path, 'a') as f: + f.write("\n# ============================================================================\n") + f.write("# AUTO-GENERATED by 'process-all-and-propose' script\n") + f.write("# ============================================================================\n") + + # Now append the versioned fields using update_config_fields + partial_pks_yaml = "\n".join([f"- '{pk}'" for pk in partial_pks]) + + # For fresh DKG (v0), also write KEY_SERVER_PK + # For rotation (v1+), KEY_SERVER_PK must match existing value from v0 + fields_to_update = { + f"PARTIAL_PKS_V{committee_version}": f"\n{partial_pks_yaml}", + f"MASTER_SHARE_V{committee_version}": f"'{master_share}'", + } + + if committee_version == 0: + # Fresh DKG - write KEY_SERVER_PK + fields_to_update["KEY_SERVER_PK"] = f"'{key_server_pk}'" + else: + # Rotation - verify KEY_SERVER_PK matches existing value + existing_key_server_pk = config.get("KEY_SERVER_PK") + if existing_key_server_pk: + # Normalize for comparison (strip quotes) + existing_pk = existing_key_server_pk.strip("'\"") + if existing_pk != key_server_pk: + print(f"ERROR: KEY_SERVER_PK mismatch!", file=sys.stderr) + print(f" Expected (from v0): {existing_pk}", file=sys.stderr) + print(f" Got (from rotation): {key_server_pk}", file=sys.stderr) + sys.exit(1) + print(f"✓ KEY_SERVER_PK verification passed (unchanged from v0)") + else: + print(f"Warning: KEY_SERVER_PK not found in config, cannot verify", file=sys.stderr) + + update_config_fields(config_path, fields_to_update) + + print(f"✓ Config updated with:") + if committee_version == 0: + print(f" KEY_SERVER_PK: {key_server_pk}") + print(f" PARTIAL_PKS_V{committee_version}: {len(partial_pks)} entries") + print(f" MASTER_SHARE_V{committee_version}: {master_share}") + + # Step 4: Switch network and address for propose + print(f"\n=== Switching to network: {network_env} and address: {my_address} ===") + switch_sui_context(network_env, my_address) + + # Step 5: Call propose function onchain + if is_rotation: + print(f"\n=== Proposing committee rotation onchain ===") + print(f" New Committee ID: {committee_id}") + print(f" Current Committee ID: {current_committee_id}") + else: + print(f"\n=== Proposing committee onchain ===") + + # Format partial PKs as vector: [x"0x...", x"0x...", x"0x..."] + partial_pks_formatted = [f'x"{pk}"' for pk in partial_pks] + partial_pks_arg = "[" + ", ".join(partial_pks_formatted) + "]" + + if is_rotation: + # Use propose_for_rotation for key rotation + subprocess.run([ + "sui", "client", "call", + "--package", committee_pkg, + "--module", "seal_committee", + "--function", "propose_for_rotation", + "--args", committee_id, partial_pks_arg, current_committee_id + ], check=True) + else: + # Use propose for fresh DKG + subprocess.run([ + "sui", "client", "call", + "--package", committee_pkg, + "--module", "seal_committee", + "--function", "propose", + "--args", committee_id, partial_pks_arg, f'x"{key_server_pk}"' + ], check=True) + + print("\n✓ Successfully processed messages and proposed committee onchain!") + print(f" Committee ID: {committee_id}") + print(f" Key Server PK: {key_server_pk}") + print(f" Partial PKs: {len(partial_pks)} entries") + + +def main(): + parser = argparse.ArgumentParser(description="DKG scripts for coordinator and member operations") + subparsers = parser.add_subparsers(dest="command", required=True) + + # publish-and-init command (coordinator) + publish_parser = subparsers.add_parser( + "publish-and-init", + help="[Coordinator] Publish committee package and initialize committee", + ) + publish_parser.add_argument( + "--config", + "-c", + default="dkg.yaml", + help="Path to configuration file (default: dkg.yaml)", + ) + + # init-rotation command (coordinator) + rotation_parser = subparsers.add_parser( + "init-rotation", + help="[Coordinator] Initialize committee rotation", + ) + rotation_parser.add_argument( + "--config", + "-c", + default="dkg.yaml", + help="Path to configuration file (default: dkg.yaml)", + ) + + # check-committee command (coordinator) + check_parser = subparsers.add_parser( + "check-committee", + help="[Coordinator] Check committee status and member registration", + ) + check_parser.add_argument( + "--config", + "-c", + default="dkg.yaml", + help="Path to configuration file (default: dkg.yaml)", + ) + + # genkey-and-register command (member) + genkey_parser = subparsers.add_parser( + "genkey-and-register", + help="[Member] Generate DKG keys locally and register onchain", + ) + genkey_parser.add_argument( + "--config", + "-c", + default="dkg.yaml", + help="Path to configuration file (default: dkg.yaml)", + ) + genkey_parser.add_argument( + "--keys-file", + "-k", + default="./dkg-state/dkg.key", + help="Path to write keys file (default: ./dkg-state/dkg.key)", + ) + + # create-message command (member) + create_msg_parser = subparsers.add_parser( + "create-message", + help="[Member] Create DKG message for phase 2", + ) + create_msg_parser.add_argument( + "--config", + "-c", + default="dkg.yaml", + help="Path to configuration file (default: dkg.yaml)", + ) + create_msg_parser.add_argument( + "--keys-file", + "-k", + default="./dkg-state/dkg.key", + help="Path to keys file (default: ./dkg-state/dkg.key)", + ) + create_msg_parser.add_argument( + "--state-dir", + "-s", + default="./dkg-state", + help="State directory (default: ./dkg-state)", + ) + create_msg_parser.add_argument( + "--old-share", + default=None, + help="Old master share for rotation (continuing members only)", + ) + + # process-all-and-propose command (coordinator) + process_parser = subparsers.add_parser( + "process-all-and-propose", + help="[Coordinator] Process all messages and propose committee onchain", + ) + process_parser.add_argument( + "--config", + "-c", + default="dkg.yaml", + help="Path to configuration file (default: dkg.yaml)", + ) + process_parser.add_argument( + "--messages-dir", + "-m", + default="./dkg-messages", + help="Directory containing message_*.json files (default: ./dkg-messages)", + ) + process_parser.add_argument( + "--keys-file", + "-k", + default="./dkg-state/dkg.key", + help="Path to keys file (default: ./dkg-state/dkg.key)", + ) + process_parser.add_argument( + "--state-dir", + "-s", + default="./dkg-state", + help="State directory (default: ./dkg-state)", + ) + + args = parser.parse_args() + + if args.command == "publish-and-init": + publish_and_init(args.config) + elif args.command == "init-rotation": + init_rotation(args.config) + elif args.command == "check-committee": + check_committee(args.config) + elif args.command == "genkey-and-register": + genkey_and_register(args.config, args.keys_file) + elif args.command == "create-message": + create_message(args.config, args.keys_file, args.state_dir, args.old_share) + elif args.command == "process-all-and-propose": + process_all_and_propose(args.config, args.messages_dir, args.keys_file, args.state_dir) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/crates/dkg-cli/scripts/dkg.example.yaml b/crates/dkg-cli/scripts/dkg.example.yaml new file mode 100644 index 00000000..fe3e7db3 --- /dev/null +++ b/crates/dkg-cli/scripts/dkg.example.yaml @@ -0,0 +1,50 @@ +# DKG Configuration File +# This file is used by both coordinator and member scripts for DKG operations. + +# ============================================================================ +# COORDINATOR SECTION - Created initially by coordinator +# ============================================================================ + +NETWORK: Testnet +THRESHOLD: 2 +COORDINATOR_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d + +# List of all committee member addresses +MEMBERS: + - 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d + - 0xe6a37ff5cd968b6a666fb033d85eabc674449f44f9fc2b600e55e27354211ed6 + - 0x223762117ab21a439f0f3f3b0577e838b8b26a37d9a1723a4be311243f4461b9 + +# ============================================================================ +# AUTO-GENERATED by 'publish-and-init' script (coordinator) +# ============================================================================ + +# COMMITTEE_ID: 0x... +# COMMITTEE_PKG: 0x... + +# ============================================================================ +# MEMBER SECTION - Added manually by each member +# ============================================================================ + +# MY_ADDRESS: 0x0636157e9d013585ff473b3b378499ac2f1d207ed07d70e2cd815711725bca9d +# MY_SERVER_URL: https://myserver.example.com +# For rotation: Continuing members must add their old share from previous committee version +# OLD_SHARE: 0x... + +# ============================================================================ +# AUTO-GENERATED by 'genkey-and-register' script (member) +# ============================================================================ + +# DKG_ENC_PK: 0x... +# DKG_SIGNING_PK: 0x... + +# ============================================================================ +# AUTO-GENERATED by 'process-all-and-propose' script +# ============================================================================ + +# KEY_SERVER_PK: '0xb7309c202e6f33ed5a3e89e36331da4a2792713186db89d7de19d582441b486111d33445a00a2e0708d98d131b6ec5d81443802e621fa37216848ed7df81caad58e8698de5c2decb229eadca1496cf1722728ec786c3882c562955d747e466e1' +# PARTIAL_PKS: +# - '0x877bda5c286050fae84ea8ab9f2bd9203cb148e8806ceac7df17f76059b5aeb963ece1d454d5b4a6e4fe861cfebbaef419b339ab72c928abf011b0066ec7756a94b7a541bab1778e94fe924a2e42a9bff364f13bc28dc1ff4c48975184124b2c' +# - '0x9351978e3711a5d03da4fb022819a6625cb639def0b3099ba498232948c83efea905533c794485a5c9393619f000551b09d22c0da8d1d81043806d556f42648d8cdf1827bbfce388e5d550b118a5eb9742bedf067723f64618a165ee6aabab83' +# - '0x933103597009aa5d7b2d5ddc0701b1f63b559145bdcc5c61737925ce7fd51ed52ddb0e37dd71d5445e84cb3061a263660dfed870307a61e52c4ed086369b6a8d30a2436ea18635a5e7b454f7e2a681ed2b3ba9a548e1b0d9c0d8d09183637f7c' +# MASTER_SHARE_V0: '0x200a7e08bd7cefb031b04965677468e114adf633c49e4f94f189034cdad5eff8' diff --git a/crates/dkg-cli/scripts/requirements.txt b/crates/dkg-cli/scripts/requirements.txt new file mode 100644 index 00000000..c1a201db --- /dev/null +++ b/crates/dkg-cli/scripts/requirements.txt @@ -0,0 +1 @@ +PyYAML>=6.0 diff --git a/crates/dkg-cli/src/main.rs b/crates/dkg-cli/src/main.rs index 15306e48..8f735394 100644 --- a/crates/dkg-cli/src/main.rs +++ b/crates/dkg-cli/src/main.rs @@ -18,10 +18,10 @@ use rand::thread_rng; use seal_committee::grpc_helper::to_partial_key_servers; use seal_committee::{ build_new_to_old_map, create_grpc_client, fetch_committee_data, fetch_key_server_by_committee, - Network, + CommitteeState, Network, ServerType, }; use serde::Serialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::num::NonZeroU16; use std::path::{Path, PathBuf}; @@ -90,6 +90,20 @@ enum Commands { /// Path to keys file #[arg(short = 'k', long, default_value = "./dkg-state/dkg.key")] keys_file: PathBuf, + /// Network (mainnet or testnet). + #[arg(short = 'n', long, value_parser = parse_network)] + network: Network, + }, + + /// Check committee status and member registration. + CheckCommittee { + /// Committee object ID to check. + #[arg(long)] + committee_id: Address, + + /// Network (mainnet or testnet). + #[arg(long, value_parser = parse_network)] + network: Network, }, } @@ -218,7 +232,7 @@ async fn main() -> Result<()> { // Fetch partial key server info from the old committee's key server object. let (_, ks) = - fetch_key_server_by_committee(&mut grpc_client, &committee_id).await?; + fetch_key_server_by_committee(&mut grpc_client, &old_committee_id).await?; let old_partial_key_infos = to_partial_key_servers(&ks).await?; // Build mapping from old party ID to partial public key. @@ -335,6 +349,7 @@ async fn main() -> Result<()> { messages_dir, state_dir, keys_file, + network, } => { let mut state = DkgState::load(&state_dir)?; let local_keys = KeysFile::load(&keys_file)?; @@ -514,6 +529,40 @@ async fn main() -> Result<()> { state.output = Some(output.clone()); + // Determine the committee version. + let version = { + let mut grpc_client = create_grpc_client(&network)?; + let committee = + fetch_committee_data(&mut grpc_client, &state.config.committee_id).await?; + + if let Some(old_committee_id) = committee.old_committee_id { + // Rotation: fetch the old committee's KeyServer version then increment by 1. + match fetch_key_server_by_committee(&mut grpc_client, &old_committee_id).await { + Ok((_, key_server_v2)) => match key_server_v2.server_type { + ServerType::Committee { version, .. } => { + println!( + "Old committee version: {}, new version will be: {}", + version, + version + 1 + ); + version + 1 + } + _ => return Err(anyhow!("Old KeyServer is not of type Committee")), + }, + Err(e) => { + return Err(anyhow!( + "Failed to fetch old committee's KeyServer for rotation: {}", + e + )); + } + } + } else { + // Fresh DKG: version is 0. + println!("Fresh DKG, version will be: 0"); + 0 + } + }; + println!("============KEY SERVER PK AND PARTIAL PKS====================="); println!("KEY_SERVER_PK={}", format_pk_hex(&output.vss_pk.c0())?); @@ -532,16 +581,143 @@ async fn main() -> Result<()> { println!("============YOUR PARTIAL KEY SHARE, KEEP SECRET====================="); if let Some(shares) = &output.shares { for share in shares { - println!("MASTER_SHARE={}", format_pk_hex(&share.value)?); + println!("MASTER_SHARE_V{}={}", version, format_pk_hex(&share.value)?); } } + println!("============COMMITTEE VERSION====================="); + println!("COMMITTEE_VERSION={version}"); + println!("============FULL VSS POLYNOMIAL COEFFICIENTS====================="); for i in 0..=output.vss_pk.degree() { let coeff = output.vss_pk.coefficient(i); println!("Coefficient {}: {}", i, format_pk_hex(coeff)?); } } + + Commands::CheckCommittee { + committee_id, + network, + } => { + // Fetch committee from onchain + let mut grpc_client = create_grpc_client(&network)?; + let committee = fetch_committee_data(&mut grpc_client, &committee_id).await?; + + println!("Committee ID: {committee_id}"); + println!("Total members: {}", committee.members.len()); + println!("Threshold: {}", committee.threshold); + println!("State: {:?}", committee.state); + + // Check which members are registered and approved based on state + match &committee.state { + CommitteeState::Init { members_info } => { + let registered_addrs: HashSet<_> = members_info + .0 + .contents + .iter() + .map(|entry| entry.key) + .collect(); + + let mut registered = Vec::new(); + let mut not_registered = Vec::new(); + + for member_addr in &committee.members { + if registered_addrs.contains(member_addr) { + registered.push(*member_addr); + } else { + not_registered.push(*member_addr); + } + } + + println!( + "\nRegistered members ({}/{}):", + registered.len(), + committee.members.len() + ); + for addr in ®istered { + println!(" ✓ {addr}"); + } + + if !not_registered.is_empty() { + println!(); + println!("⚠ Missing members ({}):", not_registered.len()); + for addr in ¬_registered { + println!(" ✗ {addr}"); + } + println!( + "\nWaiting for {} member(s) to register before proceeding to phase 2.", + not_registered.len() + ); + } else { + println!(); + println!("✓ All members registered! Good to proceed to phase 2."); + } + } + CommitteeState::PostDKG { approvals, .. } => { + let approved_addrs: HashSet<_> = approvals.contents.iter().cloned().collect(); + + // Show approval status + let mut approved = Vec::new(); + let mut not_approved = Vec::new(); + + for member_addr in &committee.members { + if approved_addrs.contains(member_addr) { + approved.push(*member_addr); + } else { + not_approved.push(*member_addr); + } + } + + println!( + "\nApproved members ({}/{}):", + approved.len(), + committee.members.len() + ); + for addr in &approved { + println!(" ✓ {addr}"); + } + + if !not_approved.is_empty() { + println!(); + println!("⚠ Members who haven't approved ({}):", not_approved.len()); + for addr in ¬_approved { + println!(" ✗ {addr}"); + } + println!( + "\nWaiting for {} member(s) to approve before finalizing.", + not_approved.len() + ); + } else { + println!(); + println!("✓ All members approved! Committee can be finalized."); + } + } + CommitteeState::Finalized => { + println!("\n✓ Committee is finalized!"); + + // Fetch key server object ID and version + println!("\nFetching key server object ID..."); + match fetch_key_server_by_committee(&mut grpc_client, &committee_id).await { + Ok((ks_obj_id, key_server)) => { + println!("KEY_SERVER_OBJ_ID: {ks_obj_id}"); + + // Extract and print committee version + match key_server.server_type { + ServerType::Committee { version, .. } => { + println!("COMMITTEE_VERSION: {version}"); + } + _ => { + println!("Warning: KeyServer is not of type Committee"); + } + } + } + Err(e) => { + println!("Warning: Could not fetch key server object: {e}"); + } + } + } + } + } } Ok(()) }