use eyre::{Result, eyre};
use solana_sdk::pubkey::Pubkey;
use std::str::FromStr;
use crate::chain_client::ARCH_SOLANA;
use crate::commands::config::config_pb::GetConfigResponse;
use crate::solana::client::{SettleEpochState, WithdrawEpochState};
use crate::wallet::Wallet;
const MIN_SOL_LAMPORTS: u64 = 3_000_000;
#[derive(Debug, Clone)]
pub struct SetWithdrawEpochCapOutcome {
pub signature: String,
pub program_id: Pubkey,
pub instance: Pubkey,
pub mint: Pubkey,
pub withdraw_epoch: Pubkey,
pub state: Option<WithdrawEpochState>,
pub admin_is_tee_signer: bool,
}
#[derive(Debug, Clone)]
pub struct SetSettleEpochCapOutcome {
pub signature: String,
pub program_id: Pubkey,
pub instance: Pubkey,
pub mint: Pubkey,
pub settle_epoch: Pubkey,
pub state: Option<SettleEpochState>,
pub admin_is_tee_signer: bool,
}
pub async fn set_withdraw_epoch_cap(
config: &GetConfigResponse,
network: &str,
token_symbol: &str,
cap: u64,
wallet: &Wallet,
) -> Result<SetWithdrawEpochCapOutcome> {
let ctx = CapContext::prepare(
config,
network,
token_symbol,
wallet,
"withdrawal",
"`MidribV3.setWithdrawEpochCap(token, cap)`, armed with infra's \
`just set-withdraw-cap` signing as the operator admin",
)
.await?;
let (withdraw_epoch, _) =
crate::solana::derive_withdraw_epoch_pda(&ctx.instance, &ctx.mint, &ctx.program_id);
let before = crate::solana::client::fetch_withdraw_epoch(
&ctx.rpc_url,
&ctx.instance,
&ctx.mint,
&ctx.program_id,
)
.await
.unwrap_or(None);
match before {
Some(state) => tracing::info!(
"current withdrawal cap for {token_symbol}: {} (epoch {}, {} base units \
already withdrawn this epoch)",
state.cap,
state.epoch,
state.withdrawn
),
None => tracing::info!(
"no WithdrawEpoch account yet for {token_symbol} — cap is unlimited; this \
call creates the account (rent paid by {})",
ctx.operator_admin
),
}
let ix = crate::solana::set_withdraw_epoch_cap_ix(
&ctx.program_id,
&ctx.instance,
&ctx.mint,
&ctx.operator_admin,
cap,
)?;
let signature =
crate::solana::client::submit_user_signed(&ctx.rpc_url, ctx.keypair, ix).await?;
let state = crate::solana::client::fetch_withdraw_epoch(
&ctx.rpc_url,
&ctx.instance,
&ctx.mint,
&ctx.program_id,
)
.await
.unwrap_or(None);
Ok(SetWithdrawEpochCapOutcome {
signature,
program_id: ctx.program_id,
instance: ctx.instance,
mint: ctx.mint,
withdraw_epoch,
state,
admin_is_tee_signer: ctx.admin_is_tee_signer,
})
}
pub async fn set_settle_epoch_cap(
config: &GetConfigResponse,
network: &str,
token_symbol: &str,
cap: u64,
wallet: &Wallet,
) -> Result<SetSettleEpochCapOutcome> {
let ctx = CapContext::prepare(
config,
network,
token_symbol,
wallet,
"settlement",
"`MidribV3.setSettleEpochCap(token, cap)` (read back with \
`settleEpochCap(token)`), called with the operator-admin key",
)
.await?;
let (settle_epoch, _) =
crate::solana::derive_settle_epoch_pda(&ctx.instance, &ctx.mint, &ctx.program_id);
let before = crate::solana::client::fetch_settle_epoch(
&ctx.rpc_url,
&ctx.instance,
&ctx.mint,
&ctx.program_id,
)
.await
.unwrap_or(None);
match before {
Some(state) => tracing::info!(
"current settlement cap for {token_symbol}: {} (epoch {}, {} base units \
already credited this epoch)",
state.cap,
state.epoch,
state.settled
),
None => tracing::info!(
"no SettleEpoch account yet for {token_symbol} — cap is unlimited; this \
call creates the account (rent paid by {})",
ctx.operator_admin
),
}
let ix = crate::solana::set_settle_epoch_cap_ix(
&ctx.program_id,
&ctx.instance,
&ctx.mint,
&ctx.operator_admin,
cap,
)?;
let signature =
crate::solana::client::submit_user_signed(&ctx.rpc_url, ctx.keypair, ix).await?;
let state = crate::solana::client::fetch_settle_epoch(
&ctx.rpc_url,
&ctx.instance,
&ctx.mint,
&ctx.program_id,
)
.await
.unwrap_or(None);
Ok(SetSettleEpochCapOutcome {
signature,
program_id: ctx.program_id,
instance: ctx.instance,
mint: ctx.mint,
settle_epoch,
state,
admin_is_tee_signer: ctx.admin_is_tee_signer,
})
}
struct CapContext<'a> {
keypair: &'a solana_sdk::signature::Keypair,
operator_admin: Pubkey,
program_id: Pubkey,
instance: Pubkey,
mint: Pubkey,
rpc_url: String,
admin_is_tee_signer: bool,
}
impl<'a> CapContext<'a> {
async fn prepare(
config: &GetConfigResponse,
network: &str,
token_symbol: &str,
wallet: &'a Wallet,
what: &str,
evm_equivalent: &str,
) -> Result<Self> {
let chain = config
.get_chain(network)
.ok_or_else(|| eyre!("Chain '{}' not found in server configuration", network))?;
if !chain.architecture.eq_ignore_ascii_case(ARCH_SOLANA) {
return Err(eyre!(
"chain '{}' has architecture '{}' — the per-epoch {} cap command \
targets Solana instances only. On EVM the equivalent is {}; it is \
gated to `operatorAdmin()`, the same stack-admin authority as \
Solana's `operator_admin`.",
network,
chain.architecture,
what,
evm_equivalent
));
}
let token = chain.tokens.get(token_symbol).ok_or_else(|| {
eyre!(
"Token '{}' not found on Solana chain '{}'. Run `config` to see \
available tokens.",
token_symbol,
network
)
})?;
let keypair = wallet.as_solana().ok_or_else(|| {
eyre!(
"Solana chain '{}' requires an Ed25519 operator-admin keypair ({})",
network,
crate::wallet::OPERATOR_ADMIN_PRIVKEY_SOLANA_ENV
)
})?;
let operator_admin = solana_sdk::signer::Signer::pubkey(keypair);
let (program_id, instance) = crate::solana::client::resolve_program_and_instance(chain)?;
let mint = Pubkey::from_str(&token.address)
.map_err(|e| eyre!("invalid Solana mint '{}': {}", token.address, e))?;
let rpc_url = crate::chain_client::checked_chain_rpc_url(chain).await?;
let authorities =
crate::solana::client::fetch_instance_authorities(&rpc_url, &instance).await?;
if authorities.operator_admin != operator_admin {
return Err(eyre!(
"{} holds {}, but instance {} has operator_admin {} — the program \
would reject this with `Unauthorized`. Load the instance's \
operator-admin keypair.",
crate::wallet::OPERATOR_ADMIN_PRIVKEY_SOLANA_ENV,
operator_admin,
instance,
authorities.operator_admin
));
}
let admin_is_tee_signer = authorities.operator_admin == authorities.signer;
if admin_is_tee_signer {
tracing::warn!(
instance = %instance,
"instance operator_admin == its TEE signer ({}) — an instance created \
before the program refused that pairing. The cap you are arming \
bounds bugs and operator error, but a compromised signer can raise \
it again. Rotate to a distinct operator-admin key \
(`set_operator_admin`).",
authorities.signer
);
}
{
use solana_client::nonblocking::rpc_client::RpcClient;
let rpc = RpcClient::new(rpc_url.clone());
let lamports = rpc.get_balance(&operator_admin).await.unwrap_or(0);
if lamports < MIN_SOL_LAMPORTS {
return Err(eyre!(
"insufficient SOL: operator-admin {operator_admin} has {lamports} \
lamports, need >= {MIN_SOL_LAMPORTS} to cover the fee plus rent \
for the epoch-cap PDA this instruction may create."
));
}
}
Ok(Self {
keypair,
operator_admin,
program_id,
instance,
mint,
rpc_url,
admin_is_tee_signer,
})
}
}