use {
crate::{
execution_limits::MAX_RESOLVED_DATA_BYTES,
sui,
types::{NexusData, NexusValue},
},
anyhow::{bail, ensure, Context as _},
serde::{Deserialize, Serialize},
sha2::{Digest as _, Sha256},
std::str::FromStr,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WalrusNetwork {
Testnet,
Mainnet,
}
impl FromStr for WalrusNetwork {
type Err = anyhow::Error;
fn from_str(chain: &str) -> Result<Self, Self::Err> {
match chain {
"testnet" => Ok(Self::Testnet),
"mainnet" => Ok(Self::Mainnet),
_ => bail!("Walrus requires Sui testnet or mainnet, got '{chain}'"),
}
}
}
impl WalrusNetwork {
pub fn aggregator_url(self) -> &'static str {
match self {
Self::Testnet => "https://aggregator.walrus-testnet.walrus.space",
Self::Mainnet => "https://aggregator.walrus-mainnet.walrus.space",
}
}
pub fn contracts(self) -> (&'static str, &'static str) {
match self {
Self::Mainnet => (
"0x2134d52768ea07e8c43570ef975eb3e4c27a39fa6396bef985b5abc58d03ddd2",
"0x10b9d30c28448939ce6c4d6c6e0ffce4a7f8a4ada8248bdad09ef8b70e4a3904",
),
Self::Testnet => (
"0x6c2547cbbc38025cf3adac45f63cb0a8d12ecf777cdc75a4971612bf97fdf6af",
"0xbe46180321c30aab2f8b3501e24048377287fa708018a5b7c2792b35fe339ee3",
),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoredBlob {
pub blob_id: String,
pub sha256: String,
pub size: usize,
pub object_id: sui::types::Address,
pub owner: sui::types::Address,
pub end_epoch: u32,
pub deletable: bool,
}
impl StoredBlob {
pub fn nexus_value(&self) -> anyhow::Result<NexusValue> {
ensure!(
self.size <= MAX_RESOLVED_DATA_BYTES,
"blob exceeds execution byte limit"
);
NexusValue::walrus_data(self.blob_id.as_bytes(), hex::decode(&self.sha256)?)
}
pub fn nexus_data(&self) -> anyhow::Result<NexusData> {
NexusData::from_values(vec![self.nexus_value()?], false)
}
pub fn verify_bytes(&self, bytes: &[u8]) -> anyhow::Result<()> {
ensure!(
bytes.len() == self.size
&& hex::decode(&self.sha256)? == Sha256::digest(bytes).to_vec(),
"blob contents do not match the reference"
);
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WalrusReference {
pub version: u8,
pub network: WalrusNetwork,
pub chain_id: String,
pub many: bool,
pub blobs: Vec<StoredBlob>,
}
impl WalrusReference {
pub fn nexus_data(&self) -> anyhow::Result<NexusData> {
ensure!(
self.version == 1,
"unsupported Walrus reference version {}",
self.version
);
ensure!(
!self.chain_id.is_empty(),
"Walrus reference has no chain identity"
);
let mut remaining = MAX_RESOLVED_DATA_BYTES;
let values = self
.blobs
.iter()
.map(|blob| {
remaining = remaining
.checked_sub(blob.size)
.context("reference exceeds execution byte limit")?;
blob.nexus_value()
})
.collect::<anyhow::Result<Vec<_>>>()?;
NexusData::from_values(values, self.many)
}
pub fn check_network(&self, network: WalrusNetwork, chain_id: &str) -> anyhow::Result<()> {
ensure!(
self.network == network && self.chain_id == chain_id,
"Walrus reference belongs to a different Sui network"
);
Ok(())
}
pub async fn download(&self, reader: &super::WalrusReader) -> anyhow::Result<Vec<Vec<u8>>> {
let ports = reader
.resolve_ports(std::collections::HashMap::from([(
"data".into(),
self.nexus_data()?,
)]))
.await?;
let mut values = Vec::new();
for (blob, value) in self.blobs.iter().zip(&ports["data"]) {
let NexusValue::InlineData { bytes } = value else {
unreachable!("resolved data reference")
};
blob.verify_bytes(bytes)?;
values.push(bytes.clone());
}
Ok(values)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reference() -> WalrusReference {
WalrusReference {
version: 1,
network: WalrusNetwork::Testnet,
chain_id: "testnet chain".into(),
many: false,
blobs: vec![StoredBlob {
blob_id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
sha256: hex::encode(Sha256::digest(b"contents")),
size: 8,
object_id: sui::types::Address::TWO,
owner: sui::types::Address::TWO,
end_epoch: 10,
deletable: false,
}],
}
}
#[test]
fn only_content_commitments_enter_the_protocol() {
let saved = reference();
let data = saved.nexus_data().unwrap();
let expected = NexusData::walrus_data(
saved.blobs[0].blob_id.as_bytes(),
Sha256::digest(b"contents").to_vec(),
)
.unwrap();
assert_eq!(data, expected);
assert!(saved
.check_network(WalrusNetwork::Testnet, "testnet chain")
.is_ok());
assert!(saved
.check_network(WalrusNetwork::Mainnet, "testnet chain")
.is_err());
assert!(saved
.check_network(WalrusNetwork::Testnet, "different chain")
.is_err());
let mut unexpected = serde_json::to_value(saved).unwrap();
unexpected["publisher"] = serde_json::json!("https://untrusted.example");
assert!(serde_json::from_value::<WalrusReference>(unexpected).is_err());
}
#[test]
fn receipt_verification_checks_size_digest_version_and_cardinality() {
let mut saved = reference();
saved.blobs[0].verify_bytes(b"contents").unwrap();
assert!(saved.blobs[0].verify_bytes(b"tampered").is_err());
saved.blobs[0].size += 1;
assert!(saved.blobs[0].verify_bytes(b"contents").is_err());
saved.version = 2;
assert!(saved.nexus_data().is_err());
saved.version = 1;
saved.blobs.push(saved.blobs[0].clone());
assert!(saved.nexus_data().is_err());
saved.many = true;
assert!(saved.nexus_data().is_ok());
}
#[tokio::test]
async fn downloading_many_preserves_order_and_checks_saved_sizes() {
let mut server = mockito::Server::new_async().await;
let mut saved = reference();
saved.many = true;
saved.blobs.push(StoredBlob {
blob_id: "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
sha256: hex::encode(Sha256::digest(b"second")),
size: 6,
..saved.blobs[0].clone()
});
let first = server
.mock(
"GET",
format!("/v1/blobs/{}", saved.blobs[0].blob_id).as_str(),
)
.with_body("contents")
.expect(2)
.create_async()
.await;
let second = server
.mock(
"GET",
format!("/v1/blobs/{}", saved.blobs[1].blob_id).as_str(),
)
.with_body("second")
.expect(2)
.create_async()
.await;
let reader = super::super::WalrusReader::new(&server.url(), 100).unwrap();
assert_eq!(
saved.download(&reader).await.unwrap(),
vec![b"contents".to_vec(), b"second".to_vec()]
);
saved.blobs[1].size += 1;
assert!(saved
.download(&reader)
.await
.unwrap_err()
.to_string()
.contains("contents do not match"));
first.assert_async().await;
second.assert_async().await;
}
#[test]
fn references_reject_missing_identity_invalid_digests_and_excessive_total_size() {
let mut saved = reference();
assert_eq!(
saved.blobs[0].nexus_data().unwrap(),
saved.nexus_data().unwrap()
);
saved.chain_id.clear();
assert!(saved
.nexus_data()
.unwrap_err()
.to_string()
.contains("chain identity"));
saved.chain_id = "testnet chain".into();
saved.blobs[0].sha256 = "not hex".into();
assert!(saved.nexus_data().is_err());
assert!(saved.blobs[0].verify_bytes(b"contents").is_err());
saved = reference();
saved.blobs[0].size = MAX_RESOLVED_DATA_BYTES;
saved.many = true;
saved.blobs.push(reference().blobs.remove(0));
assert!(saved
.nexus_data()
.unwrap_err()
.to_string()
.contains("execution byte limit"));
saved.blobs[0].size += 1;
assert!(saved.blobs[0].nexus_value().is_err());
}
}