1use crate::account::signers::ActionKind;
6use crate::client::{Network, USDC_INDEX};
7use crate::transfer::actions::{
8 AgentSendAsset, PERP_DEX, SPOT_DEX, SendAsset, SpotOut, UsdClassTransfer, UsdSend, Withdraw3,
9};
10use crate::transfer::amount::Amount;
11use crate::transfer::check::is_system_address;
12use crate::transfer::keeping::Keeping;
13use crate::transfer::token::SpotToken;
14use crate::utils::{Address, Dec};
15
16pub const USD_DECIMALS: u32 = 6;
18
19pub use crate::account::signers::Signers as Key;
22
23const KINDS: [ActionKind; 6] = [
25 ActionKind::UsdClassTransfer,
26 ActionKind::UsdSend,
27 ActionKind::SpotSend,
28 ActionKind::SendAsset,
29 ActionKind::Withdraw3,
30 ActionKind::AgentSendAsset,
31];
32
33#[must_use]
35pub fn key(kind: &str) -> Option<Key> {
36 ActionKind::from_wire(kind).filter(|kind| KINDS.contains(kind)).map(ActionKind::signers)
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
41pub enum Balance {
42 Perp(String),
45 Spot(u32),
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Change {
53 Up(Dec),
55 Down(Dec),
57 DownAtLeast(Dec),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Effect {
65 pub balance: Balance,
66 pub change: Change,
67}
68
69impl Effect {
70 #[must_use]
73 pub fn landed(&self, before: Dec, after: Dec) -> bool {
74 match self.change {
75 Change::Up(amount) => before.checked_add(amount) == Some(after),
76 Change::Down(amount) => before.checked_sub(amount) == Some(after),
77 Change::DownAtLeast(amount) => before.checked_sub(amount).is_some_and(|most| after <= most),
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Plan<A> {
85 pub network: Network,
86 pub action: A,
87 pub nonce: u64,
89 pub key: Key,
90 pub effect: Effect,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
95pub enum Refused {
96 #[error("a pooled account holds USDC in one balance, so there is nothing to move between {0}")]
97 OneBalance(&'static str),
98 #[error("the two balances are the same one")]
99 SameDex,
100 #[error("that is this account; a send goes somewhere else")]
101 ToSelf,
102 #[error("{0} is a system address: a send there crosses to HyperEVM, which `to_evm` plans")]
103 SystemAddress(Address),
104 #[error("{0} has no token on HyperEVM to cross to")]
105 NotLinked(String),
106 #[error("USDC does not cross to HyperEVM here")]
108 Usdc,
109 #[error("{amount} has more than {places} decimal places")]
110 TooFine { amount: Dec, places: u32 },
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct Ctx {
116 pub network: Network,
117 pub account: Address,
119 pub keeping: Keeping,
120 pub nonce: u64,
121}
122
123impl Ctx {
124 #[must_use]
127 pub fn usdc(&self) -> Balance {
128 match self.keeping.pools() {
129 true => Balance::Spot(USDC_INDEX),
130 false => Balance::Perp(PERP_DEX.to_owned()),
131 }
132 }
133
134 const fn plan<A>(&self, action: A, key: Key, effect: Effect) -> Plan<A> {
135 Plan { network: self.network, action, nonce: self.nonce, key, effect }
136 }
137
138 pub fn class_transfer(&self, amount: Amount, to_perp: bool) -> Result<Plan<UsdClassTransfer>, Refused> {
141 if self.keeping.pools() {
142 return Err(Refused::OneBalance("perps and spot"));
143 }
144 let value = places(&amount, USD_DECIMALS)?;
145 let change = match to_perp {
146 true => Change::Up(value),
147 false => Change::Down(value),
148 };
149 let effect = Effect { balance: Balance::Perp(PERP_DEX.to_owned()), change };
150 Ok(self.plan(UsdClassTransfer::new(amount, to_perp, self.nonce), UsdClassTransfer::KEY, effect))
151 }
152
153 pub fn usd_send(&self, to: Address, amount: Amount) -> Result<Plan<UsdSend>, Refused> {
155 self.recipient(to)?;
156 let value = places(&amount, USD_DECIMALS)?;
157 let effect = Effect { balance: self.usdc(), change: Change::DownAtLeast(value) };
158 Ok(self.plan(UsdSend::new(to, amount, self.nonce), UsdSend::KEY, effect))
159 }
160
161 pub fn send_token(&self, to: Address, token: &SpotToken, amount: Amount) -> Result<Plan<SpotOut>, Refused> {
163 self.recipient(to)?;
164 let value = places(&amount, token.wei_decimals)?;
165 let effect = Effect { balance: Balance::Spot(token.index), change: Change::DownAtLeast(value) };
166 let action = SpotOut::new(to, &token.wire(), amount, self.keeping, self.nonce);
167 Ok(self.plan(action, SpotOut::KEY, effect))
168 }
169
170 pub fn to_evm(&self, token: &SpotToken, amount: Amount) -> Result<Plan<SpotOut>, Refused> {
173 if token.index == USDC_INDEX {
174 return Err(Refused::Usdc);
175 }
176 let most = token.crossing_places(self.network).ok_or_else(|| Refused::NotLinked(token.name.clone()))?;
177 let value = places(&amount, most)?;
178 let effect = Effect { balance: Balance::Spot(token.index), change: Change::DownAtLeast(value) };
179 let action = SpotOut::new(token.system_address(self.network), &token.wire(), amount, self.keeping, self.nonce);
180 Ok(self.plan(action, SpotOut::KEY, effect))
181 }
182
183 pub fn between(&self, from: &str, to: &str, token: &SpotToken, amount: Amount) -> Result<Plan<SendAsset>, Refused> {
187 let effect = self.between_effect(from, to, token, &amount)?;
188 let action = SendAsset::new(self.account, from, to, &token.wire(), amount, self.nonce);
189 Ok(self.plan(action, SendAsset::KEY, effect))
190 }
191
192 pub fn agent_between(
194 &self,
195 from: &str,
196 to: &str,
197 token: &SpotToken,
198 amount: Amount,
199 ) -> Result<Plan<AgentSendAsset>, Refused> {
200 let effect = self.between_effect(from, to, token, &amount)?;
201 let action = AgentSendAsset::new(self.account, from, to, &token.wire(), amount, self.nonce);
202 Ok(self.plan(action, AgentSendAsset::KEY, effect))
203 }
204
205 pub fn withdraw(&self, to: Address, amount: Amount) -> Result<Plan<Withdraw3>, Refused> {
208 if is_system_address(to) {
209 return Err(Refused::SystemAddress(to));
210 }
211 let value = places(&amount, USD_DECIMALS)?;
212 let effect = Effect { balance: self.usdc(), change: Change::Down(value) };
213 Ok(self.plan(Withdraw3::new(to, amount, self.nonce), Withdraw3::KEY, effect))
214 }
215
216 fn recipient(&self, to: Address) -> Result<(), Refused> {
217 match (to == self.account, is_system_address(to)) {
218 (true, _) => Err(Refused::ToSelf),
219 (false, true) => Err(Refused::SystemAddress(to)),
220 (false, false) => Ok(()),
221 }
222 }
223
224 fn between_effect(&self, from: &str, to: &str, token: &SpotToken, amount: &Amount) -> Result<Effect, Refused> {
227 if self.keeping.pools() {
228 return Err(Refused::OneBalance("dexes"));
229 }
230 if from == to {
231 return Err(Refused::SameDex);
232 }
233 let value = places(amount, token.wei_decimals.min(USD_DECIMALS))?;
234 let balance = match from {
235 SPOT_DEX => Balance::Spot(token.index),
236 dex => Balance::Perp(dex.to_owned()),
237 };
238 Ok(Effect { balance, change: Change::Down(value) })
239 }
240}
241
242fn places(amount: &Amount, most: u32) -> Result<Dec, Refused> {
244 let value = amount.value();
245 match value.places() > most {
246 true => Err(Refused::TooFine { amount: value, places: most }),
247 false => Ok(value),
248 }
249}
250
251#[cfg(test)]
252#[path = "tests/plan.rs"]
253mod tests;