use blake3::Hasher;
use serde::{Deserialize, Serialize};
use crate::identity::Signer;
use crate::zk::{anchor_hash as zk_anchor_hash, ZkChainCommitment};
const DOMAIN_ANCHOR_SEAL: &str = "a1::dyolo::anchor::seal::v2.8.0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AnchorNetwork {
Ethereum,
EthereumSepolia,
Polygon,
Base,
Arbitrum,
Solana,
Custom { chain_id: u64, name: String },
}
impl AnchorNetwork {
pub fn chain_id(&self) -> Option<u64> {
match self {
Self::Ethereum => Some(1),
Self::EthereumSepolia => Some(11155111),
Self::Polygon => Some(137),
Self::Base => Some(8453),
Self::Arbitrum => Some(42161),
Self::Solana => None,
Self::Custom { chain_id, .. } => Some(*chain_id),
}
}
pub fn name(&self) -> &str {
match self {
Self::Ethereum => "ethereum",
Self::EthereumSepolia => "ethereum-sepolia",
Self::Polygon => "polygon",
Self::Base => "base",
Self::Arbitrum => "arbitrum",
Self::Solana => "solana",
Self::Custom { name, .. } => name,
}
}
pub fn is_evm(&self) -> bool {
!matches!(self, Self::Solana)
}
}
impl std::fmt::Display for AnchorNetwork {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchoredReceipt {
pub commitment: ZkChainCommitment,
#[serde(with = "hex_32")]
pub anchor_hash: [u8; 32],
pub passport_did: String,
pub network: AnchorNetwork,
#[serde(skip_serializing_if = "Option::is_none")]
pub evm_calldata: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub solana_instruction_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tx_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub block_number: Option<u64>,
pub authority_signature: String,
pub prepared_at_unix: u64,
}
impl AnchoredReceipt {
pub fn prepare(
commitment: ZkChainCommitment,
passport_did: impl Into<String>,
network: AnchorNetwork,
prepared_at_unix: u64,
authority: &dyn Signer,
) -> Self {
let hash = zk_anchor_hash(&commitment);
let did_str: String = passport_did.into();
let mut h = Hasher::new_derive_key(DOMAIN_ANCHOR_SEAL);
h.update(&hash);
h.update(&prepared_at_unix.to_le_bytes());
let seal_bytes = h.finalize();
let sig = authority.sign_message(seal_bytes.as_bytes());
let evm_calldata = if network.is_evm() {
Some(hex::encode(build_evm_calldata(
&hash,
&commitment.intent,
commitment.sealed_at_unix,
&did_str,
)))
} else {
None
};
let solana_instruction_data = if !network.is_evm() {
Some(hex::encode(build_solana_instruction_data(
&hash,
&commitment.intent,
commitment.sealed_at_unix,
)))
} else {
None
};
Self {
anchor_hash: hash,
passport_did: did_str,
network,
evm_calldata,
solana_instruction_data,
commitment,
tx_hash: None,
block_number: None,
authority_signature: hex::encode(sig.to_bytes()),
prepared_at_unix,
}
}
#[must_use]
pub fn with_confirmation(mut self, tx_hash: impl Into<String>, block_number: u64) -> Self {
self.tx_hash = Some(tx_hash.into());
self.block_number = Some(block_number);
self
}
pub fn is_anchored(&self) -> bool {
self.tx_hash.is_some() && self.block_number.is_some()
}
pub fn anchor_hash_hex(&self) -> String {
format!("0x{}", hex::encode(self.anchor_hash))
}
pub fn verify_integrity(&self) -> bool {
zk_anchor_hash(&self.commitment) == self.anchor_hash
}
}
fn build_evm_calldata(
anchor_hash: &[u8; 32],
intent_hash: &[u8; 32],
sealed_at: u64,
passport_did: &str,
) -> Vec<u8> {
const SELECTOR: [u8; 4] = [0xd5, 0xe5, 0xb5, 0xb0];
let did_bytes = passport_did.as_bytes();
let did_len = did_bytes.len();
let did_padded_len = did_len.div_ceil(32) * 32;
let mut out = Vec::with_capacity(4 + 32 * 4 + 32 + did_padded_len);
out.extend_from_slice(&SELECTOR);
out.extend_from_slice(anchor_hash);
out.extend_from_slice(intent_hash);
let mut sealed_slot = [0u8; 32];
sealed_slot[24..32].copy_from_slice(&sealed_at.to_be_bytes());
out.extend_from_slice(&sealed_slot);
let mut offset_slot = [0u8; 32];
offset_slot[24..32].copy_from_slice(&128u64.to_be_bytes());
out.extend_from_slice(&offset_slot);
let mut len_slot = [0u8; 32];
len_slot[24..32].copy_from_slice(&(did_len as u64).to_be_bytes());
out.extend_from_slice(&len_slot);
out.extend_from_slice(did_bytes);
out.extend(std::iter::repeat_n(0u8, did_padded_len - did_len));
out
}
fn build_solana_instruction_data(
anchor_hash: &[u8; 32],
intent_hash: &[u8; 32],
sealed_at: u64,
) -> Vec<u8> {
let mut disc_h = Hasher::new_derive_key("a1::dyolo::anchor::solana::v2.8.0");
disc_h.update(b"discriminator");
let disc = disc_h.finalize();
let mut out = Vec::with_capacity(80);
out.extend_from_slice(&disc.as_bytes()[..8]);
out.extend_from_slice(anchor_hash);
out.extend_from_slice(intent_hash);
out.extend_from_slice(&sealed_at.to_le_bytes());
out
}
mod hex_32 {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&hex::encode(v))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let h = String::deserialize(d)?;
hex::decode(&h)
.map_err(serde::de::Error::custom)?
.try_into()
.map_err(|_| serde::de::Error::custom("expected 32 bytes"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
cert::CertBuilder, chain::DyoloChain, identity::DyoloIdentity, intent::Intent,
zk::ZkChainCommitment,
};
fn make_commitment(human: &DyoloIdentity) -> ZkChainCommitment {
let agent = DyoloIdentity::generate();
let now = 1_700_000_000u64;
let intent = Intent::new("trade.equity").unwrap().hash();
let cert = CertBuilder::new(agent.verifying_key(), intent, now, now + 3600).sign(human);
let mut chain = DyoloChain::new(human.verifying_key(), intent);
chain.push(cert);
ZkChainCommitment::seal(&chain, &intent, &[0u8; 32], now, human, Some("acme-bot"))
}
#[test]
fn prepare_evm_integrity() {
let human = DyoloIdentity::generate();
let commitment = make_commitment(&human);
let receipt = AnchoredReceipt::prepare(
commitment,
"did:a1:abc123",
AnchorNetwork::Ethereum,
1_700_000_000,
&human,
);
assert!(receipt.verify_integrity());
assert!(!receipt.is_anchored());
assert!(receipt.evm_calldata.is_some());
assert!(receipt.solana_instruction_data.is_none());
}
#[test]
fn prepare_solana_integrity() {
let human = DyoloIdentity::generate();
let commitment = make_commitment(&human);
let receipt = AnchoredReceipt::prepare(
commitment,
"did:a1:abc123",
AnchorNetwork::Solana,
1_700_000_000,
&human,
);
assert!(receipt.verify_integrity());
assert!(receipt.evm_calldata.is_none());
let data = hex::decode(receipt.solana_instruction_data.unwrap()).unwrap();
assert_eq!(data.len(), 80);
}
#[test]
fn evm_calldata_selector_and_length() {
let human = DyoloIdentity::generate();
let commitment = make_commitment(&human);
let did = format!("did:a1:{}", "a".repeat(64));
let receipt =
AnchoredReceipt::prepare(commitment, &did, AnchorNetwork::Base, 1_700_000_000, &human);
let raw = hex::decode(receipt.evm_calldata.unwrap()).unwrap();
assert_eq!(&raw[0..4], &[0xd5, 0xe5, 0xb5, 0xb0]);
assert!(raw.len() >= 4 + 32 * 5);
}
#[test]
fn anchor_hash_hex_format() {
let human = DyoloIdentity::generate();
let commitment = make_commitment(&human);
let receipt = AnchoredReceipt::prepare(
commitment,
"did:a1:abc",
AnchorNetwork::Ethereum,
1_700_000_000,
&human,
);
assert!(receipt.anchor_hash_hex().starts_with("0x"));
assert_eq!(receipt.anchor_hash_hex().len(), 66);
}
#[test]
fn with_confirmation_marks_anchored() {
let human = DyoloIdentity::generate();
let commitment = make_commitment(&human);
let receipt = AnchoredReceipt::prepare(
commitment,
"did:a1:abc",
AnchorNetwork::Polygon,
1_700_000_000,
&human,
)
.with_confirmation(
"0xdeadbeef00000000000000000000000000000000000000000000000000000000",
19_000_000,
);
assert!(receipt.is_anchored());
assert_eq!(receipt.block_number, Some(19_000_000));
}
#[test]
fn anchor_hash_stable_across_prepare_calls() {
let human = DyoloIdentity::generate();
let commitment = make_commitment(&human);
let h1 = zk_anchor_hash(&commitment);
let receipt = AnchoredReceipt::prepare(
commitment.clone(),
"did:a1:abc",
AnchorNetwork::Ethereum,
1_700_000_000,
&human,
);
assert_eq!(receipt.anchor_hash, h1);
}
}