clawpowers 2.2.5 → 2.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +186 -160
- package/COMPATIBILITY.md +48 -13
- package/KNOWN_LIMITATIONS.md +20 -19
- package/LICENSE +44 -44
- package/LICENSING.md +10 -10
- package/README.md +486 -462
- package/SECURITY.md +52 -52
- package/dist/index.d.ts +17 -5
- package/dist/index.js +187 -92
- package/dist/index.js.map +1 -1
- package/native/Cargo.lock +4927 -4927
- package/native/Cargo.toml +73 -73
- package/native/crates/canonical/Cargo.toml +24 -24
- package/native/crates/canonical/src/lib.rs +677 -673
- package/native/crates/compression/Cargo.toml +20 -20
- package/native/crates/compression/benches/compression_bench.rs +42 -42
- package/native/crates/compression/src/lib.rs +393 -393
- package/native/crates/evm-eth/Cargo.toml +13 -13
- package/native/crates/evm-eth/src/lib.rs +105 -105
- package/native/crates/fee/Cargo.toml +15 -15
- package/native/crates/fee/src/lib.rs +281 -281
- package/native/crates/index/Cargo.toml +16 -16
- package/native/crates/index/src/lib.rs +277 -277
- package/native/crates/policy/Cargo.toml +17 -17
- package/native/crates/policy/src/lib.rs +614 -614
- package/native/crates/security/Cargo.toml +22 -22
- package/native/crates/security/src/lib.rs +478 -478
- package/native/crates/tokens/Cargo.toml +13 -13
- package/native/crates/tokens/src/lib.rs +534 -534
- package/native/crates/verification/Cargo.toml +23 -23
- package/native/crates/verification/src/lib.rs +333 -333
- package/native/crates/wallet/Cargo.toml +20 -20
- package/native/crates/wallet/src/lib.rs +261 -261
- package/native/crates/x402/Cargo.toml +30 -30
- package/native/crates/x402/src/lib.rs +423 -423
- package/native/ffi/Cargo.toml +34 -34
- package/native/ffi/build.rs +4 -4
- package/native/ffi/src/lib.rs +352 -352
- package/native/ffi/tests/integration.rs +354 -354
- package/native/pyo3/Cargo.toml +26 -26
- package/native/pyo3/pyproject.toml +16 -16
- package/native/pyo3/src/lib.rs +407 -407
- package/native/pyo3/tests/test_smoke.py +180 -180
- package/native/wasm/Cargo.toml +47 -44
- package/native/wasm/pkg/.gitignore +6 -6
- package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -208
- package/native/wasm/pkg/clawpowers_wasm.js +872 -872
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -40
- package/native/wasm/pkg/package.json +16 -16
- package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -143
- package/native/wasm/pkg-node/clawpowers_wasm.js +798 -798
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -40
- package/native/wasm/pkg-node/package.json +12 -12
- package/native/wasm/src/lib.rs +433 -433
- package/package.json +13 -8
- package/scripts/build-wasm.mjs +59 -0
- package/scripts/generate_hermes_wrappers.py +211 -0
- package/scripts/hermes_wrapper_overrides.json +184 -0
- package/scripts/run-python-script.mjs +48 -0
- package/scripts/verify-consumer-install.mjs +109 -0
- package/scripts/verify-wasm-artifacts.mjs +26 -3
- package/scripts/verify_hermes_wrappers.py +154 -0
- package/skill.json +20 -0
- package/skills/1password/SKILL.md +34 -0
- package/skills/README.md +44 -0
- package/skills/agent-nexus-2/SKILL.md +34 -0
- package/skills/apple-notes/SKILL.md +34 -0
- package/skills/apple-reminders/SKILL.md +34 -0
- package/skills/autoresearch/SKILL.md +43 -0
- package/skills/bear-notes/SKILL.md +34 -0
- package/skills/blogwatcher/SKILL.md +34 -0
- package/skills/blucli/SKILL.md +34 -0
- package/skills/bluebubbles/SKILL.md +34 -0
- package/skills/business-strategy/SKILL.md +41 -0
- package/skills/camsnap/SKILL.md +34 -0
- package/skills/canvas/SKILL.md +34 -0
- package/skills/clawhub/SKILL.md +34 -0
- package/skills/coding-agent/SKILL.md +34 -0
- package/skills/coding-discipline.skill/SKILL.md +34 -0
- package/skills/content-writer/SKILL.md +41 -0
- package/skills/discord/SKILL.md +34 -0
- package/skills/eightctl/SKILL.md +34 -0
- package/skills/execution-validation.skill/SKILL.md +34 -0
- package/skills/gemini/SKILL.md +34 -0
- package/skills/gh-issues/SKILL.md +34 -0
- package/skills/gifgrep/SKILL.md +34 -0
- package/skills/github/SKILL.md +41 -0
- package/skills/gog/SKILL.md +34 -0
- package/skills/goplaces/SKILL.md +34 -0
- package/skills/healthcheck/SKILL.md +34 -0
- package/skills/himalaya/SKILL.md +34 -0
- package/skills/humanize/SKILL.md +41 -0
- package/skills/imsg/SKILL.md +34 -0
- package/skills/itp/SKILL.md +112 -0
- package/skills/mcporter/SKILL.md +34 -0
- package/skills/model-usage/SKILL.md +34 -0
- package/skills/nano-pdf/SKILL.md +34 -0
- package/skills/node-connect/SKILL.md +34 -0
- package/skills/notion/SKILL.md +34 -0
- package/skills/obsidian/SKILL.md +34 -0
- package/skills/openai-whisper/SKILL.md +34 -0
- package/skills/openai-whisper-api/SKILL.md +34 -0
- package/skills/openhue/SKILL.md +34 -0
- package/skills/oracle/SKILL.md +34 -0
- package/skills/ordercli/SKILL.md +34 -0
- package/skills/peekaboo/SKILL.md +34 -0
- package/skills/polyclaw/SKILL.md +34 -0
- package/skills/prospector/SKILL.md +41 -0
- package/skills/rsi.skill/SKILL.md +34 -0
- package/skills/sag/SKILL.md +34 -0
- package/skills/security/SKILL.md +41 -0
- package/skills/session-logs/SKILL.md +34 -0
- package/skills/sherpa-onnx-tts/SKILL.md +34 -0
- package/skills/skill-creator/SKILL.md +34 -0
- package/skills/slack/SKILL.md +34 -0
- package/skills/songsee/SKILL.md +34 -0
- package/skills/sonoscli/SKILL.md +34 -0
- package/skills/spotify-player/SKILL.md +34 -0
- package/skills/strykr-prism/SKILL.md +41 -0
- package/skills/summarize/SKILL.md +34 -0
- package/skills/taskbridge/SKILL.md +34 -0
- package/skills/things-mac/SKILL.md +34 -0
- package/skills/tmux/SKILL.md +34 -0
- package/skills/trello/SKILL.md +34 -0
- package/skills/validator-agent/SKILL.md +41 -0
- package/skills/video-frames/SKILL.md +34 -0
- package/skills/voice-call/SKILL.md +34 -0
- package/skills/wacli/SKILL.md +34 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/webmcp-payments/SKILL.md +41 -0
- package/skills/xurl/SKILL.md +34 -0
- package/src/skills/catalog.ts +435 -435
- package/src/skills/executor.ts +56 -56
- package/src/skills/index.ts +3 -3
- package/src/skills/itp/SKILL.md +112 -112
- package/src/skills/loader.ts +262 -193
- package/native/ffi/index.node +0 -0
- package/native/wasm/pkg-node/.gitignore +0 -6
|
@@ -1,534 +1,534 @@
|
|
|
1
|
-
//! Token registry and decimal math for the ClawPowers agent wallet system.
|
|
2
|
-
//!
|
|
3
|
-
//! Provides [`TokenInfo`], [`TokenRegistry`], and [`TokenAmount`] — the
|
|
4
|
-
//! foundational types for expressing on-chain token quantities with correct
|
|
5
|
-
//! decimal semantics.
|
|
6
|
-
|
|
7
|
-
use alloy_primitives::{Address, U256, address};
|
|
8
|
-
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
9
|
-
use std::collections::HashMap;
|
|
10
|
-
use std::fmt;
|
|
11
|
-
use thiserror::Error;
|
|
12
|
-
|
|
13
|
-
// ── Custom serde helpers for alloy types ─────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
mod serde_address_opt {
|
|
16
|
-
use super::*;
|
|
17
|
-
|
|
18
|
-
pub fn serialize<S>(addr: &Option<Address>, s: S) -> Result<S::Ok, S::Error>
|
|
19
|
-
where
|
|
20
|
-
S: Serializer,
|
|
21
|
-
{
|
|
22
|
-
match addr {
|
|
23
|
-
Some(a) => s.serialize_some(&format!("{a:?}")),
|
|
24
|
-
None => s.serialize_none(),
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
pub fn deserialize<'de, D>(d: D) -> Result<Option<Address>, D::Error>
|
|
29
|
-
where
|
|
30
|
-
D: Deserializer<'de>,
|
|
31
|
-
{
|
|
32
|
-
let opt: Option<String> = Option::deserialize(d)?;
|
|
33
|
-
match opt {
|
|
34
|
-
None => Ok(None),
|
|
35
|
-
Some(s) => {
|
|
36
|
-
let trimmed = s.trim();
|
|
37
|
-
let hex = trimmed.strip_prefix("0x").unwrap_or(trimmed);
|
|
38
|
-
let mut bytes = [0u8; 20];
|
|
39
|
-
if hex.len() != 40 {
|
|
40
|
-
return Err(serde::de::Error::custom(format!(
|
|
41
|
-
"invalid address length: {hex}"
|
|
42
|
-
)));
|
|
43
|
-
}
|
|
44
|
-
for i in 0..20 {
|
|
45
|
-
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
|
46
|
-
.map_err(|e| serde::de::Error::custom(e.to_string()))?;
|
|
47
|
-
}
|
|
48
|
-
Ok(Some(Address::from(bytes)))
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
mod serde_u256 {
|
|
55
|
-
use super::*;
|
|
56
|
-
|
|
57
|
-
pub fn serialize<S>(v: &U256, s: S) -> Result<S::Ok, S::Error>
|
|
58
|
-
where
|
|
59
|
-
S: Serializer,
|
|
60
|
-
{
|
|
61
|
-
s.serialize_str(&v.to_string())
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
pub fn deserialize<'de, D>(d: D) -> Result<U256, D::Error>
|
|
65
|
-
where
|
|
66
|
-
D: Deserializer<'de>,
|
|
67
|
-
{
|
|
68
|
-
let s = String::deserialize(d)?;
|
|
69
|
-
U256::from_str_radix(s.trim(), 10)
|
|
70
|
-
.map_err(|e| serde::de::Error::custom(format!("invalid U256: {e}")))
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── TokenInfo ────────────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
/// Metadata about an ERC-20 token (or native asset).
|
|
77
|
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
78
|
-
pub struct TokenInfo {
|
|
79
|
-
/// Ticker symbol, e.g. `"USDC"`.
|
|
80
|
-
pub symbol: String,
|
|
81
|
-
/// Number of decimal places, e.g. `6` for USDC.
|
|
82
|
-
pub decimals: u8,
|
|
83
|
-
/// Chain ID the token lives on (1 = Ethereum mainnet).
|
|
84
|
-
pub chain_id: u64,
|
|
85
|
-
/// Contract address. `None` for native assets such as ETH.
|
|
86
|
-
#[serde(with = "serde_address_opt")]
|
|
87
|
-
pub address: Option<Address>,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── TokenRegistry ─────────────────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
/// A registry of well-known tokens, keyed by symbol.
|
|
93
|
-
///
|
|
94
|
-
/// Constructed with [`TokenRegistry::default()`] which pre-populates a set of
|
|
95
|
-
/// common mainnet tokens.
|
|
96
|
-
#[derive(Debug, Clone)]
|
|
97
|
-
pub struct TokenRegistry {
|
|
98
|
-
tokens: HashMap<String, TokenInfo>,
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
impl TokenRegistry {
|
|
102
|
-
/// Creates an empty registry.
|
|
103
|
-
pub fn empty() -> Self {
|
|
104
|
-
Self {
|
|
105
|
-
tokens: HashMap::new(),
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/// Registers a token, overwriting any existing entry with the same symbol.
|
|
110
|
-
pub fn register(&mut self, token: TokenInfo) {
|
|
111
|
-
self.tokens.insert(token.symbol.clone(), token);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/// Looks up a token by its ticker symbol.
|
|
115
|
-
pub fn get(&self, symbol: &str) -> Option<&TokenInfo> {
|
|
116
|
-
self.tokens.get(symbol)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/// Returns an iterator over all registered tokens.
|
|
120
|
-
pub fn iter(&self) -> impl Iterator<Item = &TokenInfo> {
|
|
121
|
-
self.tokens.values()
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
impl Default for TokenRegistry {
|
|
126
|
-
/// Returns a registry pre-populated with common mainnet tokens.
|
|
127
|
-
fn default() -> Self {
|
|
128
|
-
let mut r = Self::empty();
|
|
129
|
-
r.register(TokenInfo {
|
|
130
|
-
symbol: "USDC".to_string(),
|
|
131
|
-
decimals: 6,
|
|
132
|
-
chain_id: 1,
|
|
133
|
-
address: Some(address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")),
|
|
134
|
-
});
|
|
135
|
-
r.register(TokenInfo {
|
|
136
|
-
symbol: "USDT".to_string(),
|
|
137
|
-
decimals: 6,
|
|
138
|
-
chain_id: 1,
|
|
139
|
-
address: Some(address!("dAC17F958D2ee523a2206206994597C13D831ec7")),
|
|
140
|
-
});
|
|
141
|
-
r.register(TokenInfo {
|
|
142
|
-
symbol: "DAI".to_string(),
|
|
143
|
-
decimals: 18,
|
|
144
|
-
chain_id: 1,
|
|
145
|
-
address: Some(address!("6B175474E89094C44Da98b954EedeAC495271d0F")),
|
|
146
|
-
});
|
|
147
|
-
r.register(TokenInfo {
|
|
148
|
-
symbol: "WETH".to_string(),
|
|
149
|
-
decimals: 18,
|
|
150
|
-
chain_id: 1,
|
|
151
|
-
address: Some(address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")),
|
|
152
|
-
});
|
|
153
|
-
r.register(TokenInfo {
|
|
154
|
-
symbol: "ETH".to_string(),
|
|
155
|
-
decimals: 18,
|
|
156
|
-
chain_id: 1,
|
|
157
|
-
address: None,
|
|
158
|
-
});
|
|
159
|
-
r
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ── TokenAmount ──────────────────────────────────────────────────────────────
|
|
164
|
-
|
|
165
|
-
/// Error type for token arithmetic operations.
|
|
166
|
-
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
|
167
|
-
pub enum TokenError {
|
|
168
|
-
/// The two operands have mismatched decimal precision.
|
|
169
|
-
#[error("Decimal mismatch: lhs={lhs}, rhs={rhs}")]
|
|
170
|
-
DecimalMismatch { lhs: u8, rhs: u8 },
|
|
171
|
-
/// Arithmetic overflow or underflow.
|
|
172
|
-
#[error("Arithmetic overflow/underflow")]
|
|
173
|
-
Overflow,
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/// A fixed-point token amount, backed by a [`U256`] raw integer.
|
|
177
|
-
///
|
|
178
|
-
/// All arithmetic is decimal-aware. Use [`TokenAmount::from_human`] and
|
|
179
|
-
/// [`TokenAmount::to_human`] to cross the human-readable boundary.
|
|
180
|
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
181
|
-
pub struct TokenAmount {
|
|
182
|
-
/// Raw integer representation (`human × 10^decimals`), serialised as a
|
|
183
|
-
/// decimal string for portability.
|
|
184
|
-
#[serde(with = "serde_u256")]
|
|
185
|
-
pub raw: U256,
|
|
186
|
-
/// Number of decimal places this amount is expressed with.
|
|
187
|
-
pub decimals: u8,
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
impl TokenAmount {
|
|
191
|
-
/// Creates a [`TokenAmount`] from a human-readable `f64` value.
|
|
192
|
-
///
|
|
193
|
-
/// # Example
|
|
194
|
-
/// ```
|
|
195
|
-
/// use clawpowers_tokens::TokenAmount;
|
|
196
|
-
/// let one_usdc = TokenAmount::from_human(1.0, 6);
|
|
197
|
-
/// assert_eq!(one_usdc.raw, alloy_primitives::U256::from(1_000_000u64));
|
|
198
|
-
/// ```
|
|
199
|
-
pub fn from_human(human: f64, decimals: u8) -> Self {
|
|
200
|
-
let multiplier = 10f64.powi(i32::from(decimals));
|
|
201
|
-
let raw_f64 = (human * multiplier).floor();
|
|
202
|
-
let raw = if raw_f64 <= 0.0 {
|
|
203
|
-
U256::ZERO
|
|
204
|
-
} else {
|
|
205
|
-
let as_int = raw_f64 as u128;
|
|
206
|
-
U256::from(as_int)
|
|
207
|
-
};
|
|
208
|
-
Self { raw, decimals }
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/// Converts back to a human-readable `f64`.
|
|
212
|
-
///
|
|
213
|
-
/// Precision is limited by `f64` — do not use this for exact comparisons.
|
|
214
|
-
pub fn to_human(&self) -> f64 {
|
|
215
|
-
let divisor = 10f64.powi(i32::from(self.decimals));
|
|
216
|
-
let raw_str = self.raw.to_string();
|
|
217
|
-
let raw_f64: f64 = raw_str.parse().unwrap_or(0.0);
|
|
218
|
-
raw_f64 / divisor
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/// Returns `true` if the amount is zero.
|
|
222
|
-
pub fn is_zero(&self) -> bool {
|
|
223
|
-
self.raw.is_zero()
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/// Checked addition. Returns an error on overflow or decimal mismatch.
|
|
227
|
-
pub fn add(&self, other: &TokenAmount) -> Result<TokenAmount, TokenError> {
|
|
228
|
-
if self.decimals != other.decimals {
|
|
229
|
-
return Err(TokenError::DecimalMismatch {
|
|
230
|
-
lhs: self.decimals,
|
|
231
|
-
rhs: other.decimals,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
let raw = self
|
|
235
|
-
.raw
|
|
236
|
-
.checked_add(other.raw)
|
|
237
|
-
.ok_or(TokenError::Overflow)?;
|
|
238
|
-
Ok(TokenAmount {
|
|
239
|
-
raw,
|
|
240
|
-
decimals: self.decimals,
|
|
241
|
-
})
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/// Checked subtraction. Returns an error on underflow or decimal mismatch.
|
|
245
|
-
pub fn sub(&self, other: &TokenAmount) -> Result<TokenAmount, TokenError> {
|
|
246
|
-
if self.decimals != other.decimals {
|
|
247
|
-
return Err(TokenError::DecimalMismatch {
|
|
248
|
-
lhs: self.decimals,
|
|
249
|
-
rhs: other.decimals,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
let raw = self
|
|
253
|
-
.raw
|
|
254
|
-
.checked_sub(other.raw)
|
|
255
|
-
.ok_or(TokenError::Overflow)?;
|
|
256
|
-
Ok(TokenAmount {
|
|
257
|
-
raw,
|
|
258
|
-
decimals: self.decimals,
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/// Multiply by basis points (1 bps = 0.01%).
|
|
263
|
-
///
|
|
264
|
-
/// Formula: `result = self × bps / 10_000`.
|
|
265
|
-
///
|
|
266
|
-
/// Returns `None` on arithmetic overflow.
|
|
267
|
-
pub fn checked_mul_bps(&self, bps: u64) -> Option<TokenAmount> {
|
|
268
|
-
let numerator = self.raw.checked_mul(U256::from(bps))?;
|
|
269
|
-
let raw = numerator.checked_div(U256::from(10_000u64))?;
|
|
270
|
-
Some(TokenAmount {
|
|
271
|
-
raw,
|
|
272
|
-
decimals: self.decimals,
|
|
273
|
-
})
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/// Creates a zero amount with the given decimal precision.
|
|
277
|
-
pub fn zero(decimals: u8) -> Self {
|
|
278
|
-
Self {
|
|
279
|
-
raw: U256::ZERO,
|
|
280
|
-
decimals,
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/// Alias for [`TokenAmount::from_human`] accepting an `f64`.
|
|
285
|
-
///
|
|
286
|
-
/// Provided for API symmetry — `from_human` already accepts `f64`.
|
|
287
|
-
pub fn from_human_f64(human: f64, decimals: u8) -> Self {
|
|
288
|
-
Self::from_human(human, decimals)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/// Scale by basis points (1 bps = 0.01%): `self × bps / 10_000`.
|
|
292
|
-
///
|
|
293
|
-
/// Returns `None` on arithmetic overflow.
|
|
294
|
-
pub fn scale_bps(&self, bps: u32) -> Option<TokenAmount> {
|
|
295
|
-
self.checked_mul_bps(bps as u64)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/// Checked subtraction (alias exposing the same name used by the fee crate).
|
|
299
|
-
///
|
|
300
|
-
/// Returns `None` on underflow or decimal mismatch.
|
|
301
|
-
pub fn checked_sub(&self, other: &TokenAmount) -> Option<TokenAmount> {
|
|
302
|
-
self.sub(other).ok()
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// ─── FeeType ─────────────────────────────────────────────────────────────────
|
|
307
|
-
|
|
308
|
-
/// Fee classification used by the fee schedule.
|
|
309
|
-
///
|
|
310
|
-
/// Defined here in `clawpowers-tokens` and re-exported by `clawpowers-fee` so
|
|
311
|
-
/// downstream consumers can `use clawpowers_fee::FeeType` or
|
|
312
|
-
/// `use clawpowers_tokens::FeeType` interchangeably.
|
|
313
|
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
314
|
-
#[serde(rename_all = "snake_case")]
|
|
315
|
-
pub enum FeeType {
|
|
316
|
-
/// Standard on-chain transaction fee.
|
|
317
|
-
Transaction,
|
|
318
|
-
/// DEX swap fee.
|
|
319
|
-
Swap,
|
|
320
|
-
/// Custom fee with explicit basis points.
|
|
321
|
-
Custom(u32),
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
impl fmt::Display for TokenAmount {
|
|
325
|
-
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
326
|
-
write!(f, "{}", self.to_human())
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
impl PartialOrd for TokenAmount {
|
|
331
|
-
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
332
|
-
if self.decimals != other.decimals {
|
|
333
|
-
return None;
|
|
334
|
-
}
|
|
335
|
-
Some(self.raw.cmp(&other.raw))
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
#[cfg(test)]
|
|
342
|
-
mod tests {
|
|
343
|
-
use super::*;
|
|
344
|
-
|
|
345
|
-
// ── TokenInfo ──────────────────────────────────────────────────────────
|
|
346
|
-
|
|
347
|
-
#[test]
|
|
348
|
-
fn test_token_info_fields() {
|
|
349
|
-
let info = TokenInfo {
|
|
350
|
-
symbol: "USDC".to_string(),
|
|
351
|
-
decimals: 6,
|
|
352
|
-
chain_id: 1,
|
|
353
|
-
address: Some(address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")),
|
|
354
|
-
};
|
|
355
|
-
assert_eq!(info.symbol, "USDC");
|
|
356
|
-
assert_eq!(info.decimals, 6);
|
|
357
|
-
assert_eq!(info.chain_id, 1);
|
|
358
|
-
assert!(info.address.is_some());
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
#[test]
|
|
362
|
-
fn test_token_info_no_address_for_eth() {
|
|
363
|
-
let eth = TokenInfo {
|
|
364
|
-
symbol: "ETH".to_string(),
|
|
365
|
-
decimals: 18,
|
|
366
|
-
chain_id: 1,
|
|
367
|
-
address: None,
|
|
368
|
-
};
|
|
369
|
-
assert!(eth.address.is_none());
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ── TokenRegistry ──────────────────────────────────────────────────────
|
|
373
|
-
|
|
374
|
-
#[test]
|
|
375
|
-
fn test_registry_default_contains_all_tokens() {
|
|
376
|
-
let reg = TokenRegistry::default();
|
|
377
|
-
for sym in &["USDC", "USDT", "DAI", "WETH", "ETH"] {
|
|
378
|
-
assert!(reg.get(sym).is_some(), "missing token: {sym}");
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
#[test]
|
|
383
|
-
fn test_registry_usdc_decimals() {
|
|
384
|
-
let reg = TokenRegistry::default();
|
|
385
|
-
let usdc = reg.get("USDC").expect("USDC should be present");
|
|
386
|
-
assert_eq!(usdc.decimals, 6);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
#[test]
|
|
390
|
-
fn test_registry_dai_decimals() {
|
|
391
|
-
let reg = TokenRegistry::default();
|
|
392
|
-
let dai = reg.get("DAI").expect("DAI should be present");
|
|
393
|
-
assert_eq!(dai.decimals, 18);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
#[test]
|
|
397
|
-
fn test_registry_register_custom_token() {
|
|
398
|
-
let mut reg = TokenRegistry::empty();
|
|
399
|
-
reg.register(TokenInfo {
|
|
400
|
-
symbol: "MYTOKEN".to_string(),
|
|
401
|
-
decimals: 8,
|
|
402
|
-
chain_id: 137,
|
|
403
|
-
address: None,
|
|
404
|
-
});
|
|
405
|
-
let t = reg.get("MYTOKEN").expect("custom token");
|
|
406
|
-
assert_eq!(t.chain_id, 137);
|
|
407
|
-
assert_eq!(t.decimals, 8);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ── TokenAmount ────────────────────────────────────────────────────────
|
|
411
|
-
|
|
412
|
-
#[test]
|
|
413
|
-
fn test_from_human_usdc_one_dollar() {
|
|
414
|
-
let one = TokenAmount::from_human(1.0, 6);
|
|
415
|
-
assert_eq!(one.raw, U256::from(1_000_000u64));
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
#[test]
|
|
419
|
-
fn test_from_human_eth_one() {
|
|
420
|
-
let one_eth = TokenAmount::from_human(1.0, 18);
|
|
421
|
-
assert_eq!(one_eth.raw, U256::from(1_000_000_000_000_000_000u128));
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
#[test]
|
|
425
|
-
fn test_to_human_round_trip() {
|
|
426
|
-
let human = 123.456_789;
|
|
427
|
-
let amt = TokenAmount::from_human(human, 6);
|
|
428
|
-
let back = amt.to_human();
|
|
429
|
-
assert!(
|
|
430
|
-
(back - human).abs() < 0.000_001,
|
|
431
|
-
"round-trip: got {back}, want ~{human}"
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
#[test]
|
|
436
|
-
fn test_from_human_zero() {
|
|
437
|
-
let zero = TokenAmount::from_human(0.0, 6);
|
|
438
|
-
assert!(zero.is_zero());
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
#[test]
|
|
442
|
-
fn test_add_same_decimals() {
|
|
443
|
-
let a = TokenAmount::from_human(1.0, 6);
|
|
444
|
-
let b = TokenAmount::from_human(2.0, 6);
|
|
445
|
-
let sum = a.add(&b).expect("addition should succeed");
|
|
446
|
-
assert_eq!(sum, TokenAmount::from_human(3.0, 6));
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
#[test]
|
|
450
|
-
fn test_sub_valid() {
|
|
451
|
-
let a = TokenAmount::from_human(5.0, 6);
|
|
452
|
-
let b = TokenAmount::from_human(2.0, 6);
|
|
453
|
-
let diff = a.sub(&b).expect("subtraction should succeed");
|
|
454
|
-
assert_eq!(diff, TokenAmount::from_human(3.0, 6));
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
#[test]
|
|
458
|
-
fn test_sub_underflow_returns_err() {
|
|
459
|
-
let a = TokenAmount::from_human(1.0, 6);
|
|
460
|
-
let b = TokenAmount::from_human(2.0, 6);
|
|
461
|
-
let result = a.sub(&b);
|
|
462
|
-
assert!(result.is_err());
|
|
463
|
-
assert_eq!(result.unwrap_err(), TokenError::Overflow);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
#[test]
|
|
467
|
-
fn test_add_decimal_mismatch_returns_err() {
|
|
468
|
-
let a = TokenAmount::from_human(1.0, 6);
|
|
469
|
-
let b = TokenAmount::from_human(1.0, 18);
|
|
470
|
-
let result = a.add(&b);
|
|
471
|
-
assert!(matches!(result, Err(TokenError::DecimalMismatch { .. })));
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
#[test]
|
|
475
|
-
fn test_checked_mul_bps_50bps() {
|
|
476
|
-
// 50 bps = 0.5% of 1000 USDC = 5 USDC
|
|
477
|
-
let amount = TokenAmount::from_human(1000.0, 6);
|
|
478
|
-
let fee = amount.checked_mul_bps(50).expect("no overflow");
|
|
479
|
-
let expected = TokenAmount::from_human(5.0, 6);
|
|
480
|
-
assert_eq!(fee, expected);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
#[test]
|
|
484
|
-
fn test_checked_mul_bps_100bps_is_1pct() {
|
|
485
|
-
let amount = TokenAmount::from_human(200.0, 6);
|
|
486
|
-
let result = amount.checked_mul_bps(100).expect("no overflow");
|
|
487
|
-
let expected = TokenAmount::from_human(2.0, 6);
|
|
488
|
-
assert_eq!(result, expected);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
#[test]
|
|
492
|
-
fn test_checked_mul_bps_zero() {
|
|
493
|
-
let amount = TokenAmount::from_human(1000.0, 6);
|
|
494
|
-
let result = amount.checked_mul_bps(0).expect("no overflow");
|
|
495
|
-
assert!(result.is_zero());
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
#[test]
|
|
499
|
-
fn test_display_shows_human_amount() {
|
|
500
|
-
let amt = TokenAmount::from_human(42.5, 6);
|
|
501
|
-
let s = format!("{amt}");
|
|
502
|
-
assert!(s.contains("42.5"), "display: {s}");
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
#[test]
|
|
506
|
-
fn test_partial_ord_same_decimals() {
|
|
507
|
-
let a = TokenAmount::from_human(1.0, 6);
|
|
508
|
-
let b = TokenAmount::from_human(2.0, 6);
|
|
509
|
-
assert!(a < b);
|
|
510
|
-
assert!(b > a);
|
|
511
|
-
assert!(a <= a.clone());
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
#[test]
|
|
515
|
-
fn test_serde_round_trip() {
|
|
516
|
-
let amt = TokenAmount::from_human(99.99, 6);
|
|
517
|
-
let json = serde_json::to_string(&amt).expect("serialize");
|
|
518
|
-
let back: TokenAmount = serde_json::from_str(&json).expect("deserialize");
|
|
519
|
-
assert_eq!(amt, back);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
#[test]
|
|
523
|
-
fn test_token_info_serde_round_trip() {
|
|
524
|
-
let info = TokenInfo {
|
|
525
|
-
symbol: "USDC".to_string(),
|
|
526
|
-
decimals: 6,
|
|
527
|
-
chain_id: 1,
|
|
528
|
-
address: Some(address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")),
|
|
529
|
-
};
|
|
530
|
-
let json = serde_json::to_string(&info).expect("serialize");
|
|
531
|
-
let back: TokenInfo = serde_json::from_str(&json).expect("deserialize");
|
|
532
|
-
assert_eq!(info, back);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
1
|
+
//! Token registry and decimal math for the ClawPowers agent wallet system.
|
|
2
|
+
//!
|
|
3
|
+
//! Provides [`TokenInfo`], [`TokenRegistry`], and [`TokenAmount`] — the
|
|
4
|
+
//! foundational types for expressing on-chain token quantities with correct
|
|
5
|
+
//! decimal semantics.
|
|
6
|
+
|
|
7
|
+
use alloy_primitives::{Address, U256, address};
|
|
8
|
+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
9
|
+
use std::collections::HashMap;
|
|
10
|
+
use std::fmt;
|
|
11
|
+
use thiserror::Error;
|
|
12
|
+
|
|
13
|
+
// ── Custom serde helpers for alloy types ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
mod serde_address_opt {
|
|
16
|
+
use super::*;
|
|
17
|
+
|
|
18
|
+
pub fn serialize<S>(addr: &Option<Address>, s: S) -> Result<S::Ok, S::Error>
|
|
19
|
+
where
|
|
20
|
+
S: Serializer,
|
|
21
|
+
{
|
|
22
|
+
match addr {
|
|
23
|
+
Some(a) => s.serialize_some(&format!("{a:?}")),
|
|
24
|
+
None => s.serialize_none(),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub fn deserialize<'de, D>(d: D) -> Result<Option<Address>, D::Error>
|
|
29
|
+
where
|
|
30
|
+
D: Deserializer<'de>,
|
|
31
|
+
{
|
|
32
|
+
let opt: Option<String> = Option::deserialize(d)?;
|
|
33
|
+
match opt {
|
|
34
|
+
None => Ok(None),
|
|
35
|
+
Some(s) => {
|
|
36
|
+
let trimmed = s.trim();
|
|
37
|
+
let hex = trimmed.strip_prefix("0x").unwrap_or(trimmed);
|
|
38
|
+
let mut bytes = [0u8; 20];
|
|
39
|
+
if hex.len() != 40 {
|
|
40
|
+
return Err(serde::de::Error::custom(format!(
|
|
41
|
+
"invalid address length: {hex}"
|
|
42
|
+
)));
|
|
43
|
+
}
|
|
44
|
+
for i in 0..20 {
|
|
45
|
+
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
|
|
46
|
+
.map_err(|e| serde::de::Error::custom(e.to_string()))?;
|
|
47
|
+
}
|
|
48
|
+
Ok(Some(Address::from(bytes)))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
mod serde_u256 {
|
|
55
|
+
use super::*;
|
|
56
|
+
|
|
57
|
+
pub fn serialize<S>(v: &U256, s: S) -> Result<S::Ok, S::Error>
|
|
58
|
+
where
|
|
59
|
+
S: Serializer,
|
|
60
|
+
{
|
|
61
|
+
s.serialize_str(&v.to_string())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn deserialize<'de, D>(d: D) -> Result<U256, D::Error>
|
|
65
|
+
where
|
|
66
|
+
D: Deserializer<'de>,
|
|
67
|
+
{
|
|
68
|
+
let s = String::deserialize(d)?;
|
|
69
|
+
U256::from_str_radix(s.trim(), 10)
|
|
70
|
+
.map_err(|e| serde::de::Error::custom(format!("invalid U256: {e}")))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── TokenInfo ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/// Metadata about an ERC-20 token (or native asset).
|
|
77
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
78
|
+
pub struct TokenInfo {
|
|
79
|
+
/// Ticker symbol, e.g. `"USDC"`.
|
|
80
|
+
pub symbol: String,
|
|
81
|
+
/// Number of decimal places, e.g. `6` for USDC.
|
|
82
|
+
pub decimals: u8,
|
|
83
|
+
/// Chain ID the token lives on (1 = Ethereum mainnet).
|
|
84
|
+
pub chain_id: u64,
|
|
85
|
+
/// Contract address. `None` for native assets such as ETH.
|
|
86
|
+
#[serde(with = "serde_address_opt")]
|
|
87
|
+
pub address: Option<Address>,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── TokenRegistry ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/// A registry of well-known tokens, keyed by symbol.
|
|
93
|
+
///
|
|
94
|
+
/// Constructed with [`TokenRegistry::default()`] which pre-populates a set of
|
|
95
|
+
/// common mainnet tokens.
|
|
96
|
+
#[derive(Debug, Clone)]
|
|
97
|
+
pub struct TokenRegistry {
|
|
98
|
+
tokens: HashMap<String, TokenInfo>,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
impl TokenRegistry {
|
|
102
|
+
/// Creates an empty registry.
|
|
103
|
+
pub fn empty() -> Self {
|
|
104
|
+
Self {
|
|
105
|
+
tokens: HashMap::new(),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Registers a token, overwriting any existing entry with the same symbol.
|
|
110
|
+
pub fn register(&mut self, token: TokenInfo) {
|
|
111
|
+
self.tokens.insert(token.symbol.clone(), token);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Looks up a token by its ticker symbol.
|
|
115
|
+
pub fn get(&self, symbol: &str) -> Option<&TokenInfo> {
|
|
116
|
+
self.tokens.get(symbol)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Returns an iterator over all registered tokens.
|
|
120
|
+
pub fn iter(&self) -> impl Iterator<Item = &TokenInfo> {
|
|
121
|
+
self.tokens.values()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
impl Default for TokenRegistry {
|
|
126
|
+
/// Returns a registry pre-populated with common mainnet tokens.
|
|
127
|
+
fn default() -> Self {
|
|
128
|
+
let mut r = Self::empty();
|
|
129
|
+
r.register(TokenInfo {
|
|
130
|
+
symbol: "USDC".to_string(),
|
|
131
|
+
decimals: 6,
|
|
132
|
+
chain_id: 1,
|
|
133
|
+
address: Some(address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")),
|
|
134
|
+
});
|
|
135
|
+
r.register(TokenInfo {
|
|
136
|
+
symbol: "USDT".to_string(),
|
|
137
|
+
decimals: 6,
|
|
138
|
+
chain_id: 1,
|
|
139
|
+
address: Some(address!("dAC17F958D2ee523a2206206994597C13D831ec7")),
|
|
140
|
+
});
|
|
141
|
+
r.register(TokenInfo {
|
|
142
|
+
symbol: "DAI".to_string(),
|
|
143
|
+
decimals: 18,
|
|
144
|
+
chain_id: 1,
|
|
145
|
+
address: Some(address!("6B175474E89094C44Da98b954EedeAC495271d0F")),
|
|
146
|
+
});
|
|
147
|
+
r.register(TokenInfo {
|
|
148
|
+
symbol: "WETH".to_string(),
|
|
149
|
+
decimals: 18,
|
|
150
|
+
chain_id: 1,
|
|
151
|
+
address: Some(address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")),
|
|
152
|
+
});
|
|
153
|
+
r.register(TokenInfo {
|
|
154
|
+
symbol: "ETH".to_string(),
|
|
155
|
+
decimals: 18,
|
|
156
|
+
chain_id: 1,
|
|
157
|
+
address: None,
|
|
158
|
+
});
|
|
159
|
+
r
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── TokenAmount ──────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/// Error type for token arithmetic operations.
|
|
166
|
+
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
|
167
|
+
pub enum TokenError {
|
|
168
|
+
/// The two operands have mismatched decimal precision.
|
|
169
|
+
#[error("Decimal mismatch: lhs={lhs}, rhs={rhs}")]
|
|
170
|
+
DecimalMismatch { lhs: u8, rhs: u8 },
|
|
171
|
+
/// Arithmetic overflow or underflow.
|
|
172
|
+
#[error("Arithmetic overflow/underflow")]
|
|
173
|
+
Overflow,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// A fixed-point token amount, backed by a [`U256`] raw integer.
|
|
177
|
+
///
|
|
178
|
+
/// All arithmetic is decimal-aware. Use [`TokenAmount::from_human`] and
|
|
179
|
+
/// [`TokenAmount::to_human`] to cross the human-readable boundary.
|
|
180
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
181
|
+
pub struct TokenAmount {
|
|
182
|
+
/// Raw integer representation (`human × 10^decimals`), serialised as a
|
|
183
|
+
/// decimal string for portability.
|
|
184
|
+
#[serde(with = "serde_u256")]
|
|
185
|
+
pub raw: U256,
|
|
186
|
+
/// Number of decimal places this amount is expressed with.
|
|
187
|
+
pub decimals: u8,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
impl TokenAmount {
|
|
191
|
+
/// Creates a [`TokenAmount`] from a human-readable `f64` value.
|
|
192
|
+
///
|
|
193
|
+
/// # Example
|
|
194
|
+
/// ```
|
|
195
|
+
/// use clawpowers_tokens::TokenAmount;
|
|
196
|
+
/// let one_usdc = TokenAmount::from_human(1.0, 6);
|
|
197
|
+
/// assert_eq!(one_usdc.raw, alloy_primitives::U256::from(1_000_000u64));
|
|
198
|
+
/// ```
|
|
199
|
+
pub fn from_human(human: f64, decimals: u8) -> Self {
|
|
200
|
+
let multiplier = 10f64.powi(i32::from(decimals));
|
|
201
|
+
let raw_f64 = (human * multiplier).floor();
|
|
202
|
+
let raw = if raw_f64 <= 0.0 {
|
|
203
|
+
U256::ZERO
|
|
204
|
+
} else {
|
|
205
|
+
let as_int = raw_f64 as u128;
|
|
206
|
+
U256::from(as_int)
|
|
207
|
+
};
|
|
208
|
+
Self { raw, decimals }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Converts back to a human-readable `f64`.
|
|
212
|
+
///
|
|
213
|
+
/// Precision is limited by `f64` — do not use this for exact comparisons.
|
|
214
|
+
pub fn to_human(&self) -> f64 {
|
|
215
|
+
let divisor = 10f64.powi(i32::from(self.decimals));
|
|
216
|
+
let raw_str = self.raw.to_string();
|
|
217
|
+
let raw_f64: f64 = raw_str.parse().unwrap_or(0.0);
|
|
218
|
+
raw_f64 / divisor
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Returns `true` if the amount is zero.
|
|
222
|
+
pub fn is_zero(&self) -> bool {
|
|
223
|
+
self.raw.is_zero()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Checked addition. Returns an error on overflow or decimal mismatch.
|
|
227
|
+
pub fn add(&self, other: &TokenAmount) -> Result<TokenAmount, TokenError> {
|
|
228
|
+
if self.decimals != other.decimals {
|
|
229
|
+
return Err(TokenError::DecimalMismatch {
|
|
230
|
+
lhs: self.decimals,
|
|
231
|
+
rhs: other.decimals,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
let raw = self
|
|
235
|
+
.raw
|
|
236
|
+
.checked_add(other.raw)
|
|
237
|
+
.ok_or(TokenError::Overflow)?;
|
|
238
|
+
Ok(TokenAmount {
|
|
239
|
+
raw,
|
|
240
|
+
decimals: self.decimals,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Checked subtraction. Returns an error on underflow or decimal mismatch.
|
|
245
|
+
pub fn sub(&self, other: &TokenAmount) -> Result<TokenAmount, TokenError> {
|
|
246
|
+
if self.decimals != other.decimals {
|
|
247
|
+
return Err(TokenError::DecimalMismatch {
|
|
248
|
+
lhs: self.decimals,
|
|
249
|
+
rhs: other.decimals,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
let raw = self
|
|
253
|
+
.raw
|
|
254
|
+
.checked_sub(other.raw)
|
|
255
|
+
.ok_or(TokenError::Overflow)?;
|
|
256
|
+
Ok(TokenAmount {
|
|
257
|
+
raw,
|
|
258
|
+
decimals: self.decimals,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// Multiply by basis points (1 bps = 0.01%).
|
|
263
|
+
///
|
|
264
|
+
/// Formula: `result = self × bps / 10_000`.
|
|
265
|
+
///
|
|
266
|
+
/// Returns `None` on arithmetic overflow.
|
|
267
|
+
pub fn checked_mul_bps(&self, bps: u64) -> Option<TokenAmount> {
|
|
268
|
+
let numerator = self.raw.checked_mul(U256::from(bps))?;
|
|
269
|
+
let raw = numerator.checked_div(U256::from(10_000u64))?;
|
|
270
|
+
Some(TokenAmount {
|
|
271
|
+
raw,
|
|
272
|
+
decimals: self.decimals,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// Creates a zero amount with the given decimal precision.
|
|
277
|
+
pub fn zero(decimals: u8) -> Self {
|
|
278
|
+
Self {
|
|
279
|
+
raw: U256::ZERO,
|
|
280
|
+
decimals,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// Alias for [`TokenAmount::from_human`] accepting an `f64`.
|
|
285
|
+
///
|
|
286
|
+
/// Provided for API symmetry — `from_human` already accepts `f64`.
|
|
287
|
+
pub fn from_human_f64(human: f64, decimals: u8) -> Self {
|
|
288
|
+
Self::from_human(human, decimals)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Scale by basis points (1 bps = 0.01%): `self × bps / 10_000`.
|
|
292
|
+
///
|
|
293
|
+
/// Returns `None` on arithmetic overflow.
|
|
294
|
+
pub fn scale_bps(&self, bps: u32) -> Option<TokenAmount> {
|
|
295
|
+
self.checked_mul_bps(bps as u64)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Checked subtraction (alias exposing the same name used by the fee crate).
|
|
299
|
+
///
|
|
300
|
+
/// Returns `None` on underflow or decimal mismatch.
|
|
301
|
+
pub fn checked_sub(&self, other: &TokenAmount) -> Option<TokenAmount> {
|
|
302
|
+
self.sub(other).ok()
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── FeeType ─────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/// Fee classification used by the fee schedule.
|
|
309
|
+
///
|
|
310
|
+
/// Defined here in `clawpowers-tokens` and re-exported by `clawpowers-fee` so
|
|
311
|
+
/// downstream consumers can `use clawpowers_fee::FeeType` or
|
|
312
|
+
/// `use clawpowers_tokens::FeeType` interchangeably.
|
|
313
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
314
|
+
#[serde(rename_all = "snake_case")]
|
|
315
|
+
pub enum FeeType {
|
|
316
|
+
/// Standard on-chain transaction fee.
|
|
317
|
+
Transaction,
|
|
318
|
+
/// DEX swap fee.
|
|
319
|
+
Swap,
|
|
320
|
+
/// Custom fee with explicit basis points.
|
|
321
|
+
Custom(u32),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
impl fmt::Display for TokenAmount {
|
|
325
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
326
|
+
write!(f, "{}", self.to_human())
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
impl PartialOrd for TokenAmount {
|
|
331
|
+
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
332
|
+
if self.decimals != other.decimals {
|
|
333
|
+
return None;
|
|
334
|
+
}
|
|
335
|
+
Some(self.raw.cmp(&other.raw))
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
#[cfg(test)]
|
|
342
|
+
mod tests {
|
|
343
|
+
use super::*;
|
|
344
|
+
|
|
345
|
+
// ── TokenInfo ──────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
#[test]
|
|
348
|
+
fn test_token_info_fields() {
|
|
349
|
+
let info = TokenInfo {
|
|
350
|
+
symbol: "USDC".to_string(),
|
|
351
|
+
decimals: 6,
|
|
352
|
+
chain_id: 1,
|
|
353
|
+
address: Some(address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")),
|
|
354
|
+
};
|
|
355
|
+
assert_eq!(info.symbol, "USDC");
|
|
356
|
+
assert_eq!(info.decimals, 6);
|
|
357
|
+
assert_eq!(info.chain_id, 1);
|
|
358
|
+
assert!(info.address.is_some());
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn test_token_info_no_address_for_eth() {
|
|
363
|
+
let eth = TokenInfo {
|
|
364
|
+
symbol: "ETH".to_string(),
|
|
365
|
+
decimals: 18,
|
|
366
|
+
chain_id: 1,
|
|
367
|
+
address: None,
|
|
368
|
+
};
|
|
369
|
+
assert!(eth.address.is_none());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── TokenRegistry ──────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
#[test]
|
|
375
|
+
fn test_registry_default_contains_all_tokens() {
|
|
376
|
+
let reg = TokenRegistry::default();
|
|
377
|
+
for sym in &["USDC", "USDT", "DAI", "WETH", "ETH"] {
|
|
378
|
+
assert!(reg.get(sym).is_some(), "missing token: {sym}");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#[test]
|
|
383
|
+
fn test_registry_usdc_decimals() {
|
|
384
|
+
let reg = TokenRegistry::default();
|
|
385
|
+
let usdc = reg.get("USDC").expect("USDC should be present");
|
|
386
|
+
assert_eq!(usdc.decimals, 6);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[test]
|
|
390
|
+
fn test_registry_dai_decimals() {
|
|
391
|
+
let reg = TokenRegistry::default();
|
|
392
|
+
let dai = reg.get("DAI").expect("DAI should be present");
|
|
393
|
+
assert_eq!(dai.decimals, 18);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[test]
|
|
397
|
+
fn test_registry_register_custom_token() {
|
|
398
|
+
let mut reg = TokenRegistry::empty();
|
|
399
|
+
reg.register(TokenInfo {
|
|
400
|
+
symbol: "MYTOKEN".to_string(),
|
|
401
|
+
decimals: 8,
|
|
402
|
+
chain_id: 137,
|
|
403
|
+
address: None,
|
|
404
|
+
});
|
|
405
|
+
let t = reg.get("MYTOKEN").expect("custom token");
|
|
406
|
+
assert_eq!(t.chain_id, 137);
|
|
407
|
+
assert_eq!(t.decimals, 8);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── TokenAmount ────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn test_from_human_usdc_one_dollar() {
|
|
414
|
+
let one = TokenAmount::from_human(1.0, 6);
|
|
415
|
+
assert_eq!(one.raw, U256::from(1_000_000u64));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[test]
|
|
419
|
+
fn test_from_human_eth_one() {
|
|
420
|
+
let one_eth = TokenAmount::from_human(1.0, 18);
|
|
421
|
+
assert_eq!(one_eth.raw, U256::from(1_000_000_000_000_000_000u128));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#[test]
|
|
425
|
+
fn test_to_human_round_trip() {
|
|
426
|
+
let human = 123.456_789;
|
|
427
|
+
let amt = TokenAmount::from_human(human, 6);
|
|
428
|
+
let back = amt.to_human();
|
|
429
|
+
assert!(
|
|
430
|
+
(back - human).abs() < 0.000_001,
|
|
431
|
+
"round-trip: got {back}, want ~{human}"
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#[test]
|
|
436
|
+
fn test_from_human_zero() {
|
|
437
|
+
let zero = TokenAmount::from_human(0.0, 6);
|
|
438
|
+
assert!(zero.is_zero());
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[test]
|
|
442
|
+
fn test_add_same_decimals() {
|
|
443
|
+
let a = TokenAmount::from_human(1.0, 6);
|
|
444
|
+
let b = TokenAmount::from_human(2.0, 6);
|
|
445
|
+
let sum = a.add(&b).expect("addition should succeed");
|
|
446
|
+
assert_eq!(sum, TokenAmount::from_human(3.0, 6));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
#[test]
|
|
450
|
+
fn test_sub_valid() {
|
|
451
|
+
let a = TokenAmount::from_human(5.0, 6);
|
|
452
|
+
let b = TokenAmount::from_human(2.0, 6);
|
|
453
|
+
let diff = a.sub(&b).expect("subtraction should succeed");
|
|
454
|
+
assert_eq!(diff, TokenAmount::from_human(3.0, 6));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[test]
|
|
458
|
+
fn test_sub_underflow_returns_err() {
|
|
459
|
+
let a = TokenAmount::from_human(1.0, 6);
|
|
460
|
+
let b = TokenAmount::from_human(2.0, 6);
|
|
461
|
+
let result = a.sub(&b);
|
|
462
|
+
assert!(result.is_err());
|
|
463
|
+
assert_eq!(result.unwrap_err(), TokenError::Overflow);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#[test]
|
|
467
|
+
fn test_add_decimal_mismatch_returns_err() {
|
|
468
|
+
let a = TokenAmount::from_human(1.0, 6);
|
|
469
|
+
let b = TokenAmount::from_human(1.0, 18);
|
|
470
|
+
let result = a.add(&b);
|
|
471
|
+
assert!(matches!(result, Err(TokenError::DecimalMismatch { .. })));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[test]
|
|
475
|
+
fn test_checked_mul_bps_50bps() {
|
|
476
|
+
// 50 bps = 0.5% of 1000 USDC = 5 USDC
|
|
477
|
+
let amount = TokenAmount::from_human(1000.0, 6);
|
|
478
|
+
let fee = amount.checked_mul_bps(50).expect("no overflow");
|
|
479
|
+
let expected = TokenAmount::from_human(5.0, 6);
|
|
480
|
+
assert_eq!(fee, expected);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#[test]
|
|
484
|
+
fn test_checked_mul_bps_100bps_is_1pct() {
|
|
485
|
+
let amount = TokenAmount::from_human(200.0, 6);
|
|
486
|
+
let result = amount.checked_mul_bps(100).expect("no overflow");
|
|
487
|
+
let expected = TokenAmount::from_human(2.0, 6);
|
|
488
|
+
assert_eq!(result, expected);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn test_checked_mul_bps_zero() {
|
|
493
|
+
let amount = TokenAmount::from_human(1000.0, 6);
|
|
494
|
+
let result = amount.checked_mul_bps(0).expect("no overflow");
|
|
495
|
+
assert!(result.is_zero());
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#[test]
|
|
499
|
+
fn test_display_shows_human_amount() {
|
|
500
|
+
let amt = TokenAmount::from_human(42.5, 6);
|
|
501
|
+
let s = format!("{amt}");
|
|
502
|
+
assert!(s.contains("42.5"), "display: {s}");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#[test]
|
|
506
|
+
fn test_partial_ord_same_decimals() {
|
|
507
|
+
let a = TokenAmount::from_human(1.0, 6);
|
|
508
|
+
let b = TokenAmount::from_human(2.0, 6);
|
|
509
|
+
assert!(a < b);
|
|
510
|
+
assert!(b > a);
|
|
511
|
+
assert!(a <= a.clone());
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#[test]
|
|
515
|
+
fn test_serde_round_trip() {
|
|
516
|
+
let amt = TokenAmount::from_human(99.99, 6);
|
|
517
|
+
let json = serde_json::to_string(&amt).expect("serialize");
|
|
518
|
+
let back: TokenAmount = serde_json::from_str(&json).expect("deserialize");
|
|
519
|
+
assert_eq!(amt, back);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#[test]
|
|
523
|
+
fn test_token_info_serde_round_trip() {
|
|
524
|
+
let info = TokenInfo {
|
|
525
|
+
symbol: "USDC".to_string(),
|
|
526
|
+
decimals: 6,
|
|
527
|
+
chain_id: 1,
|
|
528
|
+
address: Some(address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")),
|
|
529
|
+
};
|
|
530
|
+
let json = serde_json::to_string(&info).expect("serialize");
|
|
531
|
+
let back: TokenInfo = serde_json::from_str(&json).expect("deserialize");
|
|
532
|
+
assert_eq!(info, back);
|
|
533
|
+
}
|
|
534
|
+
}
|