Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,30 @@ let client = GuardianClient::connect("http://localhost:50051")
.with_signer(signer);
```

### Abandoning a Stuck Candidate

If an approved transaction dies client-side after guardian approval (RPC
submit failure, prover timeout, crash), its candidate delta keeps the
account locked — new proposals are answered `409 conflict_pending_delta` —
until the server's grace period and retry budget run out. Only the client
knows the transaction will never land:

```rust
// Records an abandon intent; the guardian's worker confirms over a short
// quarantine that the tx did not land, then releases the account.
let response = client.abandon_candidate(&account_id, nonce).await?;
assert_eq!(response.state, "pending");

// Poll the delta for the resolution: still `candidate` -> waiting,
// `canonical` -> the tx landed after all, `discarded` with reason
// `client_abandoned` -> the account is released.
let delta = client.get_delta(&account_id, nonce).await?;
```

Retries are idempotent (the original request timestamp is preserved). The
server refuses with `GUARDIAN_CANDIDATE_LANDED` when the transaction
demonstrably landed.

## Authentication

The client uses Falcon Poseidon2 signatures for authenticated requests. Here is how to set it up:
Expand Down
29 changes: 29 additions & 0 deletions crates/client/proto/guardian.proto
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ service Guardian {
// Sign a delta proposal
rpc SignDeltaProposal(SignDeltaProposalRequest) returns (SignDeltaProposalResponse);

// Abandon a pending canonicalization candidate whose transaction will
// never land (issue #319), releasing the account immediately.
rpc AbandonDeltaCandidate(AbandonDeltaCandidateRequest) returns (AbandonDeltaCandidateResponse);

// Resolve a public-key commitment to the set of account IDs whose
// authorization set contains it. Authentication is by proof-of-possession
// against the queried commitment, using the lookup-bound signing primitive
Expand Down Expand Up @@ -131,6 +135,9 @@ message DeltaStatus {
string canonical_at = 3;
string discarded_at = 4;
}
// Why a discarded delta was discarded ("client_abandoned"); empty for
// other statuses and for discards without a recorded reason.
string discard_reason = 5;
}

// Pending status with cosigner signatures
Expand Down Expand Up @@ -260,3 +267,25 @@ message GetAccountByKeyCommitmentResponse {
message AccountRef {
string account_id = 1;
}

// Abandon-candidate request (issue #319): nonce pins the exact candidate
// so a stale request cannot release a newer one.
message AbandonDeltaCandidateRequest {
string account_id = 1;
uint64 nonce = 2;
}

message AbandonDeltaCandidateResponse {
bool success = 1;
string message = 2;
string account_id = 3;
uint64 nonce = 4;
// "pending" while the worker still has to resolve the intent (the
// account stays locked until then); "abandoned" once the delta is
// discarded as client-abandoned and the account released.
string state = 5;
// RFC 3339 UTC timestamp of the recorded abandon request. Retries
// return the original timestamp; empty once resolved.
string abandon_requested_at = 6;
string error_code = 7;
}
48 changes: 43 additions & 5 deletions crates/client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ use crate::error::{ClientError, ClientResult};
use crate::keystore::Signer;
use crate::proto::guardian_client::GuardianClient as GuardianGrpcClient;
use crate::proto::{
AuthConfig, ConfigureRequest, ConfigureResponse, GetAccountByKeyCommitmentRequest,
GetAccountByKeyCommitmentResponse, GetDeltaProposalRequest, GetDeltaProposalResponse,
GetDeltaProposalsRequest, GetDeltaProposalsResponse, GetDeltaRequest, GetDeltaResponse,
GetDeltaSinceRequest, GetDeltaSinceResponse, GetPubkeyRequest, GetStateRequest,
GetStateResponse, ProposalSignature as ProtoProposalSignature, PushDeltaProposalRequest,
AbandonDeltaCandidateRequest, AbandonDeltaCandidateResponse, AuthConfig, ConfigureRequest,
ConfigureResponse, GetAccountByKeyCommitmentRequest, GetAccountByKeyCommitmentResponse,
GetDeltaProposalRequest, GetDeltaProposalResponse, GetDeltaProposalsRequest,
GetDeltaProposalsResponse, GetDeltaRequest, GetDeltaResponse, GetDeltaSinceRequest,
GetDeltaSinceResponse, GetPubkeyRequest, GetStateRequest, GetStateResponse,
ProposalSignature as ProtoProposalSignature, PushDeltaProposalRequest,
PushDeltaProposalResponse, PushDeltaRequest, PushDeltaResponse, SignDeltaProposalRequest,
SignDeltaProposalResponse,
};
Expand Down Expand Up @@ -397,6 +398,43 @@ impl GuardianClient {

Ok(inner)
}

/// Request abandonment of a pending canonicalization candidate whose
/// transaction will never land on-chain (issue #319).
///
/// The request records an abandon *intent*: the delta stays a
/// candidate — the account stays locked — until the guardian's
/// canonicalization worker confirms over the abandon quarantine that
/// the transaction did not land, then discards the delta as
/// `client_abandoned` and releases the account (typically well under
/// a minute). Poll `get_delta` for the resolution; the returned
/// `state` is `"pending"` or, when already resolved, `"abandoned"`.
///
/// `nonce` pins the exact candidate (learned from the delta feed).
/// The server refuses with `GUARDIAN_CANDIDATE_LANDED` when the
/// transaction actually landed. Retries are idempotent and preserve
/// the original request timestamp.
pub async fn abandon_candidate(
&mut self,
account_id: &AccountId,
nonce: u64,
) -> ClientResult<AbandonDeltaCandidateResponse> {
let mut request = tonic::Request::new(AbandonDeltaCandidateRequest {
account_id: account_id.to_string(),
nonce,
});

self.add_auth_metadata(&mut request, account_id)?;

let response = self.client.abandon_delta_candidate(request).await?;
let inner = response.into_inner();

if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}

Ok(inner)
}
}

fn attach_auth_headers<T: prost::Message>(
Expand Down
48 changes: 42 additions & 6 deletions crates/client/src/testing/mocks.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use crate::proto::guardian_server::{Guardian, GuardianServer};
use crate::proto::{
AccountState, ConfigureRequest, ConfigureResponse, DeltaObject as ProtoDeltaObject,
GetAccountByKeyCommitmentRequest, GetAccountByKeyCommitmentResponse, GetDeltaProposalRequest,
GetDeltaProposalResponse, GetDeltaProposalsRequest, GetDeltaProposalsResponse, GetDeltaRequest,
GetDeltaResponse, GetDeltaSinceRequest, GetDeltaSinceResponse, GetPubkeyRequest,
GetStateRequest, GetStateResponse, PushDeltaProposalRequest, PushDeltaProposalResponse,
PushDeltaRequest, PushDeltaResponse, SignDeltaProposalRequest, SignDeltaProposalResponse,
AbandonDeltaCandidateRequest, AbandonDeltaCandidateResponse, AccountState, ConfigureRequest,
ConfigureResponse, DeltaObject as ProtoDeltaObject, GetAccountByKeyCommitmentRequest,
GetAccountByKeyCommitmentResponse, GetDeltaProposalRequest, GetDeltaProposalResponse,
GetDeltaProposalsRequest, GetDeltaProposalsResponse, GetDeltaRequest, GetDeltaResponse,
GetDeltaSinceRequest, GetDeltaSinceResponse, GetPubkeyRequest, GetStateRequest,
GetStateResponse, PushDeltaProposalRequest, PushDeltaProposalResponse, PushDeltaRequest,
PushDeltaResponse, SignDeltaProposalRequest, SignDeltaProposalResponse,
};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex as StdMutex};
Expand All @@ -26,6 +27,8 @@ pub struct MockGuardianService {
get_state_response: Arc<StdMutex<Option<Result<GetStateResponse, Status>>>>,
get_account_by_key_commitment_response:
Arc<StdMutex<Option<Result<GetAccountByKeyCommitmentResponse, Status>>>>,
abandon_delta_candidate_response:
Arc<StdMutex<Option<Result<AbandonDeltaCandidateResponse, Status>>>>,
}

impl MockGuardianService {
Expand Down Expand Up @@ -98,6 +101,14 @@ impl MockGuardianService {
*self.get_account_by_key_commitment_response.lock().unwrap() = Some(response);
self
}

pub fn with_abandon_delta_candidate(
self,
response: Result<AbandonDeltaCandidateResponse, Status>,
) -> Self {
*self.abandon_delta_candidate_response.lock().unwrap() = Some(response);
self
}
}

#[tonic::async_trait]
Expand Down Expand Up @@ -223,6 +234,31 @@ impl Guardian for MockGuardianService {
response.map(Response::new)
}

async fn abandon_delta_candidate(
&self,
request: Request<AbandonDeltaCandidateRequest>,
) -> Result<Response<AbandonDeltaCandidateResponse>, Status> {
let data = request.into_inner();
let response = self
.abandon_delta_candidate_response
.lock()
.unwrap()
.take()
.unwrap_or_else(|| {
Ok(AbandonDeltaCandidateResponse {
success: true,
message: String::new(),
account_id: data.account_id,
nonce: data.nonce,
state: "pending".to_string(),
abandon_requested_at: "2026-07-14T12:00:00Z".to_string(),
error_code: String::new(),
})
});

response.map(Response::new)
}

async fn push_delta(
&self,
_request: Request<PushDeltaRequest>,
Expand Down
26 changes: 26 additions & 0 deletions crates/miden-multisig-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,32 @@ client.sign_proposal(&to_sign.id).await?;
client.execute_proposal(&proposal.id).await?;
```

