miden-multisig-client 0.18.0

High-level SDK for interacting with multisig accounts on Miden via GUARDIAN
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! Error types for the multisig client SDK.

use miden_client::rpc::{GrpcError, RpcError};
use miden_protocol::account::AccountId;
use miden_protocol::block::BlockNumber;
use miden_protocol::note::NoteId;
use thiserror::Error;

/// Result type alias for multisig operations.
pub type Result<T> = std::result::Result<T, MultisigError>;

/// Errors that can occur during multisig operations.
#[derive(Debug, Error)]
pub enum MultisigError {
    /// Account not found in local cache.
    #[error("account not found: {0}")]
    AccountNotFound(AccountId),

    /// Proposal not found.
    #[error("proposal not found: {0}")]
    ProposalNotFound(String),

    /// GUARDIAN connection error.
    #[error("GUARDIAN connection error: {0}")]
    GuardianConnection(String),

    /// GUARDIAN server returned an error.
    #[error("GUARDIAN server error: {0}")]
    GuardianServer(String),

    /// Miden client error.
    #[error("miden client error: {0}")]
    MidenClient(String),

    #[error(
        "the transaction summary binds block commitment {summary_block_commitment} but the \
         captured chain anchor is {anchor_commitment}; a sync landed between building the \
         request and capturing its anchor, so rebuild the request and retry"
    )]
    SummaryAnchorMismatch {
        anchor_commitment: String,
        summary_block_commitment: String,
    },

    /// A multisig request binds a block in its auth args without declaring it
    /// through `TransactionRequestBuilder::block_numbers`. A proposal executes
    /// at the chain tip, where the auth procedure can read the bound block only
    /// from the transaction's partial blockchain, so the request would fail in
    /// the VM once the chain moves past that block.
    #[error(
        "the transaction request binds block {bound_block_num} in its multisig auth args but \
         does not declare it, so it cannot execute at a later chain tip; attach the auth args \
         with TransactionRequestBuilderExt::multisig_auth_args or add \
         .block_numbers([{bound_block_num}])"
    )]
    BoundBlockNotDeclared { bound_block_num: BlockNumber },

    /// The Miden client synced, and its node still has not produced the block
    /// a proposal binds, so the proposal cannot execute at this client's tip
    /// yet. Worth retrying once the node catches up.
    #[error(
        "the Miden client synced to block {synced}, below block {bound_block_num} the proposal \
         binds; its node has not reached that block yet"
    )]
    ChainBehindBoundBlock {
        synced: BlockNumber,
        bound_block_num: BlockNumber,
    },

    /// Miden client error retaining the concrete source and its RPC status.
    #[error("miden client error: {message}")]
    MidenClientSource {
        message: String,
        #[source]
        source: Box<miden_client::ClientError>,
    },

    /// Direct Miden RPC error with call-site context, retaining the typed
    /// status.
    #[error("miden RPC error: {message}")]
    MidenRpcSource {
        message: String,
        #[source]
        source: Box<RpcError>,
    },

    /// Sync panicked due to corrupted local state (miden-client v0.12.x workaround).
    #[error("sync panicked (corrupted local state): {0}")]
    SyncPanicked(String),

    /// Transaction execution failed.
    #[error("transaction execution failed: {0}")]
    TransactionExecution(String),

    /// Transaction-stage Miden client failure retaining the typed source.
    /// Never used for retry decisions — execution stages run once — but the
    /// typed status survives for caller diagnostics.
    #[error("transaction execution failed: {message}")]
    TransactionExecutionSource {
        message: String,
        #[source]
        source: Box<miden_client::ClientError>,
    },

    /// Invalid configuration.
    #[error("invalid configuration: {0}")]
    InvalidConfig(String),

    /// Invalid custom transaction prover URL.
    #[error("invalid prover URL: {0}")]
    InvalidProverUrl(String),

    /// Signature error.
    #[error("signature error: {0}")]
    Signature(String),

    /// Serialization/deserialization error.
    #[error("serialization error: {0}")]
    Serialization(#[from] serde_json::Error),

    /// User is not a cosigner for this account.
    #[error("not a cosigner for this account")]
    NotCosigner,

    /// User has already signed this proposal.
    #[error("already signed this proposal")]
    AlreadySigned,

    /// Proposal does not have enough signatures for finalization.
    #[error("proposal not ready: need {required} signatures, have {collected}")]
    ProposalNotReady { required: usize, collected: usize },

    /// Signer not configured.
    #[error("signer not configured")]
    NoSigner,

    /// Missing required configuration.
    #[error("missing required configuration: {0}")]
    MissingConfig(String),

    /// Hex decoding error.
    #[error("hex decode error: {0}")]
    HexDecode(String),

    /// Account storage error.
    #[error("account storage error: {0}")]
    AccountStorage(String),

    /// The account's code was built from a different miden-standards contract
    /// version than the one this SDK pins, so the SDK's procedure roots do not
    /// apply to it.
    #[error(
        "unsupported contract version for account {account_id}: its code does not carry \
         this SDK's pinned guarded-multisig auth procedure; use the SDK release matching \
         the contract version the account was created with (see docs/MULTISIG_SDK.md, \
         \"Contract version pinning\")"
    )]
    UnsupportedContractVersion { account_id: AccountId },

    /// Transaction unexpected success (expected Unauthorized).
    #[error("transaction executed successfully when failure was expected")]
    UnexpectedSuccess,

    /// Retained for backward compatibility; no longer produced. Unmodeled
    /// proposal types now parse into `TransactionType::Custom` (issue #266), and
    /// build/execute failures surface as `UnsupportedTransactionType`.
    #[error("unknown transaction type: {0}")]
    UnknownTransactionType(String),

    /// A custom/unmodeled proposal type cannot be built or executed by the
    /// generic SDK (issue #266). It can still be parsed, signed, and exported.
    #[error("unsupported transaction type for this operation: {0}")]
    UnsupportedTransactionType(String),

    /// Invalid filter configuration.
    #[error("invalid filter: {0}")]
    InvalidFilter(String),

    /// Transaction type is not supported in offline mode without GUARDIAN.
    #[error("offline mode only supports SwitchGuardian transactions, got: {0}")]
    OfflineUnsupportedTransaction(String),

    /// consume_notes v2 metadata: embedded `notes` array does not match
    /// declared `note_ids` (length mismatch or per-index ID mismatch).
    #[error("consume_notes metadata note binding mismatch: {0}")]
    NoteBindingMismatch(String),

    /// consume_notes metadata has an unrecognized version, or is v1 on a
    /// cut-over build that no longer supports the legacy path.
    #[error("unsupported consume_notes metadata version: {found:?}")]
    UnsupportedMetadataVersion { found: Option<u32> },

    /// consume_notes v2 metadata exceeds the per-proposal size cap.
    #[error(
        "consume_notes metadata exceeds size limit: limit={limit} bytes, actual={actual} bytes"
    )]
    ConsumeNotesMetadataOversize { limit: usize, actual: usize },

    /// consume_notes v1 verification path: the cosigner's local Miden
    /// store does not contain the referenced note. Not reachable on v2.
    #[error("consume_notes legacy verification: note not found in local store: {note_id}")]
    LegacyConsumeNotesNoteMissing { note_id: NoteId },

    /// consume_notes: a note the proposal consumes could not be authenticated
    /// (its inclusion proof fetched from the node and imported into the local
    /// store). The signed summary commits to authenticated consumption, so
    /// an unauthenticated rebuild would never match it (issue #409).
    #[error("consume_notes: note {note_id} could not be authenticated: {reason}")]
    ConsumeNoteNotAuthenticated { note_id: NoteId, reason: String },
}

