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 requester: Address,
pub service_id: u64,
pub job_index: u8,
pub price: U256,
pub timestamp: u64,
pub expiry: u64,
pub confidentiality: u8,
pub inputs_hash: B256,
}
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(address requester,uint64 serviceId,uint8 jobIndex,uint256 price,uint64 timestamp,uint64 expiry,uint8 confidentiality,bytes32 inputsHash)";
let typehash = keccak256(JOB_QUOTE_TYPEHASH_STR.as_bytes());
let encoded = (
typehash,
details.requester,
U256::from(details.service_id),
U256::from(details.job_index),
details.price,
U256::from(details.timestamp),
U256::from(details.expiry),
U256::from(details.confidentiality),
details.inputs_hash,
)
.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 {
requester: quote.details.requester,
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,
inputsHash: quote.details.inputs_hash,
},
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);
}
fn placeholder_requester() -> Address {
address!("000000000000000000000000000000000000bEEF")
}
fn empty_inputs_hash() -> B256 {
keccak256([])
}
#[test]
fn test_hash_job_quote_deterministic() {
let details = JobQuoteDetails {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(1_000_000_000_000_000_000u128),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
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 {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let details2 = JobQuoteDetails {
requester: placeholder_requester(),
service_id: 1,
job_index: 1, price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
assert_ne!(
hash_job_quote_details(&details1),
hash_job_quote_details(&details2)
);
}
#[test]
fn test_requester_changes_hash() {
let base = JobQuoteDetails {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let other = JobQuoteDetails {
requester: address!("00000000000000000000000000000000DeadBeef"),
..base.clone()
};
assert_ne!(
hash_job_quote_details(&base),
hash_job_quote_details(&other),
"different requester must yield different struct hash"
);
}
#[test]
fn test_confidentiality_changes_hash() {
let base = JobQuoteDetails {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let tee_required = JobQuoteDetails {
confidentiality: 1,
..base.clone()
};
assert_ne!(
hash_job_quote_details(&base),
hash_job_quote_details(&tee_required),
);
}
#[test]
fn test_inputs_hash_changes_hash() {
let base = JobQuoteDetails {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let other_inputs = JobQuoteDetails {
inputs_hash: keccak256(b"different job inputs"),
..base.clone()
};
assert_ne!(
hash_job_quote_details(&base),
hash_job_quote_details(&other_inputs),
);
}
#[test]
fn test_digest_differs_across_domains() {
let details = JobQuoteDetails {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
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 {
requester: placeholder_requester(),
service_id: 42,
job_index: 3,
price: U256::from(500_000_000_000_000_000u128), timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
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 {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
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 {
requester: placeholder_requester(),
service_id: 1,
job_index: 0,
price: U256::from(100u64),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
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 {
requester: address!("000000000000000000000000000000000000bEEF"),
service_id: 42,
job_index: 3,
price: U256::from(1_000_000_000_000_000_000u128), timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let struct_hash = hash_job_quote_details(&details);
assert_eq!(
struct_hash,
B256::from(hex_literal::hex!(
"223829f63247a6b4a7724cdd0b3bb9b33ffacf2ac573851b8b4d6d028885c710"
)),
"struct hash must match Solidity Vector 1 (v0.18.0 typehash with inputsHash)"
);
let digest = job_quote_digest_eip712(&details, domain);
assert_eq!(
digest,
hex_literal::hex!("9bd02b8b280e84cda6136def5cae3eb19e0c5ab5bd4619a0cad56bc2c4f15a11"),
"EIP-712 digest must match Solidity Vector 1 (v0.18.0 typehash with inputsHash)"
);
}
#[test]
fn test_eip712_compat_vector2_zero_price() {
let domain = compat_domain();
let details = JobQuoteDetails {
requester: address!("0000000000000000000000000000000000C0FFEE"),
service_id: 1,
job_index: 0,
price: U256::ZERO,
timestamp: 1000000,
expiry: 1003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let digest = job_quote_digest_eip712(&details, domain);
assert_eq!(
digest,
hex_literal::hex!("1e88efd60c60a4c1e73e353d8ded5256bfffd2b115bf79c1646a555f8936ebf1"),
"zero-price digest must match Solidity Vector 2 (v0.18.0 typehash with inputsHash)"
);
}
#[test]
fn test_eip712_compat_vector3_large_price() {
let domain = compat_domain();
let details = JobQuoteDetails {
requester: address!("000000000000000000000000000000000000bEEF"),
service_id: 999,
job_index: 7,
price: U256::from(u128::MAX), timestamp: 1700000000,
expiry: 1700007200,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
let digest = job_quote_digest_eip712(&details, domain);
assert_eq!(
digest,
hex_literal::hex!("b8c5094b407d6dd0c0e83ad9cd611be39095713ed16c720a8fe4a829ba84fc7f"),
"large-price digest must match Solidity Vector 3 (v0.18.0 typehash with inputsHash)"
);
}
#[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 {
requester: address!("000000000000000000000000000000000000bEEF"),
service_id: 42,
job_index: 3,
price: U256::from(1_000_000_000_000_000_000u128),
timestamp: 1700000000,
expiry: 1700003600,
confidentiality: 0,
inputs_hash: empty_inputs_hash(),
};
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"
);
use blueprint_crypto::BytesEncoding;
let sig_bytes = signed.signature.to_bytes();
let mut r = [0u8; 32];
r.copy_from_slice(&sig_bytes[..32]);
assert_eq!(
r,
hex_literal::hex!("2561b12e4d70171c286c5dcedd0680c480eddf0c9846d1306218681793959308"),
"signature `r` must match Solidity Vector 4 (v0.18.0 with inputsHash)"
);
let valid = verify_job_quote(&signed, &signer.verifying_key(), domain).unwrap();
assert!(valid, "signature must verify");
}
}