use crate::command::tangle::parse_address;
use alloy_json_abi::Param;
use alloy_primitives::{Address, Bytes, FixedBytes, U256, keccak256};
use alloy_sol_types::{SolCall, SolType, SolValue, sol};
use blueprint_client_tangle::{ExecutionProfile, inject_execution_profile};
use color_eyre::eyre::{Context, Result, eyre};
use serde::{Deserialize, Serialize};
use serde_json::{self, Value};
use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use url::Url;
sol! {
struct BlueprintConfig {
uint8 membership;
uint8 pricing;
uint32 minOperators;
uint32 maxOperators;
uint256 subscriptionRate;
uint64 subscriptionInterval;
uint256 eventRate;
}
struct BlueprintMetadata {
string name;
string description;
string author;
string category;
string codeRepository;
string logo;
string website;
string license;
string profilingData;
}
struct JobDefinition {
string name;
string description;
string metadataUri;
bytes paramsSchema;
bytes resultSchema;
}
struct BlueprintBinary {
uint8 arch;
uint8 os;
string name;
bytes32 sha256;
}
struct ImageRegistrySource {
string registry;
string image;
string tag;
}
struct WasmSource {
uint8 runtime;
uint8 fetcher;
string artifactUri;
string entrypoint;
}
struct NativeSource {
uint8 fetcher;
string artifactUri;
string entrypoint;
}
struct TestingSource {
string cargoPackage;
string cargoBin;
string basePath;
}
struct BlueprintSource {
uint8 kind;
ImageRegistrySource container;
WasmSource wasm;
NativeSource native;
TestingSource testing;
BlueprintBinary[] binaries;
}
struct BlueprintDefinition {
string metadataUri;
bytes32 metadataHash;
address manager;
uint32 masterManagerRevision;
bool hasConfig;
BlueprintConfig config;
BlueprintMetadata metadata;
JobDefinition[] jobs;
bytes registrationSchema;
bytes requestSchema;
BlueprintSource[] sources;
uint8[] supportedMemberships;
}
function createBlueprint(BlueprintDefinition definition) external returns (uint64 blueprintId);
}
type OnChainBlueprintBinary = BlueprintBinary;
#[derive(Debug, Clone)]
pub struct BlueprintDefinitionInput {
pub encoded: Bytes,
pub call_data: Bytes,
pub metadata_uri: String,
pub metadata_hash: FixedBytes<32>,
pub manager: Address,
}
#[derive(Debug, Clone)]
pub struct BlueprintDefinitionLoadResult {
pub definition: BlueprintDefinitionInput,
pub summaries: Vec<SourceSummary>,
}
impl BlueprintDefinitionInput {
#[must_use]
pub fn encoded_bytes(&self) -> Bytes {
self.encoded.clone()
}
#[must_use]
pub fn call_data_bytes(&self) -> Bytes {
self.call_data.clone()
}
}
pub fn decode_blueprint_definition(bytes: &[u8]) -> Result<BlueprintDefinition> {
<BlueprintDefinition as SolType>::abi_decode(bytes).map_err(|err| {
eyre!(
"failed to decode blueprint definition ({} bytes): {err}",
bytes.len()
)
})
}
pub fn load_blueprint_definition(
path: &Path,
overrides: Option<&DefinitionOverrides>,
) -> Result<BlueprintDefinitionLoadResult> {
let bytes = fs::read(path).with_context(|| {
format!(
"failed to read blueprint definition file {}",
path.display()
)
})?;
let mut spec = parse_definition_spec(&bytes, path)?;
if let Some(extra) = overrides {
spec.apply_overrides(extra)?;
}
let metadata_hash = spec.resolve_metadata_hash(path)?;
let summary = DefinitionSummary {
metadata_uri: spec.metadata_uri.clone(),
metadata_hash,
manager: parse_address(&spec.manager, "manager")?,
};
let summaries = spec.source_summaries();
let definition = spec.into_blueprint_definition(metadata_hash)?;
let encoded = Bytes::from(definition.abi_encode());
let call_data = Bytes::from(createBlueprintCall { definition }.abi_encode());
Ok(BlueprintDefinitionLoadResult {
definition: BlueprintDefinitionInput {
encoded,
call_data,
metadata_uri: summary.metadata_uri,
metadata_hash: summary.metadata_hash,
manager: summary.manager,
},
summaries,
})
}
fn parse_definition_spec(bytes: &[u8], path: &Path) -> Result<BlueprintDefinitionSpec> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
match ext {
"json" => serde_json::from_slice(bytes)
.with_context(|| format!("failed to parse JSON from {}", path.display())),
"yaml" | "yml" => serde_yaml::from_slice(bytes)
.with_context(|| format!("failed to parse YAML from {}", path.display())),
"toml" => toml::from_slice(bytes)
.with_context(|| format!("failed to parse TOML from {}", path.display())),
_ => serde_json::from_slice(bytes)
.or_else(|json_err| {
toml::from_slice(bytes).map_err(|toml_err| {
eyre!(
"Failed to parse {} as JSON ({json_err}) or TOML ({toml_err})",
path.display()
)
})
})
.with_context(|| format!("failed to parse definition file {}", path.display())),
}
}
fn compute_metadata_hash_from_file(path: &Path) -> Result<FixedBytes<32>> {
let bytes = fs::read(path)
.with_context(|| format!("failed to read metadata file {}", path.display()))?;
let value: Value = serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse metadata JSON from {}", path.display()))?;
compute_metadata_hash_from_value(value)
}
fn compute_metadata_hash_from_value(value: Value) -> Result<FixedBytes<32>> {
let mut object = value
.as_object()
.cloned()
.ok_or_else(|| eyre!("metadata_file must contain a top-level JSON object"))?;
object.remove("integrity");
let canonical = canonicalize_json_value(Value::Object(object));
Ok(FixedBytes::from(
keccak256(canonical.to_string().as_bytes()).0,
))
}
fn canonicalize_json_value(value: Value) -> Value {
match value {
Value::Array(values) => {
Value::Array(values.into_iter().map(canonicalize_json_value).collect())
}
Value::Object(map) => {
let mut entries = map.into_iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut sorted = serde_json::Map::new();
for (key, nested) in entries {
sorted.insert(key, canonicalize_json_value(nested));
}
Value::Object(sorted)
}
other => other,
}
}
#[derive(Debug, Deserialize, Clone)]
struct BlueprintDefinitionSpec {
metadata_uri: String,
#[serde(default)]
metadata_hash: Option<String>,
#[serde(default)]
metadata_file: Option<PathBuf>,
manager: String,
#[serde(default)]
master_manager_revision: u32,
#[serde(default)]
config: Option<BlueprintConfigSpec>,
#[serde(default)]
metadata: MetadataSpec,
jobs: Vec<JobSpec>,
#[serde(default)]
registration_schema: Option<String>,
#[serde(default)]
request_schema: Option<String>,
sources: Vec<SourceSpec>,
#[serde(default = "default_memberships")]
supported_memberships: Vec<MembershipModelSpec>,
}
impl BlueprintDefinitionSpec {
fn into_blueprint_definition(
self,
metadata_hash: FixedBytes<32>,
) -> Result<BlueprintDefinition> {
if self.metadata_uri.trim().is_empty() {
return Err(eyre!("metadata_uri must not be empty"));
}
if self.jobs.is_empty() {
return Err(eyre!("definition must include at least one job"));
}
if self.sources.is_empty() {
return Err(eyre!("definition must include at least one source"));
}
if self.supported_memberships.is_empty() {
return Err(eyre!(
"definition must include at least one supported membership model"
));
}
let (explicit_config, cfg_spec) = match self.config.clone() {
Some(cfg) => (true, cfg),
None => (false, BlueprintConfigSpec::default()),
};
let effective_membership = cfg_spec.membership.unwrap_or(self.supported_memberships[0]);
let effective_pricing = cfg_spec.pricing.unwrap_or_default();
let has_config = explicit_config
|| effective_membership != MembershipModelSpec::Fixed
|| effective_pricing != PricingModelSpec::PayOnce;
let config = cfg_spec.into_blueprint_config(self.supported_memberships[0]);
Ok(BlueprintDefinition {
metadataUri: self.metadata_uri,
metadataHash: metadata_hash,
manager: parse_address(&self.manager, "manager")?,
masterManagerRevision: self.master_manager_revision,
hasConfig: has_config,
config,
metadata: self.metadata.into_metadata(),
jobs: self
.jobs
.into_iter()
.map(JobSpec::into_job_definition)
.collect::<Result<_>>()?,
registrationSchema: hex_to_bytes(self.registration_schema.as_deref())?,
requestSchema: hex_to_bytes(self.request_schema.as_deref())?,
sources: self
.sources
.into_iter()
.map(SourceSpec::into_blueprint_source)
.collect::<Result<_>>()?,
supportedMemberships: self
.supported_memberships
.into_iter()
.map(MembershipModelSpec::into_membership)
.collect(),
})
}
fn resolve_metadata_hash(&self, definition_path: &Path) -> Result<FixedBytes<32>> {
self.resolve_metadata_hash_inner(definition_path.parent())
}
fn resolve_metadata_hash_inner(&self, definition_dir: Option<&Path>) -> Result<FixedBytes<32>> {
if let Some(metadata_hash) = &self.metadata_hash {
return BinaryArtifactSpec::parse_digest(metadata_hash, "metadata_hash");
}
let metadata_file = self.metadata_file.as_ref().ok_or_else(|| {
eyre!(
"metadata_hash is required unless metadata_file is provided for canonical hash computation"
)
})?;
let resolved_path = if metadata_file.is_absolute() {
metadata_file.clone()
} else if let Some(base_dir) = definition_dir {
base_dir.join(metadata_file)
} else {
metadata_file.clone()
};
compute_metadata_hash_from_file(&resolved_path)
}
fn apply_overrides(&mut self, overrides: &DefinitionOverrides) -> Result<()> {
if overrides.is_empty() {
return Ok(());
}
let mut retained: Vec<SourceSpec> = self
.sources
.drain(..)
.filter(|spec| !matches!(spec, SourceSpec::Native(_)))
.collect();
for override_spec in overrides.native_sources() {
retained.push(SourceSpec::Native(override_spec.clone().into_spec()?));
}
self.sources = retained;
Ok(())
}
fn source_summaries(&self) -> Vec<SourceSummary> {
self.sources
.iter()
.enumerate()
.map(|(idx, source)| source.summary(idx))
.collect()
}
}
#[derive(Debug, Clone, Default)]
pub struct DefinitionOverrides {
native_sources: Vec<NativeSourceOverride>,
}
impl DefinitionOverrides {
#[must_use]
pub fn new(native_sources: Vec<NativeSourceOverride>) -> Self {
Self { native_sources }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.native_sources.is_empty()
}
pub fn push_native(&mut self, override_spec: NativeSourceOverride) {
self.native_sources.push(override_spec);
}
#[must_use]
pub fn native_sources(&self) -> &[NativeSourceOverride] {
&self.native_sources
}
}
#[derive(Debug, Clone)]
pub struct NativeSourceOverride {
fetcher: FetcherKind,
entrypoint: String,
github: Option<GithubArtifactSpec>,
remote: Option<RemoteArtifactSpec>,
binaries: Vec<BinaryArtifactSpec>,
}
impl NativeSourceOverride {
#[must_use]
pub(crate) fn github(entrypoint: String, spec: GithubArtifactSpec) -> Self {
let binaries = spec.binaries.clone();
Self {
fetcher: FetcherKind::Github,
entrypoint,
github: Some(spec),
remote: None,
binaries,
}
}
#[must_use]
pub(crate) fn remote(
entrypoint: String,
fetcher: FetcherKind,
spec: RemoteArtifactSpec,
) -> Self {
let binaries = spec.binaries.clone();
Self {
fetcher,
entrypoint,
github: None,
remote: Some(spec),
binaries,
}
}
fn into_spec(&self) -> Result<NativeSourceSpec> {
if self.fetcher == FetcherKind::None {
return Err(eyre!(
"cannot override native source with fetcher `none`, please select github/http/ipfs"
));
}
Ok(NativeSourceSpec {
fetcher: self.fetcher,
artifact_uri: String::new(),
entrypoint: self.entrypoint.clone(),
github: self.github.clone(),
remote: self.remote.clone(),
binaries: self.binaries.clone(),
testing: None,
})
}
}
#[derive(Debug, Default, Deserialize, Clone)]
#[serde(default)]
struct BlueprintConfigSpec {
membership: Option<MembershipModelSpec>,
pricing: Option<PricingModelSpec>,
min_operators: Option<u32>,
max_operators: Option<u32>,
subscription_rate: Option<u128>,
subscription_interval: Option<u64>,
event_rate: Option<u128>,
}
impl BlueprintConfigSpec {
fn into_blueprint_config(self, default_membership: MembershipModelSpec) -> BlueprintConfig {
BlueprintConfig {
membership: self
.membership
.unwrap_or(default_membership)
.into_membership(),
pricing: self.pricing.unwrap_or_default().into_pricing(),
minOperators: self.min_operators.unwrap_or_default(),
maxOperators: self.max_operators.unwrap_or_default(),
subscriptionRate: U256::from(self.subscription_rate.unwrap_or_default()),
subscriptionInterval: self.subscription_interval.unwrap_or_default(),
eventRate: U256::from(self.event_rate.unwrap_or_default()),
}
}
}
#[derive(Debug, Deserialize, Clone)]
struct MetadataSpec {
#[serde(default = "default_name")]
name: String,
#[serde(default)]
description: String,
#[serde(default = "default_author")]
author: String,
#[serde(default = "default_category")]
category: String,
#[serde(default)]
code_repository: String,
#[serde(default)]
logo: String,
#[serde(default = "default_website")]
website: String,
#[serde(default = "default_license")]
license: String,
#[serde(default)]
profiling_data: String,
#[serde(default)]
execution_profile: Option<ExecutionProfileSpec>,
}
impl Default for MetadataSpec {
fn default() -> Self {
Self {
name: default_name(),
description: String::new(),
author: default_author(),
category: default_category(),
code_repository: String::new(),
logo: String::new(),
website: default_website(),
license: default_license(),
profiling_data: String::new(),
execution_profile: None,
}
}
}
impl MetadataSpec {
fn into_metadata(self) -> BlueprintMetadata {
let execution_profile = self
.execution_profile
.as_ref()
.map(ExecutionProfileSpec::to_profile);
let profiling_data = execution_profile
.map(|profile| inject_execution_profile(&self.profiling_data, profile))
.unwrap_or(self.profiling_data);
BlueprintMetadata {
name: self.name,
description: self.description,
author: self.author,
category: self.category,
codeRepository: self.code_repository,
logo: self.logo,
website: self.website,
license: self.license,
profilingData: profiling_data,
}
}
}
#[derive(Debug, Deserialize, Clone)]
struct ExecutionProfileSpec {
#[serde(default)]
confidentiality: blueprint_client_tangle::ConfidentialityPolicy,
#[serde(default)]
gpu: blueprint_client_tangle::GpuRequirements,
}
impl ExecutionProfileSpec {
fn to_profile(&self) -> ExecutionProfile {
ExecutionProfile {
confidentiality: self.confidentiality,
gpu: self.gpu,
}
}
}
#[derive(Debug, Deserialize, Clone)]
struct JobSpec {
name: String,
#[serde(default)]
description: String,
#[serde(default)]
metadata_uri: String,
#[serde(default)]
params_schema: Option<String>,
#[serde(default)]
result_schema: Option<String>,
}
impl JobSpec {
fn into_job_definition(self) -> Result<JobDefinition> {
Ok(JobDefinition {
name: self.name,
description: self.description,
metadataUri: self.metadata_uri,
paramsSchema: hex_to_bytes(self.params_schema.as_deref())?,
resultSchema: hex_to_bytes(self.result_schema.as_deref())?,
})
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum SourceSpec {
Container(ContainerSourceSpec),
Native(NativeSourceSpec),
Wasm(WasmSourceSpec),
}
impl SourceSpec {
fn into_blueprint_source(self) -> Result<BlueprintSource> {
let mut source = BlueprintSource {
kind: 0,
container: ImageRegistrySource {
registry: String::new(),
image: String::new(),
tag: String::new(),
},
wasm: WasmSource {
runtime: 0,
fetcher: 0,
artifactUri: String::new(),
entrypoint: String::new(),
},
native: NativeSource {
fetcher: 0,
artifactUri: String::new(),
entrypoint: String::new(),
},
testing: TestingSource {
cargoPackage: String::new(),
cargoBin: String::new(),
basePath: String::new(),
},
binaries: Vec::new(),
};
match self {
SourceSpec::Container(spec) => {
let (container, binaries, testing) = spec.into_parts()?;
source.kind = blueprint_source_kind(SourceKind::Container);
source.container = container;
source.testing = testing.unwrap_or_default().into_testing();
source.binaries = binaries;
}
SourceSpec::Native(spec) => {
let (native, binaries, testing) = spec.into_parts()?;
source.kind = blueprint_source_kind(SourceKind::Native);
source.native = native;
source.testing = testing.unwrap_or_default().into_testing();
source.binaries = binaries;
}
SourceSpec::Wasm(spec) => {
let (wasm, binaries, testing) = spec.into_parts()?;
source.kind = blueprint_source_kind(SourceKind::Wasm);
source.wasm = wasm;
source.testing = testing.unwrap_or_default().into_testing();
source.binaries = binaries;
}
}
Ok(source)
}
fn summary(&self, index: usize) -> SourceSummary {
match self {
SourceSpec::Container(spec) => SourceSummary {
index,
kind: SourceKind::Container,
fetcher: None,
entrypoint: None,
details: SourceSummaryDetails::Container {
registry: spec.registry.clone(),
image: spec.image.clone(),
tag: spec.tag.clone(),
},
},
SourceSpec::Native(spec) => SourceSummary {
index,
kind: SourceKind::Native,
fetcher: Some(spec.fetcher),
entrypoint: Some(spec.entrypoint.clone()),
details: SourceSummaryDetails::Native {
has_testing: spec.testing.is_some(),
},
},
SourceSpec::Wasm(spec) => SourceSummary {
index,
kind: SourceKind::Wasm,
fetcher: Some(spec.fetcher),
entrypoint: Some(spec.entrypoint.clone()),
details: SourceSummaryDetails::Wasm {
runtime: spec.runtime,
},
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Container,
Native,
Wasm,
}
#[derive(Debug, Clone)]
pub struct SourceSummary {
pub index: usize,
pub kind: SourceKind,
pub fetcher: Option<FetcherKind>,
pub entrypoint: Option<String>,
pub details: SourceSummaryDetails,
}
#[derive(Debug, Clone)]
pub enum SourceSummaryDetails {
Container {
registry: String,
image: String,
tag: String,
},
Native {
has_testing: bool,
},
Wasm {
runtime: WasmRuntimeKind,
},
}
#[derive(Debug, Deserialize, Clone)]
struct ContainerSourceSpec {
registry: String,
image: String,
tag: String,
#[serde(default)]
testing: Option<TestingSourceSpec>,
#[serde(default)]
binaries: Vec<BinaryArtifactSpec>,
}
impl ContainerSourceSpec {
fn into_parts(
self,
) -> Result<(
ImageRegistrySource,
Vec<OnChainBlueprintBinary>,
Option<TestingSourceSpec>,
)> {
let container = ImageRegistrySource {
registry: self.registry,
image: self.image,
tag: self.tag,
};
let binaries = convert_binary_specs("container source", self.binaries)?;
Ok((container, binaries, self.testing))
}
}
#[derive(Debug, Deserialize, Clone)]
struct NativeSourceSpec {
#[serde(default)]
fetcher: FetcherKind,
#[serde(default)]
artifact_uri: String,
#[serde(default)]
entrypoint: String,
#[serde(default)]
github: Option<GithubArtifactSpec>,
#[serde(default)]
remote: Option<RemoteArtifactSpec>,
#[serde(default)]
binaries: Vec<BinaryArtifactSpec>,
#[serde(default)]
testing: Option<TestingSourceSpec>,
}
impl NativeSourceSpec {
fn into_parts(
self,
) -> Result<(
NativeSource,
Vec<OnChainBlueprintBinary>,
Option<TestingSourceSpec>,
)> {
if self.entrypoint.trim().is_empty() {
return Err(eyre!("native source requires a non-empty entrypoint"));
}
let binaries = convert_binary_specs("native source", self.binaries)?;
let artifact_uri = match self.fetcher {
FetcherKind::Github => {
let spec = self
.github
.ok_or_else(|| eyre!("github native source requires `github` metadata"))?;
spec.validate()?;
serde_json::to_string(&spec)
.map_err(|err| eyre!("failed to serialize GitHub artifact: {err}"))?
}
FetcherKind::Http | FetcherKind::Ipfs => {
let spec = self
.remote
.ok_or_else(|| eyre!("http/ipfs native source requires `remote` metadata"))?;
spec.validate()?;
serde_json::to_string(&spec)
.map_err(|err| eyre!("failed to serialize remote artifact metadata: {err}"))?
}
FetcherKind::None => {
if self.artifact_uri.trim().is_empty() {
return Err(eyre!(
"native fetcher `none` requires `artifact_uri` to be configured"
));
}
self.artifact_uri
}
};
let native = NativeSource {
fetcher: self.fetcher.into_fetcher(),
artifactUri: artifact_uri,
entrypoint: self.entrypoint,
};
Ok((native, binaries, self.testing))
}
}
#[derive(Debug, Deserialize, Clone)]
struct WasmSourceSpec {
#[serde(default)]
runtime: WasmRuntimeKind,
#[serde(default)]
fetcher: FetcherKind,
#[serde(default)]
artifact_uri: String,
#[serde(default)]
entrypoint: String,
#[serde(default)]
testing: Option<TestingSourceSpec>,
#[serde(default)]
binaries: Vec<BinaryArtifactSpec>,
}
impl WasmSourceSpec {
fn into_parts(
self,
) -> Result<(
WasmSource,
Vec<OnChainBlueprintBinary>,
Option<TestingSourceSpec>,
)> {
let wasm = WasmSource {
runtime: self.runtime.into_runtime(),
fetcher: self.fetcher.into_fetcher(),
artifactUri: self.artifact_uri,
entrypoint: self.entrypoint,
};
let binaries = convert_binary_specs("wasm source", self.binaries)?;
Ok((wasm, binaries, self.testing))
}
}
#[derive(Debug, Deserialize, Clone)]
struct TestingSourceSpec {
cargo_package: String,
cargo_bin: String,
base_path: String,
}
impl Default for TestingSourceSpec {
fn default() -> Self {
Self {
cargo_package: String::new(),
cargo_bin: String::new(),
base_path: String::new(),
}
}
}
impl TestingSourceSpec {
fn into_testing(self) -> TestingSource {
TestingSource {
cargoPackage: self.cargo_package,
cargoBin: self.cargo_bin,
basePath: self.base_path,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GithubArtifactSpec {
pub owner: String,
pub repo: String,
pub tag: String,
#[serde(default)]
pub binaries: Vec<BinaryArtifactSpec>,
}
impl GithubArtifactSpec {
fn validate(&self) -> Result<()> {
if self.owner.trim().is_empty() {
return Err(eyre!("github native source requires a non-empty owner"));
}
if self.repo.trim().is_empty() {
return Err(eyre!("github native source requires a non-empty repo"));
}
if self.tag.trim().is_empty() {
return Err(eyre!("github native source requires a non-empty tag"));
}
if self.binaries.is_empty() {
return Err(eyre!(
"github native source binaries list must not be empty"
));
}
for binary in &self.binaries {
binary.validate()?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BinaryArtifactSpec {
pub name: String,
pub arch: String,
pub os: String,
pub sha256: String,
#[serde(default)]
pub blake3: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RemoteArtifactSpec {
pub dist_url: String,
pub archive_url: String,
#[serde(default)]
pub binaries: Vec<BinaryArtifactSpec>,
}
impl RemoteArtifactSpec {
fn validate(&self) -> Result<()> {
Url::parse(&self.dist_url)
.map_err(|err| eyre!("invalid dist_url `{}`: {err}", self.dist_url))?;
Url::parse(&self.archive_url)
.map_err(|err| eyre!("invalid archive_url `{}`: {err}", self.archive_url))?;
Ok(())
}
}
impl BinaryArtifactSpec {
fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(eyre!("binary name must not be empty"));
}
if self.arch.trim().is_empty() {
return Err(eyre!("binary arch must not be empty"));
}
if self.os.trim().is_empty() {
return Err(eyre!("binary os must not be empty"));
}
let _ = Self::parse_digest(&self.sha256, "sha256")?;
if let Some(blake3) = &self.blake3 {
let _ = Self::parse_digest(blake3, "blake3")?;
}
Ok(())
}
fn parse_digest(value: &str, label: &str) -> Result<FixedBytes<32>> {
let trimmed = value.trim().trim_start_matches("0x");
let bytes =
hex::decode(trimmed).map_err(|err| eyre!("invalid {label} digest `{value}`: {err}"))?;
if bytes.len() != 32 {
return Err(eyre!(
"{label} digest must be 32 bytes (got {} bytes)",
bytes.len()
));
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&bytes);
Ok(FixedBytes::from(buf))
}
fn into_blueprint_binary(self) -> Result<OnChainBlueprintBinary> {
let sha256 = Self::parse_digest(&self.sha256, "sha256")?;
let arch = parse_architecture(&self.arch)?;
let os = parse_operating_system(&self.os)?;
Ok(OnChainBlueprintBinary {
arch,
os,
name: self.name,
sha256,
})
}
}
fn convert_binary_specs(
label: &str,
specs: Vec<BinaryArtifactSpec>,
) -> Result<Vec<OnChainBlueprintBinary>> {
if specs.is_empty() {
return Err(eyre!("{label} requires at least one binary descriptor"));
}
let mut dedup = HashSet::new();
let mut converted = Vec::new();
for spec in specs {
spec.validate()?;
let binary = spec.into_blueprint_binary()?;
let digest = binary.sha256;
if dedup.insert(digest) {
converted.push(binary);
}
}
Ok(converted)
}
fn parse_architecture(value: &str) -> Result<u8> {
let normalized = value.trim().to_lowercase();
let variant = match normalized.as_str() {
"wasm32" | "wasm-32" => 0,
"wasm64" | "wasm-64" => 1,
"wasi32" | "wasi-32" => 2,
"wasi64" | "wasi-64" => 3,
"amd32" | "x86" | "i386" | "ia32" | "x86_32" => 4,
"amd64" | "x86_64" | "x64" => 5,
"arm32" | "armv7" | "armv6" | "arm" => 6,
"arm64" | "aarch64" | "armv8" => 7,
"riscv32" | "risc-v32" | "riscv-32" => 8,
"riscv64" | "risc-v64" | "riscv-64" => 9,
other => {
return Err(eyre!(
"unsupported binary architecture `{other}`, expected wasm32/64, wasi32/64, amd32/64, arm32/64, or riscv32/64"
));
}
};
Ok(variant)
}
fn parse_operating_system(value: &str) -> Result<u8> {
let normalized = value.trim().to_lowercase();
let variant = match normalized.as_str() {
"" => return Err(eyre!("binary os must not be empty")),
"unknown" => 0,
"linux" => 1,
"windows" | "win32" | "win64" => 2,
"macos" | "mac" | "osx" | "darwin" => 3,
"bsd" | "freebsd" | "openbsd" | "netbsd" => 4,
other => {
return Err(eyre!(
"unsupported binary operating system `{other}`, expected linux/windows/macos/bsd/unknown"
));
}
};
Ok(variant)
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum MembershipModelSpec {
Fixed,
Dynamic,
}
impl MembershipModelSpec {
fn into_membership(self) -> u8 {
match self {
MembershipModelSpec::Fixed => 0,
MembershipModelSpec::Dynamic => 1,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum PricingModelSpec {
PayOnce,
Subscription,
EventDriven,
}
impl Default for PricingModelSpec {
fn default() -> Self {
Self::PayOnce
}
}
impl PricingModelSpec {
fn into_pricing(self) -> u8 {
match self {
PricingModelSpec::PayOnce => 0,
PricingModelSpec::Subscription => 1,
PricingModelSpec::EventDriven => 2,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FetcherKind {
None,
Ipfs,
Http,
Github,
}
impl Default for FetcherKind {
fn default() -> Self {
Self::None
}
}
impl FetcherKind {
fn into_fetcher(self) -> u8 {
match self {
FetcherKind::None => 0,
FetcherKind::Ipfs => 1,
FetcherKind::Http => 2,
FetcherKind::Github => 3,
}
}
}
impl fmt::Display for FetcherKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FetcherKind::None => write!(f, "none"),
FetcherKind::Ipfs => write!(f, "ipfs"),
FetcherKind::Http => write!(f, "http"),
FetcherKind::Github => write!(f, "github"),
}
}
}
impl fmt::Display for SourceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SourceKind::Container => write!(f, "container"),
SourceKind::Native => write!(f, "native"),
SourceKind::Wasm => write!(f, "wasm"),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmRuntimeKind {
Unknown,
Wasmtime,
Wasmer,
}
impl Default for WasmRuntimeKind {
fn default() -> Self {
Self::Unknown
}
}
impl WasmRuntimeKind {
fn into_runtime(self) -> u8 {
match self {
WasmRuntimeKind::Unknown => 0,
WasmRuntimeKind::Wasmtime => 1,
WasmRuntimeKind::Wasmer => 2,
}
}
}
fn blueprint_source_kind(kind: SourceKind) -> u8 {
match kind {
SourceKind::Container => 0,
SourceKind::Wasm => 1,
SourceKind::Native => 2,
}
}
fn hex_to_bytes(value: Option<&str>) -> Result<Bytes> {
if let Some(raw) = value {
if raw.trim().is_empty() {
return Ok(Bytes::new());
}
let trimmed = raw.trim();
if trimmed.starts_with('[') {
return encode_json_schema_to_tlv(trimmed);
}
let hex_trimmed = trimmed.strip_prefix("0x").unwrap_or(trimmed);
if hex_trimmed.starts_with("5b") || hex_trimmed.starts_with("5B") {
if let Ok(decoded) = hex::decode(hex_trimmed) {
if let Ok(json_str) = std::str::from_utf8(&decoded) {
if json_str.trim().starts_with('[') {
return encode_json_schema_to_tlv(json_str);
}
}
}
}
let decoded = hex::decode(hex_trimmed)
.map_err(|e| eyre!("failed to decode hex schema {raw}: {e}"))?;
Ok(Bytes::from(decoded))
} else {
Ok(Bytes::new())
}
}
const SCHEMA_VERSION_2: u8 = 0x02;
fn encode_json_schema_to_tlv(json_str: &str) -> Result<Bytes> {
let params: Vec<Param> =
serde_json::from_str(json_str).map_err(|e| eyre!("failed to parse JSON schema: {e}"))?;
if params.is_empty() {
return Ok(Bytes::new());
}
let field_count = params.len();
if field_count > u16::MAX as usize {
return Err(eyre!("schema has too many fields (max {})", u16::MAX));
}
let total_size: usize = 1 + 2 + params.iter().map(calculate_field_size).sum::<usize>();
let mut buffer = vec![0u8; total_size];
buffer[0] = SCHEMA_VERSION_2;
buffer[1] = (field_count >> 8) as u8;
buffer[2] = (field_count & 0xFF) as u8;
let mut cursor = 3;
for param in ¶ms {
cursor = write_field(&mut buffer, cursor, param)?;
}
Ok(Bytes::from(buffer))
}
fn calculate_field_size(param: &Param) -> usize {
let name_len = param.name.len();
let mut size = 5 + compact_length_size(name_len) + name_len;
if param.ty.ends_with(']') && param.components.is_empty() {
if let Some(bracket_pos) = param.ty.rfind('[') {
let base_type = ¶m.ty[..bracket_pos];
if !base_type.is_empty() {
let element_param = Param {
name: String::new(),
ty: base_type.to_string(),
components: vec![],
internal_type: None,
};
size += calculate_field_size(&element_param);
return size;
}
}
}
for child in ¶m.components {
size += calculate_field_size(child);
}
size
}
fn compact_length_size(len: usize) -> usize {
if len < 0x80 {
1
} else if len < 0x4000 {
2
} else if len < 0x200000 {
3
} else {
4
}
}
#[allow(dead_code)]
fn count_nodes(param: &Param) -> usize {
if param.ty.ends_with(']') && param.components.is_empty() {
if let Some(bracket_pos) = param.ty.rfind('[') {
let base_type = ¶m.ty[..bracket_pos];
if !base_type.is_empty() {
let element_param = Param {
name: String::new(),
ty: base_type.to_string(),
components: vec![],
internal_type: None,
};
return 1 + count_nodes(&element_param);
}
}
}
1 + param.components.iter().map(count_nodes).sum::<usize>()
}
fn write_field(buffer: &mut [u8], cursor: usize, param: &Param) -> Result<usize> {
let (kind, array_length) = parse_solidity_type(¶m.ty)?;
let (child_count, synthetic_child) =
if (kind == 20 || kind == 21) && param.components.is_empty() && param.ty.ends_with(']') {
if let Some(bracket_pos) = param.ty.rfind('[') {
let base_type = ¶m.ty[..bracket_pos];
if !base_type.is_empty() {
let element_param = Param {
name: String::new(),
ty: base_type.to_string(),
components: vec![],
internal_type: None,
};
(1, Some(element_param))
} else {
(param.components.len(), None)
}
} else {
(param.components.len(), None)
}
} else {
(param.components.len(), None)
};
if child_count > u16::MAX as usize {
return Err(eyre!("field has too many children (max {})", u16::MAX));
}
buffer[cursor] = kind;
buffer[cursor + 1] = (array_length >> 8) as u8;
buffer[cursor + 2] = (array_length & 0xFF) as u8;
buffer[cursor + 3] = (child_count >> 8) as u8;
buffer[cursor + 4] = (child_count & 0xFF) as u8;
let mut next_cursor = cursor + 5;
next_cursor = write_compact_string(buffer, next_cursor, ¶m.name);
if let Some(ref element) = synthetic_child {
next_cursor = write_field(buffer, next_cursor, element)?;
}
for child in ¶m.components {
next_cursor = write_field(buffer, next_cursor, child)?;
}
Ok(next_cursor)
}
fn write_compact_string(buffer: &mut [u8], cursor: usize, s: &str) -> usize {
let bytes = s.as_bytes();
let len = bytes.len();
let cursor = write_compact_length(buffer, cursor, len);
buffer[cursor..cursor + len].copy_from_slice(bytes);
cursor + len
}
fn write_compact_length(buffer: &mut [u8], cursor: usize, len: usize) -> usize {
if len < 0x80 {
buffer[cursor] = len as u8;
cursor + 1
} else if len < 0x4000 {
buffer[cursor] = (0x80 | (len >> 8)) as u8;
buffer[cursor + 1] = (len & 0xFF) as u8;
cursor + 2
} else if len < 0x200000 {
buffer[cursor] = (0xC0 | (len >> 16)) as u8;
buffer[cursor + 1] = ((len >> 8) & 0xFF) as u8;
buffer[cursor + 2] = (len & 0xFF) as u8;
cursor + 3
} else {
buffer[cursor] = (0xE0 | (len >> 24)) as u8;
buffer[cursor + 1] = ((len >> 16) & 0xFF) as u8;
buffer[cursor + 2] = ((len >> 8) & 0xFF) as u8;
buffer[cursor + 3] = (len & 0xFF) as u8;
cursor + 4
}
}
fn parse_solidity_type(ty: &str) -> Result<(u8, u16)> {
let ty = ty.trim();
if ty.ends_with(']') {
if let Some(bracket_pos) = ty.rfind('[') {
let _base_type = &ty[..bracket_pos];
let size_str = &ty[bracket_pos + 1..ty.len() - 1];
if size_str.is_empty() {
return Ok((21, 0)); } else {
let size: u16 = size_str
.parse()
.map_err(|_| eyre!("invalid array size in type '{ty}'"))?;
return Ok((20, size)); }
}
}
if ty == "tuple" {
return Ok((22, 0)); }
let kind = match ty {
"bool" => 1,
"uint8" => 2,
"int8" => 3,
"uint16" => 4,
"int16" => 5,
"uint32" => 6,
"int32" => 7,
"uint64" => 8,
"int64" => 9,
"uint128" => 10,
"int128" => 11,
"uint256" | "uint" => 12,
"int256" | "int" => 13,
"address" => 14,
"bytes32" => 15,
"string" => 17,
"bytes" => 18,
_ => {
if let Some(size_str) = ty.strip_prefix("bytes") {
if let Ok(size) = size_str.parse::<u16>() {
if size >= 1 && size <= 32 {
if size == 32 {
return Ok((15, 0)); }
return Ok((16, size)); }
}
}
if let Some(size_str) = ty.strip_prefix("uint") {
if let Ok(bits) = size_str.parse::<u16>() {
return Ok((
match bits {
8 => 2,
16 => 4,
32 => 6,
64 => 8,
128 => 10,
256 => 12,
_ => return Err(eyre!("unsupported uint size: uint{bits}")),
},
0,
));
}
}
if let Some(size_str) = ty.strip_prefix("int") {
if let Ok(bits) = size_str.parse::<u16>() {
return Ok((
match bits {
8 => 3,
16 => 5,
32 => 7,
64 => 9,
128 => 11,
256 => 13,
_ => return Err(eyre!("unsupported int size: int{bits}")),
},
0,
));
}
}
return Err(eyre!("unsupported Solidity type: '{ty}'"));
}
};
Ok((kind, 0))
}
fn default_memberships() -> Vec<MembershipModelSpec> {
vec![MembershipModelSpec::Fixed]
}
fn default_name() -> String {
"Blueprint".into()
}
fn default_author() -> String {
"Unknown".into()
}
fn default_category() -> String {
"General".into()
}
fn default_website() -> String {
"https://tangle.network".into()
}
fn default_license() -> String {
"MIT".into()
}
struct DefinitionSummary {
metadata_uri: String,
metadata_hash: FixedBytes<32>,
manager: Address,
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
const TEST_METADATA_HASH: &str =
"0x1111111111111111111111111111111111111111111111111111111111111111";
#[test]
fn parses_minimal_definition() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{ "name": "square" }
],
"sources": [
{
"kind": "container",
"registry": "ghcr.io",
"image": "org/blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "blueprint",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let loaded = load_blueprint_definition(&path, None).unwrap();
assert_eq!(loaded.definition.metadata_uri, "ipfs://cid");
assert_eq!(
loaded.definition.manager,
Address::from_str("0x0000000000000000000000000000000000000001").unwrap()
);
assert!(
!loaded.definition.encoded.is_empty(),
"encoded definition should not be empty"
);
}
#[test]
fn computes_metadata_hash_from_metadata_file() {
let temp_dir = tempfile::tempdir().unwrap();
let metadata_dir = temp_dir.path().join("metadata");
fs::create_dir_all(&metadata_dir).unwrap();
let metadata_path = metadata_dir.join("blueprint-metadata.json");
let metadata = serde_json::json!({
"name": "Hosted Blueprint",
"description": "Canonical hash test",
"integrity": {
"schema": "tangle-blueprint-metadata/v1",
"payloadHash": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
},
"blueprintUi": {
"tier": "hosted"
}
});
fs::write(
&metadata_path,
serde_json::to_vec_pretty(&metadata).unwrap(),
)
.unwrap();
let definition_path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_file": "metadata/blueprint-metadata.json",
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{ "name": "square" }
],
"sources": [
{
"kind": "container",
"registry": "ghcr.io",
"image": "org/blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "blueprint",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
]
});
fs::write(
&definition_path,
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
let loaded = load_blueprint_definition(&definition_path, None).unwrap();
let expected = compute_metadata_hash_from_file(&metadata_path).unwrap();
assert_eq!(loaded.definition.metadata_hash, expected);
}
#[test]
fn round_trip_github_source_matches_metadata() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{"name": "square"}
],
"sources": [
{
"kind": "native",
"fetcher": "github",
"entrypoint": "./bin/cli",
"binaries": [
{
"name": "cli",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"blake3": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
],
"github": {
"owner": "tangle",
"repo": "blueprint",
"tag": "v1.2.3",
"binaries": [
{
"name": "cli",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"blake3": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
]
}
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let loaded = load_blueprint_definition(&path, None).unwrap();
assert_eq!(loaded.summaries.len(), 1);
assert_eq!(loaded.summaries[0].kind, SourceKind::Native);
assert_eq!(loaded.summaries[0].fetcher.unwrap(), FetcherKind::Github);
let decoded =
decode_blueprint_definition(loaded.definition.encoded.as_ref()).expect("decode");
assert_eq!(decoded.sources.len(), 1);
let native_source = &decoded.sources[0];
assert_eq!(
native_source.kind,
blueprint_source_kind(SourceKind::Native)
);
assert_eq!(
native_source.native.fetcher,
FetcherKind::Github.into_fetcher()
);
let metadata_json = native_source.native.artifactUri.clone().to_string();
let metadata: GithubArtifactSpec = serde_json::from_str(&metadata_json).unwrap();
assert_eq!(metadata.owner, "tangle");
assert_eq!(metadata.repo, "blueprint");
assert_eq!(metadata.tag, "v1.2.3");
assert_eq!(metadata.binaries.len(), 1);
assert_eq!(metadata.binaries[0].name, "cli");
}
#[test]
fn metadata_execution_profile_injects_structured_profiling_data() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"metadata": {
"name": "TEE Blueprint",
"execution_profile": {
"confidentiality": "tee_required"
}
},
"jobs": [
{ "name": "square" }
],
"sources": [
{
"kind": "container",
"registry": "ghcr.io",
"image": "org/blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "blueprint",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let loaded = load_blueprint_definition(&path, None).unwrap();
let decoded = decode_blueprint_definition(loaded.definition.encoded.as_ref()).unwrap();
let metadata_payload = decoded.metadata.profilingData.to_string();
let metadata_json: serde_json::Value = serde_json::from_str(&metadata_payload).unwrap();
assert_eq!(
metadata_json
.get("execution_profile")
.and_then(|v| v.get("confidentiality"))
.and_then(serde_json::Value::as_str),
Some("tee_required")
);
}
#[test]
fn metadata_execution_profile_accepts_standard_required() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"metadata": {
"execution_profile": { "confidentiality": "standard_required" }
},
"jobs": [
{ "name": "square" }
],
"sources": [
{
"kind": "container",
"registry": "ghcr.io",
"image": "org/blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "blueprint",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let loaded = load_blueprint_definition(&path, None).unwrap();
let decoded = decode_blueprint_definition(loaded.definition.encoded.as_ref()).unwrap();
let metadata_payload = decoded.metadata.profilingData.to_string();
let metadata_json: serde_json::Value = serde_json::from_str(&metadata_payload).unwrap();
assert_eq!(
metadata_json
.get("execution_profile")
.and_then(|v| v.get("confidentiality"))
.and_then(serde_json::Value::as_str),
Some("standard_required")
);
}
#[test]
fn metadata_execution_profile_wraps_legacy_profiling_blob() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let legacy_blob = "H4sIAAAAAAAA/2NgYGBgBGIOAwA6rY+4BQAAAA==";
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"metadata": {
"profiling_data": legacy_blob,
"execution_profile": {
"confidentiality": "tee_required"
}
},
"jobs": [
{ "name": "square" }
],
"sources": [
{
"kind": "container",
"registry": "ghcr.io",
"image": "org/blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "blueprint",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let loaded = load_blueprint_definition(&path, None).unwrap();
let decoded = decode_blueprint_definition(loaded.definition.encoded.as_ref()).unwrap();
let metadata_payload = decoded.metadata.profilingData.to_string();
let metadata_json: serde_json::Value = serde_json::from_str(&metadata_payload).unwrap();
assert_eq!(
metadata_json
.get("job_profiles_b64_gzip")
.and_then(serde_json::Value::as_str),
Some(legacy_blob)
);
}
#[test]
fn round_trip_http_and_ipfs_sources() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{"name": "square"}
],
"sources": [
{
"kind": "native",
"fetcher": "http",
"entrypoint": "./srv",
"binaries": [
{
"name": "srv",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
],
"remote": {
"dist_url": "https://example.com/dist.json",
"archive_url": "https://example.com/archive.tar.xz",
"binaries": [
{
"name": "srv",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
},
{
"kind": "native",
"fetcher": "ipfs",
"entrypoint": "./ipfs",
"binaries": [
{
"name": "ipfs",
"arch": "aarch64",
"os": "linux",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
],
"remote": {
"dist_url": "ipfs://manifest-cid",
"archive_url": "ipfs://archive-cid",
"binaries": [
{
"name": "ipfs",
"arch": "aarch64",
"os": "linux",
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
]
}
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let loaded = load_blueprint_definition(&path, None).unwrap();
assert_eq!(loaded.summaries.len(), 2);
assert_eq!(loaded.summaries[0].fetcher.unwrap(), FetcherKind::Http);
assert_eq!(loaded.summaries[1].fetcher.unwrap(), FetcherKind::Ipfs);
let decoded =
decode_blueprint_definition(loaded.definition.encoded.as_ref()).expect("decode");
assert_eq!(decoded.sources.len(), 2);
for (idx, fetcher) in [FetcherKind::Http, FetcherKind::Ipfs]
.into_iter()
.enumerate()
{
assert_eq!(decoded.sources[idx].native.fetcher, fetcher.into_fetcher());
}
}
#[test]
fn native_entrypoint_must_not_be_empty() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{"name": "square"}
],
"sources": [
{
"kind": "native",
"fetcher": "github",
"entrypoint": "",
"github": {
"owner": "tangle",
"repo": "blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "cli",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let err = load_blueprint_definition(&path, None).unwrap_err();
assert!(
err.to_string()
.contains("native source requires a non-empty entrypoint")
);
}
#[test]
fn remote_urls_and_binaries_are_validated() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{"name": "square"}
],
"sources": [
{
"kind": "native",
"fetcher": "http",
"entrypoint": "./srv",
"binaries": [
{
"name": "srv",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
],
"remote": {
"dist_url": "not-a-url",
"archive_url": "https://example.com/archive.tar.xz",
"binaries": [
{
"name": "srv",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let err = load_blueprint_definition(&path, None).unwrap_err();
assert!(
err.to_string().contains("invalid dist_url"),
"expected 'invalid dist_url' error but got: {}",
err
);
}
#[test]
fn remote_requires_binary_entries() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{"name": "square"}
],
"sources": [
{
"kind": "native",
"fetcher": "http",
"entrypoint": "./srv",
"binaries": [],
"remote": {
"dist_url": "https://example.com/dist.json",
"archive_url": "https://example.com/archive.tar.xz",
"binaries": []
}
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let err = load_blueprint_definition(&path, None).unwrap_err();
assert!(
err.to_string()
.contains("native source requires at least one binary descriptor"),
"expected 'native source requires at least one binary descriptor' error but got: {}",
err
);
}
#[test]
fn binary_hashes_must_be_hex() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("definition.json");
let manifest = serde_json::json!({
"metadata_uri": "ipfs://cid",
"metadata_hash": TEST_METADATA_HASH,
"manager": "0x0000000000000000000000000000000000000001",
"jobs": [
{"name": "square"}
],
"sources": [
{
"kind": "native",
"fetcher": "github",
"entrypoint": "./srv",
"binaries": [
{
"name": "srv",
"arch": "x86_64",
"os": "linux",
"sha256": "not-hex"
}
],
"github": {
"owner": "tangle",
"repo": "blueprint",
"tag": "v0.1.0",
"binaries": [
{
"name": "srv",
"arch": "x86_64",
"os": "linux",
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
}
}
]
});
fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
let err = load_blueprint_definition(&path, None).unwrap_err();
assert!(
err.to_string().contains("invalid sha256 digest"),
"expected 'invalid sha256 digest' error but got: {}",
err
);
}
#[test]
fn encodes_simple_string_schema_to_tlv() {
let json = r#"[{"name": "greeting", "type": "string"}]"#;
let result = encode_json_schema_to_tlv(json).unwrap();
assert_eq!(result.len(), 17); assert_eq!(result[0], 0x02); assert_eq!(result[1], 0x00); assert_eq!(result[2], 0x01); assert_eq!(result[3], 17); assert_eq!(result[4], 0x00); assert_eq!(result[5], 0x00); assert_eq!(result[6], 0x00); assert_eq!(result[7], 0x00); assert_eq!(result[8], 8); assert_eq!(&result[9..17], b"greeting"); }
#[test]
fn encodes_multiple_fields_schema_to_tlv() {
let json = r#"[{"name": "name", "type": "string"}, {"name": "value", "type": "uint256"}]"#;
let result = encode_json_schema_to_tlv(json).unwrap();
assert_eq!(result.len(), 24); assert_eq!(result[0], 0x02); assert_eq!(result[1], 0x00); assert_eq!(result[2], 0x02); assert_eq!(result[3], 17); assert_eq!(result[8], 4); assert_eq!(&result[9..13], b"name"); assert_eq!(result[13], 12); assert_eq!(result[18], 5); assert_eq!(&result[19..24], b"value"); }
#[test]
fn encodes_tuple_with_children_to_tlv() {
let json = r#"[{"name": "data", "type": "tuple", "components": [{"name": "x", "type": "uint64"}, {"name": "y", "type": "bool"}]}]"#;
let result = encode_json_schema_to_tlv(json).unwrap();
assert_eq!(result.len(), 27); assert_eq!(result[0], 0x02); assert_eq!(result[1], 0x00); assert_eq!(result[2], 0x01); assert_eq!(result[3], 22); assert_eq!(result[6], 0x00); assert_eq!(result[7], 0x02); assert_eq!(result[8], 4); assert_eq!(&result[9..13], b"data"); assert_eq!(result[13], 8); assert_eq!(result[18], 1); assert_eq!(result[19], b'x'); assert_eq!(result[20], 1); assert_eq!(result[25], 1); assert_eq!(result[26], b'y'); }
#[test]
fn encodes_dynamic_array_to_tlv() {
let json = r#"[{"name": "values", "type": "uint256[]"}]"#;
let result = encode_json_schema_to_tlv(json).unwrap();
assert_eq!(result.len(), 21); assert_eq!(result[0], 0x02); assert_eq!(result[3], 21); assert_eq!(result[6], 0x00); assert_eq!(result[7], 0x01); assert_eq!(result[8], 6); assert_eq!(&result[9..15], b"values"); assert_eq!(result[15], 12); assert_eq!(result[20], 0); }
#[test]
fn encodes_fixed_array_to_tlv() {
let json = r#"[{"name": "triple", "type": "uint256[3]"}]"#;
let result = encode_json_schema_to_tlv(json).unwrap();
assert_eq!(result.len(), 21); assert_eq!(result[0], 0x02); assert_eq!(result[3], 20); assert_eq!(result[4], 0x00); assert_eq!(result[5], 0x03); assert_eq!(result[6], 0x00); assert_eq!(result[7], 0x01); assert_eq!(result[8], 6); assert_eq!(&result[9..15], b"triple"); assert_eq!(result[15], 12); assert_eq!(result[20], 0); }
#[test]
fn hex_to_bytes_converts_json_schema() {
let json = r#"[{"name": "name", "type": "string"}]"#;
let result = hex_to_bytes(Some(json)).unwrap();
assert_eq!(result.len(), 13); assert_eq!(result[0], 0x02); assert_eq!(result[3], 17); }
#[test]
fn hex_to_bytes_converts_hex_encoded_json_schema() {
let json = r#"[{"name": "name", "type": "string"}]"#;
let hex_encoded = format!("0x{}", hex::encode(json.as_bytes()));
let result = hex_to_bytes(Some(&hex_encoded)).unwrap();
assert_eq!(result.len(), 13); assert_eq!(result[0], 0x02); assert_eq!(result[3], 17); }
#[test]
fn hex_to_bytes_passes_through_tlv_bytes() {
let tlv_bytes = "0x0001110000000000";
let result = hex_to_bytes(Some(tlv_bytes)).unwrap();
assert_eq!(result.len(), 8);
assert_eq!(result[2], 17); }
#[test]
fn parses_all_basic_solidity_types() {
assert_eq!(parse_solidity_type("bool").unwrap(), (1, 0));
assert_eq!(parse_solidity_type("uint8").unwrap(), (2, 0));
assert_eq!(parse_solidity_type("int8").unwrap(), (3, 0));
assert_eq!(parse_solidity_type("uint16").unwrap(), (4, 0));
assert_eq!(parse_solidity_type("int16").unwrap(), (5, 0));
assert_eq!(parse_solidity_type("uint32").unwrap(), (6, 0));
assert_eq!(parse_solidity_type("int32").unwrap(), (7, 0));
assert_eq!(parse_solidity_type("uint64").unwrap(), (8, 0));
assert_eq!(parse_solidity_type("int64").unwrap(), (9, 0));
assert_eq!(parse_solidity_type("uint128").unwrap(), (10, 0));
assert_eq!(parse_solidity_type("int128").unwrap(), (11, 0));
assert_eq!(parse_solidity_type("uint256").unwrap(), (12, 0));
assert_eq!(parse_solidity_type("int256").unwrap(), (13, 0));
assert_eq!(parse_solidity_type("uint").unwrap(), (12, 0));
assert_eq!(parse_solidity_type("int").unwrap(), (13, 0));
assert_eq!(parse_solidity_type("address").unwrap(), (14, 0));
assert_eq!(parse_solidity_type("bytes32").unwrap(), (15, 0));
assert_eq!(parse_solidity_type("string").unwrap(), (17, 0));
assert_eq!(parse_solidity_type("bytes").unwrap(), (18, 0));
assert_eq!(parse_solidity_type("tuple").unwrap(), (22, 0));
}
#[test]
fn parses_fixed_bytes_types() {
assert_eq!(parse_solidity_type("bytes1").unwrap(), (16, 1));
assert_eq!(parse_solidity_type("bytes16").unwrap(), (16, 16));
assert_eq!(parse_solidity_type("bytes31").unwrap(), (16, 31));
assert_eq!(parse_solidity_type("bytes32").unwrap(), (15, 0)); }
#[test]
fn parses_array_types() {
assert_eq!(parse_solidity_type("uint256[]").unwrap(), (21, 0));
assert_eq!(parse_solidity_type("uint256[10]").unwrap(), (20, 10));
assert_eq!(parse_solidity_type("address[5]").unwrap(), (20, 5));
}
}