Skip to main content

dynamic_waas_sdk/
keygen.rs

1//! Generic MPC keygen orchestration. Used by chain clients to implement
2//! `create_wallet_account`. Mirrors `python/dynamic_wallet_sdk/wallet_client.py:keygen`.
3//!
4//! Flow:
5//!   1. `init_keygen` (sync) — produce client-side `(keygen_id,
6//!      keygen_secret)` pairs, one per client share.
7//!   2. POST `/waas/create` with the `clientKeygenIds` — opens an SSE stream.
8//!   3. SSE delivers `keygen_complete` with `roomId` + `serverKeygenIds`.
9//!   4. While the stream stays open, run the MPC ceremony: each client
10//!      share calls `EcdsaSigner::keygen` against the relay room.
11//!   5. SSE delivers `ceremony_complete` with `walletId`.
12//!   6. `derive_pubkey` (sync) from the first share's `secret_share`.
13//!   7. `mark_key_shares_as_backed_up` (empty `locations` — flips the
14//!      shares to "active" without Dynamic-side encryption).
15
16use std::sync::Arc;
17
18use dynamic_waas_sdk_core::{
19    api::{CreateWalletKeygenReq, KeygenCompleteEvent},
20    sse::{stream_sse_keygen, SseEventData},
21    Error, Result, ServerKeyShare, ThresholdSignatureScheme, WalletProperties,
22};
23use dynamic_waas_sdk_mpc::{EcdsaSigner, KeygenId, RoomUuid};
24use tracing::{debug, instrument};
25
26use crate::client::DynamicWalletClient;
27use crate::mpc_config::{threshold_wire, MpcSchemeConfig};
28
29/// Output of [`run_keygen`] — the building block chain clients use to
30/// implement `create_wallet_account`.
31#[derive(Debug)]
32pub struct KeygenOutput {
33    /// Identity-only `WalletProperties`. Chain clients fill in
34    /// `account_address` from the derived pubkey before returning.
35    pub wallet_properties: WalletProperties,
36    /// Server key shares the customer must persist in their vault.
37    pub external_server_key_shares: Vec<ServerKeyShare>,
38    /// Uncompressed 65-byte secp256k1 pubkey (0x04 prefix). Chain clients
39    /// derive their address representation from this.
40    pub raw_public_key_uncompressed: Vec<u8>,
41    /// Compressed 33-byte secp256k1 pubkey.
42    pub raw_public_key_compressed: Vec<u8>,
43}
44
45/// Options for [`run_keygen`].
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct KeygenOpts {
49    pub chain_name: String,
50    pub threshold_signature_scheme: ThresholdSignatureScheme,
51    pub derivation_path: Vec<u32>,
52    pub address_type: Option<String>,
53    /// When true (the default), the customer's shares are encrypted with
54    /// `password` and uploaded to Dynamic's backup store; the wallet is
55    /// activated with `BackupLocation::Dynamic`. When false, no backup is
56    /// uploaded — the wallet is activated with `BackupLocation::External`
57    /// and the caller is responsible for vaulting the returned shares.
58    /// Mirrors `back_up_to_dynamic` on the Node and Python SDKs (PR #856).
59    pub back_up_to_dynamic: bool,
60    /// Required when `back_up_to_dynamic=true` — used to AES-GCM-encrypt
61    /// the customer's shares before upload. Ignored when
62    /// `back_up_to_dynamic=false`.
63    pub password: Option<String>,
64}
65
66impl KeygenOpts {
67    pub fn new(
68        chain_name: impl Into<String>,
69        threshold_signature_scheme: ThresholdSignatureScheme,
70        derivation_path: Vec<u32>,
71    ) -> Self {
72        Self {
73            chain_name: chain_name.into(),
74            threshold_signature_scheme,
75            derivation_path,
76            address_type: None,
77            back_up_to_dynamic: true,
78            password: None,
79        }
80    }
81
82    #[must_use]
83    pub fn with_address_type(mut self, t: impl Into<String>) -> Self {
84        self.address_type = Some(t.into());
85        self
86    }
87
88    /// Override `back_up_to_dynamic` (default `true`).
89    #[must_use]
90    pub fn with_back_up_to_dynamic(mut self, value: bool) -> Self {
91        self.back_up_to_dynamic = value;
92        self
93    }
94
95    /// Provide the password used to encrypt the customer's share for the
96    /// Dynamic-side backup. Required iff `back_up_to_dynamic=true`.
97    #[must_use]
98    pub fn with_password(mut self, password: impl Into<String>) -> Self {
99        self.password = Some(password.into());
100        self
101    }
102}
103
104/// Returns `Err(Error::InvalidArgument)` when `back_up_to_dynamic=true`
105/// and no password was supplied. Mirrors
106/// `_validate_password_for_backup` on the Python SDK so the failure
107/// mode matches.
108pub(crate) fn validate_password_for_backup(
109    password: Option<&str>,
110    back_up_to_dynamic: bool,
111) -> Result<()> {
112    if back_up_to_dynamic && password.unwrap_or("").is_empty() {
113        return Err(Error::InvalidArgument(
114            "password is required when back_up_to_dynamic=true".into(),
115        ));
116    }
117    Ok(())
118}
119
120#[instrument(skip(client), fields(chain = %opts.chain_name))]
121#[allow(clippy::too_many_lines)]
122pub async fn run_keygen(client: &DynamicWalletClient, opts: KeygenOpts) -> Result<KeygenOutput> {
123    if !client.is_authenticated() {
124        return Err(Error::Authentication(crate::AUTH_REQUIRED_MSG.into()));
125    }
126
127    // Fail fast before the MPC ceremony spins up: if the caller asked
128    // for Dynamic-side backup, the password must be present. Matches
129    // PR #856 on Node + the Python validate_password_for_backup helper.
130    validate_password_for_backup(opts.password.as_deref(), opts.back_up_to_dynamic)?;
131
132    let scheme_cfg = MpcSchemeConfig::from(opts.threshold_signature_scheme);
133
134    // 1. Client init keygen — synchronous.
135    let signer = EcdsaSigner::new(client.base_mpc_relay_url().to_string());
136    let mut init_results = Vec::with_capacity(scheme_cfg.client_threshold as usize);
137    for _ in 0..scheme_cfg.client_threshold {
138        init_results.push(signer.init_keygen()?);
139    }
140    let client_keygen_ids: Vec<String> = init_results
141        .iter()
142        .map(|r| r.keygen_id.as_str().to_owned())
143        .collect();
144
145    // 2. POST /waas/create — opens SSE stream.
146    let body = CreateWalletKeygenReq {
147        chain: opts.chain_name.clone(),
148        client_keygen_ids: client_keygen_ids.clone(),
149        threshold_signature_scheme: threshold_wire(opts.threshold_signature_scheme).to_string(),
150        derivation_path: Some(opts.derivation_path.clone()),
151        address_type: opts.address_type.clone(),
152    };
153    let response = client.api().create_wallet_keygen(&body).await?;
154
155    // 3+4+5. Stream stays open during MPC ceremony.
156    let host_url = client.base_mpc_relay_url().to_string();
157    let init_results = Arc::new(init_results);
158    let init_results_for_cb = Arc::clone(&init_results);
159    let client_keygen_ids_for_cb = client_keygen_ids.clone();
160    let scheme_n = scheme_cfg.n;
161    let scheme_t = scheme_cfg.t;
162
163    let (mpc_results, ceremony_data) = stream_sse_keygen(response, move |trigger| async move {
164        let event: KeygenCompleteEvent = match trigger {
165            SseEventData::Json(v) => serde_json::from_value(v).map_err(Error::from)?,
166            SseEventData::Raw(s) => {
167                return Err(Error::Sse(format!(
168                    "keygen_complete payload was not JSON: {s}"
169                )))
170            }
171        };
172        debug!(room_id = %event.room_id, "running MPC keygen");
173
174        let signer = EcdsaSigner::new(host_url);
175        let room = RoomUuid::new(event.room_id);
176
177        // For TWO_OF_TWO there's exactly one client share; loop kept for
178        // future TWO_OF_THREE support (where client_threshold = 2).
179        let mut results = Vec::with_capacity(init_results_for_cb.len());
180        for (i, init) in init_results_for_cb.iter().enumerate() {
181            let other_external_ids: Vec<KeygenId> = client_keygen_ids_for_cb
182                .iter()
183                .enumerate()
184                .filter(|(j, _)| *j != i)
185                .map(|(_, id)| KeygenId::new(id.clone()))
186                .collect();
187            let server_ids: Vec<KeygenId> = event
188                .server_keygen_ids
189                .iter()
190                .map(|s| KeygenId::new(s.clone()))
191                .collect();
192            let all_others: Vec<KeygenId> =
193                server_ids.into_iter().chain(other_external_ids).collect();
194
195            let result = signer
196                .keygen(&room, scheme_n, scheme_t, &init.keygen_secret, &all_others)
197                .await?;
198            results.push(result);
199        }
200        Ok::<_, Error>(results)
201    })
202    .await?;
203
204    // 6. derive pubkey from the first share.
205    let first = mpc_results
206        .first()
207        .ok_or(Error::Mpc(dynamic_waas_sdk_mpc::MpcError::Unknown))?;
208    let signer = EcdsaSigner::new(client.base_mpc_relay_url().to_string());
209    let (raw_uncompressed, raw_compressed) =
210        signer.derive_pubkey(&first.secret_share, &opts.derivation_path)?;
211
212    // 7. Build the (still-incomplete) WalletProperties — chain client fills
213    // in account_address.
214    let mut wallet_id = String::new();
215    if let Some(SseEventData::Json(v)) = &ceremony_data {
216        if let Some(id) = v.get("walletId").and_then(|x| x.as_str()) {
217            id.clone_into(&mut wallet_id);
218        }
219    }
220    let mut wp = WalletProperties::new(
221        opts.chain_name.clone(),
222        wallet_id.clone(),
223        String::new(), // chain client fills this in from raw_uncompressed
224    )
225    .with_threshold(opts.threshold_signature_scheme)
226    .with_derivation_path(opts.derivation_path.clone());
227
228    // Build the ServerKeyShare list, one per client share.
229    let key_shares: Vec<ServerKeyShare> = mpc_results
230        .into_iter()
231        .enumerate()
232        .map(|(i, kr)| {
233            ServerKeyShare::new(client_keygen_ids[i].clone(), kr.secret_share.into_string())
234        })
235        .collect();
236
237    // Activate the wallet. The server requires
238    // `mark_key_shares_as_backed_up` before signing — without it the
239    // relay rejects sign requests with "Wallet is not active".
240    //
241    // Two paths:
242    //  - back_up_to_dynamic=true (v1 default): encrypt shares with the
243    //    customer password and upload to Dynamic's backup store
244    //    (`BackupLocation::Dynamic`). Returns the new `KeyShareBackupInfo`
245    //    so the caller can persist the pointers.
246    //  - back_up_to_dynamic=false: skip the backup upload. Mark with
247    //    `BackupLocation::External` only — the caller is responsible for
248    //    vaulting the shares themselves.
249    if !wallet_id.is_empty() {
250        let signer = EcdsaSigner::new(client.base_mpc_relay_url().to_string());
251        let keygen_id = signer
252            .export_id(&dynamic_waas_sdk_mpc::SecretShare::from_string(
253                key_shares[0].secret_share.clone(),
254            ))?
255            .into_string();
256        if opts.back_up_to_dynamic {
257            // Safe: validate_password_for_backup ensured this is Some.
258            let password = opts.password.as_deref().unwrap_or("");
259            let backup_info = crate::backup::run_backup_dynamic(
260                client,
261                &wallet_id,
262                &key_shares,
263                &keygen_id,
264                password,
265            )
266            .await?;
267            wp.external_server_key_shares_backup_info = Some(backup_info);
268        } else {
269            crate::backup::run_mark_external_no_backup(client, &wallet_id, &key_shares, &keygen_id)
270                .await?;
271        }
272    }
273
274    Ok(KeygenOutput {
275        wallet_properties: wp,
276        external_server_key_shares: key_shares,
277        raw_public_key_uncompressed: raw_uncompressed,
278        raw_public_key_compressed: raw_compressed,
279    })
280}