### Recovering From a Dead Transaction (Abandon)

If `execute_proposal` dies after guardian approval (RPC submit failure,
prover timeout, crash), the approved candidate keeps the account locked on
GUARDIAN. Record an abandon intent and poll for the resolution:

```rust
use miden_multisig_client::{AbandonRequestState, AbandonStatus};

// The nonce the proposal was pushed with (committed account nonce + 1).
let state = client.abandon_candidate(nonce).await?;
assert_eq!(state, AbandonRequestState::Pending);

// The guardian's worker confirms over a short quarantine (typically well
// under a minute) that the transaction did not land, then releases the
// account.
loop {
match client.abandon_status(nonce).await? {
AbandonStatus::Waiting => tokio::time::sleep(Duration::from_secs(5)).await,
AbandonStatus::Abandoned => break, // account released
AbandonStatus::Landed => break, // tx landed after all
AbandonStatus::Unexpected => anyhow::bail!("unexpected candidate state"),
}
}
```

### Fallback to Offline (if GUARDIAN unavailable)

If the GUARDIAN endpoint can’t be reached, the SDK can produce an offline proposal only for `SwitchGuardian` transactions:
Expand Down
1 change: 1 addition & 0 deletions crates/miden-multisig-client/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod io;
mod notes;
mod offline;
mod proposals;
pub use proposals::{AbandonRequestState, AbandonStatus};

