Conversation
There was a problem hiding this comment.
Pull request overview
This pull request adds support for endorsing PDP (Proof of Data Possession) service providers through a new endorsement step that interacts with the ProviderIdSet smart contract. The feature introduces a configurable subset of approved providers that meet higher quality and reliability standards.
Changes:
- Adds a new
endorsed_pdp_sp_countconfiguration parameter with validation ensuring ENDORSED <= APPROVED <= ACTIVE <= MAX - Introduces a new endorsement step (Epoch 9) that endorses approved providers via smart contract transactions
- Extends the private key lookup function to support both Ethereum (0x...) and Filecoin (t4...) address formats
- Updates API exports and schema to include endorsement status for each provider
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/config.rs | Adds endorsed_pdp_sp_count configuration field with validation and default value (1) |
| src/commands/start/mod.rs | Integrates EndorsementStep into startup sequence at Epoch 9 |
| src/commands/start/step/mod.rs | Passes endorsed_pdp_sp_count configuration to step execution context |
| src/commands/start/pdp_service_provider/pdp_service_provider_step.rs | Stores provider_id in context for downstream endorsement step |
| src/commands/start/foc_deployer/mod.rs | Enhances get_private_key to lookup keys by both Ethereum and Filecoin addresses |
| src/commands/start/endorsement/mod.rs | Module definition for endorsement functionality |
| src/commands/start/endorsement/endorsement_step.rs | Implements the Step trait for provider endorsement with pre-checks, execution, and verification |
| src/commands/start/endorsement/operations.rs | Business logic for endorsing providers and verifying endorsement status via cast commands |
| src/commands/start/endorsement/constants.rs | Defines constants for endorsement operations (container prefix, wait times, gas limits) |
| src/external_api/export.rs | Builds and exports is_endorsed field for each Curio service provider |
| src/external_api/devnet_info.rs | Adds is_endorsed boolean field to CurioInfo struct |
| examples/devnet-schema.js | Updates Zod schema to include is_endorsed field in CurioInfo validation |
Comments suppressed due to low confidence (1)
src/commands/start/foc_deployer/mod.rs:64
- This function exceeds the 15-line limit specified in the coding guidelines (39 lines). It should be decomposed into smaller functions. Consider extracting the logic for finding keys by Ethereum address and finding keys by Filecoin address into separate helper functions.
pub fn get_private_key(address: &str, _lotus_container: &str) -> Result<String, Box<dyn Error>> {
// Load pre-generated keys
let keys = crate::commands::init::keys::load_keys()?;
// Determine if the address is Ethereum (0x...) or Filecoin (t4...)
let key_info = if address.starts_with("0x") || address.starts_with("0X") {
// Search by Ethereum address
keys.iter()
.find(|k| {
k.eth_address
.as_ref()
.map(|eth| eth.eq_ignore_ascii_case(address))
.unwrap_or(false)
})
.ok_or(format!(
"Private key not found for Ethereum address: {}",
address
))?
} else {
// Search by Filecoin address (t4...)
keys.iter()
.find(|k| k.filecoin_address.as_ref() == Some(&address.to_string()))
.ok_or(format!(
"Private key not found for Filecoin address: {}",
address
))?
};
// Return the private key with 0x prefix
Ok(format!("0x{}", key_info.private_key))
}
| fn execute(&self, context: &SetupContext) -> Result<(), Box<dyn Error>> { | ||
| if self.endorsed_sp_count == 0 { | ||
| info!("No providers to endorse (endorsed_pdp_sp_count = 0), skipping"); | ||
| return Ok(()); | ||
| } | ||
|
|
||
| Self::check_lotus_running(context)?; | ||
|
|
||
| let run_id = context.run_id(); | ||
| let lotus_rpc_url = lotus_utils::get_lotus_rpc_url(context)?; | ||
|
|
||
| let contract_addresses = Self::load_contract_addresses(context)?; | ||
| let endorsements_address = contract_addresses | ||
| .foc_contracts | ||
| .get("endorsements") | ||
| .ok_or("Endorsements contract address not found")? | ||
| .clone(); | ||
|
|
||
| let deployer_address = Self::get_deployer_address(context)?; | ||
|
|
||
| // Get deployer private key | ||
| let deployer_private_key = | ||
| crate::commands::start::foc_deployer::get_private_key(&deployer_address, "")?; | ||
|
|
||
| info!( | ||
| "Endorsing {} provider(s) in ProviderIdSet contract...", | ||
| self.endorsed_sp_count | ||
| ); | ||
|
|
||
| for sp_index in 1..=self.endorsed_sp_count { | ||
| let provider_id_key = format!("pdp_sp_{}_provider_id", sp_index); | ||
| let provider_id: u64 = context | ||
| .get(&provider_id_key) | ||
| .ok_or(format!("{} not found in context", provider_id_key))? | ||
| .parse()?; | ||
|
|
||
| info!("Endorsing Provider {} (ID: {})...", sp_index, provider_id); | ||
|
|
||
| let params = EndorseParams { | ||
| run_id: run_id.to_string(), | ||
| provider_id, | ||
| endorsements_contract_address: endorsements_address.clone(), | ||
| deployer_private_key: deployer_private_key.clone(), | ||
| lotus_rpc_url: lotus_rpc_url.clone(), | ||
| }; | ||
|
|
||
| let tx_hash = endorse_provider(params, context)?; | ||
|
|
||
| let tx_key = format!("pdp_sp_{}_endorsement_tx", sp_index); | ||
| context.set(&tx_key, tx_hash); | ||
|
|
||
| info!("Provider {} endorsed successfully", sp_index); | ||
| } | ||
|
|
||
| info!( | ||
| "All {} provider(s) endorsed successfully", | ||
| self.endorsed_sp_count | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
This function exceeds the 15-line limit specified in the coding guidelines (60 lines). It should be decomposed into smaller functions. Consider extracting the endorsement loop and the setup logic into separate helper functions.
| fn post_execute(&self, context: &SetupContext) -> Result<(), Box<dyn Error>> { | ||
| if self.endorsed_sp_count == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| info!("Verifying endorsements..."); | ||
|
|
||
| let lotus_rpc_url = lotus_utils::get_lotus_rpc_url(context)?; | ||
| let contract_addresses = Self::load_contract_addresses(context)?; | ||
| let endorsements_address = contract_addresses | ||
| .foc_contracts | ||
| .get("endorsements") | ||
| .ok_or("Endorsements contract address not found")? | ||
| .clone(); | ||
|
|
||
| for sp_index in 1..=self.endorsed_sp_count { | ||
| let provider_id_key = format!("pdp_sp_{}_provider_id", sp_index); | ||
| let provider_id: u64 = context | ||
| .get(&provider_id_key) | ||
| .ok_or(format!("{} not found in context", provider_id_key))? | ||
| .parse()?; | ||
|
|
||
| let params = VerifyEndorsementParams { | ||
| provider_id, | ||
| endorsements_contract_address: endorsements_address.clone(), | ||
| lotus_rpc_url: lotus_rpc_url.clone(), | ||
| }; | ||
|
|
||
| let is_endorsed = verify_endorsement(params, context)?; | ||
|
|
||
| if !is_endorsed { | ||
| return Err(format!( | ||
| "Verification failed: Provider {} is not endorsed in contract", | ||
| sp_index | ||
| ) | ||
| .into()); | ||
| } | ||
|
|
||
| let endorsed_key = format!("pdp_sp_{}_is_endorsed", sp_index); | ||
| context.set(&endorsed_key, "true".to_string()); | ||
|
|
||
| info!("Provider {} endorsement verified ✓", sp_index); | ||
| } | ||
|
|
||
| info!( | ||
| "All {} endorsements verified successfully", | ||
| self.endorsed_sp_count | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
| } |
There was a problem hiding this comment.
This function exceeds the 15-line limit specified in the coding guidelines (50 lines). It should be decomposed into smaller functions. Consider extracting the verification loop into a separate helper function.
| /// Endorse a provider in the ProviderIdSet contract. | ||
| pub fn endorse_provider( | ||
| params: EndorseParams, | ||
| context: &SetupContext, | ||
| ) -> Result<String, Box<dyn Error>> { | ||
| let container_name = format!( | ||
| "{}-{}-{}", | ||
| ENDORSEMENT_CONTAINER_PREFIX, params.run_id, params.provider_id | ||
| ); | ||
|
|
||
| let label = format!("Provider {}", params.provider_id); | ||
| info!("Endorsing {} in ProviderIdSet...", label); | ||
|
|
||
| let cast_cmd = format!( | ||
| r#"cast send {} "addProviderId(uint256)" {} \ | ||
| --rpc-url {} \ | ||
| --private-key {} \ | ||
| --gas-limit {}"#, | ||
| params.endorsements_contract_address, | ||
| params.provider_id, | ||
| params.lotus_rpc_url, | ||
| params.deployer_private_key, | ||
| ENDORSEMENT_GAS_LIMIT | ||
| ); | ||
|
|
||
| let args: Vec<String> = vec![ | ||
| "run".to_string(), | ||
| "--name".to_string(), | ||
| container_name.clone(), | ||
| "-u".to_string(), | ||
| "foc-user".to_string(), | ||
| "--network".to_string(), | ||
| "host".to_string(), | ||
| BUILDER_DOCKER_IMAGE.to_string(), | ||
| "bash".to_string(), | ||
| "-c".to_string(), | ||
| cast_cmd, | ||
| ]; | ||
|
|
||
| let key = format!("pdp_endorse_{}", params.provider_id); | ||
| let output = run_and_log_command_strings("docker", &args, context, &key)?; | ||
|
|
||
| if !output.status.success() { | ||
| let stderr = String::from_utf8_lossy(&output.stderr); | ||
| return Err(format!("Failed to endorse provider: {}", stderr).into()); | ||
| } | ||
|
|
||
| let stdout = String::from_utf8_lossy(&output.stdout); | ||
| let tx_hash = extract_tx_hash(&stdout) | ||
| .ok_or("Failed to extract transaction hash from endorsement output")?; | ||
|
|
||
| info!("Endorsement transaction: {}", tx_hash); | ||
| thread::sleep(Duration::from_secs(ENDORSEMENT_TX_WAIT_SECS)); | ||
|
|
||
| verify_transaction_status(¶ms.lotus_rpc_url, &tx_hash, params.provider_id, context)?; | ||
|
|
||
| info!("{} endorsed successfully", label); | ||
|
|
||
| Ok(tx_hash) | ||
| } |
There was a problem hiding this comment.
This function exceeds the 15-line limit specified in the coding guidelines (60 lines). It should be decomposed into smaller functions. Consider extracting the Docker command construction and transaction hash extraction/verification into separate helper functions.
| let key = format!("pdp_endorse_{}", params.provider_id); | ||
| let output = run_and_log_command_strings("docker", &args, context, &key)?; | ||
|
|
||
| if !output.status.success() { | ||
| let stderr = String::from_utf8_lossy(&output.stderr); | ||
| return Err(format!("Failed to endorse provider: {}", stderr).into()); | ||
| } | ||
|
|
||
| let stdout = String::from_utf8_lossy(&output.stdout); | ||
| let tx_hash = extract_tx_hash(&stdout) | ||
| .ok_or("Failed to extract transaction hash from endorsement output")?; | ||
|
|
||
| info!("Endorsement transaction: {}", tx_hash); | ||
| thread::sleep(Duration::from_secs(ENDORSEMENT_TX_WAIT_SECS)); | ||
|
|
||
| verify_transaction_status(¶ms.lotus_rpc_url, &tx_hash, params.provider_id, context)?; | ||
|
|
||
| info!("{} endorsed successfully", label); | ||
|
|
||
| Ok(tx_hash) | ||
| } | ||
|
|
||
| /// Extract transaction hash from cast send output. | ||
| fn extract_tx_hash(output: &str) -> Option<String> { | ||
| for line in output.lines() { | ||
| if line.contains("transactionHash") { | ||
| let parts: Vec<&str> = line.split_whitespace().collect(); | ||
| if let Some(hash) = parts.last() { | ||
| return Some(hash.to_string()); | ||
| } | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| /// Verify that the endorsement transaction succeeded. | ||
| fn verify_transaction_status( | ||
| rpc_url: &str, | ||
| tx_hash: &str, | ||
| provider_id: u64, | ||
| context: &SetupContext, | ||
| ) -> Result<(), Box<dyn Error>> { | ||
| let container_name = format!("foc-verify-endorse-{}", provider_id); | ||
|
|
||
| let cast_cmd = format!(r#"cast receipt {} --rpc-url {} --json"#, tx_hash, rpc_url); | ||
|
|
||
| let args: Vec<String> = vec![ | ||
| "run".to_string(), | ||
| "--name".to_string(), | ||
| container_name.clone(), | ||
| "-u".to_string(), | ||
| "foc-user".to_string(), | ||
| "--network".to_string(), | ||
| "host".to_string(), | ||
| BUILDER_DOCKER_IMAGE.to_string(), | ||
| "bash".to_string(), | ||
| "-c".to_string(), | ||
| cast_cmd, | ||
| ]; | ||
|
|
||
| let key = format!("pdp_verify_endorse_tx_{}", provider_id); | ||
| let output = run_and_log_command_strings("docker", &args, context, &key)?; | ||
|
|
||
| if !output.status.success() { | ||
| return Err("Failed to get transaction receipt".into()); | ||
| } | ||
|
|
||
| let stdout = String::from_utf8_lossy(&output.stdout); | ||
| let receipt: serde_json::Value = serde_json::from_str(&stdout)?; | ||
|
|
||
| let status = receipt["status"] | ||
| .as_str() | ||
| .ok_or("Transaction status not found in receipt")?; | ||
|
|
||
| if status != "0x1" { | ||
| return Err(format!( | ||
| "Endorsement transaction failed (status 0). Provider {} not endorsed.", | ||
| provider_id | ||
| ) | ||
| .into()); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Parameters for verifying endorsement | ||
| pub struct VerifyEndorsementParams { | ||
| pub provider_id: u64, | ||
| pub endorsements_contract_address: String, | ||
| pub lotus_rpc_url: String, | ||
| } | ||
|
|
||
| /// Verify that a provider is endorsed by checking the contract state. | ||
| pub fn verify_endorsement( | ||
| params: VerifyEndorsementParams, | ||
| context: &SetupContext, | ||
| ) -> Result<bool, Box<dyn Error>> { | ||
| let container_name = format!("foc-check-endorse-{}", params.provider_id); | ||
|
|
||
| let cast_cmd = format!( | ||
| r#"cast call {} "containsProviderId(uint256)(bool)" {} --rpc-url {}"#, | ||
| params.endorsements_contract_address, params.provider_id, params.lotus_rpc_url | ||
| ); | ||
|
|
||
| let args: Vec<String> = vec![ | ||
| "run".to_string(), | ||
| "--name".to_string(), | ||
| container_name.clone(), | ||
| "-u".to_string(), | ||
| "foc-user".to_string(), | ||
| "--network".to_string(), | ||
| "host".to_string(), | ||
| BUILDER_DOCKER_IMAGE.to_string(), | ||
| "bash".to_string(), | ||
| "-c".to_string(), | ||
| cast_cmd, | ||
| ]; | ||
|
|
||
| let key = format!("pdp_check_endorse_{}", params.provider_id); |
There was a problem hiding this comment.
The context key format strings like "pdp_endorse_{}", "pdp_verify_endorse_tx_{}", and "pdp_check_endorse_{}" should be defined as constants according to the coding guidelines. Consider creating constants for these key patterns to avoid typos and improve maintainability.
| fn pre_execute(&self, context: &SetupContext) -> Result<(), Box<dyn Error>> { | ||
| info!("Pre-checking {}", self.name()); | ||
|
|
||
| Self::check_lotus_running(context)?; | ||
| info!("Lotus is running"); | ||
|
|
||
| let deployer_address = Self::get_deployer_address(context)?; | ||
| info!("Deployer address: {}", deployer_address); | ||
|
|
||
| let contract_addresses = Self::load_contract_addresses(context)?; | ||
| let endorsements_address = contract_addresses | ||
| .foc_contracts | ||
| .get("endorsements") | ||
| .ok_or("Endorsements contract address not found")?; | ||
| info!("Endorsements contract: {}", endorsements_address); | ||
|
|
||
| for sp_index in 1..=self.endorsed_sp_count { | ||
| let pdp_key = format!("pdp_sp_{}_address", sp_index); | ||
| let provider_id_key = format!("pdp_sp_{}_provider_id", sp_index); | ||
| let approved_key = format!("pdp_sp_{}_is_approved", sp_index); | ||
|
|
||
| let sp_address = context | ||
| .get(&pdp_key) | ||
| .ok_or(format!("{} not found in context", pdp_key))?; | ||
|
|
||
| let provider_id: u64 = context | ||
| .get(&provider_id_key) | ||
| .ok_or(format!("{} not found in context", provider_id_key))? | ||
| .parse()?; | ||
|
|
||
| let is_approved = context | ||
| .get(&approved_key) | ||
| .and_then(|v| v.parse::<bool>().ok()) | ||
| .unwrap_or(false); | ||
|
|
||
| if !is_approved { | ||
| warn!("PDP SP {} is not approved, cannot endorse", sp_index); | ||
| return Err(format!( | ||
| "PDP SP {} must be approved before it can be endorsed", | ||
| sp_index | ||
| ) | ||
| .into()); | ||
| } | ||
|
|
||
| info!("PDP SP {} ready for endorsement:", sp_index); | ||
| info!(" Address: {}", sp_address); | ||
| info!(" Provider ID: {}", provider_id); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
This function exceeds the 15-line limit specified in the coding guidelines (50 lines). It should be decomposed into smaller functions. Consider extracting the validation loop for each service provider into a separate helper function.
| /// Verify that the endorsement transaction succeeded. | ||
| fn verify_transaction_status( | ||
| rpc_url: &str, | ||
| tx_hash: &str, | ||
| provider_id: u64, | ||
| context: &SetupContext, | ||
| ) -> Result<(), Box<dyn Error>> { | ||
| let container_name = format!("foc-verify-endorse-{}", provider_id); | ||
|
|
||
| let cast_cmd = format!(r#"cast receipt {} --rpc-url {} --json"#, tx_hash, rpc_url); | ||
|
|
||
| let args: Vec<String> = vec![ | ||
| "run".to_string(), | ||
| "--name".to_string(), | ||
| container_name.clone(), | ||
| "-u".to_string(), | ||
| "foc-user".to_string(), | ||
| "--network".to_string(), | ||
| "host".to_string(), | ||
| BUILDER_DOCKER_IMAGE.to_string(), | ||
| "bash".to_string(), | ||
| "-c".to_string(), | ||
| cast_cmd, | ||
| ]; | ||
|
|
||
| let key = format!("pdp_verify_endorse_tx_{}", provider_id); | ||
| let output = run_and_log_command_strings("docker", &args, context, &key)?; | ||
|
|
||
| if !output.status.success() { | ||
| return Err("Failed to get transaction receipt".into()); | ||
| } | ||
|
|
||
| let stdout = String::from_utf8_lossy(&output.stdout); | ||
| let receipt: serde_json::Value = serde_json::from_str(&stdout)?; | ||
|
|
||
| let status = receipt["status"] | ||
| .as_str() | ||
| .ok_or("Transaction status not found in receipt")?; | ||
|
|
||
| if status != "0x1" { | ||
| return Err(format!( | ||
| "Endorsement transaction failed (status 0). Provider {} not endorsed.", | ||
| provider_id | ||
| ) | ||
| .into()); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
This function exceeds the 15-line limit specified in the coding guidelines (43 lines). It should be decomposed into smaller functions. Consider extracting the Docker command construction and receipt parsing logic into separate helper functions.
| /// Verify that a provider is endorsed by checking the contract state. | ||
| pub fn verify_endorsement( | ||
| params: VerifyEndorsementParams, | ||
| context: &SetupContext, | ||
| ) -> Result<bool, Box<dyn Error>> { | ||
| let container_name = format!("foc-check-endorse-{}", params.provider_id); | ||
|
|
||
| let cast_cmd = format!( | ||
| r#"cast call {} "containsProviderId(uint256)(bool)" {} --rpc-url {}"#, | ||
| params.endorsements_contract_address, params.provider_id, params.lotus_rpc_url | ||
| ); | ||
|
|
||
| let args: Vec<String> = vec![ | ||
| "run".to_string(), | ||
| "--name".to_string(), | ||
| container_name.clone(), | ||
| "-u".to_string(), | ||
| "foc-user".to_string(), | ||
| "--network".to_string(), | ||
| "host".to_string(), | ||
| BUILDER_DOCKER_IMAGE.to_string(), | ||
| "bash".to_string(), | ||
| "-c".to_string(), | ||
| cast_cmd, | ||
| ]; | ||
|
|
||
| let key = format!("pdp_check_endorse_{}", params.provider_id); | ||
| let output = run_and_log_command_strings("docker", &args, context, &key)?; | ||
|
|
||
| if !output.status.success() { | ||
| let stderr = String::from_utf8_lossy(&output.stderr); | ||
| return Err(format!("Failed to check endorsement status: {}", stderr).into()); | ||
| } | ||
|
|
||
| let stdout = String::from_utf8_lossy(&output.stdout); | ||
| Ok(stdout.trim() == "true") | ||
| } |
There was a problem hiding this comment.
This function exceeds the 15-line limit specified in the coding guidelines (36 lines). It should be decomposed into smaller functions. Consider extracting the Docker command construction into a separate helper function.
| provider_id: u64, | ||
| context: &SetupContext, | ||
| ) -> Result<(), Box<dyn Error>> { | ||
| let container_name = format!("foc-verify-endorse-{}", provider_id); |
There was a problem hiding this comment.
The magic container name "foc-verify-endorse-{}" should be defined as a constant according to the coding guidelines. Consider adding it to the constants.rs file in this module, similar to ENDORSEMENT_CONTAINER_PREFIX.
| params: VerifyEndorsementParams, | ||
| context: &SetupContext, | ||
| ) -> Result<bool, Box<dyn Error>> { | ||
| let container_name = format!("foc-check-endorse-{}", params.provider_id); |
There was a problem hiding this comment.
The magic container name "foc-check-endorse-{}" should be defined as a constant according to the coding guidelines. Consider adding it to the constants.rs file in this module, similar to ENDORSEMENT_CONTAINER_PREFIX.
| let container_name = format!("foc-check-endorse-{}", params.provider_id); | ||
|
|
||
| let cast_cmd = format!( | ||
| r#"cast call {} "containsProviderId(uint256)(bool)" {} --rpc-url {}"#, |
There was a problem hiding this comment.
instead of evaluating containsProviderId for each one, you can do one getProviderIds. That might simplify the calling function (post_execute) somewhat, especially since the provider ids will be returned in the same order they were inserted.
There was a problem hiding this comment.
ah! good callout! thanks. Will try
| .ok_or("Failed to extract transaction hash from endorsement output")?; | ||
|
|
||
| info!("Endorsement transaction: {}", tx_hash); | ||
| thread::sleep(Duration::from_secs(ENDORSEMENT_TX_WAIT_SECS)); |
There was a problem hiding this comment.
instead of waiting, increment nonce and pass it to cast
There was a problem hiding this comment.
only need to wait after all transactions submitted
there should also be a more intelligent way to wait than sleeping for 10s.
There was a problem hiding this comment.
in fact I think cast might wait for confirmation by default. if the state isn't updated after cast send, maybe you need to tinker with --confirmations.
There was a problem hiding this comment.
You can use --async for the first few and await the last one
There was a problem hiding this comment.
Understandable. But these seem like minor improvement than blockers for this PR?
I would recommend creating a new issue on the lines of improving overall performance.
There was a problem hiding this comment.
ok I see you do a similar thing elsewhere (TRANSACTION_CONFIRMATION_WAIT_SECS). You can fix all of those in a separate PR. That should improve the setup time by a few minutes.
| let container_name = format!( | ||
| "{}-{}-{}", | ||
| ENDORSEMENT_CONTAINER_PREFIX, params.run_id, params.provider_id | ||
| ); |
There was a problem hiding this comment.
I dunno much about docker or how this project uses it (though I have used docker before).
Why do these run in separate containers? can they be run in the lotus container?
There was a problem hiding this comment.
Generally all tasks in foc-devnet use this approach where they do not directly go into the containers themselves, but create a foc-builder instance and run via that.
It gives us a few things:
- ensure networking is right
- things don't just work on localhost, but remotely also
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
No description provided.