impl MultisigError {
    /// Stable, machine-readable identifier for cross-SDK error parity
    /// per spec FR-021 / FR-022. Only consume_notes-feature errors are
    /// pinned here for now; broader taxonomy work is out of scope.
    pub fn code(&self) -> Option<&'static str> {
        match self {
            Self::NoteBindingMismatch(_) => Some("consume_notes_note_binding_mismatch"),
            Self::UnsupportedMetadataVersion { .. } => {
                Some("consume_notes_unsupported_metadata_version")
            }
            Self::ConsumeNotesMetadataOversize { .. } => Some("consume_notes_metadata_oversize"),
            Self::LegacyConsumeNotesNoteMissing { .. } => Some("consume_notes_legacy_note_missing"),
            Self::ConsumeNoteNotAuthenticated { .. } => {
                Some("consume_notes_note_not_authenticated")
            }
            Self::UnsupportedTransactionType(_) => Some("unsupported_transaction_type"),
            _ => None,
        }
    }

    /// Wraps a Miden client failure with call-site context while retaining the
    /// typed source for classification. The message flattens the cause chain so
    /// a plain `Display` render still names the underlying failure.
    pub(crate) fn miden_client_with_context(
        context: impl AsRef<str>,
        err: miden_client::ClientError,
    ) -> Self {
        MultisigError::MidenClientSource {
            message: format!("{}: {}", context.as_ref(), error_chain(&err)),
            source: Box::new(err),
        }
    }

    /// Wraps a direct Miden RPC failure with call-site context while
    /// retaining the typed source for classification. The message flattens the
    /// cause chain so a plain `Display` render still names the gRPC status.
    pub(crate) fn miden_rpc_with_context(context: impl AsRef<str>, err: RpcError) -> Self {
        MultisigError::MidenRpcSource {
            message: format!("{}: {}", context.as_ref(), error_chain(&err)),
            source: Box::new(err),
        }
    }

    /// Wraps a transaction-stage Miden client failure with call-site context
    /// while retaining the typed source. The message flattens the cause chain
    /// so a plain `Display` render still names the underlying failure.
    pub(crate) fn transaction_execution_with_context(
        context: impl AsRef<str>,
        err: miden_client::ClientError,
    ) -> Self {
        MultisigError::TransactionExecutionSource {
            message: format!("{}: {}", context.as_ref(), error_chain(&err)),
            source: Box::new(err),
        }
    }

    /// Returns the typed gRPC failure kind when this error originated from a
    /// Miden RPC request.
    pub fn miden_rpc_kind(&self) -> Option<&GrpcError> {
        match self {
            Self::MidenClientSource { source, .. } => rpc_kind_from_client_error(source),
            Self::MidenRpcSource { source, .. } => rpc_kind(source),
            Self::TransactionExecutionSource { source, .. } => rpc_kind_from_client_error(source),
            _ => None,
        }
    }
}