use std::path::PathBuf;
use std::sync::Arc;
Expand Down
101 changes: 101 additions & 0 deletions crates/miden-multisig-client/src/client/proposals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ use guardian_shared::{ProposalSignature, ToJson};
use miden_client::transaction::TransactionRequest;

use super::{MultisigClient, ProposalResult};

/// Immediate outcome of an abandon request (issue #319).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbandonRequestState {
/// The intent is recorded; GUARDIAN's worker resolves it after the
/// abandon quarantine. Poll [`MultisigClient::abandon_status`].
Pending,
/// The abandon was already resolved; the account is released.
Abandoned,
}

/// Resolution of an abandon request, as observed via the delta feed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbandonStatus {
/// The delta is still a candidate; the quarantine is running.
Waiting,
/// The transaction landed after all; the delta canonicalized.
Landed,
/// The abandon completed; the delta is discarded as client-abandoned
/// and the account is released.
Abandoned,
/// The delta is missing or in a state no abandon flow produces.
Unexpected,
}
use crate::error::{MultisigError, Result};
use crate::execution::{
SignatureAdvice, SignatureInput, build_final_transaction_request, collect_signature_advice,
Expand Down Expand Up @@ -556,6 +580,83 @@ impl MultisigClient {
Err(e) => Err(e),
}
}

