use crate::account::signers::ActionKind;
use crate::client::{Network, USDC_INDEX};
use crate::transfer::actions::{
AgentSendAsset, PERP_DEX, SPOT_DEX, SendAsset, SpotOut, UsdClassTransfer, UsdSend, Withdraw3,
};
use crate::transfer::amount::Amount;
use crate::transfer::check::is_system_address;
use crate::transfer::keeping::Keeping;
use crate::transfer::token::SpotToken;
use crate::utils::{Address, Dec};
pub const USD_DECIMALS: u32 = 6;
pub use crate::account::signers::Signers as Key;
const KINDS: [ActionKind; 6] = [
ActionKind::UsdClassTransfer,
ActionKind::UsdSend,
ActionKind::SpotSend,
ActionKind::SendAsset,
ActionKind::Withdraw3,
ActionKind::AgentSendAsset,
];
#[must_use]
pub fn key(kind: &str) -> Option<Key> {
ActionKind::from_wire(kind).filter(|kind| KINDS.contains(kind)).map(ActionKind::signers)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Balance {
Perp(String),
Spot(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Change {
Up(Dec),
Down(Dec),
DownAtLeast(Dec),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Effect {
pub balance: Balance,
pub change: Change,
}
impl Effect {
#[must_use]
pub fn landed(&self, before: Dec, after: Dec) -> bool {
match self.change {
Change::Up(amount) => before.checked_add(amount) == Some(after),
Change::Down(amount) => before.checked_sub(amount) == Some(after),
Change::DownAtLeast(amount) => before.checked_sub(amount).is_some_and(|most| after <= most),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan<A> {
pub network: Network,
pub action: A,
pub nonce: u64,
pub key: Key,
pub effect: Effect,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Refused {
#[error("a pooled account holds USDC in one balance, so there is nothing to move between {0}")]
OneBalance(&'static str),
#[error("the two balances are the same one")]
SameDex,
#[error("that is this account; a send goes somewhere else")]
ToSelf,
#[error("{0} is a system address: a send there crosses to HyperEVM, which `to_evm` plans")]
SystemAddress(Address),
#[error("{0} has no token on HyperEVM to cross to")]
NotLinked(String),
#[error("USDC does not cross to HyperEVM here")]
Usdc,
#[error("{amount} has more than {places} decimal places")]
TooFine { amount: Dec, places: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Ctx {
pub network: Network,
pub account: Address,
pub keeping: Keeping,
pub nonce: u64,
}
impl Ctx {
#[must_use]
pub fn usdc(&self) -> Balance {
match self.keeping.pools() {
true => Balance::Spot(USDC_INDEX),
false => Balance::Perp(PERP_DEX.to_owned()),
}
}
const fn plan<A>(&self, action: A, key: Key, effect: Effect) -> Plan<A> {
Plan { network: self.network, action, nonce: self.nonce, key, effect }
}
pub fn class_transfer(&self, amount: Amount, to_perp: bool) -> Result<Plan<UsdClassTransfer>, Refused> {
if self.keeping.pools() {
return Err(Refused::OneBalance("perps and spot"));
}
let value = places(&amount, USD_DECIMALS)?;
let change = match to_perp {
true => Change::Up(value),
false => Change::Down(value),
};
let effect = Effect { balance: Balance::Perp(PERP_DEX.to_owned()), change };
Ok(self.plan(UsdClassTransfer::new(amount, to_perp, self.nonce), UsdClassTransfer::KEY, effect))
}
pub fn usd_send(&self, to: Address, amount: Amount) -> Result<Plan<UsdSend>, Refused> {
self.recipient(to)?;
let value = places(&amount, USD_DECIMALS)?;
let effect = Effect { balance: self.usdc(), change: Change::DownAtLeast(value) };
Ok(self.plan(UsdSend::new(to, amount, self.nonce), UsdSend::KEY, effect))
}
pub fn send_token(&self, to: Address, token: &SpotToken, amount: Amount) -> Result<Plan<SpotOut>, Refused> {
self.recipient(to)?;
let value = places(&amount, token.wei_decimals)?;
let effect = Effect { balance: Balance::Spot(token.index), change: Change::DownAtLeast(value) };
let action = SpotOut::new(to, &token.wire(), amount, self.keeping, self.nonce);
Ok(self.plan(action, SpotOut::KEY, effect))
}
pub fn to_evm(&self, token: &SpotToken, amount: Amount) -> Result<Plan<SpotOut>, Refused> {
if token.index == USDC_INDEX {
return Err(Refused::Usdc);
}
let most = token.crossing_places(self.network).ok_or_else(|| Refused::NotLinked(token.name.clone()))?;
let value = places(&amount, most)?;
let effect = Effect { balance: Balance::Spot(token.index), change: Change::DownAtLeast(value) };
let action = SpotOut::new(token.system_address(self.network), &token.wire(), amount, self.keeping, self.nonce);
Ok(self.plan(action, SpotOut::KEY, effect))
}
pub fn between(&self, from: &str, to: &str, token: &SpotToken, amount: Amount) -> Result<Plan<SendAsset>, Refused> {
let effect = self.between_effect(from, to, token, &amount)?;
let action = SendAsset::new(self.account, from, to, &token.wire(), amount, self.nonce);
Ok(self.plan(action, SendAsset::KEY, effect))
}
pub fn agent_between(
&self,
from: &str,
to: &str,
token: &SpotToken,
amount: Amount,
) -> Result<Plan<AgentSendAsset>, Refused> {
let effect = self.between_effect(from, to, token, &amount)?;
let action = AgentSendAsset::new(self.account, from, to, &token.wire(), amount, self.nonce);
Ok(self.plan(action, AgentSendAsset::KEY, effect))
}
pub fn withdraw(&self, to: Address, amount: Amount) -> Result<Plan<Withdraw3>, Refused> {
if is_system_address(to) {
return Err(Refused::SystemAddress(to));
}
let value = places(&amount, USD_DECIMALS)?;
let effect = Effect { balance: self.usdc(), change: Change::Down(value) };
Ok(self.plan(Withdraw3::new(to, amount, self.nonce), Withdraw3::KEY, effect))
}
fn recipient(&self, to: Address) -> Result<(), Refused> {
match (to == self.account, is_system_address(to)) {
(true, _) => Err(Refused::ToSelf),
(false, true) => Err(Refused::SystemAddress(to)),
(false, false) => Ok(()),
}
}
fn between_effect(&self, from: &str, to: &str, token: &SpotToken, amount: &Amount) -> Result<Effect, Refused> {
if self.keeping.pools() {
return Err(Refused::OneBalance("dexes"));
}
if from == to {
return Err(Refused::SameDex);
}
let value = places(amount, token.wei_decimals.min(USD_DECIMALS))?;
let balance = match from {
SPOT_DEX => Balance::Spot(token.index),
dex => Balance::Perp(dex.to_owned()),
};
Ok(Effect { balance, change: Change::Down(value) })
}
}
fn places(amount: &Amount, most: u32) -> Result<Dec, Refused> {
let value = amount.value();
match value.places() > most {
true => Err(Refused::TooFine { amount: value, places: most }),
false => Ok(value),
}
}
#[cfg(test)]
#[path = "tests/plan.rs"]
mod tests;