Skip to content

Onchain Spend Tracker: Decode Actual Outgoing Asset + Oracle USD Pricing #19

@iamyxsh

Description

@iamyxsh

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:

  1. tx.value is in wei, not USD. A swap sending 0.1 ETH has value = 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.

  2. Most DeFi calls have value = 0. When swapping USDC → ETH on Uniswap, the ERC-20 tokens move via the calldata (transferFrom), not via msg.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.value in wei → ETH → USD as a floor estimate
  • Log a warning: "calldata not decoded — spend estimate based on msg.value only"
  • If tx.value is also 0, log with cost_usd = None and 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: 0x7e860098F58bBFC8648a4311b374B1D669a2bc6B

fn 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 &gt; 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 calls

Manual overrides for tokens without oracle feeds

[onchain.pricing.manual]
"0xSomeObscureToken" = { price_usd = 0.001, decimals = 18 }

Resolution order:

  1. Check local price cache (TTL-based)
  2. Try primary oracle
  3. Try fallback oracle
  4. Check manual overrides in config
  5. 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 than max_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 warning

RPC 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_call with 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 latestRoundData and decimals)

Acceptance Criteria

  • tx.value is 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 exactInputSingle and exactInput calldata decoded → outgoing token + amount extracted
  • Uniswap Universal Router execute commands decoded, inner swap params extracted
  • GMX createIncreasePosition collateral 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 = true denies transactions Fishnet cannot price (default behavior)
  • Manual price overrides in fishnet.toml work for exotic tokens
  • Stablecoin shortcut: USDC/USDT/DAI priced at $1.00 without oracle call, depeg alert if >2%
  • ResolvedValue struct 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_usd from resolved value, not raw tx.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.value as 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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions