use alloy_primitives::{Address, B256, ChainId, U256, keccak256};
use alloy_sol_types::SolValue;
use blueprint_crypto::k256::{K256Signature, K256SigningKey, K256VerifyingKey};
#[derive(Debug, thiserror::Error)]
pub enum JobQuoteError {
#[error("Signing error: {0}")]
Signing(String),
}
type Result<T> = core::result::Result<T, JobQuoteError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Confidentiality {
Any = 0,
Required = 1,
Preferred = 2,
}
impl From<Confidentiality> for u8 {
fn from(c: Confidentiality) -> u8 {
c as u8
}
}
impl TryFrom<u8> for Confidentiality {
type Error = JobQuoteError;
fn try_from(value: u8) -> Result<Self> {
match value {
0 => Ok(Confidentiality::Any),
1 => Ok(Confidentiality::Required),
2 => Ok(Confidentiality::Preferred),
_ => Err(JobQuoteError::Signing(format!(
"invalid confidentiality level: {value} (expected 0, 1, or 2)"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobQuoteDetails {
pub service_id: u64,
pub job_index: u8,
pub price: U256,
pub timestamp: u64,
pub expiry: u64,
pub confidentiality: u8,
}
impl JobQuoteDetails {
pub fn confidentiality_level(&self) -> Option<Confidentiality> {
Confidentiality::try_from(self.confidentiality).ok()
}
}
#[derive(Debug, Clone)]
pub struct SignedJobQuote {
pub details: JobQuoteDetails,
pub signature: K256Signature,
pub recovery_id: u8,
pub operator: Address,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuoteSigningDomain {
pub chain_id: ChainId,
pub verifying_contract: Address,
}
pub struct JobQuoteSigner {
keypair: K256SigningKey,
operator: Address,
domain: QuoteSigningDomain,
}
impl JobQuoteSigner {
pub fn new(keypair: K256SigningKey, domain: QuoteSigningDomain) -> Result<Self> {
let operator = keypair.alloy_address().map_err(|e| {
JobQuoteError::Signing(format!("Failed to derive operator address: {e}"))
})?;
Ok(Self {
keypair,
operator,
domain,
})
}
pub fn sign(&mut self, details: &JobQuoteDetails) -> Result<SignedJobQuote> {
let digest = job_quote_digest_eip712(details, self.domain);
let (signature, recovery_id) = self
.keypair
.0
.sign_prehash_recoverable(&digest)
.map_err(|e| JobQuoteError::Signing(format!("ECDSA signing failed: {e}")))?;
Ok(SignedJobQuote {
details: details.clone(),
signature: K256Signature(signature),
recovery_id: recovery_id.to_byte(),
operator: self.operator,
})
}
pub fn operator(&self) -> Address {
self.operator
}
pub fn domain(&self) -> QuoteSigningDomain {
self.domain
}
pub fn verifying_key(&self) -> K256VerifyingKey {
self.keypair.verifying_key()
}
}
pub fn verify_job_quote(
quote: &SignedJobQuote,
public_key: &K256VerifyingKey,
domain: QuoteSigningDomain,
) -> Result<bool> {
use alloy::signers::k256::ecdsa::signature::hazmat::PrehashVerifier;
let digest = job_quote_digest_eip712("e.details, domain);
Ok(public_key
.0
.verify_prehash(&digest, "e.signature.0)
.is_ok())
}
pub fn job_quote_digest_eip712(details: &JobQuoteDetails, domain: QuoteSigningDomain) -> [u8; 32] {
let domain_separator = compute_domain_separator(domain);
let struct_hash = hash_job_quote_details(details);
let mut payload = Vec::with_capacity(2 + 32 + 32);
payload.extend_from_slice(b"\x19\x01");
payload.extend_from_slice(domain_separator.as_slice());
payload.extend_from_slice(struct_hash.as_slice());
keccak256(payload).into()
}
fn compute_domain_separator(domain: QuoteSigningDomain) -> B256 {
const DOMAIN_TYPEHASH_STR: &str =
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
const NAME: &str = "TangleQuote";
const VERSION: &str = "1";
let domain_typehash = keccak256(DOMAIN_TYPEHASH_STR.as_bytes());
let name_hash = keccak256(NAME.as_bytes());
let version_hash = keccak256(VERSION.as_bytes());
let encoded = (
domain_typehash,
name_hash,
version_hash,
U256::from(domain.chain_id),
domain.verifying_contract,
)
.abi_encode();
keccak256(encoded)
}
fn hash_job_quote_details(details: &JobQuoteDetails) -> B256 {
const JOB_QUOTE_TYPEHASH_STR: &str = "JobQuoteDetails(uint64 serviceId,uint8 jobIndex,uint256 price,uint64 timestamp,uint64 expiry,uint8 confidentiality)";
let typehash = keccak256(JOB_QUOTE_TYPEHASH_STR.as_bytes());
let encoded = (
typehash,
U256::from(details.service_id),
U256::from(details.job_index),
details.price,
U256::from(details.timestamp),
U256::from(details.expiry),
U256::from(details.confidentiality),
)
.abi_encode();
keccak256(encoded)
}
impl From<SignedJobQuote> for blueprint_client_tangle::contracts::ITangleTypes::SignedJobQuote {
fn from(quote: SignedJobQuote) -> Self {
use blueprint_crypto::BytesEncoding;
let mut sig_bytes = quote.signature.to_bytes();
sig_bytes.push(27 + quote.recovery_id);
Self {
details: blueprint_client_tangle::contracts::ITangleTypes::JobQuoteDetails {
serviceId: quote.details.service_id,
jobIndex: quote.details.job_index,
price: quote.details.price,
timestamp: quote.details.timestamp,
expiry: quote.details.expiry,
confidentiality: quote.details.confidentiality,
},
signature: sig_bytes.into(),
operator: quote.operator,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloy_primitives::address;
use blueprint_crypto::BytesEncoding;
fn test_domain() -> QuoteSigningDomain {
QuoteSigningDomain {
chain_id: 31337, verifying_contract: address!("0000000000000000000000000000000000000001"),
}
}
#[test]
fn test_domain_separator_deterministic() {
let domain = test_domain();
let sep1 = compute_domain_separator(domain);
let sep2 = compute_domain_separator(domain);
assert_eq!(sep1, sep2);
}
#[test]
fn test_hash_job_quote_deterministic() {
let details = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::from(1_000_000_000_000_000_000u128),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let h1 = hash_job_quote_details(&details);
let h2 = hash_job_quote_details(&details);
assert_eq!(h1, h2);
}
#[test]
fn test_different_quotes_produce_different_hashes() {
let details1 = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let details2 = JobQuoteDetails {
service_id: 1,
job_index: 1, price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
assert_ne!(
hash_job_quote_details(&details1),
hash_job_quote_details(&details2)
);
}
#[test]
fn test_confidentiality_changes_hash() {
let base = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let tee_required = JobQuoteDetails {
confidentiality: 1,
..base.clone()
};
assert_ne!(
hash_job_quote_details(&base),
hash_job_quote_details(&tee_required),
);
}
#[test]
fn test_digest_differs_across_domains() {
let details = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let domain1 = test_domain();
let domain2 = QuoteSigningDomain {
chain_id: 1, verifying_contract: domain1.verifying_contract,
};
assert_ne!(
job_quote_digest_eip712(&details, domain1),
job_quote_digest_eip712(&details, domain2),
);
}
#[test]
fn test_sign_and_verify_roundtrip() {
let secret_bytes = [1u8; 32];
let keypair = K256SigningKey::from_bytes(&secret_bytes).expect("valid key");
let verifying_key = keypair.verifying_key();
let domain = test_domain();
let mut signer = JobQuoteSigner::new(keypair, domain).unwrap();
let details = JobQuoteDetails {
service_id: 42,
job_index: 3,
price: U256::from(500_000_000_000_000_000u128), timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let signed = signer.sign(&details).unwrap();
assert_eq!(signed.details, details);
assert_eq!(signed.operator, signer.operator());
let valid = verify_job_quote(&signed, &verifying_key, domain).unwrap();
assert!(valid, "signature should verify against the correct key");
}
#[test]
fn test_wrong_key_fails_verification() {
let keypair = K256SigningKey::from_bytes(&[1u8; 32]).unwrap();
let wrong_key = K256SigningKey::from_bytes(&[2u8; 32]).unwrap();
let domain = test_domain();
let mut signer = JobQuoteSigner::new(keypair, domain).unwrap();
let details = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let signed = signer.sign(&details).unwrap();
let valid = verify_job_quote(&signed, &wrong_key.verifying_key(), domain).unwrap();
assert!(!valid, "signature should not verify against wrong key");
}
#[test]
fn test_tampered_details_fails_verification() {
let keypair = K256SigningKey::from_bytes(&[1u8; 32]).unwrap();
let verifying_key = keypair.verifying_key();
let domain = test_domain();
let mut signer = JobQuoteSigner::new(keypair, domain).unwrap();
let details = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let mut signed = signer.sign(&details).unwrap();
signed.details.price = U256::from(999u64);
let valid = verify_job_quote(&signed, &verifying_key, domain).unwrap();
assert!(!valid, "tampered quote should not verify");
}
fn compat_domain() -> QuoteSigningDomain {
QuoteSigningDomain {
chain_id: 31337,
verifying_contract: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"),
}
}
#[test]
fn test_eip712_compat_domain_separator() {
let domain = compat_domain();
let sep = compute_domain_separator(domain);
assert_eq!(
sep,
B256::from(hex_literal::hex!(
"14a60a86c57fe72bdcbdc59af9a05606ca542a7ed2eeb732756b210d3306f149"
)),
"domain separator must match Solidity EIP712CompatibilityTest"
);
}
#[test]
fn test_eip712_compat_vector1_basic() {
let domain = compat_domain();
let details = JobQuoteDetails {
service_id: 42,
job_index: 3,
price: U256::from(1_000_000_000_000_000_000u128), timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let struct_hash = hash_job_quote_details(&details);
assert_eq!(
struct_hash,
B256::from(hex_literal::hex!(
"b5ad63b2aafeb693bc7fb591fb0cba712fff4cfafaccfb4bf97de29f069da660"
)),
"struct hash must match Solidity Vector 1 (with confidentiality field)"
);
let digest = job_quote_digest_eip712(&details, domain);
assert_eq!(
digest,
hex_literal::hex!("e13955facb4fcba51dce076d019e9509fc5d3c028a269e17e5ea1b78ca41fd26"),
"EIP-712 digest must match Solidity Vector 1 (with confidentiality field)"
);
}
#[test]
fn test_eip712_compat_vector2_zero_price() {
let domain = compat_domain();
let details = JobQuoteDetails {
service_id: 1,
job_index: 0,
price: U256::ZERO,
timestamp: 1000000,
expiry: 1003600,
confidentiality: 0,
};
let digest = job_quote_digest_eip712(&details, domain);
assert_eq!(
digest,
hex_literal::hex!("681b55c8c7602d2069ba2d5503cbec4f25e6067270e5e57bc310a0bb2f4ed7ff"),
"zero-price digest must match Solidity Vector 2 (with confidentiality field)"
);
}
#[test]
fn test_eip712_compat_vector3_large_price() {
let domain = compat_domain();
let details = JobQuoteDetails {
service_id: 999,
job_index: 7,
price: U256::from(u128::MAX), timestamp: 1700000000,
expiry: 1700007200,
confidentiality: 0,
};
let digest = job_quote_digest_eip712(&details, domain);
assert_eq!(
digest,
hex_literal::hex!("bdb556510beb8c8e04fac3e8f2edcaa98ef9d8a6afe0048554919af68cc2e603"),
"large-price digest must match Solidity Vector 3 (with confidentiality field)"
);
}
#[test]
fn test_eip712_compat_vector4_signature_roundtrip() {
let mut secret = [0u8; 32];
secret[31] = 1;
let keypair = K256SigningKey::from_bytes(&secret).expect("valid key");
let domain = compat_domain();
let details = JobQuoteDetails {
service_id: 42,
job_index: 3,
price: U256::from(1_000_000_000_000_000_000u128),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
};
let _digest = job_quote_digest_eip712(&details, domain);
let mut signer = JobQuoteSigner::new(keypair, domain).unwrap();
let signed = signer.sign(&details).unwrap();
assert_eq!(
signed.operator,
address!("7E5F4552091A69125d5DfCb7b8C2659029395Bdf"),
"signer address must match Solidity Vector 4"
);
let valid = verify_job_quote(&signed, &signer.verifying_key(), domain).unwrap();
assert!(valid, "signature must verify");
}
}