Monero: add more legacy verify functions (#383)

* Add v1 ring sig verifying

* allow calculating signature hash for v1 txs

* add unreduced scalar type with recovery

I have added this type for borromen sigs, the ee field can be a normal
scalar as in the verify function the ee
field is checked against a reduced scalar mean for it to verify as
correct ee must be reduced

* change block major/ minor versions to u8

this matches Monero

I have also changed a couple varint functions to accept the `VarInt`
trait

* expose `serialize_hashable` on `Block`

* add back MLSAG verifying functions

I still need to revert the commit removing support for >1 input MLSAG FULL

This adds a new rct type to separate Full and simple rct

* add back support for multiple inputs for RCT FULL

* comment `non_adjacent_form` function

also added `#[allow(clippy::needless_range_loop)]` around a loop as without a re-write satisfying clippy without it will make the function worse.

* Improve Mlsag verifying API

* fix rebase errors

* revert the changes on `reserialize_chain`
plus other misc changes

* fix no-std

* Reduce the amount of rpc calls needed for `get_block_by_number`.
This function was causing me problems, every now and then a node would return a block with a different number than requested.

* change `serialize_hashable` to give the POW hashing blob.

Monero calculates the POW hash and the block hash using *slightly* different blobs :/

* make ring_signatures public and add length check when verifying.

* Misc improvements and bug fixes

---------

Co-authored-by: Luke Parker <lukeparker5132@gmail.com>
This commit is contained in:
Boog900 2023-11-12 15:18:18 +00:00 committed by GitHub
parent 54f1929078
commit 995734c960
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 537 additions and 159 deletions

View file

@ -129,7 +129,9 @@ mod binaries {
// Accordingly, making sure our signature_hash algorithm is correct is great, and further // Accordingly, making sure our signature_hash algorithm is correct is great, and further
// making sure the verification functions are valid is appreciated // making sure the verification functions are valid is appreciated
match tx.rct_signatures.prunable { match tx.rct_signatures.prunable {
RctPrunable::Null | RctPrunable::MlsagBorromean { .. } => {} RctPrunable::Null |
RctPrunable::AggregateMlsagBorromean { .. } |
RctPrunable::MlsagBorromean { .. } => {}
RctPrunable::MlsagBulletproofs { bulletproofs, .. } => { RctPrunable::MlsagBulletproofs { bulletproofs, .. } => {
assert!(bulletproofs.batch_verify( assert!(bulletproofs.batch_verify(
&mut rand_core::OsRng, &mut rand_core::OsRng,

View file

@ -17,8 +17,8 @@ const EXISTING_BLOCK_HASH_202612: [u8; 32] =
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct BlockHeader { pub struct BlockHeader {
pub major_version: u64, pub major_version: u8,
pub minor_version: u64, pub minor_version: u8,
pub timestamp: u64, pub timestamp: u64,
pub previous: [u8; 32], pub previous: [u8; 32],
pub nonce: u32, pub nonce: u32,
@ -68,7 +68,7 @@ impl Block {
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> { pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.header.write(w)?; self.header.write(w)?;
self.miner_tx.write(w)?; self.miner_tx.write(w)?;
write_varint(&self.txs.len().try_into().unwrap(), w)?; write_varint(&self.txs.len(), w)?;
for tx in &self.txs { for tx in &self.txs {
w.write_all(tx)?; w.write_all(tx)?;
} }
@ -79,20 +79,27 @@ impl Block {
merkle_root(self.miner_tx.hash(), &self.txs) merkle_root(self.miner_tx.hash(), &self.txs)
} }
fn serialize_hashable(&self) -> Vec<u8> { /// Serialize the block as required for the proof of work hash.
///
/// This is distinct from the serialization required for the block hash. To get the block hash,
/// use the [`Block::hash`] function.
pub fn serialize_hashable(&self) -> Vec<u8> {
let mut blob = self.header.serialize(); let mut blob = self.header.serialize();
blob.extend_from_slice(&self.tx_merkle_root()); blob.extend_from_slice(&self.tx_merkle_root());
write_varint(&(1 + u64::try_from(self.txs.len()).unwrap()), &mut blob).unwrap(); write_varint(&(1 + u64::try_from(self.txs.len()).unwrap()), &mut blob).unwrap();
let mut out = Vec::with_capacity(8 + blob.len()); blob
write_varint(&u64::try_from(blob.len()).unwrap(), &mut out).unwrap();
out.append(&mut blob);
out
} }
pub fn hash(&self) -> [u8; 32] { pub fn hash(&self) -> [u8; 32] {
let hash = hash(&self.serialize_hashable()); let mut hashable = self.serialize_hashable();
// Monero pre-appends a VarInt of the block hashing blobs length before getting the block hash
// but doesn't do this when getting the proof of work hash :)
let mut hashing_blob = Vec::with_capacity(8 + hashable.len());
write_varint(&u64::try_from(hashable.len()).unwrap(), &mut hashing_blob).unwrap();
hashing_blob.append(&mut hashable);
let hash = hash(&hashing_blob);
if hash == CORRECT_BLOCK_HASH_202612 { if hash == CORRECT_BLOCK_HASH_202612 {
return EXISTING_BLOCK_HASH_202612; return EXISTING_BLOCK_HASH_202612;
}; };
@ -110,7 +117,7 @@ impl Block {
Ok(Block { Ok(Block {
header: BlockHeader::read(r)?, header: BlockHeader::read(r)?,
miner_tx: Transaction::read(r)?, miner_tx: Transaction::read(r)?,
txs: (0 .. read_varint(r)?).map(|_| read_bytes(r)).collect::<Result<_, _>>()?, txs: (0_usize .. read_varint(r)?).map(|_| read_bytes(r)).collect::<Result<_, _>>()?,
}) })
} }
} }

View file

@ -23,6 +23,12 @@ mod merkle;
mod serialize; mod serialize;
use serialize::{read_byte, read_u16}; use serialize::{read_byte, read_u16};
/// UnreducedScalar struct with functionality for recovering incorrectly reduced scalars.
mod unreduced_scalar;
/// Ring Signature structs and functionality.
pub mod ring_signatures;
/// RingCT structs and functionality. /// RingCT structs and functionality.
pub mod ringct; pub mod ringct;
use ringct::RctType; use ringct::RctType;

View file

@ -0,0 +1,72 @@
use std_shims::{
io::{self, *},
vec::Vec,
};
use zeroize::Zeroize;
use curve25519_dalek::{EdwardsPoint, Scalar};
use monero_generators::hash_to_point;
use crate::{serialize::*, hash_to_scalar};
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
pub struct Signature {
c: Scalar,
r: Scalar,
}
impl Signature {
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
write_scalar(&self.c, w)?;
write_scalar(&self.r, w)?;
Ok(())
}
pub fn read<R: Read>(r: &mut R) -> io::Result<Signature> {
Ok(Signature { c: read_scalar(r)?, r: read_scalar(r)? })
}
}
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
pub struct RingSignature {
sigs: Vec<Signature>,
}
impl RingSignature {
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
for sig in &self.sigs {
sig.write(w)?;
}
Ok(())
}
pub fn read<R: Read>(members: usize, r: &mut R) -> io::Result<RingSignature> {
Ok(RingSignature { sigs: read_raw_vec(Signature::read, members, r)? })
}
pub fn verify(&self, msg: &[u8; 32], ring: &[EdwardsPoint], key_image: &EdwardsPoint) -> bool {
if ring.len() != self.sigs.len() {
return false;
}
let mut buf = Vec::with_capacity(32 + (32 * 2 * ring.len()));
buf.extend_from_slice(msg);
let mut sum = Scalar::ZERO;
for (ring_member, sig) in ring.iter().zip(&self.sigs) {
#[allow(non_snake_case)]
let Li = EdwardsPoint::vartime_double_scalar_mul_basepoint(&sig.c, ring_member, &sig.r);
buf.extend_from_slice(Li.compress().as_bytes());
#[allow(non_snake_case)]
let Ri = (sig.r * hash_to_point(ring_member.compress().to_bytes())) + (sig.c * key_image);
buf.extend_from_slice(Ri.compress().as_bytes());
sum += sig.c;
}
sum == hash_to_scalar(&buf)
}
}

View file

@ -1,73 +1,63 @@
use core::fmt::Debug; use core::fmt::Debug;
use std_shims::io::{self, Read, Write}; use std_shims::io::{self, Read, Write};
use curve25519_dalek::edwards::EdwardsPoint; use curve25519_dalek::{traits::Identity, Scalar, EdwardsPoint};
#[cfg(feature = "experimental")]
use curve25519_dalek::{traits::Identity, scalar::Scalar};
#[cfg(feature = "experimental")]
use monero_generators::H_pow_2; use monero_generators::H_pow_2;
#[cfg(feature = "experimental")]
use crate::hash_to_scalar; use crate::{hash_to_scalar, unreduced_scalar::UnreducedScalar, serialize::*};
use crate::serialize::*;
/// 64 Borromean ring signatures. /// 64 Borromean ring signatures.
/// ///
/// This type keeps the data as raw bytes as Monero has some transactions with unreduced scalars in /// s0 and s1 are stored as `UnreducedScalar`s due to Monero not requiring they were reduced.
/// this field. While we could use `from_bytes_mod_order`, we'd then not be able to encode this /// `UnreducedScalar` preserves their original byte encoding and implements a custom reduction
/// back into it's original form. /// algorithm which was in use.
///
/// Those scalars also have a custom reduction algorithm...
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct BorromeanSignatures { pub struct BorromeanSignatures {
pub s0: [[u8; 32]; 64], pub s0: [UnreducedScalar; 64],
pub s1: [[u8; 32]; 64], pub s1: [UnreducedScalar; 64],
pub ee: [u8; 32], pub ee: Scalar,
} }
impl BorromeanSignatures { impl BorromeanSignatures {
pub fn read<R: Read>(r: &mut R) -> io::Result<BorromeanSignatures> { pub fn read<R: Read>(r: &mut R) -> io::Result<BorromeanSignatures> {
Ok(BorromeanSignatures { Ok(BorromeanSignatures {
s0: read_array(read_bytes, r)?, s0: read_array(UnreducedScalar::read, r)?,
s1: read_array(read_bytes, r)?, s1: read_array(UnreducedScalar::read, r)?,
ee: read_bytes(r)?, ee: read_scalar(r)?,
}) })
} }
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> { pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
for s0 in &self.s0 { for s0 in &self.s0 {
w.write_all(s0)?; s0.write(w)?;
} }
for s1 in &self.s1 { for s1 in &self.s1 {
w.write_all(s1)?; s1.write(w)?;
} }
w.write_all(&self.ee) write_scalar(&self.ee, w)
} }
#[cfg(feature = "experimental")]
fn verify(&self, keys_a: &[EdwardsPoint], keys_b: &[EdwardsPoint]) -> bool { fn verify(&self, keys_a: &[EdwardsPoint], keys_b: &[EdwardsPoint]) -> bool {
let mut transcript = [0; 2048]; let mut transcript = [0; 2048];
for i in 0 .. 64 { for i in 0 .. 64 {
// TODO: These aren't the correct reduction
// TODO: Can either of these be tightened?
#[allow(non_snake_case)] #[allow(non_snake_case)]
let LL = EdwardsPoint::vartime_double_scalar_mul_basepoint( let LL = EdwardsPoint::vartime_double_scalar_mul_basepoint(
&Scalar::from_bytes_mod_order(self.ee), &self.ee,
&keys_a[i], &keys_a[i],
&Scalar::from_bytes_mod_order(self.s0[i]), &self.s0[i].recover_monero_slide_scalar(),
); );
#[allow(non_snake_case)] #[allow(non_snake_case)]
let LV = EdwardsPoint::vartime_double_scalar_mul_basepoint( let LV = EdwardsPoint::vartime_double_scalar_mul_basepoint(
&hash_to_scalar(LL.compress().as_bytes()), &hash_to_scalar(LL.compress().as_bytes()),
&keys_b[i], &keys_b[i],
&Scalar::from_bytes_mod_order(self.s1[i]), &self.s1[i].recover_monero_slide_scalar(),
); );
transcript[i .. ((i + 1) * 32)].copy_from_slice(LV.compress().as_bytes()); transcript[(i * 32) .. ((i + 1) * 32)].copy_from_slice(LV.compress().as_bytes());
} }
// TODO: This isn't the correct reduction hash_to_scalar(&transcript) == self.ee
// TODO: Can this be tightened to from_canonical_bytes?
hash_to_scalar(&transcript) == Scalar::from_bytes_mod_order(self.ee)
} }
} }
@ -90,7 +80,6 @@ impl BorromeanRange {
write_raw_vec(write_point, &self.bit_commitments, w) write_raw_vec(write_point, &self.bit_commitments, w)
} }
#[cfg(feature = "experimental")]
pub fn verify(&self, commitment: &EdwardsPoint) -> bool { pub fn verify(&self, commitment: &EdwardsPoint) -> bool {
if &self.bit_commitments.iter().sum::<EdwardsPoint>() != commitment { if &self.bit_commitments.iter().sum::<EdwardsPoint>() != commitment {
return false; return false;

View file

@ -180,7 +180,7 @@ fn core(
let c_c = mu_C * c; let c_c = mu_C * c;
let L = (&s[i] * ED25519_BASEPOINT_TABLE) + (c_p * P[i]) + (c_c * C[i]); let L = (&s[i] * ED25519_BASEPOINT_TABLE) + (c_p * P[i]) + (c_c * C[i]);
let PH = hash_to_point(P[i]); let PH = hash_to_point(&P[i]);
// Shouldn't be an issue as all of the variables in this vartime statement are public // Shouldn't be an issue as all of the variables in this vartime statement are public
let R = (s[i] * PH) + images_precomp.vartime_multiscalar_mul([c_p, c_c]); let R = (s[i] * PH) + images_precomp.vartime_multiscalar_mul([c_p, c_c]);
@ -219,7 +219,7 @@ impl Clsag {
let pseudo_out = Commitment::new(mask, input.commitment.amount).calculate(); let pseudo_out = Commitment::new(mask, input.commitment.amount).calculate();
let z = input.commitment.mask - mask; let z = input.commitment.mask - mask;
let H = hash_to_point(input.decoys.ring[r][0]); let H = hash_to_point(&input.decoys.ring[r][0]);
let D = H * z; let D = H * z;
let mut s = Vec::with_capacity(input.decoys.ring.len()); let mut s = Vec::with_capacity(input.decoys.ring.len());
for _ in 0 .. input.decoys.ring.len() { for _ in 0 .. input.decoys.ring.len() {
@ -259,7 +259,7 @@ impl Clsag {
&msg, &msg,
nonce.deref() * ED25519_BASEPOINT_TABLE, nonce.deref() * ED25519_BASEPOINT_TABLE,
nonce.deref() * nonce.deref() *
hash_to_point(inputs[i].2.decoys.ring[usize::from(inputs[i].2.decoys.i)][0]), hash_to_point(&inputs[i].2.decoys.ring[usize::from(inputs[i].2.decoys.i)][0]),
); );
clsag.s[usize::from(inputs[i].2.decoys.i)] = clsag.s[usize::from(inputs[i].2.decoys.i)] =
(-((p * inputs[i].0.deref()) + c)) + nonce.deref(); (-((p * inputs[i].0.deref()) + c)) + nonce.deref();

View file

@ -116,7 +116,7 @@ impl ClsagMultisig {
ClsagMultisig { ClsagMultisig {
transcript, transcript,
H: hash_to_point(output_key), H: hash_to_point(&output_key),
image: EdwardsPoint::identity(), image: EdwardsPoint::identity(),
details, details,

View file

@ -3,6 +3,6 @@ use curve25519_dalek::edwards::EdwardsPoint;
pub use monero_generators::{hash_to_point as raw_hash_to_point}; pub use monero_generators::{hash_to_point as raw_hash_to_point};
/// Monero's hash to point function, as named `ge_fromfe_frombytes_vartime`. /// Monero's hash to point function, as named `ge_fromfe_frombytes_vartime`.
pub fn hash_to_point(key: EdwardsPoint) -> EdwardsPoint { pub fn hash_to_point(key: &EdwardsPoint) -> EdwardsPoint {
raw_hash_to_point(key.compress().to_bytes()) raw_hash_to_point(key.compress().to_bytes())
} }

View file

@ -3,17 +3,82 @@ use std_shims::{
io::{self, Read, Write}, io::{self, Read, Write},
}; };
use curve25519_dalek::scalar::Scalar; use zeroize::Zeroize;
#[cfg(feature = "experimental")]
use curve25519_dalek::edwards::EdwardsPoint;
use crate::serialize::*; use curve25519_dalek::{traits::IsIdentity, Scalar, EdwardsPoint};
#[cfg(feature = "experimental")]
use crate::{hash_to_scalar, ringct::hash_to_point};
#[derive(Clone, PartialEq, Eq, Debug)] use monero_generators::H;
use crate::{hash_to_scalar, ringct::hash_to_point, serialize::*};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg_attr(feature = "std", derive(thiserror::Error))]
pub enum MlsagError {
#[cfg_attr(feature = "std", error("invalid ring"))]
InvalidRing,
#[cfg_attr(feature = "std", error("invalid amount of key images"))]
InvalidAmountOfKeyImages,
#[cfg_attr(feature = "std", error("invalid ss"))]
InvalidSs,
#[cfg_attr(feature = "std", error("key image was identity"))]
IdentityKeyImage,
#[cfg_attr(feature = "std", error("invalid ci"))]
InvalidCi,
}
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
pub struct RingMatrix {
matrix: Vec<Vec<EdwardsPoint>>,
}
impl RingMatrix {
pub fn new(matrix: Vec<Vec<EdwardsPoint>>) -> Result<Self, MlsagError> {
if matrix.is_empty() {
Err(MlsagError::InvalidRing)?;
}
for member in &matrix {
if member.is_empty() || (member.len() != matrix[0].len()) {
Err(MlsagError::InvalidRing)?;
}
}
Ok(RingMatrix { matrix })
}
/// Construct a ring matrix for an individual output.
pub fn individual(
ring: &[[EdwardsPoint; 2]],
pseudo_out: EdwardsPoint,
) -> Result<Self, MlsagError> {
let mut matrix = Vec::with_capacity(ring.len());
for ring_member in ring {
matrix.push(vec![ring_member[0], ring_member[1] - pseudo_out]);
}
RingMatrix::new(matrix)
}
pub fn iter(&self) -> impl Iterator<Item = &[EdwardsPoint]> {
self.matrix.iter().map(AsRef::as_ref)
}
/// Return the amount of members in the ring.
pub fn members(&self) -> usize {
self.matrix.len()
}
/// Returns the length of a ring member.
///
/// A ring member is a vector of points for which the signer knows all of the discrete logarithms
/// of.
pub fn member_len(&self) -> usize {
// this is safe to do as the constructors don't allow empty rings
self.matrix[0].len()
}
}
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
pub struct Mlsag { pub struct Mlsag {
pub ss: Vec<[Scalar; 2]>, pub ss: Vec<Vec<Scalar>>,
pub cc: Scalar, pub cc: Scalar,
} }
@ -25,47 +90,124 @@ impl Mlsag {
write_scalar(&self.cc, w) write_scalar(&self.cc, w)
} }
pub fn read<R: Read>(mixins: usize, r: &mut R) -> io::Result<Mlsag> { pub fn read<R: Read>(mixins: usize, ss_2_elements: usize, r: &mut R) -> io::Result<Mlsag> {
Ok(Mlsag { Ok(Mlsag {
ss: (0 .. mixins).map(|_| read_array(read_scalar, r)).collect::<Result<_, _>>()?, ss: (0 .. mixins)
.map(|_| read_raw_vec(read_scalar, ss_2_elements, r))
.collect::<Result<_, _>>()?,
cc: read_scalar(r)?, cc: read_scalar(r)?,
}) })
} }
#[cfg(feature = "experimental")]
pub fn verify( pub fn verify(
&self, &self,
msg: &[u8; 32], msg: &[u8; 32],
ring: &[[EdwardsPoint; 2]], ring: &RingMatrix,
key_image: &EdwardsPoint, key_images: &[EdwardsPoint],
) -> bool { ) -> Result<(), MlsagError> {
if ring.is_empty() { // Mlsag allows for layers to not need linkability, hence they don't need key images
return false; // Monero requires that there is always only 1 non-linkable layer - the amount commitments.
if ring.member_len() != (key_images.len() + 1) {
Err(MlsagError::InvalidAmountOfKeyImages)?;
} }
let mut buf = Vec::with_capacity(6 * 32); let mut buf = Vec::with_capacity(6 * 32);
let mut ci = self.cc;
for (i, ring_member) in ring.iter().enumerate() {
buf.extend_from_slice(msg); buf.extend_from_slice(msg);
#[allow(non_snake_case)] let mut ci = self.cc;
let L =
|r| EdwardsPoint::vartime_double_scalar_mul_basepoint(&ci, &ring_member[r], &self.ss[i][r]);
buf.extend_from_slice(ring_member[0].compress().as_bytes()); // This is an iterator over the key images as options with an added entry of `None` at the
buf.extend_from_slice(L(0).compress().as_bytes()); // end for the non-linkable layer
let key_images_iter = key_images.iter().map(|ki| Some(*ki)).chain(core::iter::once(None));
if ring.matrix.len() != self.ss.len() {
Err(MlsagError::InvalidSs)?;
}
for (ring_member, ss) in ring.iter().zip(&self.ss) {
if ring_member.len() != ss.len() {
Err(MlsagError::InvalidSs)?;
}
for ((ring_member_entry, s), ki) in ring_member.iter().zip(ss).zip(key_images_iter.clone()) {
#[allow(non_snake_case)]
let L = EdwardsPoint::vartime_double_scalar_mul_basepoint(&ci, ring_member_entry, s);
buf.extend_from_slice(ring_member_entry.compress().as_bytes());
buf.extend_from_slice(L.compress().as_bytes());
// Not all dimensions need to be linkable, e.g. commitments, and only linkable layers need
// to have key images.
if let Some(ki) = ki {
if ki.is_identity() {
Err(MlsagError::IdentityKeyImage)?;
}
#[allow(non_snake_case)] #[allow(non_snake_case)]
let R = (self.ss[i][0] * hash_to_point(ring_member[0])) + (ci * key_image); let R = (s * hash_to_point(ring_member_entry)) + (ci * ki);
buf.extend_from_slice(R.compress().as_bytes()); buf.extend_from_slice(R.compress().as_bytes());
}
buf.extend_from_slice(ring_member[1].compress().as_bytes()); }
buf.extend_from_slice(L(1).compress().as_bytes());
ci = hash_to_scalar(&buf); ci = hash_to_scalar(&buf);
buf.clear(); // keep the msg in the buffer.
buf.drain(msg.len() ..);
} }
ci == self.cc if ci != self.cc {
Err(MlsagError::InvalidCi)?
}
Ok(())
}
}
/// An aggregate ring matrix builder, usable to set up the ring matrix to prove/verify an aggregate
/// MLSAG signature.
#[derive(Clone, PartialEq, Eq, Debug, Zeroize)]
pub struct AggregateRingMatrixBuilder {
key_ring: Vec<Vec<EdwardsPoint>>,
amounts_ring: Vec<EdwardsPoint>,
sum_out: EdwardsPoint,
}
impl AggregateRingMatrixBuilder {
/// Create a new AggregateRingMatrixBuilder.
///
/// Takes in the transaction's outputs; commitments and fee.
pub fn new(commitments: &[EdwardsPoint], fee: u64) -> Self {
AggregateRingMatrixBuilder {
key_ring: vec![],
amounts_ring: vec![],
sum_out: commitments.iter().sum::<EdwardsPoint>() + (H() * Scalar::from(fee)),
}
}
/// Push a ring of [output key, commitment] to the matrix.
pub fn push_ring(&mut self, ring: &[[EdwardsPoint; 2]]) -> Result<(), MlsagError> {
if self.key_ring.is_empty() {
self.key_ring = vec![vec![]; ring.len()];
// Now that we know the length of the ring, fill the `amounts_ring`.
self.amounts_ring = vec![-self.sum_out; ring.len()];
}
if (self.amounts_ring.len() != ring.len()) || ring.is_empty() {
// All the rings in an aggregate matrix must be the same length.
return Err(MlsagError::InvalidRing);
}
for (i, ring_member) in ring.iter().enumerate() {
self.key_ring[i].push(ring_member[0]);
self.amounts_ring[i] += ring_member[1]
}
Ok(())
}
/// Build and return the [`RingMatrix`]
pub fn build(mut self) -> Result<RingMatrix, MlsagError> {
for (i, amount_commitment) in self.amounts_ring.drain(..).enumerate() {
self.key_ring[i].push(amount_commitment);
}
RingMatrix::new(self.key_ring)
} }
} }

View file

@ -28,7 +28,7 @@ use crate::{
/// Generate a key image for a given key. Defined as `x * hash_to_point(xG)`. /// Generate a key image for a given key. Defined as `x * hash_to_point(xG)`.
pub fn generate_key_image(secret: &Zeroizing<Scalar>) -> EdwardsPoint { pub fn generate_key_image(secret: &Zeroizing<Scalar>) -> EdwardsPoint {
hash_to_point(ED25519_BASEPOINT_TABLE * secret.deref()) * secret.deref() hash_to_point(&(ED25519_BASEPOINT_TABLE * secret.deref())) * secret.deref()
} }
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
@ -61,7 +61,7 @@ impl EncryptedAmount {
pub enum RctType { pub enum RctType {
/// No RCT proofs. /// No RCT proofs.
Null, Null,
/// One MLSAG for a single input and a Borromean range proof (RCTTypeFull). /// One MLSAG for multiple inputs and Borromean range proofs (RCTTypeFull).
MlsagAggregate, MlsagAggregate,
// One MLSAG for each input and a Borromean range proof (RCTTypeSimple). // One MLSAG for each input and a Borromean range proof (RCTTypeSimple).
MlsagIndividual, MlsagIndividual,
@ -194,6 +194,10 @@ impl RctBase {
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub enum RctPrunable { pub enum RctPrunable {
Null, Null,
AggregateMlsagBorromean {
borromean: Vec<BorromeanRange>,
mlsag: Mlsag,
},
MlsagBorromean { MlsagBorromean {
borromean: Vec<BorromeanRange>, borromean: Vec<BorromeanRange>,
mlsags: Vec<Mlsag>, mlsags: Vec<Mlsag>,
@ -220,6 +224,10 @@ impl RctPrunable {
pub fn write<W: Write>(&self, w: &mut W, rct_type: RctType) -> io::Result<()> { pub fn write<W: Write>(&self, w: &mut W, rct_type: RctType) -> io::Result<()> {
match self { match self {
RctPrunable::Null => Ok(()), RctPrunable::Null => Ok(()),
RctPrunable::AggregateMlsagBorromean { borromean, mlsag } => {
write_raw_vec(BorromeanRange::write, borromean, w)?;
mlsag.write(w)
}
RctPrunable::MlsagBorromean { borromean, mlsags } => { RctPrunable::MlsagBorromean { borromean, mlsags } => {
write_raw_vec(BorromeanRange::write, borromean, w)?; write_raw_vec(BorromeanRange::write, borromean, w)?;
write_raw_vec(Mlsag::write, mlsags, w) write_raw_vec(Mlsag::write, mlsags, w)
@ -270,9 +278,13 @@ impl RctPrunable {
Ok(match rct_type { Ok(match rct_type {
RctType::Null => RctPrunable::Null, RctType::Null => RctPrunable::Null,
RctType::MlsagAggregate | RctType::MlsagIndividual => RctPrunable::MlsagBorromean { RctType::MlsagAggregate => RctPrunable::AggregateMlsagBorromean {
borromean: read_raw_vec(BorromeanRange::read, outputs, r)?, borromean: read_raw_vec(BorromeanRange::read, outputs, r)?,
mlsags: decoys.iter().map(|d| Mlsag::read(*d, r)).collect::<Result<_, _>>()?, mlsag: Mlsag::read(decoys[0], decoys.len() + 1, r)?,
},
RctType::MlsagIndividual => RctPrunable::MlsagBorromean {
borromean: read_raw_vec(BorromeanRange::read, outputs, r)?,
mlsags: decoys.iter().map(|d| Mlsag::read(*d, 2, r)).collect::<Result<_, _>>()?,
}, },
RctType::Bulletproofs | RctType::BulletproofsCompactAmount => { RctType::Bulletproofs | RctType::BulletproofsCompactAmount => {
RctPrunable::MlsagBulletproofs { RctPrunable::MlsagBulletproofs {
@ -287,13 +299,13 @@ impl RctPrunable {
} }
Bulletproofs::read(r)? Bulletproofs::read(r)?
}, },
mlsags: decoys.iter().map(|d| Mlsag::read(*d, r)).collect::<Result<_, _>>()?, mlsags: decoys.iter().map(|d| Mlsag::read(*d, 2, r)).collect::<Result<_, _>>()?,
pseudo_outs: read_raw_vec(read_point, decoys.len(), r)?, pseudo_outs: read_raw_vec(read_point, decoys.len(), r)?,
} }
} }
RctType::Clsag | RctType::BulletproofsPlus => RctPrunable::Clsag { RctType::Clsag | RctType::BulletproofsPlus => RctPrunable::Clsag {
bulletproofs: { bulletproofs: {
if read_varint(r)? != 1 { if read_varint::<_, u64>(r)? != 1 {
Err(io::Error::new(io::ErrorKind::Other, "n bulletproofs instead of one"))?; Err(io::Error::new(io::ErrorKind::Other, "n bulletproofs instead of one"))?;
} }
(if rct_type == RctType::Clsag { Bulletproofs::read } else { Bulletproofs::read_plus })( (if rct_type == RctType::Clsag { Bulletproofs::read } else { Bulletproofs::read_plus })(
@ -309,6 +321,7 @@ impl RctPrunable {
pub(crate) fn signature_write<W: Write>(&self, w: &mut W) -> io::Result<()> { pub(crate) fn signature_write<W: Write>(&self, w: &mut W) -> io::Result<()> {
match self { match self {
RctPrunable::Null => panic!("Serializing RctPrunable::Null for a signature"), RctPrunable::Null => panic!("Serializing RctPrunable::Null for a signature"),
RctPrunable::AggregateMlsagBorromean { borromean, .. } |
RctPrunable::MlsagBorromean { borromean, .. } => { RctPrunable::MlsagBorromean { borromean, .. } => {
borromean.iter().try_for_each(|rs| rs.write(w)) borromean.iter().try_for_each(|rs| rs.write(w))
} }
@ -329,30 +342,8 @@ impl RctSignatures {
pub fn rct_type(&self) -> RctType { pub fn rct_type(&self) -> RctType {
match &self.prunable { match &self.prunable {
RctPrunable::Null => RctType::Null, RctPrunable::Null => RctType::Null,
RctPrunable::MlsagBorromean { .. } => { RctPrunable::AggregateMlsagBorromean { .. } => RctType::MlsagAggregate,
/* RctPrunable::MlsagBorromean { .. } => RctType::MlsagIndividual,
This type of RctPrunable may have no outputs, yet pseudo_outs are per input
This will only be a valid RctSignatures if it's for a TX with inputs
That makes this valid for any valid RctSignatures
While it will be invalid for any invalid RctSignatures, potentially letting an invalid
MlsagAggregate be interpreted as a valid MlsagIndividual (or vice versa), they have
incompatible deserializations
This means it's impossible to receive a MlsagAggregate over the wire and interpret it
as a MlsagIndividual (or vice versa)
That only makes manual manipulation unsafe, which will always be true since these fields
are all pub
TODO: Consider making them private with read-only accessors?
*/
if self.base.pseudo_outs.is_empty() {
RctType::MlsagAggregate
} else {
RctType::MlsagIndividual
}
}
// RctBase ensures there's at least one output, making the following // RctBase ensures there's at least one output, making the following
// inferences guaranteed/expects impossible on any valid RctSignatures // inferences guaranteed/expects impossible on any valid RctSignatures
RctPrunable::MlsagBulletproofs { .. } => { RctPrunable::MlsagBulletproofs { .. } => {

View file

@ -305,8 +305,17 @@ impl<R: RpcConnection> Rpc<R> {
} }
pub async fn get_block_by_number(&self, number: usize) -> Result<Block, RpcError> { pub async fn get_block_by_number(&self, number: usize) -> Result<Block, RpcError> {
match self.get_block(self.get_block_hash(number).await?).await { #[derive(Deserialize, Debug)]
Ok(block) => { struct BlockResponse {
blob: String,
}
let res: BlockResponse =
self.json_rpc_call("get_block", Some(json!({ "height": number }))).await?;
let block = Block::read::<&[u8]>(&mut rpc_hex(&res.blob)?.as_ref())
.map_err(|_| RpcError::InvalidNode("invalid block"))?;
// Make sure this is actually the block for this number // Make sure this is actually the block for this number
match block.miner_tx.prefix.inputs.first() { match block.miner_tx.prefix.inputs.first() {
Some(Input::Gen(actual)) => { Some(Input::Gen(actual)) => {
@ -316,12 +325,7 @@ impl<R: RpcConnection> Rpc<R> {
Err(RpcError::InvalidNode("different block than requested (number)")) Err(RpcError::InvalidNode("different block than requested (number)"))
} }
} }
_ => { _ => Err(RpcError::InvalidNode("block's miner_tx didn't have an input of kind Input::Gen")),
Err(RpcError::InvalidNode("block's miner_tx didn't have an input of kind Input::Gen"))
}
}
}
e => e,
} }
} }

View file

@ -12,7 +12,7 @@ use curve25519_dalek::{
const VARINT_CONTINUATION_MASK: u8 = 0b1000_0000; const VARINT_CONTINUATION_MASK: u8 = 0b1000_0000;
mod sealed { mod sealed {
pub trait VarInt: TryInto<u64> {} pub trait VarInt: TryInto<u64> + TryFrom<u64> + Copy {}
impl VarInt for u8 {} impl VarInt for u8 {}
impl VarInt for u32 {} impl VarInt for u32 {}
impl VarInt for u64 {} impl VarInt for u64 {}
@ -29,8 +29,9 @@ pub(crate) fn write_byte<W: Write>(byte: &u8, w: &mut W) -> io::Result<()> {
w.write_all(&[*byte]) w.write_all(&[*byte])
} }
pub(crate) fn write_varint<W: Write>(varint: &u64, w: &mut W) -> io::Result<()> { // This will panic if the VarInt exceeds u64::MAX
let mut varint = *varint; pub(crate) fn write_varint<W: Write, U: sealed::VarInt>(varint: &U, w: &mut W) -> io::Result<()> {
let mut varint: u64 = (*varint).try_into().map_err(|_| "varint exceeded u64").unwrap();
while { while {
let mut b = u8::try_from(varint & u64::from(!VARINT_CONTINUATION_MASK)).unwrap(); let mut b = u8::try_from(varint & u64::from(!VARINT_CONTINUATION_MASK)).unwrap();
varint >>= 7; varint >>= 7;
@ -67,7 +68,7 @@ pub(crate) fn write_vec<T, W: Write, F: Fn(&T, &mut W) -> io::Result<()>>(
values: &[T], values: &[T],
w: &mut W, w: &mut W,
) -> io::Result<()> { ) -> io::Result<()> {
write_varint(&values.len().try_into().unwrap(), w)?; write_varint(&values.len(), w)?;
write_raw_vec(f, values, w) write_raw_vec(f, values, w)
} }
@ -93,7 +94,7 @@ pub(crate) fn read_u64<R: Read>(r: &mut R) -> io::Result<u64> {
read_bytes(r).map(u64::from_le_bytes) read_bytes(r).map(u64::from_le_bytes)
} }
pub(crate) fn read_varint<R: Read>(r: &mut R) -> io::Result<u64> { pub(crate) fn read_varint<R: Read, U: sealed::VarInt>(r: &mut R) -> io::Result<U> {
let mut bits = 0; let mut bits = 0;
let mut res = 0; let mut res = 0;
while { while {
@ -109,7 +110,9 @@ pub(crate) fn read_varint<R: Read>(r: &mut R) -> io::Result<u64> {
bits += 7; bits += 7;
b & VARINT_CONTINUATION_MASK == VARINT_CONTINUATION_MASK b & VARINT_CONTINUATION_MASK == VARINT_CONTINUATION_MASK
} {} } {}
Ok(res) res
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "VarInt does not fit into integer type"))
} }
// All scalar fields supported by monero-serai are checked to be canonical for valid transactions // All scalar fields supported by monero-serai are checked to be canonical for valid transactions
@ -162,5 +165,5 @@ pub(crate) fn read_vec<R: Read, T, F: Fn(&mut R) -> io::Result<T>>(
f: F, f: F,
r: &mut R, r: &mut R,
) -> io::Result<Vec<T>> { ) -> io::Result<Vec<T>> {
read_raw_vec(f, read_varint(r)?.try_into().unwrap(), r) read_raw_vec(f, read_varint(r)?, r)
} }

View file

@ -1,3 +1,4 @@
mod unreduced_scalar;
mod clsag; mod clsag;
mod bulletproofs; mod bulletproofs;
mod address; mod address;

View file

@ -0,0 +1,32 @@
use curve25519_dalek::scalar::Scalar;
use crate::unreduced_scalar::*;
#[test]
fn recover_scalars() {
let test_recover = |stored: &str, recovered: &str| {
let stored = UnreducedScalar(hex::decode(stored).unwrap().try_into().unwrap());
let recovered =
Scalar::from_canonical_bytes(hex::decode(recovered).unwrap().try_into().unwrap()).unwrap();
assert_eq!(stored.recover_monero_slide_scalar(), recovered);
};
// https://www.moneroinflation.com/static/data_py/report_scalars_df.pdf
// Table 4.
test_recover(
"cb2be144948166d0a9edb831ea586da0c376efa217871505ad77f6ff80f203f8",
"b8ffd6a1aee47828808ab0d4c8524cb5c376efa217871505ad77f6ff80f20308",
);
test_recover(
"343d3df8a1051c15a400649c423dc4ed58bef49c50caef6ca4a618b80dee22f4",
"21113355bc682e6d7a9d5b3f2137a30259bef49c50caef6ca4a618b80dee2204",
);
test_recover(
"c14f75d612800ca2c1dcfa387a42c9cc086c005bc94b18d204dd61342418eba7",
"4f473804b1d27ab2c789c80ab21d034a096c005bc94b18d204dd61342418eb07",
);
test_recover(
"000102030405060708090a0b0c0d0e0f826c4f6e2329a31bc5bc320af0b2bcbb",
"a124cfd387f461bf3719e03965ee6877826c4f6e2329a31bc5bc320af0b2bc0b",
);
}

View file

@ -6,14 +6,12 @@ use std_shims::{
use zeroize::Zeroize; use zeroize::Zeroize;
use curve25519_dalek::{ use curve25519_dalek::edwards::{EdwardsPoint, CompressedEdwardsY};
scalar::Scalar,
edwards::{EdwardsPoint, CompressedEdwardsY},
};
use crate::{ use crate::{
Protocol, hash, Protocol, hash,
serialize::*, serialize::*,
ring_signatures::RingSignature,
ringct::{bulletproofs::Bulletproofs, RctType, RctBase, RctPrunable, RctSignatures}, ringct::{bulletproofs::Bulletproofs, RctType, RctBase, RctPrunable, RctSignatures},
}; };
@ -208,7 +206,7 @@ impl TransactionPrefix {
self.timelock.write(w)?; self.timelock.write(w)?;
write_vec(Input::write, &self.inputs, w)?; write_vec(Input::write, &self.inputs, w)?;
write_vec(Output::write, &self.outputs, w)?; write_vec(Output::write, &self.outputs, w)?;
write_varint(&self.extra.len().try_into().unwrap(), w)?; write_varint(&self.extra.len(), w)?;
w.write_all(&self.extra) w.write_all(&self.extra)
} }
@ -253,7 +251,7 @@ impl TransactionPrefix {
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Transaction { pub struct Transaction {
pub prefix: TransactionPrefix, pub prefix: TransactionPrefix,
pub signatures: Vec<Vec<(Scalar, Scalar)>>, pub signatures: Vec<RingSignature>,
pub rct_signatures: RctSignatures, pub rct_signatures: RctSignatures,
} }
@ -272,11 +270,8 @@ impl Transaction {
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> { pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
self.prefix.write(w)?; self.prefix.write(w)?;
if self.prefix.version == 1 { if self.prefix.version == 1 {
for sigs in &self.signatures { for ring_sig in &self.signatures {
for sig in sigs { ring_sig.write(w)?;
write_scalar(&sig.0, w)?;
write_scalar(&sig.1, w)?;
}
} }
Ok(()) Ok(())
} else if self.prefix.version == 2 { } else if self.prefix.version == 2 {
@ -305,12 +300,7 @@ impl Transaction {
.inputs .inputs
.iter() .iter()
.filter_map(|input| match input { .filter_map(|input| match input {
Input::ToKey { key_offsets, .. } => Some( Input::ToKey { key_offsets, .. } => Some(RingSignature::read(key_offsets.len(), r)),
key_offsets
.iter()
.map(|_| Ok((read_scalar(r)?, read_scalar(r)?)))
.collect::<Result<_, io::Error>>(),
),
_ => None, _ => None,
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
@ -397,6 +387,10 @@ impl Transaction {
/// Calculate the hash of this transaction as needed for signing it. /// Calculate the hash of this transaction as needed for signing it.
pub fn signature_hash(&self) -> [u8; 32] { pub fn signature_hash(&self) -> [u8; 32] {
if self.prefix.version == 1 {
return self.prefix.hash();
}
let mut buf = Vec::with_capacity(2048); let mut buf = Vec::with_capacity(2048);
let mut sig_hash = Vec::with_capacity(96); let mut sig_hash = Vec::with_capacity(96);

View file

@ -0,0 +1,137 @@
use core::cmp::Ordering;
use std_shims::{
sync::OnceLock,
io::{self, *},
};
use curve25519_dalek::scalar::Scalar;
use crate::serialize::*;
static PRECOMPUTED_SCALARS_CELL: OnceLock<[Scalar; 8]> = OnceLock::new();
/// Precomputed scalars used to recover an incorrectly reduced scalar.
#[allow(non_snake_case)]
pub(crate) fn PRECOMPUTED_SCALARS() -> [Scalar; 8] {
*PRECOMPUTED_SCALARS_CELL.get_or_init(|| {
let mut precomputed_scalars = [Scalar::ONE; 8];
for (i, scalar) in precomputed_scalars.iter_mut().enumerate().skip(1) {
*scalar = Scalar::from(((i * 2) + 1) as u8);
}
precomputed_scalars
})
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct UnreducedScalar(pub [u8; 32]);
impl UnreducedScalar {
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
w.write_all(&self.0)
}
pub fn read<R: Read>(r: &mut R) -> io::Result<UnreducedScalar> {
Ok(UnreducedScalar(read_bytes(r)?))
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
fn as_bits(&self) -> [u8; 256] {
let mut bits = [0; 256];
for (i, bit) in bits.iter_mut().enumerate() {
*bit = core::hint::black_box(1 & (self.0[i / 8] >> (i % 8)))
}
bits
}
/// Computes the non-adjacent form of this scalar with width 5.
///
/// This matches Monero's `slide` function and intentionally gives incorrect outputs under
/// certain conditions in order to match Monero.
///
/// This function does not execute in constant time.
fn non_adjacent_form(&self) -> [i8; 256] {
let bits = self.as_bits();
let mut naf = [0i8; 256];
for (b, bit) in bits.into_iter().enumerate() {
naf[b] = bit as i8;
}
for i in 0 .. 256 {
if naf[i] != 0 {
// if the bit is a one, work our way up through the window
// combining the bits with this bit.
for b in 1 .. 6 {
if (i + b) >= 256 {
// if we are at the length of the array then break out
// the loop.
break;
}
// potential_carry - the value of the bit at i+b compared to the bit at i
let potential_carry = naf[i + b] << b;
if potential_carry != 0 {
if (naf[i] + potential_carry) <= 15 {
// if our current "bit" plus the potential carry is less than 16
// add it to our current "bit" and set the potential carry bit to 0.
naf[i] += potential_carry;
naf[i + b] = 0;
} else if (naf[i] - potential_carry) >= -15 {
// else if our current "bit" minus the potential carry is more than -16
// take it away from our current "bit".
// we then work our way up through the bits setting ones to zero, when
// we hit the first zero we change it to one then stop, this is to factor
// in the minus.
naf[i] -= potential_carry;
#[allow(clippy::needless_range_loop)]
for k in (i + b) .. 256 {
if naf[k] == 0 {
naf[k] = 1;
break;
}
naf[k] = 0;
}
} else {
break;
}
}
}
}
}
naf
}
/// Recover the scalar that an array of bytes was incorrectly interpreted as by Monero's `slide`
/// function.
///
/// In Borromean range proofs Monero was not checking that the scalars used were
/// reduced. This lead to the scalar stored being interpreted as a different scalar,
/// this function recovers that scalar.
///
/// See: https://github.com/monero-project/monero/issues/8438
pub fn recover_monero_slide_scalar(&self) -> Scalar {
if self.0[31] & 128 == 0 {
// Computing the w-NAF of a number can only give an output with 1 more bit than
// the number, so even if the number isn't reduced, the `slide` function will be
// correct when the last bit isn't set.
return Scalar::from_bytes_mod_order(self.0);
}
let precomputed_scalars = PRECOMPUTED_SCALARS();
let mut recovered = Scalar::ZERO;
for &numb in self.non_adjacent_form().iter().rev() {
recovered += recovered;
match numb.cmp(&0) {
Ordering::Greater => recovered += precomputed_scalars[(numb as usize) / 2],
Ordering::Less => recovered -= precomputed_scalars[((-numb) as usize) / 2],
Ordering::Equal => (),
}
}
recovered
}
}

View file

@ -110,11 +110,7 @@ impl ExtraField {
} }
nonce nonce
}), }),
3 => ExtraField::MergeMining( 3 => ExtraField::MergeMining(read_varint(r)?, read_bytes(r)?),
usize::try_from(read_varint(r)?)
.map_err(|_| io::Error::new(io::ErrorKind::Other, "varint for height exceeds usize"))?,
read_bytes(r)?,
),
4 => ExtraField::PublicKeys(read_vec(read_point, r)?), 4 => ExtraField::PublicKeys(read_vec(read_point, r)?),
_ => Err(io::Error::new(io::ErrorKind::Other, "unknown extra field"))?, _ => Err(io::Error::new(io::ErrorKind::Other, "unknown extra field"))?,
}) })

View file

@ -74,7 +74,7 @@ pub(crate) fn shared_key(
.copy_from_slice(&hash(&[output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]); .copy_from_slice(&hash(&[output_derivation.as_ref(), [0x8d].as_ref()].concat())[.. 8]);
// || o // || o
write_varint(&o.try_into().unwrap(), &mut output_derivation).unwrap(); write_varint(&o, &mut output_derivation).unwrap();
let view_tag = hash(&[b"view_tag".as_ref(), &output_derivation].concat())[0]; let view_tag = hash(&[b"view_tag".as_ref(), &output_derivation].concat())[0];

View file

@ -406,7 +406,9 @@ impl SignatureMachine<Transaction> for TransactionSignatureMachine {
pseudo_outs.push(pseudo_out); pseudo_outs.push(pseudo_out);
} }
} }
RctPrunable::MlsagBorromean { .. } | RctPrunable::MlsagBulletproofs { .. } => { RctPrunable::AggregateMlsagBorromean { .. } |
RctPrunable::MlsagBorromean { .. } |
RctPrunable::MlsagBulletproofs { .. } => {
unreachable!("attempted to sign a multisig TX which wasn't CLSAG") unreachable!("attempted to sign a multisig TX which wasn't CLSAG")
} }
} }