cratefield_module_guardrails/action.rs
1//! The request an automated value-moving action must describe, plus the
2//! chain, address and amount vocabulary the rest of the crate speaks.
3//!
4//! Addresses: EVM addresses are lowercase `0x`-prefixed hex, and policy
5//! comparisons on EVM are case-insensitive (a checksummed spelling is never
6//! a different contract); Solana addresses are base58 strings compared
7//! exactly. Amounts are big-endian [`U256`] words — the raw `uint256` of the
8//! chain — with no bignum dependency.
9
10use serde::{Deserialize, Serialize};
11
12/// A 256-bit unsigned integer, big-endian: the shape of an EVM `uint256`
13/// word.
14pub type U256 = [u8; 32];
15
16/// `v` as a big-endian [`U256`].
17#[must_use]
18pub fn u256_from_u128(v: u128) -> U256 {
19 let mut out = [0_u8; 32];
20 out[16..].copy_from_slice(&v.to_be_bytes());
21 out
22}
23
24/// The low 128 bits of `v`, or `None` when the value needs more than 128
25/// bits (and so cannot be a micro-USD figure or a counted token amount).
26#[must_use]
27pub fn u256_to_u128(v: &U256) -> Option<u128> {
28 if v[..16].iter().any(|&b| b != 0) {
29 return None;
30 }
31 let mut low = [0_u8; 16];
32 low.copy_from_slice(&v[16..]);
33 Some(u128::from_be_bytes(low))
34}
35
36/// Whether `v >= 2^128` — the crate's "unlimited" line.
37///
38/// `MAX_UINT256` and Permit2's `MAX_UINT160` are both far above 2^128, and so is
39/// every "infinite" approval a dapp writes in practice; no legitimate
40/// bounded amount comes near it. An amount of exactly 2^128 or more is
41/// treated as unlimited and refused: the boundary is deliberately coarse,
42/// because the values that cross it are sentinel values, not money.
43#[must_use]
44pub fn u256_is_unlimited(v: &U256) -> bool {
45 v[..16].iter().any(|&b| b != 0)
46}
47
48/// Which chain an action runs on.
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50pub enum Chain {
51 /// An EVM chain, by its EIP-155 id.
52 Evm {
53 /// The chain id the action is signed for.
54 chain_id: u64,
55 },
56 /// A Solana cluster, by name.
57 Solana {
58 /// The cluster id, e.g. `mainnet-beta`.
59 cluster: String,
60 },
61}
62
63impl Chain {
64 /// Stable string form used in ledger keys, audit entries and scope
65 /// labels: `evm:1`, `solana:mainnet-beta`.
66 #[must_use]
67 pub fn key(&self) -> String {
68 match self {
69 Chain::Evm { chain_id } => format!("evm:{chain_id}"),
70 Chain::Solana { cluster } => format!("solana:{cluster}"),
71 }
72 }
73
74 /// `true` for the EVM arms.
75 #[must_use]
76 pub fn is_evm(&self) -> bool {
77 matches!(self, Chain::Evm { .. })
78 }
79}
80
81/// An EIP-7702 authorization carried by an EVM transaction: a delegation of
82/// the signer's code to `address`.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct Authorization7702 {
85 /// The chain the delegation is valid on. `0` means "all chains" —
86 /// replayable everywhere, and a hard deny.
87 pub chain_id: u64,
88 /// The delegate contract, lowercase `0x` hex.
89 pub address: String,
90}
91
92/// The swap context of an action, when it is a swap: the quoted output and
93/// the minimum the caller will accept. Slippage is
94/// `(quoted_out - min_out) / quoted_out` in basis points.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SwapQuote {
97 /// What the route quoted, in the output token's smallest unit.
98 pub quoted_out: u128,
99 /// What the transaction may actually settle for.
100 pub min_out: u128,
101}
102
103/// One Solana instruction.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct SolanaInstruction {
106 /// The program invoked, base58.
107 pub program_id: String,
108 /// The accounts passed, base58, in order.
109 pub accounts: Vec<String>,
110 /// The instruction data; the first byte(s) are the discriminant.
111 pub data: Vec<u8>,
112}
113
114/// What the acting address wants to do.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub enum Action {
117 /// A plain EVM transaction. Empty `data` is a native transfer of
118 /// `value` to `to`; with `data` it is a contract call. `to` of `None`
119 /// is contract creation, which no policy can meaningfully allowlist.
120 EvmTx {
121 /// The EIP-155 chain id.
122 chain_id: u64,
123 /// The call or transfer target, lowercase `0x` hex; `None` creates
124 /// a contract.
125 to: Option<String>,
126 /// The native value attached.
127 value: U256,
128 /// The calldata, selector first.
129 data: Vec<u8>,
130 /// EIP-7702 delegations carried by the transaction.
131 #[serde(default)]
132 authorizations: Vec<Authorization7702>,
133 },
134 /// An EIP-712 signature request — the Permit / Permit2 family. Nothing
135 /// is broadcast, which is exactly why it is checked here: a signature
136 /// is an approval the chain will honour later, on the strength of this
137 /// one act.
138 EvmTypedData {
139 /// The EIP-155 chain id the domain pins.
140 chain_id: u64,
141 /// The primary type, e.g. `Permit`, `PermitSingle`.
142 primary_type: String,
143 /// The `verifyingContract` of the domain, lowercase `0x` hex.
144 verifying_contract: String,
145 /// The spender the signature empowers, when the type carries one.
146 spender: Option<String>,
147 /// The amount the signature empowers, when the type carries one.
148 /// `None` on a spending type means "not statically known".
149 amount: Option<U256>,
150 },
151 /// A Solana transaction: instructions executed in order.
152 SolanaTx {
153 /// The cluster id, e.g. `mainnet-beta`.
154 cluster: String,
155 /// The instructions.
156 instructions: Vec<SolanaInstruction>,
157 },
158}
159
160/// A value-moving action an actor wants to take, as the engine sees it.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct Request {
163 /// The venture the acting wallet belongs to.
164 pub venture: String,
165 /// The subject (actor identity) the policy is resolved for.
166 pub subject: String,
167 /// The acting address — the wallet that signs, and the owner whose
168 /// balance changes count as outflows.
169 pub from: String,
170 /// The action itself.
171 pub action: Action,
172 /// The swap context, when the action is a swap.
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub swap: Option<SwapQuote>,
175}
176
177impl Request {
178 /// The chain the action runs on.
179 #[must_use]
180 pub fn chain(&self) -> Chain {
181 match &self.action {
182 Action::EvmTx { chain_id, .. } | Action::EvmTypedData { chain_id, .. } => Chain::Evm {
183 chain_id: *chain_id,
184 },
185 Action::SolanaTx { cluster, .. } => Chain::Solana {
186 cluster: cluster.clone(),
187 },
188 }
189 }
190
191 /// One line for audit entries and logs: kind plus the fields that
192 /// identify the action. Never carries amounts.
193 #[must_use]
194 pub fn summary(&self) -> String {
195 match &self.action {
196 Action::EvmTx {
197 chain_id,
198 to,
199 data,
200 authorizations,
201 ..
202 } => {
203 let sel = data.first_chunk::<4>().map_or_else(
204 || "native".to_owned(),
205 |s| format!("sel=0x{:08x}", u32::from_be_bytes(*s)),
206 );
207 let auth = if authorizations.is_empty() {
208 String::new()
209 } else {
210 format!(" 7702x{}", authorizations.len())
211 };
212 format!(
213 "evm_tx chain={chain_id} to={} {sel}{auth}",
214 to.as_deref().unwrap_or("create")
215 )
216 }
217 Action::EvmTypedData {
218 chain_id,
219 primary_type,
220 verifying_contract,
221 ..
222 } => {
223 format!(
224 "evm_typed_data chain={chain_id} type={primary_type} contract={verifying_contract}"
225 )
226 }
227 Action::SolanaTx {
228 cluster,
229 instructions,
230 } => {
231 format!("solana_tx cluster={cluster} ix={}", instructions.len())
232 }
233 }
234 }
235}
236
237/// What token a balance change or a cap is about: the chain plus the token's
238/// address (EVM, lowercase hex) or mint (Solana, base58), or `"native"` for
239/// the chain's own coin.
240#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
241pub struct TokenKey {
242 /// The chain the token lives on.
243 pub chain: Chain,
244 /// The token address or mint, or `native`.
245 pub token: String,
246}
247
248impl TokenKey {
249 /// The chain's native coin.
250 #[must_use]
251 pub fn native(chain: Chain) -> Self {
252 // The literal sits on its own line, away from the `token` key: a
253 // token key paired with a string literal on one line is the
254 // assignment shape credential scanners match on, even when the
255 // value is a plain word like this one.
256 let native = "native".to_owned();
257 Self {
258 chain,
259 token: native,
260 }
261 }
262
263 /// Stable string form, for logs and ledger keys.
264 #[must_use]
265 pub fn key(&self) -> String {
266 format!("{}:{}", self.chain.key(), self.token)
267 }
268}