Skip to content

feat: endorsed curio SP support#57

Merged
redpanda-f merged 11 commits intomainfrom
feat/endorsed-sps
Feb 6, 2026
Merged

feat: endorsed curio SP support#57
redpanda-f merged 11 commits intomainfrom
feat/endorsed-sps

Conversation

@redpanda-f
Copy link
Collaborator

No description provided.

@FilOzzy FilOzzy added this to FOC Feb 3, 2026
@github-project-automation github-project-automation bot moved this to 📌 Triage in FOC Feb 3, 2026
@rjan90 rjan90 moved this from 📌 Triage to ⌨️ In Progress in FOC Feb 4, 2026
@rjan90 rjan90 added this to the M4.0: mainnet staged milestone Feb 4, 2026
@redpanda-f redpanda-f marked this pull request as ready for review February 5, 2026 04:09
Copilot AI review requested due to automatic review settings February 5, 2026 04:09
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_count configuration 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))
}

Comment on lines +123 to +183
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(())
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 185 to 236
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(())
}
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +23 to +82
/// 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(&params.lotus_rpc_url, &tx_hash, params.provider_id, context)?;

info!("{} endorsed successfully", label);

Ok(tx_hash)
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 62 to 180
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(&params.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);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +71 to +121
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(())
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 97 to 145
/// 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(())
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 154 to 190
/// 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")
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
provider_id: u64,
context: &SetupContext,
) -> Result<(), Box<dyn Error>> {
let container_name = format!("foc-verify-endorse-{}", provider_id);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
params: VerifyEndorsementParams,
context: &SetupContext,
) -> Result<bool, Box<dyn Error>> {
let container_name = format!("foc-check-endorse-{}", params.provider_id);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
let container_name = format!("foc-check-endorse-{}", params.provider_id);

let cast_cmd = format!(
r#"cast call {} "containsProviderId(uint256)(bool)" {} --rpc-url {}"#,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah! good callout! thanks. Will try

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wjmelements can you review again?

@rjan90 rjan90 moved this from ⌨️ In Progress to 🔎 Awaiting review in FOC Feb 5, 2026
@rjan90 rjan90 linked an issue Feb 5, 2026 that may be closed by this pull request
.ok_or("Failed to extract transaction hash from endorsement output")?;

info!("Endorsement transaction: {}", tx_hash);
thread::sleep(Duration::from_secs(ENDORSEMENT_TX_WAIT_SECS));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of waiting, increment nonce and pass it to cast

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only need to wait after all transactions submitted
there should also be a more intelligent way to wait than sleeping for 10s.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use --async for the first few and await the last one

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#59

Comment on lines +28 to +31
let container_name = format!(
"{}-{}-{}",
ENDORSEMENT_CONTAINER_PREFIX, params.run_id, params.provider_id
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@github-project-automation github-project-automation bot moved this from 🔎 Awaiting review to ⌨️ In Progress in FOC Feb 5, 2026
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@BigLep BigLep moved this from ⌨️ In Progress to ✔️ Approved by reviewer in FOC Feb 5, 2026
@redpanda-f redpanda-f merged commit a8c21e6 into main Feb 6, 2026
2 checks passed
@github-project-automation github-project-automation bot moved this from ✔️ Approved by reviewer to 🎉 Done in FOC Feb 6, 2026
@redpanda-f redpanda-f deleted the feat/endorsed-sps branch February 6, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🎉 Done

Development

Successfully merging this pull request may close these issues.

Ability to set an "endorsed SP"

3 participants