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
//! Offline proposal operations for MultisigClient.
//!
//! This module handles creating, signing, and executing proposals
//! without GUARDIAN coordination (offline/side-channel mode).
use std::collections::HashSet;
use super::MultisigClient;
use crate::error::{MultisigError, Result};
use crate::execution::{SignatureInput, build_final_transaction_request, collect_signature_advice};
use crate::export::{EXPORT_VERSION, ExportedMetadata, ExportedProposal, ExportedSignature};
use crate::guardian_endpoint::verify_endpoint_commitment;
use crate::keystore::proposal_public_key_hex;
use crate::proposal::TransactionType;
use guardian_shared::ToJson;
impl MultisigClient {
/// Creates a proposal offline without pushing to GUARDIAN.
///
/// Only `SwitchGuardian` transactions can be executed fully offline because
/// all other transaction types require a GUARDIAN acknowledgment signature.
///
/// This returns an `ExportedProposal` that can be serialized to JSON and
/// shared with cosigners.
///
/// The proposer's signature is automatically included in the exported proposal.
///
/// # Example
///
/// ```ignore
/// use miden_multisig_client::TransactionType;
///
/// // Create proposal offline
/// let exported = client.create_proposal_offline(
/// TransactionType::SwitchGuardian { new_endpoint, new_commitment }
/// ).await?;
///
/// // Save to file for sharing
/// std::fs::write("proposal.json", exported.to_json()?)?;
/// ```
pub async fn create_proposal_offline(
&mut self,
transaction_type: TransactionType,
) -> Result<ExportedProposal> {
self.sync_network_only().await?;
let account = self.require_account()?.clone();
let account_id = account.id();
let signatures_required =
account.effective_threshold_for_transaction(&transaction_type)? as usize;
let salt = crate::transaction::generate_salt();
let (new_endpoint, new_commitment) = match &transaction_type {
TransactionType::SwitchGuardian {
new_endpoint,
new_commitment,
} => {
verify_endpoint_commitment(new_endpoint, *new_commitment).await?;
(new_endpoint.clone(), *new_commitment)
}
_ => {
return Err(MultisigError::OfflineUnsupportedTransaction(
transaction_type.type_name().to_string(),
));
}
};
let tx_request = crate::transaction::build_update_guardian_transaction_request(
new_commitment,
self.key_manager.scheme(),
salt,
std::iter::empty(),
)?;
let (tx_summary, chain_anchor) =
crate::transaction::execute_for_summary(&mut self.miden_client, account_id, tx_request)
.await?;
let metadata = ExportedMetadata {
proposal_type: "switch_guardian".to_string(),
salt_hex: Some(crate::transaction::word_to_hex(&salt)),
new_guardian_pubkey_hex: Some(crate::transaction::word_to_hex(&new_commitment)),
new_guardian_endpoint: Some(new_endpoint),
chain_anchor: Some(crate::transaction::chain_anchor_to_base64(&chain_anchor)),
..Default::default()
};
let tx_commitment = tx_summary.to_commitment();
let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
let id = crate::transaction::word_to_hex(&tx_commitment);
let exported = ExportedProposal {
version: EXPORT_VERSION,
account_id: account_id.to_string(),
id,
nonce: account.nonce() + 1,
tx_summary: tx_summary.to_json(),
signatures: vec![ExportedSignature {
signer_commitment: self.key_manager.commitment_hex(),
signature: signature_hex,
scheme: self.key_manager.scheme(),
public_key_hex: proposal_public_key_hex(self.key_manager.as_ref()),
}],
signatures_required,
metadata,
};
Ok(exported)
}
/// Signs an imported proposal locally (without GUARDIAN).
///
/// The signature is added directly to the proposal. After signing,
/// export the proposal again to share with other cosigners.
///
/// Only `SwitchGuardian` proposals are supported in this mode.
///
/// # Example
///
/// ```ignore
/// let mut proposal = client.import_proposal("/tmp/proposal.json").await?;
/// client.sign_imported_proposal(&mut proposal).await?;
/// let json = proposal.to_json()?;
/// std::fs::write("/tmp/proposal_signed.json", json)?;
/// ```
pub async fn sign_imported_proposal(&mut self, proposal: &mut ExportedProposal) -> Result<()> {
let bound_proposal = proposal.to_proposal()?;
if !bound_proposal.transaction_type.supports_offline_execution() {
return Err(MultisigError::OfflineUnsupportedTransaction(
bound_proposal.transaction_type.type_name().to_string(),
));
}
self.verify_proposal_summary_binding(&bound_proposal)
.await?;
let account = self.require_account()?;
let account_id = account.id();
proposal.validate(Some(account_id))?;
// Check if user is a cosigner
let user_commitment = self.key_manager.commitment();
if !account.is_cosigner(&user_commitment) {
return Err(MultisigError::NotCosigner);
}
Self::ensure_proposal_account_id(&proposal.account_id, &account_id)?;
// Check if already signed
let user_commitment_hex = self.key_manager.commitment_hex();
if proposal.signatures.iter().any(|s| {
s.signer_commitment
.eq_ignore_ascii_case(&user_commitment_hex)
}) {
return Err(MultisigError::AlreadySigned);
}
// Sign the transaction summary commitment
let tx_commitment = bound_proposal.tx_summary.to_commitment();
let signature_hex = self.key_manager.sign_word_hex(tx_commitment);
// Add signature to proposal
proposal.add_signature(ExportedSignature {
signer_commitment: user_commitment_hex,
signature: signature_hex,
scheme: self.key_manager.scheme(),
public_key_hex: proposal_public_key_hex(self.key_manager.as_ref()),
})?;
Ok(())
}
/// Executes an imported proposal (with all signatures already collected).
///
/// This builds and submits the transaction directly to the Miden network
/// without contacting GUARDIAN.
///
/// Only `SwitchGuardian` transactions are supported in this mode.
///
/// Deliberately skips the pre-switch proposal-note import (issue #417):
/// it would contact the very GUARDIAN this flow exists to avoid. When
/// the old GUARDIAN is in fact still reachable, call
/// [`MultisigClient::preserve_pre_switch_proposal_notes`] before
/// executing.
///
/// # Example
///
/// ```ignore
/// let proposal = client.import_proposal("/tmp/proposal_final.json").await?;
/// client.execute_imported_proposal(&proposal).await?;
/// ```
pub async fn execute_imported_proposal(&mut self, exported: &ExportedProposal) -> Result<()> {
self.sync_network_only().await?;
let account = self.require_account()?.clone();
let account_id = account.id();
exported.validate(Some(account_id))?;
// Verify proposal is ready
if !exported.is_ready() {
return Err(MultisigError::ProposalNotReady {
collected: exported.signatures_collected(),
required: exported.signatures_required,
});
}
// Parse the proposal
let proposal = exported.to_proposal()?;
if !proposal.transaction_type.supports_offline_execution() {
return Err(MultisigError::OfflineUnsupportedTransaction(
proposal.transaction_type.type_name().to_string(),
));
}
self.verify_proposal_summary_binding(&proposal).await?;
let tx_summary = proposal.tx_summary.clone();
let tx_summary_commitment = tx_summary.to_commitment();
// Convert exported signatures to SignatureInput format
let signature_inputs: Vec<SignatureInput> = exported
.signatures
.iter()
.map(|sig| SignatureInput {
signer_commitment: sig.signer_commitment.clone(),
signature_hex: sig.signature.clone(),
scheme: sig.scheme,
public_key_hex: sig.public_key_hex.clone(),
})
.collect();
// Build signature advice from cosigner signatures
let required_commitments: HashSet<String> =
account.cosigner_commitments_hex().into_iter().collect();
let signature_advice = collect_signature_advice(
signature_inputs,
&required_commitments,
tx_summary_commitment,
)?;
// Build the final transaction request with all signatures
let salt = proposal.metadata.salt()?;
// Execute and finalize at the proposal's anchored reference block; the
// anchor was checked against the signed summary's block commitment in
// `verify_proposal_summary_binding` above. It also carries the fee
// faucet used to derive native fee conversion info during execution.
let chain_anchor = proposal.metadata.chain_anchor()?;
let final_tx_request = build_final_transaction_request(
&self.miden_client,
&proposal.transaction_type,
account.inner(),
salt,
signature_advice,
None,
None,
self.key_manager.scheme(),
)
.await?;
// Make the deliberate import skip observable rather than a silent
// loss when the old GUARDIAN was in fact still reachable.
tracing::warn!(
"offline switch execution skips the pre-switch proposal-note import; if the \
old GUARDIAN is still reachable, call preserve_pre_switch_proposal_notes \
before executing to keep notes embedded in its pending proposals"
);
self.finalize_transaction(
account_id,
final_tx_request,
&proposal.transaction_type,
chain_anchor,
)
.await
}
}