diff --git a/arm/Cargo.toml b/arm/Cargo.toml index 3fd5467e..9248ea9a 100644 --- a/arm/Cargo.toml +++ b/arm/Cargo.toml @@ -31,6 +31,20 @@ hex-literal = "0.4" lazy_static = { version = "1.5.0", optional = true } bytemuck = { version = "1.12", features = ["derive"] } thiserror = "2.0.6" +borsh = { version = "1.5", default-features = false, features = [ + "derive", +], optional = true } +sha2 = { version = "0.10", optional = true } +solana-program = { version = "2.1", optional = true } +solana-secp256k1 = { version = "0.1", optional = true } +dashu = { version = "0.4", optional = true } + +[dev-dependencies] +k256 = { version = "=0.13.3", features = [ + "arithmetic", + "ecdsa", + "std", +], default-features = false } [features] default = ["transaction", "prove", "k256"] @@ -43,3 +57,12 @@ bonsai = ["zkvm", "risc0-zkvm/bonsai"] cuda = ["zkvm", "risc0-zkvm/cuda"] aggregation = ["aggregation_circuit", "transaction", "zkvm", "k256"] aggregation_circuit = [] +borsh = ["dep:borsh"] +solana = [ + "transaction", + "borsh", + "dep:sha2", + "dep:solana-program", + "dep:solana-secp256k1", + "dep:dashu", +] diff --git a/arm/src/action.rs b/arm/src/action.rs index 6fced995..d4a80615 100644 --- a/arm/src/action.rs +++ b/arm/src/action.rs @@ -10,6 +10,10 @@ use crate::{action_tree::MerkleTree, logic_proof::LogicVerifier, Digest}; /// An action consists of compliance units and logic verifier inputs. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct Action { /// The compliance units in this action. pub compliance_units: Vec, diff --git a/arm/src/action_tree.rs b/arm/src/action_tree.rs index c6617262..317f2a65 100644 --- a/arm/src/action_tree.rs +++ b/arm/src/action_tree.rs @@ -87,7 +87,7 @@ impl MerkleTree { ) { if cur_layer.len() > 1 { let sibling = { - let is_sibling_left = !position.is_multiple_of(2); + let is_sibling_left = position % 2 != 0; let sibling_value = if is_sibling_left { cur_layer[position - 1] } else { diff --git a/arm/src/compliance.rs b/arm/src/compliance.rs index 0b70b6a2..e101425b 100644 --- a/arm/src/compliance.rs +++ b/arm/src/compliance.rs @@ -4,12 +4,11 @@ const COMPLIANCE_INSTANCE_SIZE: usize = 56; use crate::error::ArmError; -use crate::utils::bytes_to_words; use crate::{constants::EMPTY_HASH_BYTES, Digest}; use serde_with::serde_as; -#[cfg(feature = "zkvm")] -use crate::utils::words_to_bytes; +#[cfg(all(feature = "zkvm", feature = "k256"))] +use crate::utils::bytes_to_words; #[cfg(all(feature = "zkvm", feature = "k256"))] use crate::{merkle_path::MerklePath, nullifier_key::NullifierKey, resource::Resource}; #[cfg(feature = "k256")] @@ -29,6 +28,10 @@ pub fn initial_root() -> Digest { /// The compliance instance contains all public inputs to the compliance proof. #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct ComplianceInstance { /// The nullifier of the consumed resource. pub consumed_nullifier: Digest, @@ -50,6 +53,10 @@ pub struct ComplianceInstance { /// serialization(used in the aggregation circuit). #[serde_as] #[derive(serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct ComplianceInstanceWords { /// The compliance instance as an array of u32 words. #[serde_as(as = "[_; COMPLIANCE_INSTANCE_SIZE]")] @@ -86,18 +93,28 @@ impl ComplianceInstance { impl ComplianceInstance { /// Serializes this instance to journal bytes (risc0 serde format). pub fn to_journal(&self) -> Result, ArmError> { + use crate::utils::words_to_bytes; let words = risc0_zkvm::serde::to_vec(self).map_err(|_| ArmError::InstanceSerializationFailed)?; Ok(words_to_bytes(&words).to_vec()) } } +#[cfg(all(feature = "solana", not(feature = "zkvm")))] +impl ComplianceInstance { + /// Serializes this instance to journal bytes (borsh format for Solana). + pub fn to_journal(&self) -> Result, ArmError> { + borsh::to_vec(self).map_err(|_| ArmError::InstanceSerializationFailed) + } +} + impl ComplianceInstanceWords { /// Creates a ComplianceInstanceWords from a byte slice. pub fn from_bytes(instance_bytes: &[u8]) -> Result { - let u32_words: [u32; COMPLIANCE_INSTANCE_SIZE] = bytes_to_words(instance_bytes) - .try_into() - .map_err(|_| ArmError::InstanceSerializationFailed)?; + let u32_words: [u32; COMPLIANCE_INSTANCE_SIZE] = + crate::utils::bytes_to_words(instance_bytes) + .try_into() + .map_err(|_| ArmError::InstanceSerializationFailed)?; Ok(ComplianceInstanceWords { u32_words }) } } @@ -160,7 +177,10 @@ impl ComplianceWitness { ephemeral_root: initial_root(), } } +} +#[cfg(all(feature = "zkvm", feature = "k256"))] +impl ComplianceWitness { /// Compliance constraints pub fn constrain(&self) -> Result { let consumed_cm = self.consumed_commitment(); diff --git a/arm/src/compliance_unit.rs b/arm/src/compliance_unit.rs index 706c8e77..8313991e 100644 --- a/arm/src/compliance_unit.rs +++ b/arm/src/compliance_unit.rs @@ -18,6 +18,10 @@ use crate::{ /// A compliance unit consists of a compliance proof and its corresponding instance. /// The vk is a constant in the compliance unit, so we don't place it here. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct ComplianceUnit { /// The compliance proof (optional, would be absent when aggregation is enabled). pub proof: Option>, diff --git a/arm/src/constants.rs b/arm/src/constants.rs index 01641159..c7968607 100644 --- a/arm/src/constants.rs +++ b/arm/src/constants.rs @@ -2,18 +2,59 @@ use hex_literal::hex; -/// Compliance verification key bytes. +/// Compliance verification key bytes (default circuit, risc0 serde). +#[cfg(not(feature = "solana"))] pub const COMPLIANCE_VK_BYTES: [u8; 32] = hex!("919e13001cd3319be5a5a7cb189203be083674acb3fff23d05aae9c3ed86314d"); -/// Padding logic verification key bytes. +/// Compliance verification key bytes (Borsh-serializing circuit). +#[cfg(feature = "solana")] +pub const COMPLIANCE_VK_BYTES: [u8; 32] = + hex!("600a952bc393092b6f47daa8259eb541133aaa5ebe90bce837b25e00eb53969d"); + +/// Padding logic verification key bytes (default circuit, risc0 serde). +#[cfg(not(feature = "solana"))] pub const PADDING_LOGIC_VK_BYTES: [u8; 32] = hex!("21fcc2fc2c07f9753405d3070f2488c67389f7d797b6f6e20a9f2529fe4a0bff"); +/// Padding logic verification key bytes (Borsh-serializing circuit). +#[cfg(feature = "solana")] +pub const PADDING_LOGIC_VK_BYTES: [u8; 32] = + hex!("9f28d8464937c5cad995819b37c89dbba1035c711fe810ab7a8872874cd776fc"); + +/// Batch aggregation verification key bytes (default circuit, risc0 serde). +#[cfg(not(feature = "solana"))] +pub const BATCH_AGGREGATION_VK_BYTES: [u8; 32] = + hex!("5ca0cbd4d5c267f42e0883b1ae7a28689d792230d9c4c61ca4f5df56aaf5fede"); + +/// Batch aggregation verification key bytes (Borsh-serializing circuit). +#[cfg(feature = "solana")] +pub const BATCH_AGGREGATION_VK_BYTES: [u8; 32] = + hex!("584aa83eda1588b1cda9d94ac1b51e504f6ce6b940e662a76073c1df43cf5a05"); + /// Hash of the empty string (used for PADDING_LEAF and INITIAL_ROOT). pub const EMPTY_HASH_BYTES: [u8; 32] = hex!("cc1d2f838445db7aec431df9ee8a871f40e7aa5e064fc056633ef8c60fab7b06"); +/// EMPTY_HASH as little-endian u32 words (for constructing `Digest` in const context). +pub const EMPTY_HASH_WORDS: [u32; 8] = bytes_to_words_const(EMPTY_HASH_BYTES); + +/// Convert a 32-byte array to 8 little-endian u32 words at compile time. +pub const fn bytes_to_words_const(bytes: [u8; 32]) -> [u32; 8] { + let mut words = [0u32; 8]; + let mut i = 0; + while i < 8 { + words[i] = u32::from_le_bytes([ + bytes[i * 4], + bytes[i * 4 + 1], + bytes[i * 4 + 2], + bytes[i * 4 + 3], + ]); + i += 1; + } + words +} + /// Compliance proving key / compliance guest ELF binary. #[cfg(feature = "zkvm")] pub const COMPLIANCE_PK: &[u8] = include_bytes!("../elfs/compliance-guest.bin"); @@ -44,5 +85,5 @@ lazy_static! { lazy_static! { /// Batch aggregation verification key / Batch aggregation image id. pub static ref BATCH_AGGREGATION_VK: crate::Digest = - crate::Digest::try_from(hex!("5ca0cbd4d5c267f42e0883b1ae7a28689d792230d9c4c61ca4f5df56aaf5fede").as_slice()).unwrap(); + crate::Digest::try_from(BATCH_AGGREGATION_VK_BYTES.as_slice()).unwrap(); } diff --git a/arm/src/delta_proof.rs b/arm/src/delta_proof.rs index 465e3126..d9f57bb9 100644 --- a/arm/src/delta_proof.rs +++ b/arm/src/delta_proof.rs @@ -8,7 +8,12 @@ use k256::{ use serde::{Deserialize, Serialize}; use crate::error::ArmError; -use sha3::{Digest, Keccak256}; + +#[cfg(not(feature = "solana"))] +use sha3::{Digest, Keccak256 as MessageHasher}; + +#[cfg(feature = "solana")] +use sha2::{Digest, Sha256 as MessageHasher}; /// The delta proof consists of an ECDSA signature and a recovery ID. #[derive(Clone, Debug, PartialEq, Eq)] @@ -36,8 +41,7 @@ pub struct DeltaInstance { impl DeltaProof { /// Generates a delta proof by signing the given message with the provided witness. pub fn prove(message: &[u8], witness: &DeltaWitness) -> Result { - // Hash the message using Keccak256 - let mut digest = Keccak256::new(); + let mut digest = MessageHasher::new(); digest.update(message); // Sign the hashed message using RFC6979 @@ -72,8 +76,7 @@ impl DeltaProof { return Err(ArmError::InvalidDeltaProof); } - // Hash the message using Keccak256 - let mut digest = Keccak256::new(); + let mut digest = MessageHasher::new(); digest.update(message); // Verify the signature @@ -209,25 +212,98 @@ impl<'de> Deserialize<'de> for DeltaWitness { where D: serde::Deserializer<'de>, { - let bytes = <[u8; 32]>::deserialize(deserializer)?; + let bytes: Vec = Vec::deserialize(deserializer)?; + if bytes.len() != 32 { + return Err(serde::de::Error::custom( + "Invalid byte length for DeltaWitness", + )); + } DeltaWitness::from_bytes(&bytes).map_err(|e| { serde::de::Error::custom(format!("Failed to deserialize DeltaWitness: {:?}", e)) }) } } -#[test] -fn test_delta_proof() { +#[cfg(feature = "borsh")] +mod borsh_impl { + use super::{DeltaProof, DeltaWitness}; + use borsh::io::{Error, ErrorKind, Read, Result, Write}; + use borsh::{BorshDeserialize, BorshSerialize}; + + impl BorshSerialize for DeltaProof { + fn serialize(&self, writer: &mut W) -> Result<()> { + let bytes = self.to_bytes(); + writer.write_all(&bytes) + } + } + + impl BorshDeserialize for DeltaProof { + fn deserialize_reader(reader: &mut R) -> Result { + let mut bytes = [0u8; 65]; + reader.read_exact(&mut bytes)?; + DeltaProof::from_bytes(&bytes) + .map_err(|e| Error::new(ErrorKind::InvalidData, format!("{:?}", e))) + } + } + + impl BorshSerialize for DeltaWitness { + fn serialize(&self, writer: &mut W) -> Result<()> { + let bytes = self.to_bytes(); + writer.write_all(&bytes) + } + } + + impl BorshDeserialize for DeltaWitness { + fn deserialize_reader(reader: &mut R) -> Result { + let mut bytes = [0u8; 32]; + reader.read_exact(&mut bytes)?; + DeltaWitness::from_bytes(&bytes) + .map_err(|e| Error::new(ErrorKind::InvalidData, format!("{:?}", e))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; use k256::elliptic_curve::rand_core::OsRng; - let mut rng = OsRng; - let signing_key = SigningKey::random(&mut rng); - let verifying_key = VerifyingKey::from(&signing_key); + #[test] + fn test_delta_proof() { + let mut rng = OsRng; + let signing_key = SigningKey::random(&mut rng); + let verifying_key = VerifyingKey::from(&signing_key); - let message = b"Hello, world!"; - let witness = DeltaWitness { signing_key }; - let proof = DeltaProof::prove(message, &witness).unwrap(); - let instance = DeltaInstance { verifying_key }; + let message = b"Hello, world!"; + let witness = DeltaWitness { signing_key }; + let proof = DeltaProof::prove(message, &witness).unwrap(); + let instance = DeltaInstance { verifying_key }; - DeltaProof::verify(message, &proof, instance).unwrap(); + DeltaProof::verify(message, &proof, instance).unwrap(); + } + + /// DeltaProof: serialize then deserialize via bincode must round-trip. + #[test] + fn delta_proof_bincode_roundtrip() { + let mut rng = OsRng; + let signing_key = SigningKey::random(&mut rng); + let witness = DeltaWitness { signing_key }; + let proof = DeltaProof::prove(b"roundtrip", &witness).unwrap(); + + let encoded = bincode::serialize(&proof).unwrap(); + let decoded: DeltaProof = bincode::deserialize(&encoded).unwrap(); + assert_eq!(proof, decoded); + } + + /// DeltaWitness: serialize then deserialize via bincode must round-trip. + #[test] + fn delta_witness_bincode_roundtrip() { + let mut rng = OsRng; + let signing_key = SigningKey::random(&mut rng); + let witness = DeltaWitness { signing_key }; + + let encoded = bincode::serialize(&witness).unwrap(); + let decoded: DeltaWitness = bincode::deserialize(&encoded).unwrap(); + assert_eq!(witness, decoded); + } } diff --git a/arm/src/delta_types.rs b/arm/src/delta_types.rs index 698e8afb..ce0c2b93 100644 --- a/arm/src/delta_types.rs +++ b/arm/src/delta_types.rs @@ -13,12 +13,37 @@ mod placeholder { /// Opaque 65-byte delta proof (signature + recovery ID). #[derive(Clone, Debug, PartialEq, Eq)] + #[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) + )] pub struct DeltaProof(pub [u8; 65]); /// Opaque 32-byte delta witness (signing key). #[derive(Clone, Debug, PartialEq, Eq)] + #[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) + )] pub struct DeltaWitness(pub [u8; 32]); + impl DeltaProof { + /// Returns the length of the proof bytes. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns true if the proof is empty (always false for valid proofs). + pub fn is_empty(&self) -> bool { + false + } + + /// Returns the proof bytes as a slice. + pub fn as_slice(&self) -> &[u8] { + &self.0 + } + } + impl DeltaWitness { /// Panics - composition requires k256. pub fn compose(&self, _other: &DeltaWitness) -> DeltaWitness { @@ -49,7 +74,10 @@ mod placeholder { impl<'de> Deserialize<'de> for DeltaWitness { fn deserialize>(d: D) -> Result { - <[u8; 32]>::deserialize(d).map(DeltaWitness) + let bytes: Vec = Vec::deserialize(d)?; + bytes.try_into().map(DeltaWitness).map_err(|v: Vec| { + serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len())) + }) } } } diff --git a/arm/src/digest.rs b/arm/src/digest.rs index a95e537d..1b9f429a 100644 --- a/arm/src/digest.rs +++ b/arm/src/digest.rs @@ -12,7 +12,11 @@ pub const DIGEST_WORDS: usize = 8; /// This type is wire-compatible with `risc0_zkvm::sha::Digest`: /// both store `[u32; 8]` and expose bytes via `bytemuck::cast_slice`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Digest([u32; DIGEST_WORDS]); +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] +pub struct Digest(pub [u32; DIGEST_WORDS]); impl Digest { /// Creates a new Digest from u32 words. @@ -20,6 +24,33 @@ impl Digest { Digest(words) } + /// Create a Digest from 32 bytes. + pub fn from_bytes(bytes: [u8; DIGEST_BYTES]) -> Self { + let mut words = [0u32; DIGEST_WORDS]; + for i in 0..DIGEST_WORDS { + words[i] = u32::from_le_bytes([ + bytes[4 * i], + bytes[4 * i + 1], + bytes[4 * i + 2], + bytes[4 * i + 3], + ]); + } + Digest(words) + } + + /// Convert to 32 bytes. + pub fn to_bytes(&self) -> [u8; DIGEST_BYTES] { + let mut bytes = [0u8; DIGEST_BYTES]; + for i in 0..DIGEST_WORDS { + let wb = self.0[i].to_le_bytes(); + bytes[4 * i] = wb[0]; + bytes[4 * i + 1] = wb[1]; + bytes[4 * i + 2] = wb[2]; + bytes[4 * i + 3] = wb[3]; + } + bytes + } + /// Returns the digest as a slice of u32 words. pub fn as_words(&self) -> &[u32; DIGEST_WORDS] { &self.0 @@ -44,7 +75,9 @@ impl TryFrom<&[u8]> for Digest { if bytes.len() != DIGEST_BYTES { return Err("Invalid byte length for Digest"); } - Ok(Digest(*bytemuck::from_bytes(bytes))) + let mut arr = [0u8; DIGEST_BYTES]; + arr.copy_from_slice(bytes); + Ok(Digest::from_bytes(arr)) } } @@ -56,7 +89,9 @@ impl hex::FromHex for Digest { if bytes.len() != DIGEST_BYTES { return Err(hex::FromHexError::InvalidStringLength); } - Ok(Digest(*bytemuck::from_bytes(&bytes))) + let mut arr = [0u8; DIGEST_BYTES]; + arr.copy_from_slice(&bytes); + Ok(Digest::from_bytes(arr)) } } diff --git a/arm/src/error.rs b/arm/src/error.rs index e0a90154..18749730 100644 --- a/arm/src/error.rs +++ b/arm/src/error.rs @@ -38,6 +38,8 @@ pub enum ArmError { VerifyingKeyMismatch, #[error("Tag not found")] TagNotFound, + #[error("Logic instance does not match action context")] + LogicInstanceMismatch, #[error("Delta proof verification failed")] DeltaProofVerificationFailed, #[error("Expected delta proof, but found witness")] @@ -78,4 +80,8 @@ pub enum ArmError { TreeTooLarge, #[error("Invalid delta proof: pls regenerate the proof")] InvalidDeltaProof, + #[error("Delta point not on curve")] + DeltaPointNotOnCurve, + #[error("Delta mismatch")] + DeltaMismatch, } diff --git a/arm/src/lib.rs b/arm/src/lib.rs index 6f677df1..a5b3ed0c 100644 --- a/arm/src/lib.rs +++ b/arm/src/lib.rs @@ -2,6 +2,12 @@ #![deny(missing_docs)] +#[cfg(all(feature = "solana", feature = "zkvm"))] +compile_error!("Invalid feature set: `solana` and `zkvm` are mutually exclusive."); + +#[cfg(all(feature = "solana", feature = "k256"))] +compile_error!("Invalid feature set: `solana` and `k256` are mutually exclusive."); + #[cfg(not(feature = "zkvm"))] mod digest; #[cfg(not(feature = "zkvm"))] @@ -37,6 +43,8 @@ pub mod proving_system; pub mod resource; #[cfg(all(feature = "zkvm", feature = "k256"))] pub mod resource_logic; +#[cfg(all(feature = "solana", not(feature = "zkvm"), not(feature = "k256")))] +pub mod solana_delta; #[cfg(feature = "transaction")] pub mod transaction; pub mod utils; diff --git a/arm/src/logic_instance.rs b/arm/src/logic_instance.rs index 51f561d3..3af46db3 100644 --- a/arm/src/logic_instance.rs +++ b/arm/src/logic_instance.rs @@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize}; /// Represents a logic instance with its associated data. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct LogicInstance { /// The logic instance's tag (either commitment or nullifier) pub tag: Digest, @@ -18,6 +22,10 @@ pub struct LogicInstance { /// Application data contains four different types of payloads. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct AppData { /// The resource payload blobs. pub resource_payload: Vec, @@ -31,6 +39,10 @@ pub struct AppData { /// An expirable blob consists of a blob and a deletion criterion. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct ExpirableBlob { /// The blob data as a vector of u32 words. pub blob: Vec, @@ -70,7 +82,8 @@ impl AppData { } } -#[cfg(feature = "zkvm")] +/// For EVM/default context (zkvm without borsh): risc0 serde format. +#[cfg(all(feature = "zkvm", not(feature = "borsh")))] impl LogicInstance { /// Serializes this instance to journal bytes (risc0 serde format). pub fn to_journal(&self) -> Result, crate::error::ArmError> { @@ -80,8 +93,29 @@ impl LogicInstance { } } +/// For Solana/Borsh context: Borsh format, padded to 4-byte alignment. +#[cfg(feature = "borsh")] +impl LogicInstance { + /// Serializes this instance to journal bytes (Borsh format). + /// + /// Output is zero-padded to 4-byte alignment because risc0's `env::verify` + /// operates on `&[u32]` journals. The `bytes_to_words` / `words_to_bytes` + /// round-trip must be lossless for receipt claim digests to match. + pub fn to_journal(&self) -> Result, crate::error::ArmError> { + let mut bytes = + borsh::to_vec(self).map_err(|_| crate::error::ArmError::InstanceSerializationFailed)?; + let padding = (4 - bytes.len() % 4) % 4; + bytes.resize(bytes.len() + padding, 0); + Ok(bytes) + } +} + /// Inputs required to create a logic verifier. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct LogicVerifierInputs { /// The tag (either commitment or nullifier) for the logic instance. pub tag: Digest, @@ -91,6 +125,9 @@ pub struct LogicVerifierInputs { pub app_data: AppData, /// The logic proof (optional, would be absent when aggregation is enabled). pub proof: Option>, + /// Pre-serialized LogicInstance journal bytes from proving. + /// Preserved so on-chain code can use them directly without recomputing. + pub instance_journal: Vec, } impl LogicVerifierInputs { diff --git a/arm/src/logic_proof.rs b/arm/src/logic_proof.rs index ee732a74..7de29bbd 100644 --- a/arm/src/logic_proof.rs +++ b/arm/src/logic_proof.rs @@ -86,15 +86,43 @@ impl LogicVerifierInputs { is_consumed: bool, root: Digest, ) -> Result { - let instance = self.to_instance(is_consumed, root); + let expected_instance = self.to_instance(is_consumed, root); + let provided_instance = decode_instance_journal(&self.instance_journal)?; + if provided_instance != expected_instance { + return Err(ArmError::LogicInstanceMismatch); + } + Ok(LogicVerifier { proof: self.proof, - instance: instance.to_journal()?, + instance: self.instance_journal, verifying_key: self.verifying_key, }) } } +fn decode_instance_journal(journal: &[u8]) -> Result { + #[cfg(feature = "borsh")] + { + if let Ok(instance) = decode_borsh_instance(journal) { + return Ok(instance); + } + } + + journal_to_instance(journal) +} + +#[cfg(feature = "borsh")] +fn decode_borsh_instance(journal: &[u8]) -> Result { + let mut input = journal; + let instance = ::deserialize(&mut input) + .map_err(|_| ArmError::JournalDecodingError)?; + if input.iter().all(|byte| *byte == 0) { + Ok(instance) + } else { + Err(ArmError::JournalDecodingError) + } +} + impl TryFrom for LogicVerifierInputs { type Error = ArmError; @@ -105,6 +133,7 @@ impl TryFrom for LogicVerifierInputs { verifying_key: logic_proof.verifying_key, app_data: instance.app_data, proof: logic_proof.proof, + instance_journal: logic_proof.instance, }) } } diff --git a/arm/src/solana_delta.rs b/arm/src/solana_delta.rs new file mode 100644 index 00000000..25fdea49 --- /dev/null +++ b/arm/src/solana_delta.rs @@ -0,0 +1,267 @@ +//! Delta proof verification for Solana on-chain programs. +//! +//! Uses Solana syscalls (hashv, secp256k1_recover) and solana-secp256k1 for +//! EC point arithmetic instead of k256, which exceeds SBF stack frame limits. +//! +//! Algorithm: +//! 1. Collect tags (nullifiers and commitments) in compliance unit order +//! 2. Compute verifying key = SHA-256(tags) using Solana syscall +//! 3. Accumulate delta points via secp256k1 EC point addition +//! 4. Verify ECDSA signature using Solana secp256k1_recover syscall + +use crate::error::ArmError; +use crate::transaction::{Delta, Transaction}; + +use dashu::integer::UBig; +use solana_program::hash::hashv; +use solana_program::secp256k1_recover::secp256k1_recover; +use solana_secp256k1::{Curve, Secp256k1Point, UncompressedPoint}; + +/// Collect tags (nullifiers and commitments) in compliance unit order. +/// Returns tags as 32-byte arrays in the order: [nf0, cm0, nf1, cm1, ...] +pub fn collect_tags(tx: &Transaction) -> Vec<[u8; 32]> { + let mut tags = Vec::new(); + for action in &tx.actions { + for cu in &action.compliance_units { + tags.push(cu.instance.consumed_nullifier.to_bytes()); + tags.push(cu.instance.created_commitment.to_bytes()); + } + } + tags +} + +/// Compute verifying key = SHA-256(concatenated tags) using Solana syscall. +pub fn compute_verifying_key(tags: &[[u8; 32]]) -> [u8; 32] { + let refs: Vec<&[u8]> = tags.iter().map(|t| t.as_slice()).collect(); + hashv(&refs).to_bytes() +} + +/// Convert [u32; 8] words to [u8; 32] bytes (bytemuck cast, matching arm-risc0 convention). +fn words_to_bytes(words: &[u32; 8]) -> [u8; 32] { + *bytemuck::cast_ref(words) +} + +/// Parse delta coordinates from a compliance instance and return as an UncompressedPoint. +/// +/// All compliance units must provide valid secp256k1 curve points. The point (0, 0) +/// is NOT on the curve and will error - the identity point (point at infinity) has +/// no valid affine representation. +fn parse_delta_point( + x_words: &[u32; 8], + y_words: &[u32; 8], +) -> Result { + let x_bytes = words_to_bytes(x_words); + let y_bytes = words_to_bytes(y_words); + + // solana-secp256k1 does not validate that points lie on the curve. + // We must check the curve equation manually: y^2 = x^3 + 7 (mod p) + let p = UBig::from_be_bytes(&Curve::P); + let x = UBig::from_be_bytes(&x_bytes); + let y = UBig::from_be_bytes(&y_bytes); + + let y_squared = y.sqr() % &p; + let x_cubed_plus_7 = (x.cubic() + UBig::from_word(7)) % &p; + + if y_squared != x_cubed_plus_7 { + return Err(ArmError::DeltaPointNotOnCurve); + } + + // Point is valid - construct UncompressedPoint + let mut point_bytes = [0u8; 64]; + point_bytes[..32].copy_from_slice(&x_bytes); + point_bytes[32..].copy_from_slice(&y_bytes); + Ok(UncompressedPoint(point_bytes)) +} + +/// Accumulate delta points from all compliance instances using EC point addition. +/// Returns the accumulated point as an UncompressedPoint, or None if the result is the identity. +pub fn accumulate_deltas(tx: &Transaction) -> Result, ArmError> { + // UncompressedPoint has no identity representation, so we track it with Option. + let mut accumulated: Option = None; + + for action in &tx.actions { + for cu in &action.compliance_units { + let point = parse_delta_point(&cu.instance.delta_x, &cu.instance.delta_y)?; + + accumulated = match accumulated { + None => Some(point), + Some(acc) => { + // solana-secp256k1's Add does not handle point doubling (P + P) + // or inverse points (P + (-P)). We detect and handle these cases. + let x_acc = acc.x(); + let x_pt = point.x(); + + if x_acc == x_pt { + // Same x-coordinate: either doubling or inverse + if acc.y() == point.y() { + // Point doubling: P + P - use scalar multiplication by 2 + #[rustfmt::skip] + let two: [u8; 32] = [ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2 + ]; + Some( + Curve::ecmul(&acc, &two) + .map_err(|_| ArmError::DeltaPointNotOnCurve)?, + ) + } else { + // Inverse points: P + (-P) = identity + None + } + } else { + // Standard case - library's Add is safe here + Some(acc + point) + } + } + }; + } + } + + Ok(accumulated) +} + +/// Convert an uncompressed point to its "address" representation using Solana syscall. +/// address = last 20 bytes of SHA-256(x || y) +fn point_to_address(point: &UncompressedPoint) -> [u8; 20] { + let hash: [u8; 32] = hashv(&[&point.0]).to_bytes(); + let mut address = [0u8; 20]; + address.copy_from_slice(&hash[12..32]); + address +} + +/// Verify the delta proof using Solana syscalls for optimal CU usage. +/// +/// The delta proof is an ECDSA signature that proves the transaction is balanced. +/// The signer's public key must correspond to the accumulated delta point. +pub fn verify_delta_proof(tx: &Transaction) -> Result<(), ArmError> { + // 1. Check delta_proof type FIRST to avoid expensive operations in Witness mode + let signature_bytes = match &tx.delta_proof { + Delta::Proof(proof) => { + if proof.len() != 65 { + return Err(ArmError::InvalidDeltaProof); + } + proof.as_slice() + } + Delta::Witness(_) => { + return Err(ArmError::ExpectedDeltaProof); + } + }; + + // 2. Collect tags + let tags = collect_tags(tx); + + // Empty transaction - no delta verification needed + if tags.is_empty() { + return Ok(()); + } + + // 3. Compute verifying key (message hash) using Solana syscall + let verifying_key = compute_verifying_key(&tags); + + // 4. Accumulate delta points + let accumulated = accumulate_deltas(tx)?; + + // 5. Parse signature (64 bytes) and recovery ID (1 byte) + let sig_bytes: [u8; 64] = signature_bytes[0..64] + .try_into() + .map_err(|_| ArmError::InvalidDeltaProof)?; + + // DeltaProof::to_bytes() stores recid as recid + 27 (Ethereum convention). + // Solana's secp256k1_recover expects raw recid (0 or 1). + let recid = signature_bytes[64] + .checked_sub(27) + .ok_or(ArmError::InvalidDeltaProof)?; + + // 6. Recover the public key using Solana syscall (much cheaper than software recovery) + let recovered_pubkey = secp256k1_recover(&verifying_key, recid, &sig_bytes) + .map_err(|_| ArmError::DeltaProofVerificationFailed)?; + + // 7. Convert recovered key to address using Solana syscall + // secp256k1_recover returns 64 bytes (x || y), no 0x04 prefix + let recovered_hash: [u8; 32] = hashv(&[&recovered_pubkey.0]).to_bytes(); + let recovered_address: [u8; 20] = recovered_hash[12..32].try_into().unwrap(); + + // 8. Compare with expected address from accumulated delta + let expected_address = match accumulated { + Some(point) => point_to_address(&point), + None => { + return Err(ArmError::DeltaProofVerificationFailed); + } + }; + + if recovered_address != expected_address { + return Err(ArmError::DeltaMismatch); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::action::Action; + use crate::compliance::ComplianceInstance; + use crate::compliance_unit::ComplianceUnit; + use crate::delta_types::DeltaProof; + use crate::Digest; + use k256::ecdsa::SigningKey; + use k256::elliptic_curve::rand_core::OsRng; + + /// Build a Transaction with a single compliance unit whose delta point + /// matches `signing_key`'s public key, signed by that key. + fn make_signed_transaction(signing_key: &SigningKey) -> Transaction { + let vk = k256::ecdsa::VerifyingKey::from(signing_key); + let encoded = vk.to_encoded_point(false); + let x_bytes: [u8; 32] = encoded.x().unwrap().as_slice().try_into().unwrap(); + let y_bytes: [u8; 32] = encoded.y().unwrap().as_slice().try_into().unwrap(); + + let delta_x: [u32; 8] = bytemuck::cast(x_bytes); + let delta_y: [u32; 8] = bytemuck::cast(y_bytes); + + let instance = ComplianceInstance { + consumed_nullifier: Digest::default(), + consumed_logic_ref: Digest::default(), + consumed_commitment_tree_root: Digest::default(), + created_commitment: Digest::default(), + created_logic_ref: Digest::default(), + delta_x, + delta_y, + }; + + let cu = ComplianceUnit { + proof: None, + instance, + }; + + let action = Action { + compliance_units: vec![cu], + logic_verifier_inputs: vec![], + }; + + // Compute the message hash (same path as verify_delta_proof) + let nf_bytes = Digest::default().to_bytes(); + let cm_bytes = Digest::default().to_bytes(); + let msg_hash = compute_verifying_key(&[nf_bytes, cm_bytes]); + + // Sign the message hash with k256 + let (sig, recid) = signing_key.sign_prehash_recoverable(&msg_hash).unwrap(); + + let mut proof_bytes = [0u8; 65]; + proof_bytes[..64].copy_from_slice(&sig.to_bytes()); + proof_bytes[64] = recid.to_byte() + 27; + + Transaction { + actions: vec![action], + delta_proof: Delta::Proof(DeltaProof(proof_bytes)), + expected_balance: None, + aggregation_proof: None, + } + } + + #[test] + fn test_delta_proof() { + let signing_key = SigningKey::random(&mut OsRng); + let tx = make_signed_transaction(&signing_key); + verify_delta_proof(&tx).unwrap(); + } +} diff --git a/arm/src/transaction.rs b/arm/src/transaction.rs index 21142408..b123c8f7 100644 --- a/arm/src/transaction.rs +++ b/arm/src/transaction.rs @@ -22,6 +22,10 @@ use risc0_zkvm::{default_prover, ExecutorEnv, ProverOpts, Receipt, VerifierConte /// Represents a transaction consisting of actions, delta proof, expected balance, /// and optional aggregation proof. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub struct Transaction { /// The actions included in the transaction. pub actions: Vec, @@ -39,6 +43,10 @@ pub struct Transaction { /// full cryptographic operations. Without `k256`, they are opaque byte containers /// that maintain wire compatibility. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr( + feature = "borsh", + derive(borsh::BorshSerialize, borsh::BorshDeserialize) +)] pub enum Delta { /// The delta witness used for proving the delta proof. Witness(DeltaWitness), diff --git a/arm_circuits/trivial_logic/methods/guest/Cargo.toml b/arm_circuits/trivial_logic/methods/guest/Cargo.toml index f4d02d0b..cc64a7de 100644 --- a/arm_circuits/trivial_logic/methods/guest/Cargo.toml +++ b/arm_circuits/trivial_logic/methods/guest/Cargo.toml @@ -8,10 +8,18 @@ edition = "2021" [dependencies] # If you want to try (experimental) std support, add `features = [ "std" ]` to risc0-zkvm risc0-zkvm = { version = "3.0.3", features = ["std", "unstable"] } -anoma-rm-risc0 = { path = "../../../../arm", default-features = false, features = [ +anoma-rm-risc0 = { path = "../../../../arm", features = [ "zkvm", "k256", -] } +], default-features = false } +borsh = { version = "1.5", default-features = false, features = [ + "derive", +], optional = true } +bincode = { version = "1.3", optional = true } + +[features] +borsh = ["dep:borsh", "anoma-rm-risc0/borsh"] +bin = ["dep:bincode"] [patch.crates-io] # Placing this patch statement in the workspace Cargo.toml will add RISC Zero SHA-256 accelerator diff --git a/arm_tests/arm_test_app/src/lib.rs b/arm_tests/arm_test_app/src/lib.rs index 4492d2af..8e62a40f 100644 --- a/arm_tests/arm_test_app/src/lib.rs +++ b/arm_tests/arm_test_app/src/lib.rs @@ -271,9 +271,12 @@ fn test_cannot_aggregate_invalid_proofs() { // Create a transaction with one invalid proof. let bad_lproof = LogicVerifierInputs { proof: tx.actions[0].logic_verifier_inputs[0].clone().proof, - verifying_key: Digest::from_bytes([66u8; 32]), //vec![666u32; 8], // Bad key. + verifying_key: Digest::from_bytes([66u8; 32]), tag: tx.actions[0].logic_verifier_inputs[0].tag, app_data: tx.actions[0].logic_verifier_inputs[0].app_data.clone(), + instance_journal: tx.actions[0].logic_verifier_inputs[0] + .instance_journal + .clone(), }; let bad_action = Action {