monero_wallet/send/
multisig.rs

1use std_shims::{
2  vec::Vec,
3  io::{self, Read},
4  collections::HashMap,
5};
6
7use rand_core::{RngCore, CryptoRng};
8
9use curve25519_dalek::{traits::Identity as _, Scalar, EdwardsPoint};
10
11use transcript::{Transcript as _, RecommendedTranscript};
12use frost::{
13  curve::Ed25519,
14  Participant, FrostError, ThresholdKeys,
15  sign::{
16    Writable, Preprocess, CachedPreprocess, SignatureShare, PreprocessMachine, SignMachine,
17    SignatureMachine, AlgorithmMachine, AlgorithmSignMachine, AlgorithmSignatureMachine,
18  },
19};
20
21use monero_oxide::{
22  ed25519::CompressedPoint,
23  ringct::{
24    clsag::{ClsagContext, ClsagMultisigMaskSender, ClsagAddendum, ClsagMultisig},
25    RctPrunable, RctProofs,
26  },
27  transaction::Transaction,
28};
29use crate::send::{SendError, SignableTransaction, key_image_sort};
30
31/// Initial FROST machine to produce a signed transaction.
32pub struct TransactionMachine {
33  signable: SignableTransaction,
34
35  keys: ThresholdKeys<Ed25519>,
36
37  // The key image generator, and the (scalar, offset) linear combination from the spend key
38  key_image_generators_and_lincombs: Vec<(EdwardsPoint, (Scalar, Scalar))>,
39  clsags: Vec<(ClsagMultisigMaskSender, AlgorithmMachine<Ed25519, ClsagMultisig>)>,
40}
41
42/// Second FROST machine to produce a signed transaction.
43///
44/// Panics if a non-empty message is provided, or if `cache`, `from_cache` are called.
45///
46/// This MUST only be passed preprocesses obtained via calling `read_preprocess` with this very
47/// machine. Other machines representing distinct executions of the protocol will almost certainly
48/// be incompatible.
49pub struct TransactionSignMachine {
50  signable: SignableTransaction,
51
52  keys: ThresholdKeys<Ed25519>,
53
54  key_image_generators_and_lincombs: Vec<(EdwardsPoint, (Scalar, Scalar))>,
55  clsags: Vec<(ClsagMultisigMaskSender, AlgorithmSignMachine<Ed25519, ClsagMultisig>)>,
56
57  our_preprocess: Vec<Preprocess<Ed25519, ClsagAddendum>>,
58}
59
60/// Final FROST machine to produce a signed transaction.
61///
62/// This MUST only be passed shares obtained via calling `read_share` with this very machine.
63/// Shares from other machines, representing distinct executions of the signing protocol, will be
64/// incompatible.
65pub struct TransactionSignatureMachine {
66  tx: Transaction,
67  clsags: Vec<AlgorithmSignatureMachine<Ed25519, ClsagMultisig>>,
68}
69
70impl SignableTransaction {
71  /// Create a FROST signing machine out of this signable transaction.
72  ///
73  /// The created machine is expected to be called with an empty message, as it will generate its
74  /// own, and may panic if a message is provided. The created machine DOES NOT support caching and
75  /// may panic if `cache`, `from_cache` are called.
76  ///
77  /// This function runs in time variable to the validity of the arguments and the public data.
78  pub fn multisig(self, keys: ThresholdKeys<Ed25519>) -> Result<TransactionMachine, SendError> {
79    let mut clsags = vec![];
80
81    let mut key_image_generators_and_lincombs = vec![];
82    for input in &self.inputs {
83      // Check this is the right set of keys
84      let key_scalar = Scalar::ONE;
85      let key_offset = input.key_offset();
86
87      let offset = keys
88        .clone()
89        .scale(key_scalar)
90        .expect("non-zero scalar (1) was zero")
91        .offset(key_offset.into());
92      if offset.group_key().0 != input.key().into() {
93        Err(SendError::WrongPrivateKey)?;
94      }
95
96      let context = ClsagContext::new(input.decoys().clone(), input.commitment().clone())
97        .map_err(SendError::ClsagError)?;
98      let (clsag, clsag_mask_send) = ClsagMultisig::new(
99        RecommendedTranscript::new(b"Monero Multisignature Transaction"),
100        context,
101      );
102      key_image_generators_and_lincombs
103        .push((clsag.key_image_generator(), (offset.current_scalar(), offset.current_offset())));
104      clsags.push((clsag_mask_send, AlgorithmMachine::new(clsag, offset)));
105    }
106
107    Ok(TransactionMachine { signable: self, keys, key_image_generators_and_lincombs, clsags })
108  }
109}
110
111/// The preprocess for a transaction.
112// Opaque wrapper around the CLSAG preprocesses, forcing users to use `read_preprocess` to obtain
113// this.
114#[derive(Clone, PartialEq)]
115pub struct TransactionPreprocess(Vec<Preprocess<Ed25519, ClsagAddendum>>);
116impl Writable for TransactionPreprocess {
117  fn write<W: io::Write>(&self, writer: &mut W) -> io::Result<()> {
118    for preprocess in &self.0 {
119      preprocess.write(writer)?;
120    }
121    Ok(())
122  }
123}
124
125impl PreprocessMachine for TransactionMachine {
126  type Preprocess = TransactionPreprocess;
127  type Signature = Transaction;
128  type SignMachine = TransactionSignMachine;
129
130  fn preprocess<R: RngCore + CryptoRng>(
131    mut self,
132    rng: &mut R,
133  ) -> (TransactionSignMachine, Self::Preprocess) {
134    // Iterate over each CLSAG calling preprocess
135    let mut preprocesses = Vec::with_capacity(self.clsags.len());
136    let clsags = self
137      .clsags
138      .drain(..)
139      .map(|(clsag_mask_send, clsag)| {
140        let (clsag, preprocess) = clsag.preprocess(rng);
141        preprocesses.push(preprocess);
142        (clsag_mask_send, clsag)
143      })
144      .collect();
145    let our_preprocess = preprocesses.clone();
146
147    (
148      TransactionSignMachine {
149        signable: self.signable,
150
151        keys: self.keys,
152
153        key_image_generators_and_lincombs: self.key_image_generators_and_lincombs,
154        clsags,
155
156        our_preprocess,
157      },
158      TransactionPreprocess(preprocesses),
159    )
160  }
161}
162
163/// The signature share for a transaction.
164// Opaque wrapper around the CLSAG signature shares, forcing users to use `read_share` to
165// obtain this.
166#[derive(Clone, PartialEq)]
167pub struct TransactionSignatureShare(Vec<SignatureShare<Ed25519>>);
168impl Writable for TransactionSignatureShare {
169  fn write<W: io::Write>(&self, writer: &mut W) -> io::Result<()> {
170    for share in &self.0 {
171      share.write(writer)?;
172    }
173    Ok(())
174  }
175}
176
177impl SignMachine<Transaction> for TransactionSignMachine {
178  type Params = ();
179  type Keys = ThresholdKeys<Ed25519>;
180  type Preprocess = TransactionPreprocess;
181  type SignatureShare = TransactionSignatureShare;
182  type SignatureMachine = TransactionSignatureMachine;
183
184  fn cache(self) -> CachedPreprocess {
185    unimplemented!(
186      "Monero transactions don't support caching their preprocesses due to {}",
187      "being already bound to a specific transaction"
188    );
189  }
190
191  fn from_cache(
192    (): (),
193    _: ThresholdKeys<Ed25519>,
194    _: CachedPreprocess,
195  ) -> (Self, Self::Preprocess) {
196    unimplemented!(
197      "Monero transactions don't support caching their preprocesses due to {}",
198      "being already bound to a specific transaction"
199    );
200  }
201
202  fn read_preprocess<R: Read>(&self, reader: &mut R) -> io::Result<Self::Preprocess> {
203    Ok(TransactionPreprocess(
204      self.clsags.iter().map(|clsag| clsag.1.read_preprocess(reader)).collect::<Result<_, _>>()?,
205    ))
206  }
207
208  fn sign(
209    self,
210    mut commitments: HashMap<Participant, Self::Preprocess>,
211    msg: &[u8],
212  ) -> Result<(TransactionSignatureMachine, Self::SignatureShare), FrostError> {
213    assert!(
214      msg.is_empty(),
215      "message was passed to the TransactionMachine when it generates its own"
216    );
217
218    #[expect(clippy::iter_over_hash_type)]
219    for preprocess in commitments.values() {
220      if preprocess.0.len() != self.clsags.len() {
221        Err(FrostError::InternalError(
222          "preprocesses from another instance of the signing protocol were passed in",
223        ))?;
224      }
225    }
226
227    // We do not need to be included here, yet this set of signers has yet to be validated
228    // We explicitly remove ourselves to ensure we aren't included twice, if we were redundantly
229    // included
230    commitments.remove(&self.keys.params().i());
231
232    // Find out who's included
233    let mut included = commitments.keys().copied().collect::<Vec<_>>();
234    // This push won't duplicate due to the above removal
235    included.push(self.keys.params().i());
236    // unstable sort may reorder elements of equal order
237    // Given our lack of duplicates, we should have no elements of equal order
238    included.sort_unstable();
239
240    // Start calculating the key images, as needed on the TX level
241    let mut key_images = vec![EdwardsPoint::identity(); self.clsags.len()];
242
243    // Convert the serialized nonces commitments to a parallelized Vec
244    let view = self.keys.view(included.clone()).map_err(|_| {
245      FrostError::InvalidSigningSet("couldn't form an interpolated view of the key")
246    })?;
247    let mut commitments = (0 .. self.clsags.len())
248      .map(|c| {
249        included
250          .iter()
251          .map(|l| {
252            let preprocess = if *l == self.keys.params().i() {
253              self.our_preprocess[c].clone()
254            } else {
255              commitments.get_mut(l).ok_or(FrostError::MissingParticipant(*l))?.0[c].clone()
256            };
257
258            // While here, calculate the key image as needed to call sign
259            // The CLSAG algorithm will independently calculate the key image/verify these shares
260            key_images[c] += preprocess.addendum.key_image_share().0 *
261              view.interpolation_factor(*l).ok_or(FrostError::InternalError(
262                "view successfully formed with participant without an interpolation factor",
263              ))?;
264
265            Ok((*l, preprocess))
266          })
267          .collect::<Result<HashMap<_, _>, _>>()
268      })
269      .collect::<Result<Vec<_>, _>>()?;
270
271    let key_images: Vec<_> = key_images
272      .into_iter()
273      .zip(&self.key_image_generators_and_lincombs)
274      .map(|(mut key_image, (generator, (scalar, offset)))| {
275        key_image *= scalar;
276        key_image += generator * offset;
277        CompressedPoint::from(key_image.compress().to_bytes())
278      })
279      .collect();
280
281    // The above inserted our own preprocess into these maps (which is unnecessary)
282    // Remove it now
283    for map in &mut commitments {
284      map.remove(&self.keys.params().i());
285    }
286
287    // The actual TX will have sorted its inputs by key image
288    // We apply the same sort now to our CLSAG machines
289    let mut clsags = Vec::with_capacity(self.clsags.len());
290    for ((key_image, clsag), commitments) in key_images.iter().zip(self.clsags).zip(commitments) {
291      clsags.push((key_image, clsag, commitments));
292    }
293    clsags.sort_by(|x, y| key_image_sort(x.0, y.0));
294    let clsags =
295      clsags.into_iter().map(|(_, clsag, commitments)| (clsag, commitments)).collect::<Vec<_>>();
296
297    // Specify the TX's key images
298    let tx = self.signable.with_key_images(key_images);
299
300    // We now need to decide the masks for each CLSAG
301    let clsag_len = clsags.len();
302    let output_masks = tx.intent.sum_output_masks(&tx.key_images);
303    let mut rng = tx.intent.seeded_rng(b"multisig_pseudo_out_masks");
304    let mut sum_pseudo_outs = Scalar::ZERO;
305    let mut to_sign = Vec::with_capacity(clsag_len);
306    for (i, ((clsag_mask_send, clsag), commitments)) in clsags.into_iter().enumerate() {
307      let mut mask = monero_oxide::ed25519::Scalar::random(&mut rng).into();
308      if i == (clsag_len - 1) {
309        mask = output_masks.into() - sum_pseudo_outs;
310      } else {
311        sum_pseudo_outs += mask;
312      }
313      clsag_mask_send.send(mask);
314      to_sign.push((clsag, commitments));
315    }
316
317    let tx = tx.transaction_without_signatures();
318    let msg = tx.signature_hash().expect("signing a transaction which isn't signed?");
319
320    // Iterate over each CLSAG calling sign
321    let mut shares = Vec::with_capacity(to_sign.len());
322    let clsags = to_sign
323      .drain(..)
324      .map(|(clsag, commitments)| {
325        let (clsag, share) = clsag.sign(commitments, &msg)?;
326        shares.push(share);
327        Ok(clsag)
328      })
329      .collect::<Result<_, _>>()?;
330
331    Ok((TransactionSignatureMachine { tx, clsags }, TransactionSignatureShare(shares)))
332  }
333}
334
335impl SignatureMachine<Transaction> for TransactionSignatureMachine {
336  type SignatureShare = TransactionSignatureShare;
337
338  fn read_share<R: Read>(&self, reader: &mut R) -> io::Result<Self::SignatureShare> {
339    Ok(TransactionSignatureShare(
340      self.clsags.iter().map(|clsag| clsag.read_share(reader)).collect::<Result<_, _>>()?,
341    ))
342  }
343
344  fn complete(
345    mut self,
346    shares: HashMap<Participant, Self::SignatureShare>,
347  ) -> Result<Transaction, FrostError> {
348    #[expect(clippy::iter_over_hash_type)]
349    for share in shares.values() {
350      if share.0.len() != self.clsags.len() {
351        Err(FrostError::InternalError(
352          "signature shares from another instance of the signing protocol were passed in",
353        ))?;
354      }
355    }
356
357    let mut tx = self.tx;
358    #[expect(clippy::wildcard_enum_match_arm)]
359    match tx {
360      Transaction::V2 {
361        proofs:
362          Some(RctProofs {
363            prunable: RctPrunable::Clsag { ref mut clsags, ref mut pseudo_outs, .. },
364            ..
365          }),
366        ..
367      } => {
368        for (c, clsag) in self.clsags.drain(..).enumerate() {
369          let (clsag, pseudo_out) = clsag.complete(
370            shares.iter().map(|(l, shares)| (*l, shares.0[c].clone())).collect::<HashMap<_, _>>(),
371          )?;
372          clsags.push(clsag);
373          pseudo_outs.push(CompressedPoint::from(pseudo_out.compress().to_bytes()));
374        }
375      }
376      _ => unreachable!("attempted to sign a multisig TX which wasn't CLSAG"),
377    }
378    Ok(tx)
379  }
380}