-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Onchain Spend Tracker: Decode Actual Outgoing Asset + Oracle USD Pricing
Owner: Yash
Priority: P0 — current implementation makes all USD-denominated limits meaningless
Crate: policy/onchain.rs, proxy/
Depends on: Policy Engine (#XX), Onchain Permits (#XX)
Blocked by: None
The Bug
Current code treats tx.value as raw USD:
// onchain.rs:123-125
// treat value as raw USD for now
let tx_value: f64 = req.value.parse().unwrap_or(0.0);
This is wrong on two levels:
-
tx.valueis in wei, not USD. A swap sending 0.1 ETH hasvalue = 100000000000000000(10^17 wei). Parsed as USD, that's $100 quadrillion. Every transaction gets denied, or if it wraps around, every transaction gets approved. Either way, spend limits are useless. -
Most DeFi calls have
value = 0. When swapping USDC → ETH on Uniswap, the ERC-20 tokens move via the calldata (transferFrom), not viamsg.value. The transaction's native value is zero. Fishnet currently tracks $0 spend for every token swap — the daily spend cap never increments.
Result: max_tx_value_usd and daily_spend_cap_usd in fishnet.toml are decorative. They don't protect against anything.
The Fix
Decode the transaction calldata to identify the actual outgoing asset and amount, then price it in USD via an onchain oracle (Chainlink or Supra price feeds).
Value Resolution Pipeline
Transaction arrives
↓
Step 1: Identify outgoing asset + amount from calldata
↓
Step 2: Fetch USD price from oracle (Chainlink / Supra)
↓
Step 3: outgoing_amount × price_usd = tx_cost_usd
↓
Step 4: Compare tx_cost_usd against policy limits
↓
Step 5: Increment daily spend counter with tx_cost_usd
Step 1: Calldata Decoding
Native ETH sends (value > 0, no calldata or simple transfer)
// tx.value is in wei — convert to ETH first
let eth_amount = wei_to_eth(tx.value); // value / 10^18
// Then price via oracle (Step 2)
Known DEX routers (Uniswap, GMX, 1inch, etc.)
Decode the function calldata to extract the input token and amount. Each router has its own ABI, but the pattern is consistent:
Uniswap V3 Router — exactInputSingle:
function exactInputSingle(ExactInputSingleParams calldata params)
// params.tokenIn → the asset the user is spending
// params.amountIn → how much they're spending
Uniswap Universal Router — execute(bytes,bytes[],uint256):
Outer: execute(commands, inputs, deadline)
↓
Decode commands byte-by-byte → each command maps to a sub-action
↓
For SWAP commands: decode inner params → tokenIn, amountIn
GMX — createIncreasePosition:
// path[0] → collateral token (the asset being spent)
// amountIn (uint256)→ collateral amount
// sizeDelta → position size (for leverage tracking)
Token approvals (approve / permit2)
An approve(spender, amount) call doesn't move funds but signals intent. Fishnet should:
- Log the approval with the approved amount as informational
- NOT count it toward spend (no funds moved yet)
- Flag if approval amount is
type(uint256).max(unlimited approval — warn user)
Unknown calldata
If Fishnet can't decode the calldata (unknown router, exotic protocol):
- Use
tx.valuein wei → ETH → USD as a floor estimate - Log a warning:
"calldata not decoded — spend estimate based on msg.value only" - If
tx.valueis also 0, log withcost_usd = Noneand flag as"unpriced" - Never silently record $0 for an undecodable transaction
Step 2: Oracle Price Fetching
Fetch the USD price of the outgoing asset. Support two oracle providers:
Chainlink (default, widest coverage)
// Chainlink price feed addresses (per chain) // Base mainnet examples: // ETH/USD: 0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70 // USDC/USD: 0x7e860098F58bBFC8648a4311b374B1D669a2bc6Bfn get_price_usd(token: Address, chain_id: u64) -> Result<f64> {
let feed = lookup_price_feed(token, chain_id)?;
let (_, answer, _, updated_at, _) = feed.latestRoundData();// Staleness check — reject prices older than threshold if now() - updated_at > MAX_PRICE_STALENESS { return Err("price feed stale"); } // Chainlink returns price with `decimals()` precision (usually 8) let decimals = feed.decimals(); Ok(answer as f64 / 10_f64.powi(decimals))
}
Supra (secondary, for tokens Chainlink doesn't cover + Supra integration story)
fn get_price_supra(token: Address, chain_id: u64) -> Result<f64> {
// Supra pull oracle — fetch from Supra's price feed contract
let pair_id = lookup_supra_pair(token, chain_id)?;
let price_data = supra_oracle.getPrice(pair_id);
// ... decimals conversion
}
Oracle Selection Strategy
# fishnet.toml [onchain.pricing] primary_oracle = "chainlink" # or "supra" fallback_oracle = "supra" # optional max_price_staleness_seconds = 300 # 5 minutes cache_ttl_seconds = 30 # cache prices locally to reduce RPC callsManual overrides for tokens without oracle feeds
[onchain.pricing.manual]
"0xSomeObscureToken" = { price_usd = 0.001, decimals = 18 }
Resolution order:
- Check local price cache (TTL-based)
- Try primary oracle
- Try fallback oracle
- Check manual overrides in config
- If all fail → deny the transaction with reason
"unable to price outgoing asset"
Denying on price failure is the safe default. A transaction Fishnet can't price is a transaction Fishnet can't enforce limits on. The user can add a manual override if they're trading something exotic.
Step 3: USD Value Computation
struct ResolvedValue { outgoing_token: Address, // 0xEeeee...eeE for native ETH outgoing_amount_raw: U256, // raw amount in token's smallest unit token_decimals: u8, // 18 for ETH, 6 for USDC, etc. outgoing_amount_human: f64, // amount in human-readable units price_usd: f64, // per-token price from oracle total_usd: f64, // outgoing_amount_human × price_usd price_source: PriceSource, // Chainlink | Supra | Manual | Unavailable confidence: ValueConfidence, // Exact | Estimated | Unpriced }
enum ValueConfidence {
Exact, // calldata decoded + oracle price available
Estimated, // only tx.value used (calldata not decoded), oracle price available
Unpriced, // could not determine value — tx denied by default
}
This struct replaces the current f64 cost field everywhere — audit log, spend counter, policy evaluation.
Step 4: Policy Evaluation Changes
// Before (broken): let tx_value: f64 = req.value.parse().unwrap_or(0.0); if tx_value > policy.max_tx_value_usd { return Deny(...) }// After:
let resolved = resolve_tx_value(&req, chain_id, &oracle_config)?;match resolved.confidence {
ValueConfidence::Unpriced => {
return Deny("unable to price outgoing asset — configure manual price or use a supported token");
}
_ => {}
}if resolved.total_usd > policy.max_tx_value_usd {
return Deny(format!(
"tx value ${:.2} exceeds limit ${:.2} ({:.6} {} @ ${:.2})",
resolved.total_usd, policy.max_tx_value_usd,
resolved.outgoing_amount_human, resolved.outgoing_token,
resolved.price_usd
));
}
let daily = state.daily_spend(chain_id);
if daily + resolved.total_usd > policy.daily_spend_cap_usd {
return Deny(format!(
"daily spend ${:.2} + this tx ${:.2} exceeds cap ${:.2}",
daily, resolved.total_usd, policy.daily_spend_cap_usd
));
}
Step 5: Audit Log Update
The audit entry should capture the full pricing context, not just a bare cost_usd:
struct AuditEntry { // ... existing fields ... cost_usd: Option<f64>, // total_usd from resolved value cost_detail: Option<CostDetail>, // NEW — full breakdown }
struct CostDetail {
outgoing_token: Address,
outgoing_amount: String, // human-readable, e.g. "0.1"
token_symbol: Option<String>, // "ETH", "USDC" — best effort
price_usd: f64,
price_source: String, // "chainlink" | "supra" | "manual"
confidence: String, // "exact" | "estimated" | "unpriced"
}
This makes the audit log actually useful — instead of cost_usd: 0.0 for every token swap, you get "spent 150 USDC ($150.00) via Uniswap, priced by Chainlink".
Known DEX Decoder Registry
Ship with decoders for the routers already in the whitelist config. Add more over time.
| Protocol | Router | Functions to Decode | Chain |
|---|---|---|---|
| Uniswap V3 | SwapRouter02 | exactInputSingle, exactInput, exactOutputSingle | Base, Arb, ETH |
| Uniswap Universal Router | Universal Router | execute (decode commands + inner inputs) | Base, Arb, ETH |
| GMX V2 | ExchangeRouter | createIncreasePosition, createDecreasePosition | Arbitrum |
| 1inch | AggregationRouter | swap, unoswap | Multi |
| Native ETH | any | value > 0 → direct ETH transfer | All |
Architecture: each decoder implements a trait:
trait CallDecoder { /// Returns the outgoing token + amount, or None if this decoder /// doesn't recognize the calldata fn decode_outgoing( &self, target: Address, calldata: &[u8], value: U256, ) -> Option<(Address, U256, u8)>; // (token, amount_raw, decimals) }
// Registry tries decoders in order, first match wins
let decoders: Vec<Box<dyn CallDecoder>> = vec![
Box::new(UniswapV3Decoder),
Box::new(UniswapUniversalDecoder),
Box::new(GmxDecoder),
Box::new(NativeEthDecoder), // fallback: uses tx.value
];
New decoders can be added without touching the core pipeline. Community can contribute decoders for new protocols.
Price Feed Registry
Ship with a hardcoded map of common tokens → oracle feed addresses per chain. User can extend via config.
// Built-in registry (compiled into binary)
fn builtin_feeds(chain_id: u64) -> HashMap<Address, Address> {
match chain_id {
8453 => hashmap! { // Base
WETH => "0x71041d...", // Chainlink ETH/USD on Base
USDC => "0x7e8600...", // Chainlink USDC/USD on Base
CBBTC => "0x...", // Chainlink BTC/USD on Base
},
42161 => hashmap! { // Arbitrum
WETH => "0x639Fe6...",
USDC => "0x50834F...",
ARB => "0xb2A824...",
},
_ => hashmap! {}
}
}
# fishnet.toml — user extensions
[onchain.pricing.feeds]
# token_address = "chainlink_feed_address"
"0xNewToken" = "0xNewFeedAddress"
Dashboard Impact
Spend Analytics Page
Current spend chart just shows a number. With resolved values, show:
- Per-transaction breakdown: "0.1 ETH ($250.00) on Uniswap" instead of "$0.00"
- Token breakdown pie chart: how spend distributes across ETH, USDC, etc.
- Confidence indicators: green badge for "Exact", yellow for "Estimated", red for "Unpriced"
- Price source column in audit log table: Chainlink / Supra / Manual icon
Onchain Page
- Show the current oracle prices for whitelisted tokens (refreshing)
- Show staleness indicator per feed (green = fresh, red = stale)
- Manual price override editor for exotic tokens
Alerts
New alert types:
"price_feed_stale"— oracle price older thanmax_price_staleness_seconds"unpriced_transaction_denied"— tx blocked because Fishnet couldn't determine value"high_value_transaction"— tx value exceeds 80% of daily cap (warning, not denial)
Config
[onchain.pricing] primary_oracle = "chainlink" # "chainlink" | "supra" fallback_oracle = "supra" # optional, omit to disable fallback max_price_staleness_seconds = 300 # reject oracle prices older than this cache_ttl_seconds = 30 # local price cache duration deny_on_price_failure = true # default: true (safe). false = allow with warningRPC endpoints for oracle reads (reuses existing chain RPC config)
No additional config needed if [onchain.rpc] is already set
[onchain.pricing.feeds]
Manual feed overrides: token_address = "feed_address"
[onchain.pricing.manual]
Hardcoded prices for tokens without any oracle feed
"token_address" = { price_usd = 0.50, decimals = 18 }
Implementation Notes
RPC Efficiency
Oracle reads are view calls (no gas). But hammering RPC for every transaction is wasteful. Strategy:
- Cache prices locally with configurable TTL (default 30s)
- Batch reads if multiple tokens in a multicall transaction — single
eth_callwith multicall3 - Pre-warm cache on Fishnet start for all tokens in the whitelist config
- Lazy fetch for tokens not in the whitelist (they'd be denied anyway, but log the price attempt)
Token Decimals
Never assume 18 decimals. USDC is 6, WBTC is 8, some tokens are 4. The decoder must return the token's decimals alongside the amount, or Fishnet should call decimals() on the ERC-20 contract (cached per token).
Stablecoins Shortcut
For known stablecoins (USDC, USDT, DAI), skip the oracle call and hardcode $1.00 ± threshold. If the oracle reports a depeg > 2%, use the oracle price and fire an alert. Saves RPC calls for the most common case.
Crate Dependencies
alloy-sol-types/alloy-primitives— ABI decoding for calldata and oracle responses- Existing
k256, RPC client — no new major dependencies - Oracle ABIs are tiny (just
latestRoundDataanddecimals)
Acceptance Criteria
-
tx.valueis correctly interpreted as wei and converted to ETH before pricing - Native ETH transfers (
value > 0) are priced via oracle, not treated as raw USD - Uniswap V3
exactInputSingleandexactInputcalldata decoded → outgoing token + amount extracted - Uniswap Universal Router
executecommands decoded, inner swap params extracted - GMX
createIncreasePositioncollateral token + amount extracted - Decoded outgoing asset is priced via Chainlink (primary) with Supra fallback
- Price cache with configurable TTL prevents excessive RPC calls
- Stale price detection — transactions denied if oracle price exceeds staleness threshold
-
deny_on_price_failure = truedenies transactions Fishnet cannot price (default behavior) - Manual price overrides in
fishnet.tomlwork for exotic tokens - Stablecoin shortcut: USDC/USDT/DAI priced at $1.00 without oracle call, depeg alert if >2%
-
ResolvedValuestruct with confidence level used in policy evaluation and audit log - Audit log entries include
CostDetail— token, amount, price, source, confidence - Daily spend counter increments by
total_usdfrom resolved value, not rawtx.value - Denial messages include human-readable breakdown: amount, token, price, limit
- Dashboard spend chart reflects real USD values with token breakdown
- Dashboard audit log shows price source badge and confidence indicator
- New alerts:
price_feed_stale,unpriced_transaction_denied,high_value_transaction - Unknown calldata logs warning, uses
tx.valueas floor estimate, never silently records $0 - CallDecoder trait + registry pattern — new protocol decoders can be added without touching core
- Unit tests: wei→ETH conversion, Uniswap calldata decode, oracle price parse, staleness check, cache TTL
- Integration test: mock Uniswap swap → decode → mock Chainlink price → verify correct USD in audit log
- Integration test: undecodable calldata →
deny_on_price_failure→ transaction denied with clear reason