use std::str::FromStr;
use crate::chain_client::named_chain_for;
use alloy::network::EthereumWallet;
use alloy::primitives::{Address, Bytes, U256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::signers::Signer;
use alloy::signers::local::PrivateKeySigner;
use eyre::Result;
use url::Url;
use crate::chain_client::ARCH_SOLANA;
use crate::commands::config::config_pb::GetConfigResponse;
use crate::evm::rpc::MidribV3;
use crate::grpc::create_channel;
use crate::wallet::{CurveType, Wallet};
use super::arborter_pb::WithdrawRequest;
use super::arborter_pb::arborter_service_client::ArborterServiceClient;
const MIN_GAS_BALANCE: u128 = 100_000_000_000_000;
pub(crate) enum VoucherSource<'a> {
Grpc(&'a str),
#[cfg(feature = "fce")]
Fce(&'a crate::fce::FceClient),
}
pub(crate) struct Voucher {
amount: String,
nonce: u64,
expiry: u64,
signature: Vec<u8>,
}
impl VoucherSource<'_> {
async fn fetch(
&self,
network: &str,
token: &str,
account: &str,
amount: &str,
signature: Vec<u8>,
) -> Result<Voucher> {
match self {
Self::Grpc(url) => {
let channel = create_channel(url).await?;
let mut client = ArborterServiceClient::new(channel);
let v = client
.withdraw(tonic::Request::new(WithdrawRequest {
network: network.to_string(),
token: token.to_string(),
account: account.to_string(),
amount: amount.to_string(),
signature,
}))
.await?
.into_inner();
Ok(Voucher {
amount: v.amount,
nonce: v.nonce,
expiry: v.expiry,
signature: v.signature,
})
}
#[cfg(feature = "fce")]
Self::Fce(fce) => {
let outcome = fce
.withdraw(&crate::fce::WithdrawRequest {
network: network.to_string(),
token: token.to_string(),
account: account.to_string(),
amount: amount.to_string(),
signature,
})
.await?;
let v = outcome.into_data()?;
Ok(Voucher {
amount: v.amount,
nonce: v.nonce,
expiry: v.expiry,
signature: v.signature,
})
}
}
}
#[cfg(feature = "solana")]
async fn instance_signer_pubkey(
&self,
chain: &crate::commands::config::config_pb::Chain,
) -> Result<String> {
match self {
Self::Grpc(url) => {
let resp = crate::commands::config::get_signer_public_key(
url.to_string(),
Some(chain.network.clone()),
)
.await?;
resp.chain_keys
.get(&chain.network)
.map(|k| k.public_key.clone())
.ok_or_else(|| {
eyre::eyre!("no signer public key for chain '{}'", chain.network)
})
}
#[cfg(feature = "fce")]
Self::Fce(_) => {
let key = chain.instance_signer_address.trim();
if key.is_empty() {
return Err(eyre::eyre!(
"chain '{}' has no instance_signer_address in the config, and the FCE \
direct-action wire has no GetSignerPublicKey command — the Solana \
voucher's signer key cannot be resolved. Set it on the chain entry \
(aspens-admin set-chain --instance-signer-address) and re-fetch config.",
chain.network
));
}
Ok(key.to_string())
}
}
}
}
pub async fn call_withdraw_from_config_with_wallet(
url: String,
network: String,
token_symbol: String,
amount: u128,
wallet: &Wallet,
config: GetConfigResponse,
) -> Result<()> {
call_withdraw_from_config_with_wallet_opts(
url,
network,
token_symbol,
amount,
wallet,
config,
WithdrawOpts::default(),
)
.await
}
#[derive(Debug, Clone)]
pub struct WithdrawOpts {
pub unwrap_native: bool,
}
impl Default for WithdrawOpts {
fn default() -> Self {
Self {
unwrap_native: true,
}
}
}
pub async fn call_withdraw_from_config_with_wallet_opts(
url: String,
network: String,
token_symbol: String,
amount: u128,
wallet: &Wallet,
config: GetConfigResponse,
opts: WithdrawOpts,
) -> Result<()> {
withdraw_from_source(
VoucherSource::Grpc(&url),
network,
token_symbol,
amount,
wallet,
config,
opts,
)
.await
}
#[cfg(feature = "fce")]
pub async fn call_withdraw_from_config_with_fce_opts(
fce: &crate::fce::FceClient,
network: String,
token_symbol: String,
amount: u128,
wallet: &Wallet,
config: GetConfigResponse,
opts: WithdrawOpts,
) -> Result<()> {
withdraw_from_source(
VoucherSource::Fce(fce),
network,
token_symbol,
amount,
wallet,
config,
opts,
)
.await
}
async fn withdraw_from_source(
source: VoucherSource<'_>,
network: String,
token_symbol: String,
amount: u128,
wallet: &Wallet,
config: GetConfigResponse,
opts: WithdrawOpts,
) -> Result<()> {
let chain_for_arch = config
.get_chain(&network)
.ok_or_else(|| eyre::eyre!("Chain '{}' not found in configuration", network))?;
if chain_for_arch
.architecture
.eq_ignore_ascii_case(ARCH_SOLANA)
{
let spl_amount: u64 = amount.try_into().map_err(|_| {
eyre::eyre!("amount {amount} exceeds the SPL token u64 max on Solana chain '{network}'")
})?;
return solana_withdraw(
source,
chain_for_arch,
&token_symbol,
spl_amount,
wallet,
opts.unwrap_native,
)
.await;
}
if wallet.curve() != CurveType::Secp256k1 {
return Err(eyre::eyre!(
"EVM chain '{}' requires a secp256k1 wallet, got {:?}",
network,
wallet.curve()
));
}
let signer = wallet
.as_evm()
.ok_or_else(|| eyre::eyre!("expected EVM wallet for chain '{}'", network))?
.clone();
call_withdraw_from_config_evm(source, network, token_symbol, amount, signer, config).await
}
#[cfg(feature = "solana")]
async fn solana_withdraw(
source: VoucherSource<'_>,
chain: &crate::commands::config::config_pb::Chain,
token_symbol: &str,
amount: u64,
wallet: &Wallet,
unwrap_native: bool,
) -> Result<()> {
use solana_sdk::pubkey::Pubkey;
use std::str::FromStr;
let token = chain.tokens.get(token_symbol).ok_or_else(|| {
eyre::eyre!(
"Token '{}' not found on Solana chain '{}'",
token_symbol,
chain.network
)
})?;
let keypair = wallet.as_solana().ok_or_else(|| {
eyre::eyre!(
"Solana chain '{}' requires an Ed25519 wallet (TRADER_PRIVKEY_SOLANA)",
chain.network
)
})?;
let (program_id, instance) = crate::solana::client::resolve_program_and_instance(chain)?;
let rpc_url = crate::chain_client::checked_chain_rpc_url(chain).await?;
let user = solana_sdk::signer::Signer::pubkey(keypair);
let mint = Pubkey::from_str(&token.address)
.map_err(|e| eyre::eyre!("invalid Solana mint '{}': {}", token.address, e))?;
let user_ata = crate::solana::derive_associated_token_account(&user, &mint);
{
use solana_client::nonblocking::rpc_client::RpcClient;
let rpc = RpcClient::new(rpc_url.clone());
let lamports = rpc.get_balance(&user).await.unwrap_or(0);
if lamports < MIN_SOL_LAMPORTS {
return Err(eyre::eyre!(
"insufficient SOL for fees: wallet {user} has {lamports} lamports, \
need >= {MIN_SOL_LAMPORTS}. Fund/airdrop SOL before withdrawing — \
requesting a voucher now would place an off-chain hold you can't submit.",
));
}
}
let account_str = user.to_string();
let canonical = format!(
"{}|{}|{}|{}",
chain.network, token.address, account_str, amount
);
let req_sig = wallet.sign_message(canonical.as_bytes()).await?;
let voucher = source
.fetch(
&chain.network,
&token.address,
&account_str,
&amount.to_string(),
req_sig,
)
.await?;
tracing::info!(
"Received Solana withdrawal voucher (nonce={}, deadline_slot={})",
voucher.nonce,
voucher.expiry
);
let signer_str = source.instance_signer_pubkey(chain).await?;
let signer_pk = Pubkey::from_str(&signer_str)
.map_err(|e| eyre::eyre!("invalid signer pubkey '{}': {}", signer_str, e))?;
let deadline = voucher.expiry; let nonce = voucher.nonce;
let voucher_amount: u64 = voucher
.amount
.parse()
.map_err(|_| eyre::eyre!("invalid voucher amount '{}'", voucher.amount))?;
let voucher_sig: [u8; 64] = voucher
.signature
.as_slice()
.try_into()
.map_err(|_| eyre::eyre!("voucher signature must be 64 bytes (Ed25519)"))?;
let msg = crate::solana::withdrawal_voucher_signing_message(
&instance,
&user,
&mint,
voucher_amount,
nonce,
deadline,
)?;
let verify_ix = crate::solana::ed25519_verify_ix(&signer_pk.to_bytes(), &voucher_sig, &msg);
let args = crate::solana::WithdrawVoucherArgs {
amount: voucher_amount,
nonce,
deadline,
signature: voucher_sig,
};
let wd_ix = crate::solana::withdraw_voucher_ix(
&program_id,
&instance,
&user,
&mint,
&user_ata,
&user,
&args,
)?;
let ata_ix = crate::solana::create_idempotent_ata_ix(&user, &user, &mint, &user_ata);
let mut ixs = vec![ata_ix, verify_ix, wd_ix];
if unwrap_native && crate::solana::is_wsol_mint(&token.address) {
tracing::info!("WSOL withdrawal: appending unwrap (CloseAccount) to the voucher tx");
ixs.push(crate::solana::close_token_account_ix(
&user_ata, &user, &user,
));
}
let mut last_err = None;
let mut sig = None;
for attempt in 0..VOUCHER_SUBMIT_MAX_ATTEMPTS {
match crate::solana::client::submit_user_signed_multi(&rpc_url, keypair, &ixs).await {
Ok(s) => {
sig = Some(s);
break;
}
Err(e) => {
let msg = e.to_string();
let transient = msg.contains("0x1771") || msg.contains("InsufficientBalance");
if transient && attempt + 1 < VOUCHER_SUBMIT_MAX_ATTEMPTS {
tracing::warn!(
attempt,
"voucher submit hit transient InsufficientBalance (settle not yet visible); retrying"
);
tokio::time::sleep(std::time::Duration::from_millis(VOUCHER_SUBMIT_RETRY_MS))
.await;
last_err = Some(e);
continue;
}
return Err(e);
}
}
}
let sig = sig.ok_or_else(|| {
last_err.unwrap_or_else(|| eyre::eyre!("voucher submit failed after retries"))
})?;
tracing::info!("Solana withdraw voucher submitted: {}", sig);
Ok(())
}
#[cfg(feature = "solana")]
const MIN_SOL_LAMPORTS: u64 = 3_000_000;
#[cfg(feature = "solana")]
const VOUCHER_SUBMIT_MAX_ATTEMPTS: usize = 6;
#[cfg(feature = "solana")]
const VOUCHER_SUBMIT_RETRY_MS: u64 = 700;
#[cfg(not(feature = "solana"))]
async fn solana_withdraw(
_source: VoucherSource<'_>,
chain: &crate::commands::config::config_pb::Chain,
_token_symbol: &str,
_amount: u64,
_wallet: &Wallet,
_unwrap_native: bool,
) -> Result<()> {
Err(eyre::eyre!(
"chain '{}' is Solana but the `solana` feature is disabled",
chain.network
))
}
async fn call_withdraw_from_config_evm(
source: VoucherSource<'_>,
network: String,
token_symbol: String,
amount: u128,
signer: PrivateKeySigner,
config: GetConfigResponse,
) -> Result<()> {
let chain = config.get_chain(&network).ok_or_else(|| {
let available_chains = config
.config
.as_ref()
.map(|c| {
c.chains
.iter()
.map(|ch| ch.network.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
eyre::eyre!(
"Chain '{}' not found in configuration. Available chains: {}",
network,
available_chains
)
})?;
let token = config.get_token(&network, &token_symbol).ok_or_else(|| {
let available_tokens = chain
.tokens
.keys()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
eyre::eyre!(
"Token '{}' not found on chain '{}'. Available tokens: {}",
token_symbol,
network,
available_tokens
)
})?;
let contract_address = chain
.trade_contract
.as_ref()
.ok_or_else(|| {
eyre::eyre!(
"Trade contract not found for chain '{}'. Please ensure the contract is deployed.",
network
)
})?
.address
.clone();
let named_chain = named_chain_for(chain.chain_id as u64);
let rpc_url = crate::chain_client::checked_chain_rpc_url(chain).await?;
tracing::info!(
"Withdrawing {} {} from {} (chain_id: {}, rpc: {})",
amount,
token_symbol,
network,
chain.chain_id,
crate::chain_client::redact_rpc_url(&rpc_url)
);
let contract_addr: Address = contract_address.parse()?;
let token_addr: Address = token.address.parse()?;
let signer_address = signer.address();
let wallet = EthereumWallet::new(signer.clone());
let rpc_url = Url::parse(&rpc_url)?;
let provider = match named_chain {
Some(named) => ProviderBuilder::new()
.with_chain(named)
.wallet(wallet)
.connect_http(rpc_url)
.erased(),
None => ProviderBuilder::new()
.wallet(wallet)
.connect_http(rpc_url)
.erased(),
};
let gas_balance = provider.get_balance(signer_address).await?;
tracing::info!("Gas balance: {} wei", gas_balance);
if gas_balance < U256::from(MIN_GAS_BALANCE) {
let balance_eth = gas_balance.to::<u128>() as f64 / 1e18;
return Err(eyre::eyre!(
"insufficient gas: wallet has {:.6} native tokens, need at least 0.0001 for gas. \
Fund your wallet ({}) with native tokens on {} to pay for transaction fees. \
(No voucher requested — your withdrawable balance is untouched.)",
balance_eth,
signer_address,
network
));
}
let req_account = signer_address.to_string();
let req_token = token.address.clone();
let req_amount = amount.to_string();
let canonical = format!("{network}|{req_token}|{req_account}|{req_amount}");
let req_sig = signer.sign_message(canonical.as_bytes()).await?;
let voucher = source
.fetch(
&network,
&req_token,
&req_account,
&req_amount,
req_sig.as_bytes().to_vec(),
)
.await?;
tracing::info!(
"Received withdrawal voucher (nonce={}, expiry={})",
voucher.nonce,
voucher.expiry
);
let contract = MidribV3::new(contract_addr, &provider);
let onchain_voucher = MidribV3::WithdrawalVoucher {
account: signer_address,
token: token_addr,
amount: U256::from_str(&voucher.amount).unwrap_or(U256::from(amount)),
nonce: U256::from(voucher.nonce),
expiry: U256::from(voucher.expiry),
};
let voucher_sig = Bytes::from(voucher.signature);
let mut last_err = None;
let mut result = None;
for attempt in 0..EVM_VOUCHER_SUBMIT_MAX_ATTEMPTS {
let outcome = match contract
.withdraw(onchain_voucher.clone(), voucher_sig.clone())
.send()
.await
{
Ok(pending) => pending
.with_required_confirmations(1)
.watch()
.await
.map_err(|e| eyre::eyre!("{e}")),
Err(e) => Err(eyre::eyre!("{e}")),
};
match outcome {
Ok(tx) => {
result = Some(tx);
break;
}
Err(e) => {
last_err = Some(e);
if attempt + 1 < EVM_VOUCHER_SUBMIT_MAX_ATTEMPTS {
tracing::warn!(
attempt,
"voucher submit failed (possibly settle-propagation race); \
resubmitting the same voucher"
);
tokio::time::sleep(std::time::Duration::from_millis(
EVM_VOUCHER_SUBMIT_RETRY_MS,
))
.await;
}
}
}
}
let result = result.ok_or_else(|| {
last_err.unwrap_or_else(|| eyre::eyre!("voucher submit failed after retries"))
})?;
tracing::info!("Withdraw voucher submitted on-chain: {result:?}");
Ok(())
}
const EVM_VOUCHER_SUBMIT_MAX_ATTEMPTS: usize = 4;
const EVM_VOUCHER_SUBMIT_RETRY_MS: u64 = 700;