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        // Run one keygen call per client share **concurrently** in the
178        // same room. TWO_OF_TWO has one (client_threshold=1); TWO_OF_THREE
179        // has two. Running them serially deadlocks the second share — the
180        // server share finishes its handshake during the first iteration
181        // and is no longer in the room when the second iteration starts.
182        // Mirrors `Promise.all(...)` over the client shares in
183        // `@dynamic-labs-wallet/node/src/client.ts`.
184        let server_ids: Vec<KeygenId> = event
185            .server_keygen_ids
186            .iter()
187            .map(|s| KeygenId::new(s.clone()))
188            .collect();
189        let signer_ref = &signer;
190        let room_ref = &room;
191        let keygen_futures = init_results_for_cb.iter().enumerate().map(|(i, init)| {
192            let other_external_ids: Vec<KeygenId> = client_keygen_ids_for_cb
193                .iter()
194                .enumerate()
195                .filter(|(j, _)| *j != i)
196                .map(|(_, id)| KeygenId::new(id.clone()))
197                .collect();
198            let all_others: Vec<KeygenId> = server_ids
199                .iter()
200                .cloned()
201                .chain(other_external_ids)
202                .collect();
203            async move {
204                signer_ref
205                    .keygen(
206                        room_ref,
207                        scheme_n,
208                        scheme_t,
209                        &init.keygen_secret,
210                        &all_others,
211                    )
212                    .await
213            }
214        });
215        let results: Vec<_> = futures::future::try_join_all(keygen_futures).await?;
216        Ok::<_, Error>(results)
217    })
218    .await?;
219
220    // 6. derive pubkey from the first share.
221    let first = mpc_results
222        .first()
223        .ok_or(Error::Mpc(dynamic_waas_sdk_mpc::MpcError::Unknown))?;
224    let signer = EcdsaSigner::new(client.base_mpc_relay_url().to_string());
225    let (raw_uncompressed, raw_compressed) =
226        signer.derive_pubkey(&first.secret_share, &opts.derivation_path)?;
227
228    // 7. Build the (still-incomplete) WalletProperties — chain client fills
229    // in account_address.
230    let mut wallet_id = String::new();
231    if let Some(SseEventData::Json(v)) = &ceremony_data {
232        if let Some(id) = v.get("walletId").and_then(|x| x.as_str()) {
233            id.clone_into(&mut wallet_id);
234        }
235    }
236    let mut wp = WalletProperties::new(
237        opts.chain_name.clone(),
238        wallet_id.clone(),
239        String::new(), // chain client fills this in from raw_uncompressed
240    )
241    .with_threshold(opts.threshold_signature_scheme)
242    .with_derivation_path(opts.derivation_path.clone());
243
244    // Build the ServerKeyShare list, one per client share.
245    let key_shares: Vec<ServerKeyShare> = mpc_results
246        .into_iter()
247        .enumerate()
248        .map(|(i, kr)| {
249            ServerKeyShare::new(client_keygen_ids[i].clone(), kr.secret_share.into_string())
250        })
251        .collect();
252
253    // Activate the wallet. The server requires
254    // `mark_key_shares_as_backed_up` before signing — without it the
255    // relay rejects sign requests with "Wallet is not active".
256    //
257    // Two paths:
258    //  - back_up_to_dynamic=true (v1 default): encrypt shares with the
259    //    customer password and upload to Dynamic's backup store
260    //    (`BackupLocation::Dynamic`). Returns the new `KeyShareBackupInfo`
261    //    so the caller can persist the pointers.
262    //  - back_up_to_dynamic=false: skip the backup upload. Mark with
263    //    `BackupLocation::External` only — the caller is responsible for
264    //    vaulting the shares themselves.
265    if !wallet_id.is_empty() {
266        let signer = EcdsaSigner::new(client.base_mpc_relay_url().to_string());
267        let keygen_id = signer
268            .export_id(&dynamic_waas_sdk_mpc::SecretShare::from_string(
269                key_shares[0].secret_share.clone(),
270            ))?
271            .into_string();
272        if opts.back_up_to_dynamic {
273            // Safe: validate_password_for_backup ensured this is Some.
274            let password = opts.password.as_deref().unwrap_or("");
275            let backup_info = crate::backup::run_backup_dynamic(
276                client,
277                &wallet_id,
278                &key_shares,
279                &keygen_id,
280                password,
281            )
282            .await?;
283            wp.external_server_key_shares_backup_info = Some(backup_info);
284        } else {
285            crate::backup::run_mark_external_no_backup(client, &wallet_id, &key_shares, &keygen_id)
286                .await?;
287        }
288    }
289
290    Ok(KeygenOutput {
291        wallet_properties: wp,
292        external_server_key_shares: key_shares,
293        raw_public_key_uncompressed: raw_uncompressed,
294        raw_public_key_compressed: raw_compressed,
295    })
296}