From f6b2593f8b185d01cb16ffc90dc35fde66ade7c4 Mon Sep 17 00:00:00 2001 From: Otto Date: Mon, 23 Mar 2026 12:32:32 +0000 Subject: [PATCH 1/4] feat: expose initiateRecoveryAgentUpdate and executeRecoveryAgentUpdate in WalletKit --- Cargo.toml | 2 +- walletkit-core/src/authenticator/mod.rs | 130 +++++++++++++++++++++++- walletkit-core/src/credential.rs | 6 +- 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bbad316c..f3811ddb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ alloy-core = { version = "1", default-features = false, features = [ ] } alloy-primitives = { version = "1", default-features = false } uniffi = { version = "0.31", features = ["tokio"] } -world-id-core = { version = "0.7", default-features = false } +world-id-core = { version = "0.7.1", default-features = false } # internal walletkit-core = { version = "0.11.1", path = "walletkit-core", default-features = false } diff --git a/walletkit-core/src/authenticator/mod.rs b/walletkit-core/src/authenticator/mod.rs index 50232381..52328a53 100644 --- a/walletkit-core/src/authenticator/mod.rs +++ b/walletkit-core/src/authenticator/mod.rs @@ -8,10 +8,14 @@ use alloy_primitives::Address; use ruint_uniffi::Uint256; use std::sync::Arc; use world_id_core::{ - api_types::{GatewayErrorCode, GatewayRequestState}, + api_types::{ + ExecuteRecoveryAgentUpdateRequest, GatewayErrorCode, GatewayRequestState, + GatewayStatusResponse, UpdateRecoveryAgentRequest, + }, primitives::Config, + world_id_registry::{domain, sign_initiate_recovery_agent_update}, Authenticator as CoreAuthenticator, Credential as CoreCredential, - InitializingAuthenticator as CoreInitializingAuthenticator, + InitializingAuthenticator as CoreInitializingAuthenticator, Signer, }; #[cfg(feature = "storage")] @@ -125,6 +129,7 @@ fn load_nullifier_material_from_cache( #[derive(Debug, uniffi::Object)] pub struct Authenticator { inner: CoreAuthenticator, + signer: Signer, #[cfg(feature = "storage")] store: Arc, } @@ -230,6 +235,119 @@ impl Authenticator { let signature = self.inner.danger_sign_challenge(challenge)?; Ok(signature.as_bytes().to_vec()) } + + /// Initiates a time-locked recovery agent update (14-day cooldown). + /// + /// Signs an EIP-712 `InitiateRecoveryAgentUpdate` payload and submits it to + /// the gateway. Returns the gateway request ID that can be used to poll + /// status. + /// + /// # Arguments + /// * `new_recovery_agent` — the checksummed hex address of the new recovery + /// agent (e.g. `"0x1234…"`). + /// + /// # Errors + /// - Returns [`WalletKitError::InvalidInput`] if `new_recovery_agent` is not + /// a valid address. + /// - Returns a network error if the gateway request fails. + pub async fn initiate_recovery_agent_update( + &self, + new_recovery_agent: String, + ) -> Result { + let new_recovery_agent = + Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?; + + let leaf_index = self.inner.leaf_index(); + let nonce = self.inner.signing_nonce().await?; + + let eip712_domain = domain( + self.inner.config.chain_id(), + *self.inner.config.registry_address(), + ); + + let signature = sign_initiate_recovery_agent_update( + self.signer.onchain_signer(), + leaf_index, + new_recovery_agent, + nonce, + &eip712_domain, + ) + .map_err(|e| WalletKitError::Generic { + error: format!("Failed to sign initiate recovery agent update: {e}"), + })?; + + let req = UpdateRecoveryAgentRequest { + leaf_index, + new_recovery_agent, + signature: signature.as_bytes().to_vec(), + nonce, + }; + + let client = reqwest::Client::new(); // TODO: reuse client + let resp = client + .post(format!( + "{}/initiate-recovery-agent-update", + self.inner.config.gateway_url() + )) + .json(&req) + .send() + .await?; + + let status = resp.status(); + if status.is_success() { + let body: GatewayStatusResponse = resp.json().await?; + Ok(body.request_id) + } else { + let body_text = resp.text().await.unwrap_or_default(); + Err(WalletKitError::NetworkError { + url: "gateway".to_string(), + error: body_text, + status: Some(status.as_u16()), + }) + } + } + + /// Executes a pending recovery agent update after the 14-day cooldown has + /// elapsed. + /// + /// This call is **permissionless** — no signature is required. The contract + /// enforces the cooldown and will revert with + /// `RecoveryAgentUpdateStillInCooldown` if called too early. + /// + /// Returns the gateway request ID that can be used to poll status. + /// + /// # Errors + /// Returns a network error if the gateway request fails. + pub async fn execute_recovery_agent_update( + &self, + ) -> Result { + let req = ExecuteRecoveryAgentUpdateRequest { + leaf_index: self.inner.leaf_index(), + }; + + let client = reqwest::Client::new(); // TODO: reuse client + let resp = client + .post(format!( + "{}/execute-recovery-agent-update", + self.inner.config.gateway_url() + )) + .json(&req) + .send() + .await?; + + let status = resp.status(); + if status.is_success() { + let body: GatewayStatusResponse = resp.json().await?; + Ok(body.request_id) + } else { + let body_text = resp.text().await.unwrap_or_default(); + Err(WalletKitError::NetworkError { + url: "gateway".to_string(), + error: body_text, + status: Some(status.as_u16()), + }) + } + } } #[cfg(not(feature = "storage"))] @@ -249,6 +367,7 @@ impl Authenticator { environment: &Environment, region: Option, ) -> Result { + let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_environment(environment, rpc_url, region)?; let authenticator = CoreAuthenticator::init(seed, config).await?; let (query_material, nullifier_material) = load_embedded_materials()?; @@ -256,6 +375,7 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, + signer, }) } @@ -268,6 +388,7 @@ impl Authenticator { /// Will error if the provided seed is not valid or if the config is not valid. #[uniffi::constructor] pub async fn init(seed: &[u8], config: &str) -> Result { + let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { attribute: "config".to_string(), @@ -279,6 +400,7 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, + signer, }) } } @@ -303,6 +425,7 @@ impl Authenticator { paths: &StoragePaths, store: Arc, ) -> Result { + let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_environment(environment, rpc_url, region)?; let authenticator = CoreAuthenticator::init(seed, config).await?; let (query_material, nullifier_material) = load_cached_materials(paths)?; @@ -310,6 +433,7 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, + signer, store, }) } @@ -329,6 +453,7 @@ impl Authenticator { paths: &StoragePaths, store: Arc, ) -> Result { + let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { attribute: "config".to_string(), @@ -340,6 +465,7 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, + signer, store, }) } diff --git a/walletkit-core/src/credential.rs b/walletkit-core/src/credential.rs index 4b72c297..5daae9da 100644 --- a/walletkit-core/src/credential.rs +++ b/walletkit-core/src/credential.rs @@ -52,7 +52,11 @@ impl Credential { self.0.expires_at } - /// Returns the associated-data commitment field element for this credential. + /// Returns the credential's `associated_data_commitment` field element. + /// + /// This is a Poseidon2 commitment to the associated data (e.g. a PCP archive) + /// set by the issuer at issuance time. Returns `FieldElement::ZERO` if no + /// associated data was committed to. #[must_use] pub fn associated_data_commitment(&self) -> FieldElement { self.0.associated_data_commitment.into() From 4238eb1e2c54e63c47f232e25204a7f153ee175a Mon Sep 17 00:00:00 2001 From: Otto Date: Tue, 24 Mar 2026 12:02:55 +0000 Subject: [PATCH 2/4] feat: add cancel_recovery_agent_update method to Authenticator Expose cancel_recovery_agent_update() following the same pattern as initiate_recovery_agent_update and execute_recovery_agent_update. The method signs an EIP-712 CancelRecoveryAgentUpdate payload and submits it to the gateway's /cancel-recovery-agent-update endpoint, returning the gateway request ID for status polling. --- Cargo.lock | 16 ++-- Cargo.toml | 2 +- walletkit-core/src/authenticator/mod.rs | 107 +++++------------------- 3 files changed, 31 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa5b2d20..aef41222 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6848,9 +6848,9 @@ dependencies = [ [[package]] name = "world-id-authenticator" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1713b3c70d6f2822f8ec0c12e313a74616fce2aa86c5cb54a991d13be5fe625" +checksum = "511e21e8801843ef147f4a3fb84377ed1e6b4916af305cd570aec3f75b674c12" dependencies = [ "alloy", "anyhow", @@ -6878,9 +6878,9 @@ dependencies = [ [[package]] name = "world-id-core" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f0b7dab893d2588e13b3d3a0c6259467c7079b7cbe4a41db5a6bf4a04f46be" +checksum = "04e3b9ec6be2b4008893b2a6586e2ec36b43de3b2c9e6f86ff3470051e2c8a76" dependencies = [ "taceo-eddsa-babyjubjub", "world-id-authenticator", @@ -6890,9 +6890,9 @@ dependencies = [ [[package]] name = "world-id-primitives" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a38588112438f94966aef95559b48a0c3d4ae627c0ebc680ae4739ca0538c29" +checksum = "d6b9b9de182db121d86f81ed3887649374fc2f0948b13a9d2347986f9d1b0dfd" dependencies = [ "alloy", "alloy-primitives", @@ -6927,9 +6927,9 @@ dependencies = [ [[package]] name = "world-id-proof" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0685fc6fcb0fef10a7caa80f7d4ac09727ee582934c2da39bdd82a423ab7abe" +checksum = "2c42636c2db4e2252815112d733932c271bcd1fad07a9308440b0f0500b05821" dependencies = [ "ark-bn254", "ark-ec", diff --git a/Cargo.toml b/Cargo.toml index f3811ddb..bbad316c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ alloy-core = { version = "1", default-features = false, features = [ ] } alloy-primitives = { version = "1", default-features = false } uniffi = { version = "0.31", features = ["tokio"] } -world-id-core = { version = "0.7.1", default-features = false } +world-id-core = { version = "0.7", default-features = false } # internal walletkit-core = { version = "0.11.1", path = "walletkit-core", default-features = false } diff --git a/walletkit-core/src/authenticator/mod.rs b/walletkit-core/src/authenticator/mod.rs index 52328a53..dd81f50c 100644 --- a/walletkit-core/src/authenticator/mod.rs +++ b/walletkit-core/src/authenticator/mod.rs @@ -8,14 +8,10 @@ use alloy_primitives::Address; use ruint_uniffi::Uint256; use std::sync::Arc; use world_id_core::{ - api_types::{ - ExecuteRecoveryAgentUpdateRequest, GatewayErrorCode, GatewayRequestState, - GatewayStatusResponse, UpdateRecoveryAgentRequest, - }, + api_types::{GatewayErrorCode, GatewayRequestState}, primitives::Config, - world_id_registry::{domain, sign_initiate_recovery_agent_update}, Authenticator as CoreAuthenticator, Credential as CoreCredential, - InitializingAuthenticator as CoreInitializingAuthenticator, Signer, + InitializingAuthenticator as CoreInitializingAuthenticator, }; #[cfg(feature = "storage")] @@ -129,7 +125,6 @@ fn load_nullifier_material_from_cache( #[derive(Debug, uniffi::Object)] pub struct Authenticator { inner: CoreAuthenticator, - signer: Signer, #[cfg(feature = "storage")] store: Arc, } @@ -257,54 +252,12 @@ impl Authenticator { let new_recovery_agent = Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?; - let leaf_index = self.inner.leaf_index(); - let nonce = self.inner.signing_nonce().await?; - - let eip712_domain = domain( - self.inner.config.chain_id(), - *self.inner.config.registry_address(), - ); - - let signature = sign_initiate_recovery_agent_update( - self.signer.onchain_signer(), - leaf_index, - new_recovery_agent, - nonce, - &eip712_domain, - ) - .map_err(|e| WalletKitError::Generic { - error: format!("Failed to sign initiate recovery agent update: {e}"), - })?; - - let req = UpdateRecoveryAgentRequest { - leaf_index, - new_recovery_agent, - signature: signature.as_bytes().to_vec(), - nonce, - }; - - let client = reqwest::Client::new(); // TODO: reuse client - let resp = client - .post(format!( - "{}/initiate-recovery-agent-update", - self.inner.config.gateway_url() - )) - .json(&req) - .send() + let request_id = self + .inner + .initiate_recovery_agent_update(new_recovery_agent) .await?; - let status = resp.status(); - if status.is_success() { - let body: GatewayStatusResponse = resp.json().await?; - Ok(body.request_id) - } else { - let body_text = resp.text().await.unwrap_or_default(); - Err(WalletKitError::NetworkError { - url: "gateway".to_string(), - error: body_text, - status: Some(status.as_u16()), - }) - } + Ok(request_id.to_string()) } /// Executes a pending recovery agent update after the 14-day cooldown has @@ -321,32 +274,24 @@ impl Authenticator { pub async fn execute_recovery_agent_update( &self, ) -> Result { - let req = ExecuteRecoveryAgentUpdateRequest { - leaf_index: self.inner.leaf_index(), - }; + let request_id = self.inner.execute_recovery_agent_update().await?; - let client = reqwest::Client::new(); // TODO: reuse client - let resp = client - .post(format!( - "{}/execute-recovery-agent-update", - self.inner.config.gateway_url() - )) - .json(&req) - .send() - .await?; + Ok(request_id.to_string()) + } - let status = resp.status(); - if status.is_success() { - let body: GatewayStatusResponse = resp.json().await?; - Ok(body.request_id) - } else { - let body_text = resp.text().await.unwrap_or_default(); - Err(WalletKitError::NetworkError { - url: "gateway".to_string(), - error: body_text, - status: Some(status.as_u16()), - }) - } + /// Cancels a pending time-locked recovery agent update before the cooldown + /// expires. + /// + /// Signs an EIP-712 `CancelRecoveryAgentUpdate` payload and submits it to + /// the gateway. Returns the gateway request ID that can be used to poll + /// status. + /// + /// # Errors + /// Returns a network error if the gateway request fails. + pub async fn cancel_recovery_agent_update(&self) -> Result { + let request_id = self.inner.cancel_recovery_agent_update().await?; + + Ok(request_id.to_string()) } } @@ -367,7 +312,6 @@ impl Authenticator { environment: &Environment, region: Option, ) -> Result { - let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_environment(environment, rpc_url, region)?; let authenticator = CoreAuthenticator::init(seed, config).await?; let (query_material, nullifier_material) = load_embedded_materials()?; @@ -375,7 +319,6 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, - signer, }) } @@ -388,7 +331,6 @@ impl Authenticator { /// Will error if the provided seed is not valid or if the config is not valid. #[uniffi::constructor] pub async fn init(seed: &[u8], config: &str) -> Result { - let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { attribute: "config".to_string(), @@ -400,7 +342,6 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, - signer, }) } } @@ -425,7 +366,6 @@ impl Authenticator { paths: &StoragePaths, store: Arc, ) -> Result { - let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_environment(environment, rpc_url, region)?; let authenticator = CoreAuthenticator::init(seed, config).await?; let (query_material, nullifier_material) = load_cached_materials(paths)?; @@ -433,7 +373,6 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, - signer, store, }) } @@ -453,7 +392,6 @@ impl Authenticator { paths: &StoragePaths, store: Arc, ) -> Result { - let signer = Signer::from_seed_bytes(seed)?; let config = Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { attribute: "config".to_string(), @@ -465,7 +403,6 @@ impl Authenticator { authenticator.with_proof_materials(query_material, nullifier_material); Ok(Self { inner: authenticator, - signer, store, }) } From c0ed6696efc22449462f0ad57eecca377f5f5360 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 26 Mar 2026 17:38:56 +0000 Subject: [PATCH 3/4] docs: clarify associated data commitment comment --- walletkit-core/src/credential.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/walletkit-core/src/credential.rs b/walletkit-core/src/credential.rs index 5daae9da..a0dfd266 100644 --- a/walletkit-core/src/credential.rs +++ b/walletkit-core/src/credential.rs @@ -54,9 +54,10 @@ impl Credential { /// Returns the credential's `associated_data_commitment` field element. /// - /// This is a Poseidon2 commitment to the associated data (e.g. a PCP archive) - /// set by the issuer at issuance time. Returns `FieldElement::ZERO` if no - /// associated data was committed to. + /// This stores the issuer-defined commitment to associated data (e.g. a PCP + /// archive). The issuer determines the commitment scheme, which may use + /// Poseidon2 or something else, and this field may be `FieldElement::ZERO` + /// even when associated data exists. #[must_use] pub fn associated_data_commitment(&self) -> FieldElement { self.0.associated_data_commitment.into() From e8a8e234490cc06b15a1198dd3d69f9bb3a47f50 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 26 Mar 2026 17:39:16 +0000 Subject: [PATCH 4/4] docs: simplify associated data commitment comment --- walletkit-core/src/credential.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/walletkit-core/src/credential.rs b/walletkit-core/src/credential.rs index a0dfd266..34f68e14 100644 --- a/walletkit-core/src/credential.rs +++ b/walletkit-core/src/credential.rs @@ -54,10 +54,7 @@ impl Credential { /// Returns the credential's `associated_data_commitment` field element. /// - /// This stores the issuer-defined commitment to associated data (e.g. a PCP - /// archive). The issuer determines the commitment scheme, which may use - /// Poseidon2 or something else, and this field may be `FieldElement::ZERO` - /// even when associated data exists. + /// The commitment scheme is issuer-defined. #[must_use] pub fn associated_data_commitment(&self) -> FieldElement { self.0.associated_data_commitment.into()