Skip to content
42 changes: 14 additions & 28 deletions crates/walletkit-cli/src/commands/recovery_agent.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! `walletkit recovery-agent` subcommands — recovery agent management.
//! `walletkit recovery-agent` subcommands — recovery agent management (WIP-102).

use clap::Subcommand;
use eyre::WrapErr as _;
Expand All @@ -9,58 +9,44 @@ use super::{init_authenticator, Cli};

#[derive(Subcommand)]
pub enum RecoveryAgentCommand {
/// Initiate a time-locked recovery agent update (14-day cooldown).
Initiate {
/// Update the holder's recovery agent. Effective immediately, reversible
/// during the revert window.
Update {
/// Checksummed hex address of the new recovery agent (e.g. "0x1234…").
new_recovery_agent: String,
},
/// Execute a pending recovery agent update after the cooldown has elapsed.
Execute,
/// Cancel a pending recovery agent update before the cooldown expires.
Cancel,
/// Revert an in-flight recovery agent update during the revert window.
Revert,
}

pub async fn run(cli: &Cli, action: &RecoveryAgentCommand) -> eyre::Result<()> {
let (authenticator, _store) = init_authenticator(cli).await?;

match action {
RecoveryAgentCommand::Initiate { new_recovery_agent } => {
RecoveryAgentCommand::Update { new_recovery_agent } => {
let request_id = authenticator
.initiate_recovery_agent_update(new_recovery_agent.clone())
.update_recovery_agent(new_recovery_agent.clone())
.await
.wrap_err("initiate recovery agent update failed")?;
.wrap_err("update recovery agent failed")?;

let data = serde_json::json!({ "request_id": request_id });
if cli.json {
output::print_json_data(&data, true);
} else {
println!("Recovery agent update initiated. Request ID: {request_id}");
println!("Recovery agent update submitted. Request ID: {request_id}");
}
}
RecoveryAgentCommand::Execute => {
RecoveryAgentCommand::Revert => {
let request_id = authenticator
.execute_recovery_agent_update()
.revert_recovery_agent_update()
.await
.wrap_err("execute recovery agent update failed")?;
.wrap_err("revert recovery agent update failed")?;

let data = serde_json::json!({ "request_id": request_id });
if cli.json {
output::print_json_data(&data, true);
} else {
println!("Recovery agent update executed. Request ID: {request_id}");
}
}
RecoveryAgentCommand::Cancel => {
let request_id = authenticator
.cancel_recovery_agent_update()
.await
.wrap_err("cancel recovery agent update failed")?;

let data = serde_json::json!({ "request_id": request_id });
if cli.json {
output::print_json_data(&data, true);
} else {
println!("Recovery agent update cancelled. Request ID: {request_id}");
println!("Recovery agent update reverted. Request ID: {request_id}");
}
}
}
Expand Down
61 changes: 21 additions & 40 deletions crates/walletkit-core/src/authenticator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,8 @@ impl Authenticator {
/// raw signature bytes and signing nonce without submitting anything to the
/// gateway.
///
/// This is the signing-only counterpart of [`Self::initiate_recovery_agent_update`].
/// Callers can use the returned bytes to build and submit the gateway request
/// themselves.
/// Callers can use the returned bytes to build and submit the gateway
/// request themselves.
///
/// # Warning
/// This method uses the `onchain_signer` (secp256k1 ECDSA) and produces a
Expand Down Expand Up @@ -301,11 +300,14 @@ impl Authenticator {
})
}

/// Initiates a time-locked recovery agent update (14-day cooldown).
/// Updates the holder's recovery agent (WIP-102).
///
/// Signs an EIP-712 `InitiateRecoveryAgentUpdate` payload and submits it to
/// the gateway. Returns the gateway request ID that can be used to poll
/// status.
/// On a V2 registry the new agent becomes effective immediately, but for a
/// revert window any authenticator can call
/// [`Self::revert_recovery_agent_update`] to roll back. During that window
/// the *previous* agent remains the only valid signer for `recoverAccount`,
/// which mitigates a compromised authenticator silently swapping in an
/// attacker-controlled recovery address.
///
/// # Arguments
/// * `new_recovery_agent` — the checksummed hex address of the new recovery
Expand All @@ -315,54 +317,33 @@ impl Authenticator {
/// - Returns [`WalletKitError::InvalidInput`] if `new_recovery_agent` is not
/// a valid address.
/// - Returns a network error if the gateway request fails.
#[allow(deprecated)]
pub async fn initiate_recovery_agent_update(
pub async fn update_recovery_agent(
&self,
new_recovery_agent: String,
) -> Result<String, WalletKitError> {
let new_recovery_agent =
Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;

let request_id = self
.inner
.initiate_recovery_agent_update(new_recovery_agent)
.await?;
let request_id = self.inner.update_recovery_agent(new_recovery_agent).await?;

Ok(request_id.to_string())
}

/// 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.
/// Reverts an in-flight recovery agent update during the revert window
/// (WIP-102).
///
/// Returns the gateway request ID that can be used to poll status.
///
/// # Errors
/// Returns a network error if the gateway request fails.
#[allow(deprecated)]
pub async fn execute_recovery_agent_update(
&self,
) -> Result<String, WalletKitError> {
let request_id = self.inner.execute_recovery_agent_update().await?;

Ok(request_id.to_string())
}

/// Cancels a pending time-locked recovery agent update before the cooldown
/// expires.
/// Must be called within the revert window after
/// [`Self::update_recovery_agent`]. During that window any authenticator
/// can revert the update; the previous recovery agent stays effective
/// until the window 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.
/// Signs an EIP-712 `CancelRecoveryAgentUpdate` payload (the typehash is
/// reused on V2) and submits it to the gateway.
///
/// # Errors
/// Returns a network error if the gateway request fails.
#[allow(deprecated)]
pub async fn cancel_recovery_agent_update(&self) -> Result<String, WalletKitError> {
let request_id = self.inner.cancel_recovery_agent_update().await?;
pub async fn revert_recovery_agent_update(&self) -> Result<String, WalletKitError> {
let request_id = self.inner.revert_recovery_agent_update().await?;

Ok(request_id.to_string())
}
Expand Down
Loading