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