use crate::checkpoint::{CheckpointMetadata, CheckpointStorage};
use crate::{AzothError, Result};
use alloy_network::EthereumWallet;
use alloy_primitives::{Address, FixedBytes};
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types::TransactionRequest;
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_types::{sol, SolCall};
use async_trait::async_trait;
use std::path::Path;
use std::str::FromStr;
sol! {
function submitBackup(bytes32 backupId, string calldata ipfsHash) external;
function getBackup(address user, bytes32 backupId) external view returns (string memory ipfsHash, uint256 timestamp);
function getUserBackupIds(address user) external view returns (bytes32[] memory);
}
#[derive(Debug, Clone)]
pub struct OnChainConfig {
pub contract_address: Address,
pub rpc_url: String,
pub chain_id: u64,
pub private_key: String,
}
impl OnChainConfig {
pub fn from_env() -> Result<Self> {
use std::env;
let contract_address = env::var("BACKUP_REGISTRY_ADDRESS")
.map_err(|_| AzothError::Config("BACKUP_REGISTRY_ADDRESS not set".to_string()))?
.parse()
.map_err(|e| AzothError::Config(format!("Invalid contract address: {}", e)))?;
let rpc_url = env::var("BACKUP_REGISTRY_RPC_URL")
.map_err(|_| AzothError::Config("BACKUP_REGISTRY_RPC_URL not set".to_string()))?;
let chain_id = env::var("BACKUP_REGISTRY_CHAIN_ID")
.unwrap_or_else(|_| "11155111".to_string()) .parse()
.map_err(|e| AzothError::Config(format!("Invalid chain ID: {}", e)))?;
let private_key = env::var("BACKUP_REGISTRY_PRIVATE_KEY")
.or_else(|_| env::var("SERVER_WALLET_PRIVATE_KEY"))
.map_err(|_| AzothError::Config("BACKUP_REGISTRY_PRIVATE_KEY not set".to_string()))?;
Ok(Self {
contract_address,
rpc_url,
chain_id,
private_key,
})
}
pub fn sepolia(contract_address: Address, rpc_url: String, private_key: String) -> Self {
Self {
contract_address,
rpc_url,
chain_id: 11155111,
private_key,
}
}
pub fn mainnet(contract_address: Address, rpc_url: String, private_key: String) -> Self {
Self {
contract_address,
rpc_url,
chain_id: 1,
private_key,
}
}
}
pub struct OnChainRegistry {
config: OnChainConfig,
backup_storage: Box<dyn CheckpointStorage>,
signer: PrivateKeySigner,
}
impl OnChainRegistry {
pub async fn new(config: OnChainConfig, storage: Box<dyn CheckpointStorage>) -> Result<Self> {
let private_key = config.private_key.trim_start_matches("0x");
let signer = PrivateKeySigner::from_str(private_key)
.map_err(|e| AzothError::Config(format!("Invalid private key: {}", e)))?;
Ok(Self {
config,
backup_storage: storage,
signer,
})
}
pub async fn from_env(storage: Box<dyn CheckpointStorage>) -> Result<Self> {
let config = OnChainConfig::from_env()?;
Self::new(config, storage).await
}
fn backup_id_from_metadata(metadata: &CheckpointMetadata) -> FixedBytes<32> {
use alloy_primitives::keccak256;
keccak256(metadata.id.as_bytes())
}
fn user_address(&self) -> Address {
self.signer.address()
}
}
#[async_trait]
impl CheckpointStorage for OnChainRegistry {
async fn upload(&self, path: &Path, metadata: &CheckpointMetadata) -> Result<String> {
let cid = self.backup_storage.upload(path, metadata).await?;
let backup_id = Self::backup_id_from_metadata(metadata);
tracing::info!(
"Registering backup on-chain: id={}, cid={}, chain_id={}",
metadata.id,
cid,
self.config.chain_id
);
let call = submitBackupCall {
backupId: backup_id,
ipfsHash: cid.clone(),
};
let calldata = call.abi_encode();
let mut tx = TransactionRequest::default()
.to(self.config.contract_address)
.input(calldata.into())
.from(self.user_address());
tx.chain_id = Some(self.config.chain_id);
let wallet = EthereumWallet::from(self.signer.clone());
let url = self
.config
.rpc_url
.parse()
.map_err(|e| AzothError::Config(format!("Invalid RPC URL: {}", e)))?;
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http(url);
let tx_hash = provider
.send_transaction(tx)
.await
.map_err(|e| AzothError::Backup(format!("Failed to send transaction: {}", e)))?
.watch()
.await
.map_err(|e| AzothError::Backup(format!("Transaction failed: {}", e)))?;
tracing::info!(
"Backup registered on-chain: tx={:?}, backup_id={:?}",
tx_hash,
backup_id
);
Ok(cid)
}
async fn download(&self, id: &str, path: &Path) -> Result<()> {
self.backup_storage.download(id, path).await
}
async fn delete(&self, id: &str) -> Result<()> {
tracing::warn!(
"Cannot delete from blockchain (immutable), deleting from underlying storage only"
);
self.backup_storage.delete(id).await
}
async fn list(&self) -> Result<Vec<CheckpointMetadata>> {
let user = self.user_address();
tracing::debug!("Querying on-chain backups for user: {:?}", user);
let call = getUserBackupIdsCall { user };
let calldata = call.abi_encode();
let tx = TransactionRequest::default()
.to(self.config.contract_address)
.input(calldata.into());
let url = self
.config
.rpc_url
.parse()
.map_err(|e| AzothError::Config(format!("Invalid RPC URL: {}", e)))?;
let provider = ProviderBuilder::new().on_http(url);
let result = provider
.call(&tx)
.await
.map_err(|e| AzothError::Backup(format!("Failed to query backup IDs: {}", e)))?;
let backup_ids = getUserBackupIdsCall::abi_decode_returns(&result, false)
.map_err(|e| AzothError::Backup(format!("Failed to decode backup IDs: {}", e)))?
._0;
tracing::debug!("Found {} backups on-chain", backup_ids.len());
let mut metadata_list = Vec::new();
for backup_id in backup_ids {
let call = getBackupCall {
user,
backupId: backup_id,
};
let calldata = call.abi_encode();
let tx = TransactionRequest::default()
.to(self.config.contract_address)
.input(calldata.into());
let url = self
.config
.rpc_url
.parse()
.map_err(|e| AzothError::Config(format!("Invalid RPC URL: {}", e)))?;
let provider = ProviderBuilder::new().on_http(url);
let result = provider
.call(&tx)
.await
.map_err(|e| AzothError::Backup(format!("Failed to query backup: {}", e)))?;
let decoded = getBackupCall::abi_decode_returns(&result, false)
.map_err(|e| AzothError::Backup(format!("Failed to decode backup: {}", e)))?;
let ipfs_hash = decoded.ipfsHash;
let timestamp = decoded.timestamp;
if !ipfs_hash.is_empty() {
use chrono::{TimeZone, Utc};
metadata_list.push(CheckpointMetadata {
id: ipfs_hash,
timestamp: Utc.timestamp_opt(timestamp.to::<i64>(), 0).unwrap(),
sealed_event_id: 0, size_bytes: 0, name: None,
storage_type: "ipfs".to_string(),
});
}
}
Ok(metadata_list)
}
fn storage_type(&self) -> &str {
"onchain"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_from_env() {
use std::env;
env::set_var(
"BACKUP_REGISTRY_ADDRESS",
"0x1234567890123456789012345678901234567890",
);
env::set_var(
"BACKUP_REGISTRY_RPC_URL",
"https://sepolia.infura.io/v3/test",
);
env::set_var("BACKUP_REGISTRY_CHAIN_ID", "11155111");
env::set_var(
"BACKUP_REGISTRY_PRIVATE_KEY",
"0x1234567890123456789012345678901234567890123456789012345678901234",
);
let config = OnChainConfig::from_env().unwrap();
assert_eq!(config.chain_id, 11155111);
assert_eq!(config.rpc_url, "https://sepolia.infura.io/v3/test");
env::remove_var("BACKUP_REGISTRY_ADDRESS");
env::remove_var("BACKUP_REGISTRY_RPC_URL");
env::remove_var("BACKUP_REGISTRY_CHAIN_ID");
env::remove_var("BACKUP_REGISTRY_PRIVATE_KEY");
}
#[test]
fn test_sepolia_preset() {
use std::str::FromStr;
let config = OnChainConfig::sepolia(
Address::from_str("0x1234567890123456789012345678901234567890").unwrap(),
"https://sepolia.infura.io/v3/test".to_string(),
"0x1234567890123456789012345678901234567890123456789012345678901234".to_string(),
);
assert_eq!(config.chain_id, 11155111);
}
}