fn rpc_kind_from_client_error(error: &miden_client::ClientError) -> Option<&GrpcError> {
    match error {
        miden_client::ClientError::RpcError(error) => rpc_kind(error),
        miden_client::ClientError::SubmissionOutcomeUnknown { source, .. } => rpc_kind(source),
        miden_client::ClientError::ApplyTransactionAfterSubmitFailed { source, .. } => {
            rpc_kind_from_client_error(source)
        }
        _ => None,
    }
}

pub(crate) fn rpc_kind(error: &RpcError) -> Option<&GrpcError> {
    match error {
        RpcError::RequestError { error_kind, .. } => Some(error_kind),
        _ => None,
    }
}

impl From<guardian_client::ClientError> for MultisigError {
    fn from(err: guardian_client::ClientError) -> Self {
        MultisigError::GuardianServer(err.to_string())
    }
}

/// Flattens an error's full `source()` chain into one string so callers see the underlying cause
/// (e.g. the gRPC status behind a terse "RPC error"), not just the outermost `Display`.
pub(crate) fn error_chain(err: &dyn std::error::Error) -> String {
    let mut message = err.to_string();
    let mut source = err.source();
    while let Some(cause) = source {
        message.push_str(": ");
        message.push_str(&cause.to_string());
        source = cause.source();
    }
    message
}

impl From<miden_client::ClientError> for MultisigError {
    fn from(err: miden_client::ClientError) -> Self {
        MultisigError::MidenClientSource {
            message: error_chain(&err),
            source: Box::new(err),
        }
    }
}

impl From<miden_client::transaction::TransactionRequestError> for MultisigError {
    fn from(err: miden_client::transaction::TransactionRequestError) -> Self {
        MultisigError::TransactionExecution(err.to_string())
    }
}

impl From<miden_client::transaction::TransactionExecutorError> for MultisigError {
    fn from(err: miden_client::transaction::TransactionExecutorError) -> Self {
        MultisigError::TransactionExecution(err.to_string())
    }
}

#[cfg(test)]
mod tests {
    use miden_client::rpc::RpcEndpoint;

    use super::*;

    pub(crate) fn request_error(kind: GrpcError) -> miden_client::ClientError {
        RpcError::RequestError {
            endpoint: RpcEndpoint::SyncChainMmr,
            error_kind: kind,
            endpoint_error: None,
            source: None,
        }
        .into()
    }

    #[test]
    fn miden_client_conversion_retains_typed_rpc_kind() {
        let error = MultisigError::from(request_error(GrpcError::ResourceExhausted));

        assert!(matches!(
            error.miden_rpc_kind(),
            Some(GrpcError::ResourceExhausted)
        ));
        assert!(error.to_string().contains("sync_chain_mmr"));
    }

    #[test]
    fn context_wrapper_keeps_call_site_detail_and_typed_source() {
        let error = MultisigError::miden_client_with_context(
            "failed to fetch on-chain commitment for account 0xabc",
            request_error(GrpcError::Unavailable),
        );

        assert!(error.to_string().contains("account 0xabc"));
        assert!(error.to_string().contains("sync_chain_mmr"));
        assert!(matches!(
            error.miden_rpc_kind(),
            Some(GrpcError::Unavailable)
        ));
    }

    #[test]
    fn errors_without_rpc_origin_have_no_rpc_kind() {
        assert!(
            MultisigError::GuardianServer("There's already a pending change".to_string())
                .miden_rpc_kind()
                .is_none()
        );
        assert!(
            MultisigError::MidenClient("account not found on chain".to_string())
                .miden_rpc_kind()
                .is_none()
        );
    }

    #[test]
    fn rpc_context_wrapper_keeps_call_site_detail_and_typed_source() {
        let error = MultisigError::miden_rpc_with_context(
            "failed to fetch on-chain commitment for account 0xabc",
            RpcError::RequestError {
                endpoint: RpcEndpoint::GetAccount,
                error_kind: GrpcError::ResourceExhausted,
                endpoint_error: None,
                source: None,
            },
        );

        assert!(error.to_string().contains("account 0xabc"));
        assert!(error.to_string().contains("get_account"));
        assert!(matches!(
            error.miden_rpc_kind(),
            Some(GrpcError::ResourceExhausted)
        ));
    }

    #[test]
    fn execution_context_wrapper_keeps_typed_source() {
        let error = MultisigError::transaction_execution_with_context(
            "transaction submission failed",
            request_error(GrpcError::Unavailable),
        );

        assert!(error.to_string().contains("transaction submission failed"));
        assert!(error.to_string().contains("sync_chain_mmr"));
        assert!(matches!(
            error.miden_rpc_kind(),
            Some(GrpcError::Unavailable)
        ));
    }
}