use super::{
blueprint::resolve_runtime_options,
chain::{log_tx, upgrade_tx_ctx, upgrade_view_ctx},
};
use alloy_primitives::{Address, Bytes, U256};
use blueprint_client_tangle::contracts::ITangleTypes;
use cargo_tangle::command::list;
use cargo_tangle::command::run::tangle::{RunOpts, run_blueprint};
use cargo_tangle::command::service::{
approve_service, approve_service_with_commitments, build_request_params, join_service,
leave_service, reject_service, request_service, with_security_requirements,
};
use cargo_tangle::command::tangle::{
PreferredSourceArg, SpawnMethod, TangleClientArgs, parse_address,
};
use cargo_tangle::settings::RuntimePreferences;
use clap::Subcommand;
use color_eyre::eyre::{Context, Result, ensure, eyre};
use serde_json::json;
use std::{fs, path::PathBuf, str::FromStr};
use url::Url;
#[derive(Subcommand, Debug)]
pub(super) enum ServiceCommands {
Request {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
blueprint_id: u64,
#[arg(long = "operator", required = true)]
operators: Vec<String>,
#[arg(long = "operator-exposure-bps")]
operator_exposures: Vec<u16>,
#[arg(long = "permitted-caller")]
permitted_callers: Vec<String>,
#[arg(long = "config-file", value_name = "PATH")]
config_file: Option<PathBuf>,
#[arg(long = "config-hex", value_name = "HEX")]
config_hex: Option<String>,
#[arg(long, default_value_t = 600)]
ttl: u64,
#[arg(long, default_value = "0x0000000000000000000000000000000000000000")]
payment_token: String,
#[arg(long, default_value_t = 0)]
payment_amount: u128,
#[arg(
long = "security-requirement",
value_name = "KIND:TOKEN:MIN:MAX",
value_parser = parse_security_requirement
)]
security_requirements: Vec<SecurityRequirementArg>,
#[arg(long)]
json: bool,
},
Approve {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
request_id: u64,
#[arg(long = "staking-percent", default_value_t = 50)]
staking_percent: u8,
#[arg(
long = "security-commitment",
value_name = "KIND:TOKEN:EXPOSURE",
value_parser = parse_security_commitment
)]
security_commitments: Vec<SecurityCommitmentArg>,
#[arg(long)]
json: bool,
},
Reject {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
request_id: u64,
#[arg(long)]
json: bool,
},
Join {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
service_id: u64,
#[arg(long, default_value_t = MAX_BPS)]
exposure_bps: u16,
#[arg(long, value_name = "KIND:TOKEN:EXPOSURE_BPS")]
commitment: Vec<String>,
#[arg(long)]
json: bool,
},
Leave {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
service_id: u64,
#[arg(long)]
json: bool,
},
Spawn {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
blueprint_id: u64,
#[arg(long)]
service_id: u64,
#[arg(long, value_enum, default_value_t = SpawnMethod::Vm)]
spawn_method: SpawnMethod,
#[arg(long, value_name = "PATH")]
data_dir: Option<PathBuf>,
#[arg(long)]
allow_unchecked_attestations: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_enum)]
preferred_source: Option<PreferredSourceArg>,
#[arg(long)]
vm: bool,
#[arg(long)]
no_vm: bool,
},
List {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
json: bool,
},
Requests {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
json: bool,
},
Show {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
request_id: u64,
},
SetPolicy {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
service_id: u64,
#[arg(long, value_enum)]
policy: cargo_tangle::command::upgrade::UpgradePolicyArg,
#[arg(long)]
json: bool,
},
AckVersion {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
service_id: u64,
#[arg(long)]
version_id: u64,
#[arg(long)]
json: bool,
},
EffectiveVersion {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
service_id: u64,
#[arg(long)]
blueprint_id: u64,
#[arg(long)]
json: bool,
},
UpgradeStatus {
#[command(flatten)]
network: TangleClientArgs,
#[arg(long)]
service_id: u64,
#[arg(long)]
blueprint_id: u64,
#[arg(long)]
json: bool,
},
Upgrades {
#[arg(long)]
service_id: u64,
#[arg(long, value_name = "URL")]
manager_url: Option<Url>,
#[arg(long)]
json: bool,
},
UpgradeLocal {
#[arg(long)]
service_id: u64,
#[arg(long)]
version_id: u64,
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "URL")]
manager_url: Option<Url>,
#[arg(long)]
json: bool,
},
UpgradeWhitelist {
#[arg(long)]
service_id: u64,
#[arg(long, value_name = "LIST")]
versions: String,
#[arg(long, value_name = "URL")]
manager_url: Option<Url>,
#[arg(long)]
json: bool,
},
UpgradeSkip {
#[arg(long)]
service_id: u64,
#[arg(long)]
version_id: u64,
#[arg(long, value_name = "TEXT")]
reason: String,
#[arg(long, value_name = "URL")]
manager_url: Option<Url>,
#[arg(long)]
json: bool,
},
UpgradeAuthz {
#[arg(long)]
service_id: u64,
#[arg(long, value_name = "URL")]
manager_url: Option<Url>,
#[arg(long)]
json: bool,
},
}
pub(super) async fn run(command: ServiceCommands) -> Result<()> {
match command {
ServiceCommands::Request {
network,
blueprint_id,
operators,
operator_exposures,
permitted_callers,
config_file,
config_hex,
ttl,
payment_token,
payment_amount,
security_requirements,
json,
} => {
let operators = parse_address_list(&operators, "operator")?;
let operator_exposures =
normalize_operator_exposures(&operator_exposures, operators.len())?;
let permitted_callers = parse_address_list(&permitted_callers, "permitted caller")?;
let config = load_config_payload(config_file, config_hex)?;
let payment_token = parse_address(&payment_token, "PAYMENT_TOKEN")?;
let security_requirements: Vec<ITangleTypes::AssetSecurityRequirement> =
security_requirements
.into_iter()
.map(requirement_to_abi)
.collect();
let client = network.connect(blueprint_id, None).await?;
let params = with_security_requirements(
build_request_params(
blueprint_id,
operators,
operator_exposures,
permitted_callers,
ttl,
payment_token,
U256::from(payment_amount),
config,
),
security_requirements,
);
let (tx, request_id) = request_service(&client, params).await?;
log_tx("Service request", &tx, json);
if json {
println!(
"{}",
json!({
"event": "service_request_id",
"request_id": request_id,
"tx_hash": format!("{:#x}", tx.tx_hash),
})
);
} else {
println!("Request ID: {request_id}");
}
}
ServiceCommands::Approve {
network,
request_id,
staking_percent,
json,
security_commitments,
} => {
let client = network.connect(0, None).await?;
let tx = if security_commitments.is_empty() {
approve_service(&client, request_id, staking_percent).await?
} else {
let commitments: Vec<ITangleTypes::AssetSecurityCommitment> = security_commitments
.into_iter()
.map(commitment_to_abi)
.collect();
approve_service_with_commitments(&client, request_id, commitments).await?
};
log_tx("Service approval", &tx, json);
}
ServiceCommands::Reject {
network,
request_id,
json,
} => {
let client = network.connect(0, None).await?;
let tx = reject_service(&client, request_id).await?;
log_tx("Service rejection", &tx, json);
}
ServiceCommands::Join {
network,
service_id,
exposure_bps,
commitment,
json,
} => {
ensure!(exposure_bps > 0, "Exposure must be greater than 0 bps");
ensure!(
exposure_bps <= MAX_BPS,
"Exposure cannot exceed {MAX_BPS} bps"
);
let client = network.connect(0, Some(service_id)).await?;
let tx = if commitment.is_empty() {
join_service(&client, service_id, exposure_bps).await?
} else {
let commitments = parse_commitments(&commitment)?;
client
.join_service_with_commitments(service_id, exposure_bps, commitments)
.await
.map_err(|e| eyre!(e.to_string()))?
};
log_tx("Service join", &tx, json);
if json {
println!(
"{}",
json!({
"event": "service_joined",
"service_id": service_id,
"exposure_bps": exposure_bps,
"tx_hash": format!("{:#x}", tx.tx_hash),
})
);
} else {
println!("Joined service {service_id} with exposure {exposure_bps} bps");
}
}
ServiceCommands::Leave {
network,
service_id,
json,
} => {
let client = network.connect(0, Some(service_id)).await?;
let operator = client.account();
let operator_info = client
.get_service_operator(service_id, operator)
.await
.map_err(|e| eyre!(e.to_string()))?;
ensure!(
operator_info.active,
"Operator is not active in service {service_id}"
);
let tx = leave_service(&client, service_id).await?;
log_tx("Service leave", &tx, json);
if json {
println!(
"{}",
json!({
"event": "service_left",
"service_id": service_id,
"tx_hash": format!("{:#x}", tx.tx_hash),
})
);
} else {
println!("Left service {service_id}");
}
}
ServiceCommands::Spawn {
network,
blueprint_id,
service_id,
spawn_method,
data_dir,
allow_unchecked_attestations,
dry_run,
preferred_source,
vm,
no_vm,
} => {
let client_config = network.client_config(blueprint_id, Some(service_id))?;
let settings = client_config.settings.clone();
let (preferred_source_value, use_vm_value) = resolve_runtime_options(
spawn_method,
RuntimePreferences::default(),
preferred_source,
vm,
no_vm,
)?;
let run_opts = RunOpts {
http_rpc_url: client_config.http_rpc_endpoint.clone(),
ws_rpc_url: client_config.ws_rpc_endpoint.clone(),
blueprint_id,
service_id: Some(service_id),
tangle_contract: settings.tangle_contract,
staking_contract: settings.staking_contract,
status_registry_contract: settings.status_registry_contract,
keystore_path: network.keystore_path()?.display().to_string(),
data_dir,
allow_unchecked_attestations,
registration_mode: false,
registration_capture_only: false,
preferred_source: preferred_source_value,
use_vm: use_vm_value,
dry_run,
shutdown_after: None,
};
run_blueprint(run_opts).await?;
}
ServiceCommands::List { network, json } => {
let client = network.connect(0, None).await?;
let services = list::services::list_services(&client).await?;
list::services::print_services(&services, json);
}
ServiceCommands::Requests { network, json } => {
let client = network.connect(0, None).await?;
let requests = list::requests::list_requests(&client).await?;
list::requests::print_requests(&requests, json);
}
ServiceCommands::Show {
network,
request_id,
} => {
let client = network.connect(0, None).await?;
let request = client
.get_service_request_info(request_id)
.await
.map_err(|e| eyre!(e.to_string()))?;
list::requests::print_request(&request);
}
command => run_upgrade(command).await?,
}
Ok(())
}
pub(super) fn parse_address_list(values: &[String], label: &str) -> Result<Vec<Address>> {
values
.iter()
.map(|value| parse_address(value, label))
.collect()
}
fn normalize_operator_exposures(
exposures: &[u16],
operator_len: usize,
) -> Result<Option<Vec<u16>>> {
if exposures.is_empty() {
return Ok(None);
}
ensure!(
exposures.len() == operator_len,
"Expected {operator_len} operator exposure values but received {}",
exposures.len()
);
exposures
.iter()
.enumerate()
.try_for_each(|(idx, value)| -> Result<()> {
ensure!(
*value <= MAX_BPS,
"Operator exposure #{idx} exceeds {MAX_BPS} bps"
);
Ok(())
})?;
Ok(Some(exposures.to_vec()))
}
#[derive(Clone, Debug)]
pub(super) struct SecurityRequirementArg {
kind: AssetKindArg,
token: Address,
min: u16,
max: u16,
}
#[derive(Clone, Debug)]
pub(super) struct SecurityCommitmentArg {
kind: AssetKindArg,
token: Address,
exposure: u16,
}
#[derive(Clone, Copy, Debug)]
pub(super) enum AssetKindArg {
Native,
Erc20,
}
fn parse_security_requirement(value: &str) -> std::result::Result<SecurityRequirementArg, String> {
let parts: Vec<_> = value.split(':').collect();
if parts.len() != 4 {
return Err("Expected format KIND:TOKEN:MIN:MAX".to_string());
}
let kind = parse_asset_kind(parts[0])?;
let token = parse_token(kind, parts[1])?;
let min = parse_bps(parts[2], "min exposure")?;
let max = parse_bps(parts[3], "max exposure")?;
if min == 0 {
return Err("minimum exposure must be greater than 0".to_string());
}
if min > max {
return Err("minimum exposure cannot exceed maximum exposure".to_string());
}
Ok(SecurityRequirementArg {
kind,
token,
min,
max,
})
}
fn parse_security_commitment(value: &str) -> std::result::Result<SecurityCommitmentArg, String> {
let parts: Vec<_> = value.split(':').collect();
if parts.len() != 3 {
return Err("Expected format KIND:TOKEN:EXPOSURE".to_string());
}
let kind = parse_asset_kind(parts[0])?;
let token = parse_token(kind, parts[1])?;
let exposure = parse_bps(parts[2], "exposure")?;
Ok(SecurityCommitmentArg {
kind,
token,
exposure,
})
}
fn parse_asset_kind(value: &str) -> std::result::Result<AssetKindArg, String> {
match value.to_lowercase().as_str() {
"native" | "eth" => Ok(AssetKindArg::Native),
"erc20" => Ok(AssetKindArg::Erc20),
other => Err(format!("unsupported asset kind '{other}'")),
}
}
fn parse_token(kind: AssetKindArg, raw: &str) -> std::result::Result<Address, String> {
match kind {
AssetKindArg::Native => {
if raw.is_empty() || raw == "_" || raw == "0" {
return Ok(Address::ZERO);
}
if raw.eq_ignore_ascii_case("native") {
return Ok(Address::ZERO);
}
let addr = Address::from_str(raw)
.map_err(|e| format!("invalid native token placeholder '{raw}': {e}"))?;
if addr != Address::ZERO {
return Err("native asset must use the zero address placeholder".to_string());
}
Ok(Address::ZERO)
}
AssetKindArg::Erc20 => {
Address::from_str(raw).map_err(|e| format!("invalid ERC-20 address '{raw}': {e}"))
}
}
}
fn parse_bps(value: &str, label: &str) -> std::result::Result<u16, String> {
let parsed = value
.parse::<u16>()
.map_err(|e| format!("invalid {label} '{value}': {e}"))?;
if parsed > MAX_BPS {
return Err(format!("{label} '{value}' exceeds {MAX_BPS} bps"));
}
Ok(parsed)
}
fn requirement_to_abi(arg: SecurityRequirementArg) -> ITangleTypes::AssetSecurityRequirement {
ITangleTypes::AssetSecurityRequirement {
asset: asset_to_abi(arg.kind, arg.token),
minExposureBps: arg.min,
maxExposureBps: arg.max,
}
}
fn commitment_to_abi(arg: SecurityCommitmentArg) -> ITangleTypes::AssetSecurityCommitment {
ITangleTypes::AssetSecurityCommitment {
asset: asset_to_abi(arg.kind, arg.token),
exposureBps: arg.exposure,
}
}
pub(super) fn parse_commitments(
commitments: &[String],
) -> Result<Vec<ITangleTypes::AssetSecurityCommitment>> {
commitments
.iter()
.map(|s| {
parse_security_commitment(s)
.map(commitment_to_abi)
.map_err(|e| eyre!("Invalid commitment '{}': {}", s, e))
})
.collect()
}
fn asset_to_abi(kind: AssetKindArg, token: Address) -> ITangleTypes::Asset {
let kind_value = match kind {
AssetKindArg::Native => ITangleTypes::AssetKind::from_underlying(0).into_underlying(),
AssetKindArg::Erc20 => ITangleTypes::AssetKind::from_underlying(1).into_underlying(),
};
ITangleTypes::Asset {
kind: kind_value,
token,
}
}
const MAX_BPS: u16 = 10_000;
fn load_config_payload(config_file: Option<PathBuf>, config_hex: Option<String>) -> Result<Bytes> {
if let Some(path) = config_file {
let data = fs::read(&path)
.with_context(|| format!("Failed to read config file {}", path.display()))?;
return Ok(Bytes::from(data));
}
if let Some(hex_value) = config_hex {
let trimmed = hex_value.trim_start_matches("0x");
let bytes = hex::decode(trimmed).context("invalid config hex")?;
return Ok(Bytes::from(bytes));
}
Ok(Bytes::new())
}
async fn run_upgrade(command: ServiceCommands) -> Result<()> {
match command {
ServiceCommands::SetPolicy {
network,
service_id,
policy,
json,
} => {
let ctx = upgrade_tx_ctx(&network)?;
let tx = cargo_tangle::command::upgrade::set_service_policy(&ctx, service_id, policy)
.await?;
cargo_tangle::command::upgrade::print_simple_tx(
&format!("set-policy({} -> {})", service_id, policy.as_str()),
&tx,
json,
);
}
ServiceCommands::AckVersion {
network,
service_id,
version_id,
json,
} => {
let ctx = upgrade_tx_ctx(&network)?;
let tx =
cargo_tangle::command::upgrade::ack_version(&ctx, service_id, version_id).await?;
cargo_tangle::command::upgrade::print_simple_tx(
&format!("ack-version(service={service_id}, version={version_id})"),
&tx,
json,
);
}
ServiceCommands::EffectiveVersion {
network,
service_id,
blueprint_id,
json,
} => {
let view = upgrade_view_ctx(&network, None)?;
let v = cargo_tangle::command::upgrade::get_effective_version(
&view,
blueprint_id,
service_id,
)
.await?;
cargo_tangle::command::upgrade::print_effective_version(service_id, &v, json);
}
ServiceCommands::UpgradeStatus {
network,
service_id,
blueprint_id,
json,
} => {
let view = upgrade_view_ctx(&network, None)?;
let policy =
cargo_tangle::command::upgrade::get_service_policy(&view, service_id).await?;
let acked =
cargo_tangle::command::upgrade::get_service_acked_version_id(&view, service_id)
.await?;
let active =
cargo_tangle::command::upgrade::get_active_version_id(&view, blueprint_id).await?;
let effective = cargo_tangle::command::upgrade::get_effective_version(
&view,
blueprint_id,
service_id,
)
.await?;
let versions =
cargo_tangle::command::upgrade::list_versions(&view, blueprint_id).await?;
let latest = versions
.last()
.map(|v| v.version_id)
.unwrap_or(effective.version_id);
let up_to_date = effective.version_id == latest;
let status = cargo_tangle::command::upgrade::UpgradeStatus {
service_id,
policy,
acked_version_id: acked,
active_version_id: active,
effective_version_id: effective.version_id,
latest_version_id: latest,
up_to_date,
};
cargo_tangle::command::upgrade::print_upgrade_status(&status, json);
}
ServiceCommands::Upgrades {
service_id,
manager_url,
json,
} => {
let manager =
cargo_tangle::command::upgrade_local::resolve_manager_url(manager_url.as_ref())?;
let list =
cargo_tangle::command::upgrade_local::list_upgrades(&manager, service_id).await?;
cargo_tangle::command::upgrade_local::print_available(&list, json);
}
ServiceCommands::UpgradeLocal {
service_id,
version_id,
dry_run,
manager_url,
json,
} => {
let manager =
cargo_tangle::command::upgrade_local::resolve_manager_url(manager_url.as_ref())?;
let result = cargo_tangle::command::upgrade_local::pin_version(
&manager, service_id, version_id, dry_run,
)
.await?;
cargo_tangle::command::upgrade_local::print_pin_result(service_id, &result, json);
}
ServiceCommands::UpgradeWhitelist {
service_id,
versions,
manager_url,
json,
} => {
let manager =
cargo_tangle::command::upgrade_local::resolve_manager_url(manager_url.as_ref())?;
let parsed = cargo_tangle::command::upgrade_local::parse_version_list(&versions)?;
let result =
cargo_tangle::command::upgrade_local::set_whitelist(&manager, service_id, parsed)
.await?;
cargo_tangle::command::upgrade_local::print_whitelist_result(service_id, &result, json);
}
ServiceCommands::UpgradeSkip {
service_id,
version_id,
reason,
manager_url,
json,
} => {
let manager =
cargo_tangle::command::upgrade_local::resolve_manager_url(manager_url.as_ref())?;
let result = cargo_tangle::command::upgrade_local::add_skip(
&manager, service_id, version_id, reason,
)
.await?;
cargo_tangle::command::upgrade_local::print_skip_result(service_id, &result, json);
}
ServiceCommands::UpgradeAuthz {
service_id,
manager_url,
json,
} => {
let manager =
cargo_tangle::command::upgrade_local::resolve_manager_url(manager_url.as_ref())?;
let view =
cargo_tangle::command::upgrade_local::show_authz(&manager, service_id).await?;
cargo_tangle::command::upgrade_local::print_authz(&view, json);
}
_ => unreachable!("command dispatched by its owning module"),
}
Ok(())
}