Skip to main content

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}