You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.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.
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).
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
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");
}
_ => {}
}
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! {}
}
}
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
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.valueas raw USD: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_usdanddaily_spend_cap_usdinfishnet.tomlare 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
Step 1: Calldata Decoding
Native ETH sends (
value > 0, no calldata or simple transfer)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:Uniswap Universal Router —
execute(bytes,bytes[],uint256):GMX —
createIncreasePosition:Token approvals (
approve/permit2)An
approve(spender, amount)call doesn't move funds but signals intent. Fishnet should:type(uint256).max(unlimited approval — warn user)Unknown calldata
If Fishnet can't decode the calldata (unknown router, exotic protocol):
tx.valuein wei → ETH → USD as a floor estimate"calldata not decoded — spend estimate based on msg.value only"tx.valueis also 0, log withcost_usd = Noneand flag as"unpriced"Step 2: Oracle Price Fetching
Fetch the USD price of the outgoing asset. Support two oracle providers:
Chainlink (default, widest coverage)
Supra (secondary, for tokens Chainlink doesn't cover + Supra integration story)
Oracle Selection Strategy
Resolution order:
"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
This struct replaces the current
f64cost field everywhere — audit log, spend counter, policy evaluation.Step 4: Policy Evaluation Changes
Step 5: Audit Log Update
The audit entry should capture the full pricing context, not just a bare
cost_usd:This makes the audit log actually useful — instead of
cost_usd: 0.0for 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.
Architecture: each decoder implements a trait:
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.
Dashboard Impact
Spend Analytics Page
Current spend chart just shows a number. With resolved values, show:
Onchain Page
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
Implementation Notes
RPC Efficiency
Oracle reads are
viewcalls (no gas). But hammering RPC for every transaction is wasteful. Strategy:eth_callwith multicall3Token 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 responsesk256, RPC client — no new major dependencieslatestRoundDataanddecimals)Acceptance Criteria
tx.valueis correctly interpreted as wei and converted to ETH before pricingvalue > 0) are priced via oracle, not treated as raw USDexactInputSingleandexactInputcalldata decoded → outgoing token + amount extractedexecutecommands decoded, inner swap params extractedcreateIncreasePositioncollateral token + amount extracteddeny_on_price_failure = truedenies transactions Fishnet cannot price (default behavior)fishnet.tomlwork for exotic tokensResolvedValuestruct with confidence level used in policy evaluation and audit logCostDetail— token, amount, price, source, confidencetotal_usdfrom resolved value, not rawtx.valueprice_feed_stale,unpriced_transaction_denied,high_value_transactiontx.valueas floor estimate, never silently records $0deny_on_price_failure→ transaction denied with clear reason