use std::str::FromStr;
use alloy::network::EthereumWallet;
use alloy::primitives::{Address, Bytes, U256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::signers::local::PrivateKeySigner;
use alloy::signers::Signer;
use alloy_chains::NamedChain;
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};
#[allow(missing_docs)]
pub mod arborter_pb {
include!("../../../proto/generated/xyz.aspens.arborter.v1.rs");
}
use arborter_pb::arborter_service_client::ArborterServiceClient;
use arborter_pb::WithdrawRequest;
const MIN_GAS_BALANCE: u128 = 100_000_000_000_000;
pub async fn call_withdraw_from_config_with_wallet(
url: String,
network: String,
token_symbol: String,
amount: u64,
wallet: &Wallet,
config: GetConfigResponse,
) -> 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)
{
return solana_withdraw(url, chain_for_arch, &token_symbol, amount, wallet).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(url, network, token_symbol, amount, signer, config).await
}
#[cfg(feature = "solana")]
async fn solana_withdraw(
url: String,
chain: &crate::commands::config::config_pb::Chain,
token_symbol: &str,
amount: u64,
wallet: &Wallet,
) -> 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 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(chain.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 channel = create_channel(&url).await?;
let mut client = ArborterServiceClient::new(channel);
let voucher = client
.withdraw(tonic::Request::new(WithdrawRequest {
network: chain.network.clone(),
token: token.address.clone(),
account: account_str,
amount: amount.to_string(),
signature: req_sig,
}))
.await?
.into_inner();
tracing::info!(
"Received Solana withdrawal voucher (nonce={}, deadline_slot={})",
voucher.nonce,
voucher.expiry
);
let signer_resp =
crate::commands::config::get_signer_public_key(url.clone(), Some(chain.network.clone()))
.await?;
let signer_str = signer_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))?;
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 ixs = [verify_ix, wd_ix];
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(&chain.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 = 1_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(
_url: String,
chain: &crate::commands::config::config_pb::Chain,
_token_symbol: &str,
_amount: u64,
_wallet: &Wallet,
) -> Result<()> {
Err(eyre::eyre!(
"chain '{}' is Solana but the `solana` feature is disabled",
chain.network
))
}
async fn call_withdraw_from_config_evm(
url: String,
network: String,
token_symbol: String,
amount: u64,
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 chain_type = match chain.chain_id {
1 => NamedChain::Mainnet,
5 => NamedChain::Goerli,
11155111 => NamedChain::Sepolia,
8453 => NamedChain::Base,
84531 => NamedChain::BaseGoerli,
84532 => NamedChain::BaseSepolia,
10 => NamedChain::Optimism,
420 => NamedChain::OptimismGoerli,
11155420 => NamedChain::OptimismSepolia,
_ => {
tracing::warn!(
"Unknown chain ID {}, using chain ID directly",
chain.chain_id
);
NamedChain::try_from(chain.chain_id as u64)?
}
};
tracing::info!(
"Withdrawing {} {} from {} (chain_id: {}, rpc: {})",
amount,
token_symbol,
network,
chain.chain_id,
chain.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(&chain.rpc_url)?;
let provider = ProviderBuilder::new()
.with_chain(chain_type)
.wallet(wallet)
.connect_http(rpc_url);
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 channel = create_channel(&url).await?;
let mut client = ArborterServiceClient::new(channel);
let voucher = client
.withdraw(tonic::Request::new(WithdrawRequest {
network: network.clone(),
token: req_token,
account: req_account,
amount: req_amount,
signature: req_sig.as_bytes().to_vec(),
}))
.await?
.into_inner();
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;