Skip to content
Merged
21 changes: 21 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 @@ -260,3 +264,20 @@ 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;
// RFC 3339 UTC timestamp of the release.
string abandoned_at = 5;
string error_code = 6;
}
40 changes: 35 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,35 @@ impl GuardianClient {

Ok(inner)
}

/// Abandon a pending canonicalization candidate whose transaction will
/// never land on-chain (issue #319), releasing the account immediately
/// instead of waiting out the server's grace period and retry budget.
///
/// `nonce` pins the exact candidate (learned from the delta feed) so a
/// stale request cannot release a newer one. The server refuses with
/// `GUARDIAN_CANDIDATE_LANDED` when the transaction actually landed.
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
47 changes: 41 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,30 @@ 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,
abandoned_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
33 changes: 33 additions & 0 deletions crates/miden-multisig-client/src/client/proposals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,39 @@ impl MultisigClient {
Err(e) => Err(e),
}
}

/// Abandons a pending canonicalization candidate whose transaction will
/// never land on-chain, releasing the account on GUARDIAN immediately.
///
/// Call this after an approved transaction died client-side (RPC submit
/// failure, prover timeout, crash): from GUARDIAN's perspective such a
/// candidate is indistinguishable from one that is slowly proving, so
/// without this call the account stays locked — every new proposal
/// answered with `conflict_pending_delta` — until the server's
/// submission grace period and retry budget run out.
///
/// `nonce` pins the exact candidate to release; it is the nonce the
/// proposal was pushed with (committed account nonce + 1).
///
/// # 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<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the TS multisig client hasn't been extended with this new method yet, which breaks SDK parity.

let account_id = self.require_account()?.id();

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

Ok(())
}
}

#[cfg(test)]
Expand Down
21 changes: 21 additions & 0 deletions crates/server/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 @@ -298,3 +302,20 @@ 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;
// RFC 3339 UTC timestamp of the release.
string abandoned_at = 5;
string error_code = 6;
}
143 changes: 143 additions & 0 deletions crates/server/src/api/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,42 @@ impl Guardian for GuardianService {
}
}

/// Abandon a pending canonicalization candidate whose transaction will
/// never land, releasing the account immediately (issue #319). Mirror
/// of HTTP `POST /delta/candidate/abandon`.
async fn abandon_delta_candidate(
&self,
request: Request<AbandonDeltaCandidateRequest>,
) -> Result<Response<AbandonDeltaCandidateResponse>, Status> {
let credentials = authenticated_request(&request)?;
let data = request.into_inner();

let params = services::AbandonCandidateParams {
account_id: data.account_id.clone(),
nonce: data.nonce,
credentials,
};

match services::abandon_candidate(&self.app_state, params).await {
Ok(response) => Ok(Response::new(AbandonDeltaCandidateResponse {
success: true,
message: "Candidate abandoned; account released".to_string(),
account_id: response.account_id,
nonce: response.nonce,
abandoned_at: response.abandoned_at,
error_code: String::new(),
})),
Err(e) => Ok(Response::new(AbandonDeltaCandidateResponse {
success: false,
message: e.to_string(),
account_id: data.account_id,
nonce: data.nonce,
abandoned_at: String::new(),
error_code: e.code().to_string(),
})),
}
}

/// Resolve a public-key commitment to the set of account IDs that
/// authorize it. Mirror of HTTP `GET /state/lookup`.
async fn get_account_by_key_commitment(
Expand Down Expand Up @@ -743,6 +779,113 @@ mod tests {
assert_eq!(inner.delta.unwrap().nonce, 1);
}

fn abandon_grpc_fixtures(
storage: &MockStorageBackend,
network: &MockNetworkClient,
metadata: &MockMetadataStore,
account_id: &str,
signer: &TestSigner,
landed: bool,
) {
let account_json: serde_json::Value = serde_json::from_str(fixtures::ACCOUNT_JSON).unwrap();
let delta_fixture: serde_json::Value =
serde_json::from_str(fixtures::DELTA_1_JSON).unwrap();
let candidate = crate::delta_object::DeltaObject {
account_id: account_id.to_string(),
nonce: 1,
prev_commitment: "0x123".to_string(),
new_commitment: Some("0x456".to_string()),
delta_payload: delta_fixture["delta_payload"].clone(),
ack_sig: String::new(),
ack_pubkey: String::new(),
ack_scheme: String::new(),
status: DeltaStatus::candidate("2024-11-14T12:00:00Z".to_string()),
metadata: None,
};

let _ = metadata.clone().with_get(Ok(Some(create_account_metadata(
account_id.to_string(),
vec![signer.commitment_hex.clone()],
))));
let _ = storage
.clone()
.with_pull_delta(Ok(candidate))
.with_pull_state(Ok(create_state_object(
account_id.to_string(),
"0x123".to_string(),
account_json,
)));
let verify = if landed {
Ok(crate::network::StateVerification::Match)
} else {
Ok(crate::network::StateVerification::Mismatch {
on_chain: "0x123".to_string(),
})
};
let _ = network
.clone()
.with_apply_delta(Ok((serde_json::json!({"new": true}), "0x456".to_string())))
.with_verify_state(verify);
}

#[tokio::test]
async fn test_grpc_abandon_delta_candidate_success() {
let (state, storage, network, metadata) = create_test_state();
let account_id = "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string();
let signer = TestSigner::new();
abandon_grpc_fixtures(&storage, &network, &metadata, &account_id, &signer, false);
let service = create_service(state);

let request = create_request_with_auth(
AbandonDeltaCandidateRequest {
account_id: account_id.clone(),
nonce: 1,
},
&signer,
&account_id,
);
let response = service
.abandon_delta_candidate(request)
.await
.expect("gRPC call should succeed")
.into_inner();

assert!(response.success, "expected success: {}", response.message);
assert_eq!(response.account_id, account_id);
assert_eq!(response.nonce, 1);
assert!(!response.abandoned_at.is_empty());
assert!(response.error_code.is_empty());
assert_eq!(storage.get_delete_delta_calls(), vec![(account_id, 1)]);
}

#[tokio::test]
async fn test_grpc_abandon_delta_candidate_landed_error_code() {
let (state, storage, network, metadata) = create_test_state();
let account_id = "0x7b7b7b7a7b7b7b017b7b7b7b7b7b7b".to_string();
let signer = TestSigner::new();
abandon_grpc_fixtures(&storage, &network, &metadata, &account_id, &signer, true);
let service = create_service(state);

let request = create_request_with_auth(
AbandonDeltaCandidateRequest {
account_id: account_id.clone(),
nonce: 1,
},
&signer,
&account_id,
);
let response = service
.abandon_delta_candidate(request)
.await
.expect("gRPC transport should not error")
.into_inner();

assert!(!response.success);
assert_eq!(response.error_code, "GUARDIAN_CANDIDATE_LANDED");
assert!(response.abandoned_at.is_empty());
assert!(storage.get_delete_delta_calls().is_empty());
}

#[tokio::test]
async fn test_grpc_push_delta_proposal_missing_tx_summary() {
let (state, storage, _network, metadata) = create_test_state();
Expand Down
Loading
Loading