/// Requests abandonment of a pending canonicalization candidate whose
/// transaction will never land on-chain (issue #319).
///
/// Call this after an approved transaction died client-side (RPC
/// submit failure, prover timeout, crash). The request records an
/// abandon *intent* on GUARDIAN; the account stays locked until the
/// guardian's canonicalization worker confirms over a short
/// quarantine (typically well under a minute) that the transaction
/// did not land, then releases the account. Poll [`Self::abandon_status`]
/// for the resolution.
///
/// `nonce` pins the exact candidate to release; it is the nonce the
/// proposal was pushed with (committed account nonce + 1). Retries
/// are idempotent and preserve the original request timestamp.
///
/// # Errors
///
/// Returns an error if the candidate's transaction actually landed
/// on-chain (`GUARDIAN_CANDIDATE_LANDED` — the server will
/// canonicalize it shortly), if no candidate exists at this nonce,
/// or if GUARDIAN cannot be reached.
pub async fn abandon_candidate(&mut self, nonce: u64) -> Result<AbandonRequestState> {
let account_id = self.require_account()?.id();

let mut guardian_client = self.create_authenticated_guardian_client().await?;
let response = guardian_client
.abandon_candidate(&account_id, nonce)
.await
.map_err(|e| {
MultisigError::GuardianServer(format!("failed to abandon candidate: {}", e))
})?;

Ok(match response.state.as_str() {
"abandoned" => AbandonRequestState::Abandoned,
_ => AbandonRequestState::Pending,
})
}

/// Polls GUARDIAN for the resolution of an abandon request made with
/// [`Self::abandon_candidate`].
pub async fn abandon_status(&mut self, nonce: u64) -> Result<AbandonStatus> {
let account_id = self.require_account()?.id();

let mut guardian_client = self.create_authenticated_guardian_client().await?;
let response = match guardian_client.get_delta(&account_id, nonce).await {
Ok(response) => response,
// A missing delta is unexpected for an abandon in flight: the
// worker preserves an abandoned delta as discarded history.
Err(e) if e.to_string().to_lowercase().contains("not found") => {
return Ok(AbandonStatus::Unexpected);
}
Err(e) => {
return Err(MultisigError::GuardianServer(format!(
"failed to poll abandon status: {}",
e
)));
}
};

let Some(delta) = response.delta else {
return Ok(AbandonStatus::Unexpected);
};
let Some(status) = delta.status else {
return Ok(AbandonStatus::Unexpected);
};

use guardian_client::delta_status::Status as ProtoStatus;
Ok(match status.status {
Some(ProtoStatus::CandidateAt(_)) => AbandonStatus::Waiting,
Some(ProtoStatus::CanonicalAt(_)) => AbandonStatus::Landed,
Some(ProtoStatus::DiscardedAt(_)) if status.discard_reason == "client_abandoned" => {
AbandonStatus::Abandoned
}
_ => AbandonStatus::Unexpected,
})
}
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions crates/miden-multisig-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ pub(crate) type MidenSdkClient = Client<FilesystemKeyStore>;

// Main client
pub use builder::MultisigClientBuilder;
pub use client::{AbandonRequestState, AbandonStatus};
pub use client::{
ConsumableNote, MultisigClient, NoteFilter, ProposalResult, RecoveredAccount,
StateVerificationResult,
Expand Down
Loading
Loading