# Audit report

> Audit the contracts in walletchan/walletchan as they are.

| | |
|---|---|
| Repository | https://github.com/walletchan/walletchan.git |
| Commit | `b54334a791bae7c88e80049585b79292ad054683` |
| Job | `efb7d159-16cd-437f-896b-929bbc1b65aa` |
| Judged | 2026-11-10 21:38 UTC |
| Findings | 2 medium · 12 low · 8 info |

Four agents audited the code as it is at `b54334a`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: Single-transaction asset preview is deterministically bypassable: the sender is given 13,616 bytes of code and exactly 100000 ETH during simulation and no un-injected run cross-checks the result

`apps/extension/src/chrome/simulation/singleSimulation.ts:263`

```
      buildIsolatedSimulatorOverride(from, parseEther("100000")),
```

simulateAssetChanges -> runSimulation performs eth_call {from: user, to: user} with a state override that installs the TxSimulator runtime (SIMULATOR_BYTECODE, 13,616 bytes) at the user's address and sets its balance to exactly 100000 ETH, then TxSimulator.simulate does to.call{value}(data). Inside that inner call the target observes msg.sender.code.length == 13616, msg.sender.codehash == keccak256(SIMULATOR_BYTECODE) and msg.sender.balance == 100000 ether, none of which hold when the real transaction executes from a plain EOA. A target that branches on any of these produces a different trace in the preview than on-chain. Unlike the non-atomic batch path (nonAtomicBatch.ts merges an eth_simulateV1 run without code injection) and the approval projection (ethSimulateClient.ts), the single-tx path's tokens/deltas/txSuccess come solely from the injected run (resultBuilder.ts). _docs/ASSET_CHANGES_SIMULATION.md:277 notes the extcodesize case as 'rare in modern DeFi' but does not treat it as adversarial, which for a wallet's asset-change preview is the primary threat. Consequences: (1) a malicious contract hides an outflow, the preview shows txSuccess=true with no asset rows and no reverted banner; (2) legitimate only-EOA guards (require(msg.sender.code.length == 0), common on anti-bot mints) revert only in the preview, the retry with balance overrides reverts too, and a valid transaction is shown as 'likely to revert'. Fix preserving the design: also run the existing eth_simulateV1 transfer-log path (no code override) for single transactions and merge like mergeNonAtomicSimulationResults, taking ERC-20/native deltas and the revert verdict from the un-injected run and only NFT token IDs from the injected run; when they disagree on outgoing assets show the worse and mark the preview inconsistent. Independently use a non-round balance override (real balance + value + gas headroom) so the 100000 ETH fingerprint disappears.

**Reproduction**

Reproduced with Foundry (forge 1.9.0, solc 0.8.33, optimizer off, evm cancun) against apps/contracts/src/utils/TxSimulator.sol from git commit 1372daf, whose compiled runtime equals SIMULATOR_BYTECODE byte-for-byte after stripping the CBOR metadata trailer (13,616 bytes both; checked with node). Test: Token with balanceOf/approve/transferFrom; user 0xBEEF holds 1000e18 and approved Drainer; Drainer.go(token) is `if (msg.sender.code.length > 0) return; token.transferFrom(msg.sender, attacker, token.balanceOf(msg.sender));`. Preview: vm.etch(user, runtime); vm.deal(user, 100000 ether); vm.prank(user); TxSimulator(user).simulate(drainer, 0, abi.encodeCall(Drainer.go,(token)), [token, drainer]) returned success=true, tokens.length==0, user balance still 1000e18 (UI: 'no asset changes', no banner). On-chain: vm.etch(user, ''); vm.prank(user); drainer.go(token) -> user 0, attacker 1000e18. Expected: preview shows -1000 TOKEN. Actual: preview shows nothing. Same result with a variant that branches on `msg.sender.balance == 100000 ether`. Inverse: OnlyEoa.act() with require(msg.sender.code.length == 0) returns success=false from simulate but succeeds when called by the plain EOA. All three tests passed (6/6 in the scratch suite).

### 2. Medium: Approvals are staged from the indicative /price quote while the executed transaction comes from an independently re-quoted firm /quote, so a 0x-vs-WCHAN-V4 route flip stages approvals for the wrong sp

`apps/extension/src/components/Swap/prepareSameChainSwap.ts:107`

```
  const allowanceSpender = indicativeQuote.issues?.allowance?.spender;
```

For an ETH<>WCHAN swap on Base the website API compares a 0x quote against the custom Uniswap V4 route independently in both endpoints: apps/website/app/api/swap/price/route.ts:153-179 (indicative) and apps/website/app/api/swap/quote/route.ts:163-189 (firm) each call compareBestRoute (apps/website/app/api/swap/wchanRoute.ts:332-399), which picks whichever buyAmount is higher at that instant. The two winners settle differently: the 0x allowance-holder quote needs an ERC-20 allowance to the 0x AllowanceHolder and no Permit2 step, while the WCHAN V4 response (wchanRoute.ts:268-285) sets allowanceTarget = Permit2, issues.allowance.spender = Permit2 and issues.permit2Approval = {token: WCHAN, spender: UniversalRouter}, and its transaction targets the Universal Router which pulls the sell token through Permit2. prepareSameChainSwap builds the approval set from indicativeQuote.issues (lines 107 and 145) but executes firmQuote.data.transaction (line 188) and never reads firmQuote.data.issues or compares firmQuote.data.allowanceTarget with the indicative one. Because the firm quote is a later, independent pair of 0x/RPC calls, the winner flips whenever the two routes are within a few bps, which is the normal case since 0x routes over the same Base pools. When it flips, the staged approvals target a spender the swap never uses and the swap reverts after the approvals were paid for, leaving a live allowance. Fix: derive allowanceSpender and permit2Approval from firmQuote.data.issues (fall back to the indicative quote only when the firm quote carries no issues block) and refuse to build the plan when firmQuote.data.allowanceTarget or transaction.to disagrees with the quote the approvals were built from; alternatively let /quote accept and pin the routeSource that /price chose.

**Reproduction**

Reproduced by code trace (no live call needed): compareBestRoute at wchanRoute.ts:378-399 returns the wchan-v4 response iff wchanBuyAmount > zeroXBuyAmount at call time, and both route handlers invoke it separately. State: Base (8453), sequential (privateKey) account, taker holds 1e24 WCHAN with no approvals, sells 1e24 WCHAN -> ETH. Step 1 indicative /price: 0x wins by a few bps -> indicativeQuote.issues.allowance.spender = 0x AllowanceHolder, no permit2Approval. Step 2 firm /quote seconds later: V4 wins -> firmQuote.data.allowanceTarget = Permit2, issues.permit2Approval = {WCHAN, UniversalRouter}, transaction.to = UniversalRouter. Step 3 prepareSameChainSwap stages tx1 = WCHAN.approve(AllowanceHolder, 1e24) (from line 107/125-129), no Permit2 approval (indicativeQuote.issues.permit2Approval undefined at line 145), tx2 = UniversalRouter.execute(...) from line 188. Expected: approvals match the executed transaction (WCHAN.approve(Permit2) + Permit2.approve(WCHAN, UniversalRouter)) and the swap succeeds. Actual: tx1 lands and grants an allowance the swap never uses; tx2 reverts in Permit2.transferFrom (AllowanceExpired(0)/insufficient allowance) because WCHAN was never approved to Permit2. User loses gas on both transactions. Mirror case (V4 indicative, 0x firm) stages two Permit2-side approvals and the 0x AllowanceHolder transaction reverts for lack of allowance.

### 3. Low: Custom slippage input below 0.005% rounds to 0 bps and is persisted, contradicting the stated 0.01% floor

`apps/extension/src/components/Swap/SlippageSettings.tsx:67`

```
      onSlippageChange(Math.round(amount * 100));
```

handleCustomChange guards amount > 0 && amount <= 50 before rounding percent to basis points with Math.round(amount * 100), so any entry in (0, 0.005) passes the guard and becomes 0 bps. onSlippageChange(0) is written to chrome.storage.sync by useSwapSlippage.setSlippageBps and used as the live slippageBps. The helper text on line 152 promises 'Choose between 0.01% and 50%'. Downstream prepareSameChainSwap sends slippageBps=0 to the swap API and prepareBridgeSwap sends slippage: 0 (slippageBps / 100, line 106), i.e. a zero-tolerance order that fails on any price movement or is rejected by the aggregator. The stored value is also inconsistent: useSwapSlippage only accepts stored > 0 on reload, so after a restart the slippage silently reverts to the default while the current session used 0. Fix: compute bps first and reject or clamp when the rounded value is 0, e.g. const bps = Math.round(amount * 100); if (bps >= 1 && bps <= 5000) onSlippageChange(bps).

**Reproduction**

Type 0.004 in the Custom tolerance field. Expected: rejected or clamped to 1 bps (0.01%). Actual: Number.parseFloat('0.004') = 0.004 > 0 passes line 66; Math.round(0.4) = 0; onSlippageChange(0) fires; swapSlippageBps = 0 is stored and the next fetchSwapQuote/fetchBridgeQuote carries slippageBps=0 / slippage=0. Verified with node: Math.round(0.004*100) === 0 and Math.round(0.001*100) === 0.

### 4. Low: Slippage button renders bps with one decimal, so 1 bps shows as 0.0% and 25 bps as 0.3%

`apps/extension/src/components/Swap/SlippageSettings.tsx:52`

```
  const displayPercent = (slippageBps / 100).toFixed(
```

displayPercent formats the active tolerance with at most one fractional digit (toFixed(0) for multiples of 1%, otherwise toFixed(1)), while the input accepts 0.01% granularity (1 bps). Any custom value whose bps is not a multiple of 10 is misreported: 1 bps renders 'Slippage 0.0%', 25 bps renders 'Slippage 0.3%', 125 bps renders '1.3%'. The same string feeds the aria-label on line 76, so assistive technology announces the wrong tolerance. This label is the only place the user sees the tolerance applied to the firm quote. Fix: format with two decimals and trim trailing zeros, e.g. (slippageBps / 100).toFixed(2).replace(/\.?0+$/, '').

**Reproduction**

Set custom tolerance 0.25 (slippageBps = 25). Expected button text 'Slippage 0.25%'. Actual (25/100).toFixed(1) = '0.3' -> 'Slippage 0.3%'. Set 0.01 (slippageBps = 1): expected 'Slippage 0.01%', actual '0.0'. Verified with node: (25/100).toFixed(1) === '0.3'; (1/100).toFixed(1) === '0.0'.

### 5. Low: formatTokenAmount (thousands-separator mode) and formatTokenBalance collapse values to 6 significant digits, misstating amounts of 1,000,000 or more on the swap confirmation, approval review and token

`apps/extension/src/lib/tokenFormatUtils.ts:40`

```
    return Number(num.toPrecision(6)).toLocaleString("en-US", {
```

Same root cause in two functions of this file. formatTokenAmount with thousandsSeparator: true (line 40) first collapses the value to 6 significant digits via toPrecision(6) and only then applies maximumFractionDigits: 6, so for any amount with 7 or more significant digits the integer part itself is rounded. That path is used by SwapConfirmation.tsx (formatOutputAmount line 44-45 and the 'You sell' row line 285) and by AssetChanges/ApprovalChangesGroup.tsx amountLabel (lines 32-34), so the confirmation screen and the approval-change review show a different number than will be sent or approved. The non-separator path on line 44 keeps full precision (toFixed(6)), so the two modes disagree. formatTokenBalance (line 19, `parseFloat(num.toPrecision(6)).toString()`), used by Swap/TokenPickerContent.tsx:117 and Swap/BridgeChainTokenPickerScreen.tsx:143, has the same defect: a 1,234,567.5 balance is listed as 1234570 (2.5 tokens more than owned); a user who types the displayed figure as the sell amount exceeds the balance and prepareSameChainSwap silently clamps it (lines 72-74), so the figure confirmed is not the one quoted. formatTokenBalance also prints '<0.0001' for negative or NaN input. Fix: format from the exact decimal string (group the integer part, keep up to 6 fractional digits) instead of via toPrecision, e.g. reuse formatTokenDecimalAmount from lib/tokenAmountFormat.ts.

**Reproduction**

Executed the function bodies with node. formatTokenAmount('1234567.89', {thousandsSeparator:true}) -> '1,234,570' (expected '1,234,567.89'); formatTokenAmount('999999.5', {thousandsSeparator:true}) -> '1,000,000'; the non-separator call formatTokenAmount('1234567.89') -> '1234567.89'. formatTokenBalance('1234567.5') -> '1234570' (expected '1234567.5'); formatTokenBalance('999999.5') -> '1000000'; formatTokenBalance('-5') -> '<0.0001'. formatTokenAmountFromBase('1234567890000', 6, {thousandsSeparator:true}) for a 1,234,567.89 USDC sell therefore renders '1,234,570' on the confirmation screen.

### 6. Low: Dapp-supplied maxFeePerGas without maxPriorityFeePerGas is paired with the wallet's own floored tip, which can exceed the cap and leaves confirmation disabled

`apps/extension/src/chrome/gas/singleEstimator.ts:151`

```
      dappPriorityFee ?? fees?.maxPriorityFeePerGas ?? 0n;
```

When a dapp supplies only maxFeePerGas (valid EIP-1559 request; the node normally fills the tip), estimateGas keeps the dapp's cap (line 149) and fills maxPriorityFeePerGas from the wallet's standard tier (line 151), which feeEstimator.ts floors per chain via getPriorityFeeFloor (feePolicy.ts: 30 gwei on Polygon 137, 1 gwei on BNB 56, 0.1 gwei default). Nothing clamps the tip to the cap, so the estimate can carry maxPriorityFeePerGas > maxFeePerGas, an invalid pair (viem throws TipAboveFeeCapError; nodes reject it). dappProvidedGas is true and dappGasInvalid is false (lines 74-78 flag only literal zeros), so GasEstimateDisplay.tsx:232-235 defaults the picker to Custom seeded with these values; its maxFeeCoversBase check (line 416-427, maxFee >= baseFee + priority) fails, validForBroadcast (line 486) is false and the Confirm action stays disabled until the user edits the fee fields or switches tier. estimatedCostWei is also computed from this pair. Fix: clamp the filled tip to the dapp cap (min(tip, dappMaxFee)) or treat tip > cap as dappGasInvalid so the picker falls back to the stored tier as it does for zero fees.

**Reproduction**

Chain 137: dapp sends eth_sendTransaction with maxFeePerGas = 0x3b9aca00 (1 gwei) and no maxPriorityFeePerGas; fee sampling yields the floored standard tip 30 gwei (PRIORITY_FEE_FLOOR_WEI[137] = 30_000_000_000n, applied by applyPriorityFloor at feeEstimator.ts:60-66). Expected: a valid pair (tip <= 1 gwei) or the dapp values flagged unusable so the stored tier applies. Actual: estimateGas returns maxFeePerGas = 1000000000, maxPriorityFeePerGas = 30000000000; GasEstimateDisplay seeds Custom with these values, maxFeeCoversBase is false, validForBroadcast is false and confirmation cannot proceed until the user edits gas by hand.

### 7. Low: ERC-5792 sell path grants an unlimited, never-expiring Permit2 allowance to the Universal Router where the sibling paths bound it to the sell amount and 30 days

`apps/website/app/swap-wchan/components/SwapButton.tsx:61`

```
const PERMIT2_MAX_AMOUNT = (1n << 160n) - 1n;
```

When the connected wallet reports ERC-5792 atomic capability, the sell flow pushes Permit2.approve(WCHAN, universalRouter, PERMIT2_MAX_AMOUNT, PERMIT2_MAX_EXPIRATION) (lines 358-375), i.e. amount 2^160-1 and expiration 2^48-1. The legacy path on the same page signs a PermitSingle for exactly quote.amountIn with a 30-day expiration (packages/wchan-swap/src/permit2.ts:61-72), and the extension's Permit2 builder bounds the allowance to the sell amount (capped at uint160) and 30 days (apps/extension/src/chrome/swap/permit2.ts:63-66). The batch path therefore leaves a permanent unlimited Permit2 allowance to the router after a single sell, and the re-approval check (permit2Allowance.amount < quote.amountIn || expiration < now, lines 358-361) never triggers again. The Universal Router only spends on behalf of msg.sender, so this is exposure surface (a phished router call bundle, or a future router/Permit2 defect, can move the user's whole WCHAN balance), not a direct loss. Fix: pass quote.amountIn (capped at uint160) and a 30-day expiration, matching the other two paths.

**Reproduction**

Base, wallet with atomic capability 'supported', user sells 1 WCHAN (1e18) with no prior Permit2 allowance. Expected (as on the legacy and extension paths): Permit2.allowance(user, WCHAN, UniversalRouter) after the batch is at most 1e18 with expiration about now+30d. Actual: the batch contains Permit2.approve(WCHAN, universalRouter, 1461501637330902918203684832716283019655932542975, 281474976710655); afterwards the allowance is 2^160-1 with expiration 2^48-1 and every later sell skips the approval step. Verified by reading lines 61, 65 and 358-375; constants computed with node.

### 8. Low: Batched stake deposit approves the vault for maxUint256 while the sequential deposit path approves exactly the deposit amount

`apps/website/app/stake/StakeContent.tsx:1011`

```
          args: [WCHAN_VAULT_ADDR, maxUint256],
```

handleApprove (lines 901-908) approves WCHAN to the vault for exactly parsedAmount, and the migrate paths approve exactly the old-vault balance (lines 237-243 and 293-299). handleBatchedDeposit, taken whenever the wallet supports ERC-5792 atomic batching, instead approves maxUint256 (line 1011) before deposit(parsedAmount, address). The path is chosen by wallet capability, not by the user, so the same action leaves either a zero residual allowance or an unlimited permanent one depending only on which wallet is connected. An ERC-4626 vault's deposit pulls only from msg.sender, so the residual allowance is exposure surface rather than direct loss. Fix: approve parsedAmount in the batched call; the batch is atomic so an exact-amount approval cannot race the deposit.

**Reproduction**

Base, wallet with atomic capability, user deposits 100 WCHAN (1e20) with a prior allowance of 0. Expected (as on the sequential path): WCHAN.allowance(user, vault) == 0 after the deposit. Actual: the batch is [WCHAN.approve(vault, 2^256-1), vault.deposit(1e20, user)]; the residual allowance is 2^256-1 (or 2^256-1 - 1e20) and the needsApproval pre-check (line 1004) never adds an approval again. Verified by reading lines 1004-1016 against 901-908.

### 9. Low: Sponsored USDC relay: premium gate lets one holder drain relayer gas with unbounded self-transfers

`apps/website/app/api/sponsored-transfer/route.ts:19`

```
const RATE_LIMIT_MAX = 10;
```

The relay's only authorization is 'from holds >= 20M sWCHAN per the indexer' (lines 165-192) plus a per-address sliding window of 10 requests per minute kept in a module-level Map (lines 16-30). Nothing bounds sponsored transactions per day, total relayer spend, or the economic sense of the transfer: `to` may equal `from` and `value` may be 1 unit (line 108 only requires > 0). The relayer key (createRelayerWalletClient, lines 196-215) pays Base gas for every accepted call. Because rateLimitMap is per process, each serverless instance keeps its own counter, so the effective limit is 10/min per instance. The guard is correct (premium holders only) and the primitive is correct (relayer pays), but a permitted actor can systematically extract relayer ETH. Fix preserving the feature: keep the premium gate, add a durable per-address daily cap and a global relayer budget check, and reject to == from.

**Reproduction**

State: address A with >= 20M sWCHAN shares in the indexer. Input, repeated every 6 s with a fresh random 32-byte nonce: POST /api/sponsored-transfer {from: A, to: A, value: '1', validAfter: '0', validBefore: '<now+3600>', nonce: '0x<random>', signature: A's EIP-712 TransferWithAuthorization signature over those fields under the USDC Base domain}. Expected: the relay refuses economically pointless or over-budget sponsorship. Actual: every request passes the field checks (lines 101-116), rate limit (127), signature verification (135-157) and premium check (165-192), and the relayer broadcasts transferWithAuthorization(A, A, 1, ...) paying gas each time, 10 per minute per instance indefinitely with no spend ceiling. Verified by reading the full handler; no spend or daily counter exists in the file.

### 10. Low: Retry with fabricated balances/allowances replaces the revert verdict: a transaction that will revert on-chain is previewed as succeeding with asset deltas

`apps/extension/src/chrome/simulation/singleSimulation.ts:197`

```
    if (!simResult.txSuccess && simResult.tokens.length === 0) {
```

When the first injected run reverts with no token deltas, buildRetryOverrides (stateOverrides.ts:130-185) writes 10^30 into every candidate token's balance slot for the user and max allowances to Permit2 and to `to`, then runSimulation is repeated. If the retry produces any token or ETH delta (line 202), buildSimulationResult(retryResult) is returned as-is (lines 204-211), and resultBuilder.ts passes raw.txSuccess through (lines 36, 87, 96), so the displayed verdict is the retry's (true) rather than the real-state verdict (false). useAssetChangesSimulation then reports onRevertedChange(false): no SimulationRevertedBanner, and the asset panel shows outgoing rows for transfers that cannot happen. The comment on lines 194-196 scopes this to impersonator accounts, but the code applies to every account; the separate estimateGas path may still fail, so the two widgets contradict each other. Fix: keep the first run's txSuccess (return {...retryResult, txSuccess: false}) or add a hypothetical flag the UI renders as 'would do X if you had balance/approval', taking the retry verdict only for impersonated/watch-only accounts.

**Reproduction**

User EOA holds 0 USDC on Base. Dapp requests eth_sendTransaction {to: USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, data: transfer(bob, 100e6)}. Run 1: inner call reverts (insufficient balance), tokens=[] -> condition at line 197 true. Retry: USDC balance slot overridden to 1e30, transfer succeeds, tokens=[USDC], deltas=[-100e6], txSuccess=true -> returned at line 204. Expected: txSuccess=false and a reverted banner. Actual: panel shows '-100 USDC' with txSuccess=true and no banner; the on-chain transaction reverts and the user pays gas. Verified by code trace of lines 197-212 and resultBuilder.ts.

### 11. Low: Tier-2 batch gas injection ignores allSuccess and returns normal-looking estimates for calls that reverted in simulation

`apps/extension/src/chrome/gas/batchInjection.ts:91`

```
    return gasUsedPerCall.map((gas) => ({
```

estimateBatchGasSequential tier 1 (batchSimulation.ts:85-91) deliberately returns null when any eth_simulateV1 call has status != 0x1, 'falling through to tier 2 for accurate successful-path gas'. Tier 2 calls TxSimulator.simulateBatchGas, which returns allSuccess=false when any call reverted, but tryBatchGasInjection only logs allSuccess (line 88-90) and maps every gasUsedPerCall entry to {gasLimit: gas*2, fallbackUsed: false} (lines 91-94). batchResult.ts:35-37 then emits estimationFailed=false and fallbackUsed=false for a call known to revert, so MultiTxGasEstimateDisplay shows no warning, whereas tier 3 (batchFallback.ts:32) would have marked the same call fallbackUsed=true. The measured gas for a reverted call is only the gas to the revert point, so the 2x limit is also far below what a successful execution would need if state changes make the call succeed at inclusion. Fix: return null when allSuccess is false so tier 3 runs, or extend the ABI to a per-call success array and set fallbackUsed/estimationFailed for the reverted indices.

**Reproduction**

Batch with calls=[{to: USDC, data: transfer(bob, 1e30)}] from an account holding less. Tier 1: eth_simulateV1 reports status 0x0 -> returns null (batchSimulation.ts:91). Tier 2: simulateBatchGas returns allSuccess=false, gasUsedPerCall[0] = revert gas + 21000 + calldata gas (~27-30k) -> gasLimit ~55-60k, fallbackUsed=false, estimationFailed=false. Expected: the estimate flagged as failed/fallback as tier 3 does. Actual: a clean estimate with no warning. Verified by reading batchInjection.ts:78-94 (allSuccess decoded then unused) and the historical TxSimulator.simulateBatchGas (lines 249-264 of commit 1372daf, byte-identical to the shipped runtime) which sets allSuccess=false on any failed call but still records gas.

### 12. Low: Tier-2 batch gas limits omit the EIP-7623 calldata floor, so calldata-heavy calls get a limit below the intrinsic floor and are rejected on chains 1 and 8453

`apps/extension/src/chrome/gas/batchInjection.ts:92`

```
      gasLimit: gas * 2n,
```

The injected contract computes gasUsedPerCall[i] = execGas + 21000 + (4 per zero byte + 16 per non-zero byte) (TxSimulator._calldataGas, commit 1372daf lines 259-275, byte-identical to SIMULATOR_BYTECODE). Since Prague on Ethereum mainnet and Isthmus on Base, EIP-7623 requires gasLimit >= 21000 + 10 * tokens where tokens = zeroBytes + 4 * nonZeroBytes, and nodes reject transactions below that floor at submission. The TS side applies only a 2x multiplier. For D non-zero calldata bytes and E execution gas the limit 2*(21000 + E + 16D) is below the floor 21000 + 40D whenever 8D > 21000 + 2E, i.e. above ~3.9k non-zero bytes for a 5k-gas call. Tier 1 (eth_simulateV1 gasUsed) and tier 3 (eth_estimateGas) include the floor because the node applies it; only tier 2 does not. Fix: compute floor = 21000 + 10 * (zeroBytes + 4 * nonZeroBytes) from call.data in tryBatchGasInjection and return max(gas * 2, floor), or add the floor to _calldataGas in a rebuilt bytecode.

**Reproduction**

Foundry run against the historical source compiled with solc 0.8.33 (runtime equal to the shipped constant): simulateBatchGas([{to: contract with empty payable fallback, value: 0, data: 6000 bytes of 0x01}]) returned gasUsedPerCall[0] = 122164; tier-2 limit = 2 * 122164 = 244328; EIP-7623 floor = 21000 + 10 * (4 * 6000) = 261000; assertion limit < floor held. Expected: a gas limit the node accepts. Actual: eth_sendRawTransaction fails with an intrinsic-gas/floor error on chains 1 and 8453 whenever tier 1 is unavailable (RPC without eth_simulateV1) and the call carries more than ~4k non-zero calldata bytes with little execution.

### 13. Low: Any candidate contract can abort the whole asset simulation with an arithmetic panic (balanceOf >= 2^255), forcing the 'simulation unavailable' state instead of a preview

`apps/extension/src/chrome/simulation/simulatorContract.ts:10`

```
export const SIMULATOR_BYTECODE: `0x${string}` =
```

The shipped runtime is the historical apps/contracts/src/utils/TxSimulator.sol (commit 1372daf; compiles byte-for-byte with solc 0.8.33, optimizer off, cancun). Its _computeDeltas does `rawDeltas[i] = int256(after_[i]) - int256(before_[i]);` (line 414) under checked arithmetic, and _tryBalanceOf accepts any 32-byte return, so a candidate whose balanceOf returns a value with the top bit set makes the uint256->int256 cast wrap negative and the subtraction overflow, raising Panic(0x11) in the outer simulate()/simulateBatch() frame. The eth_call then reverts, singleSimulation.ts:224-231 catches and returns simulationFailed=true, and the confirmation UI replaces the asset rows with the simulation-failure warning dialog, which the user can click through. Candidates come from the access list of the reviewed call and only need to answer balanceOf(user) to pass preflightAssetCandidates, so any contract the dapp's transaction touches qualifies; real asset movements in the same transaction (e.g. a USDC transferFrom) are then no longer shown. Fix in the contract: skip or clamp balances >= 2^255 or compute the delta in unchecked arithmetic with an explicit sign test; in TS, when the eth_call reverts with a Panic, retry once without the offending candidates rather than reporting the preview unavailable.

**Reproduction**

Foundry run against the historical source (same bytecode as shipped): EvilCandidate.balanceOf returns 2^255 while flag==false and 2^255-1 after setFlag(); vm.etch(user, runtime); vm.prank(user); vm.expectRevert(Panic(0x11)); TxSimulator(user).simulate(evil, 0, abi.encodeCall(setFlag,()), [evil]) -> reverted with Panic(0x11) as expected (test passed). In the extension: a dapp tx touching such a contract -> createAccessList lists it -> preflight keeps it (balanceOf succeeds) -> eth_call reverts -> {simulationFailed: true, simulationError: '... panic 0x11'}. Expected: preview shows the real deltas and ignores the malformed candidate. Actual: 'simulation unavailable' dialog.

### 14. Low: ERC-1271 shim in the injected simulator rejects 64-byte EIP-2098 compact signatures and accepts any 65-byte signature from any signer, so Permit2 flows diverge between preview and chain

`apps/extension/src/chrome/simulation/simulatorContract.ts:6`

```
 *  Includes ERC-1271 isValidSignature so Permit2 signature verification
```

Because the user's address has code during the preview, Permit2's SignatureVerification takes the contract branch and calls isValidSignature(hash, sig) on the injected simulator. The shipped isValidSignature (historical source lines 628-645, confirmed by disassembly-equivalent compilation) only accepts signature.length == 65 and returns the magic value whenever ecrecover is non-zero, without comparing the recovered address to address(this). Two divergences: (1) Permit2 accepts 64-byte EIP-2098 compact signatures for EOAs, but the shim returns 0xffffffff, so Permit2 reverts with InvalidContractSignature only in the preview; txSuccess=false and the reverted banner appear on a valid permit transaction, and the retry overrides cannot repair it. (2) Any 65-byte signature with v in {27,28} and canonical s validates for the user regardless of signer, so calldata carrying a third-party or stale-key Permit2 signature previews as moving the user's tokens although it reverts on-chain (InvalidSigner). The permissive behaviour is intentional so Permit2 works under simulation, but it is undocumented and the compact-signature gap is a plain bug. Fix: unpack 64-byte compact signatures (v = 27 + (vs >> 255), s = vs & ~(1<<255)) and require ecrecover(hash, v, r, s) == address(this); keep the any-signer acceptance only for impersonated/watch-only accounts where the real signature cannot exist, and document it.

**Reproduction**

Foundry run against the historical source compiled with solc 0.8.33 (runtime equal to SIMULATOR_BYTECODE): after vm.etch(user, runtime), TxSimulator(user).isValidSignature(bytes32(1), new bytes(64)) returned 0xffffffff, and isValidSignature(bytes32(1), sig65) with sig65 produced by vm.sign(0xA11CE, bytes32(1)) (a key unrelated to user) returned 0x1626ba7e; both assertions passed. End-to-end: a dapp tx calling Permit2.permitTransferFrom(..., sig64) succeeds on-chain (EOA branch) but previews as success=false with tokens=[] -> 'likely to revert'; a tx carrying a foreign 65-byte Permit2 signature previews as succeeding while Permit2 reverts on-chain.

### 15. Info: bridgeToBase converts the ETH amount to wei with floating point, producing a value that differs from the requested amount (currently has no caller)

`apps/extension/src/utils/wei.ts:274`

```
  const value = BigInt(Math.floor(parseFloat(amount) * 1e18));
```

The exported bridgeToBase helper converts a decimal ETH string to wei via parseFloat(amount) * 1e18. IEEE-754 doubles have 53 mantissa bits, so for any amount above ~0.009 ETH or with a non-dyadic fraction the computed wei differs from the exact decimal value, in either direction. The result is used both as msg.value and as the uint256 _value argument encoded into depositTransaction calldata for the Base OptimismPortal (lines 277-301), so a caller would bridge a different amount than requested. The function is exported in the module's default object but has no caller in the extension (grep shows only its definition), so severity is informational today; it is the only place in the codebase that converts user amounts without parseEther/parseUnits and should be fixed or removed. Fix: const value = parseEther(amount) (viem) and reject non-numeric input.

**Reproduction**

Executed with node: BigInt(Math.floor(1.1 * 1e18)) = 1100000000000000128n (expected 1100000000000000000n, 128 wei too many); BigInt(Math.floor(parseFloat('1.000000000000000001') * 1e18)) = 1000000000000000000n (expected 1000000000000000001n, the final wei lost). Reachability: `grep -rn bridgeToBase apps/extension/src` returns only utils/wei.ts.

### 16. Info: Fee editor round-trips wei through a floating-point gwei string, altering exact fee values above 2^53 wei

`apps/extension/src/components/GasEstimateDisplay.tsx:68`

```
  const gwei = Number(BigInt(wei)) / 1e9;
```

The gas editor converts every maxFeePerGas/maxPriorityFeePerGas to a gwei string with Number(BigInt(wei)) / 1e9 (weiToGweiStr, lines 67-71) and back with BigInt(Math.round(Number(gweiStr) * 1e9)) (gweiStrToWei, lines 82-91); the same helpers are duplicated in MultiTxGasEstimateDisplay.tsx lines 122-141. The overrides sent to the signer are the round-tripped values, not the estimate's bigints. For any wei value above Number.MAX_SAFE_INTEGER (9,007,199,254,740,991 wei, about 9,007 gwei) the conversion is lossy, so a dapp's exact fee cap, or a fee on a chain with very high gas prices, is changed before signing even when the user does not touch the fields. Below 2^53 the round trip is exact. Fix: keep bigint wei as the source of truth and only derive display strings from it; parse user input with viem parseGwei.

**Reproduction**

Executed the two helper bodies with node: weiToGweiStr('9007199254740993') -> '9007199.254740993'; gweiStrToWei of that -> '9007199254740992' (1 wei less than the dapp's cap). 12345678901234567 -> 12345678901234568. Expected: the override equals the input wei string exactly.

### 17. Info: formatEth prints 18 fractional digits from a double, fabricating trailing digits for amounts over 2^53 wei

`apps/extension/src/lib/gasFormatUtils.ts:7`

```
  const eth = Number(BigInt(wei)) / 1e18;
```

formatEth converts the wei string to a double and then calls toFixed(18), which prints more digits than a double carries (15-17 significant). For any value at or above 2^53 wei (~0.009 ETH) the printed trailing digits are rounding noise, so the exact-looking figure is wrong in its last digits. The file already contains an exact bigint formatter, formatEthExact (line 14), which should be used wherever full precision is printed. Fix: implement formatEth via formatEthExact (or viem formatEther) and reserve the float path for deliberately compact displays.

**Reproduction**

Executed the function body with node: formatEth('1234567890123456789') -> '1.23456789012345669 ETH' (expected '1.234567890123456789 ETH'); formatEth('100000000000000001') -> '0.100000000000000006 ETH' (expected '0.100000000000000001 ETH').

### 18. Info: Route comparison silently drops a failed direct WETH/WCHAN quote, so callers cannot tell an empty direct pool from a healthy two-route comparison

`packages/wchan-swap/src/quotes.ts:173`

```
  } else if (viaBnkrwQuote) {
```

getBestQuote runs both quotes with Promise.allSettled and, when one rejects, returns the other (lines 166-175) with no signal to the caller beyond a console.log (lines 155-162). The website's compareBestRoute and the extension's quote display treat the result as 'best of two routes' while one may be permanently unavailable (the specialist reported the direct pool reverting NotEnoughLiquidity on Base at review time; this review verified the code path, not the live pool state). The WchanQuote type has no field recording that the other route failed, so neither the UI nor API logs can distinguish an unfunded direct pool from a genuine comparison. Nothing is wrong with the fallback itself. Recommend returning the losing/failed route status in WchanQuote, or raising when the direct pool has failed for a full window.

**Reproduction**

Input: getBestQuote(rpc, 8453, 'buy', 1e16) with getQuote rejecting ('execution reverted') and getQuoteViaBnkrw fulfilling. Expected: a quote that records that the direct route failed. Actual: lines 149-152 map the rejection to null, line 173-174 select viaBnkrwQuote, and the returned {route: 'via-bnkrw', amountOut, ...} carries no indication that the direct pool was unusable; the only trace is the console.log at line 155.

### 19. Info: Sponsored USDC relay: missing or non-numeric validBefore/validAfter throws outside the try block and returns an unhandled 500

`apps/website/app/api/sponsored-transfer/route.ts:118`

```
  const validBeforeBn = BigInt(validBefore);
```

Field validation at lines 101-116 checks from, to, value, nonce and signature but never validBefore or validAfter. Line 118 calls BigInt(validBefore) outside any try/catch, so an absent or non-numeric value throws (TypeError for undefined, SyntaxError for 'abc') and the route returns a framework 500 instead of a 400. BigInt(validAfter) at line 145 is inside the signature try and surfaces as a misleading 401 'Signature verification failed'. Line 118 runs before the rate limit at line 127, so it is also outside the per-address limiter. Fix: validate validAfter and validBefore as decimal or 0x quantity strings alongside the other fields.

**Reproduction**

POST /api/sponsored-transfer with body {from: 0x...01, to: 0x...01, value: '1', nonce: '0x01', signature: '0x01'} and no validBefore. Expected: HTTP 400 with a field error. Actual: BigInt(undefined) throws TypeError at line 118 -> HTTP 500. Variant 'validBefore': 'abc' -> same 500; 'validAfter': 'abc' with a valid validBefore -> 401 'Signature verification failed'. Verified with node: BigInt(undefined) and BigInt('abc') both throw.

### 20. Info: Pimlico gas proxy: eth_sendUserOperation for EOAs is an open relay for any self-signed sender, bounded only by a spoofable per-IP limit

`apps/website/app/api/gas/pimlico/[chainId]/route.ts:80`

```
    request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
```

verifyPimlicoSendEnvelope (policy.ts lines 263-317) authorizes a non-Safe UserOperation purely by checking that op.signature is a valid EIP-712 signature by op.sender over the PackedUserOperation typed data (lines 308-313). It does not require that the sender is a WalletChan account, that the sender is or is being delegated to the official delegate when eip7702Auth is absent, or that a paymaster/fee token is used. Any EOA can therefore use WalletChan's PIMLICO_API_KEY quota as a bundler. The only limiter is consumeRateLimit keyed by clientKey(), which falls back to the first x-forwarded-for value (line 80), a client-controlled header outside Vercel; SEND_RATE_LIMIT is 10 per window per key. The paymaster methods are ERC-20 funded (user pays), so the exposure is API quota and abuse attribution, not sponsor funds. Documented as a trust assumption with a concrete limiter bypass. Fix: when eip7702Auth is absent, require the sender's current code to equal the official delegation designator (as safePolicy does for Safes), and key the limiter on a platform-attested address only.

**Reproduction**

From any host not behind Vercel: POST /api/gas/pimlico/8453 with {jsonrpc:'2.0', id:1, method:'eth_sendUserOperation', params:[<UserOperation with sender = attacker EOA, no eip7702Auth, no paymaster, signature = attacker's typed-data signature from getUserOperationTypedData>, '0x0000000071727De22E5E9d8BAf0edAc6f37da032']}, rotating the x-forwarded-for header each call. Expected: only WalletChan-delegated accounts within a fixed limit are forwarded. Actual: parsePimlicoProxyEnvelope passes, verifyPimlicoSendEnvelope returns true for any correctly signed EOA op (policy.ts:273 and 308-313 are the only checks on that path), and the per-key limit resets with each new header value because the key is derived at route.ts:77-83.

### 21. Info: Pimlico Safe policy: authority read depends on viem's hardcoded public default RPC with no retry; an RPC outage rejects every Safe UserOperation as 'signature is invalid'

`apps/website/app/api/gas/pimlico/[chainId]/safePolicy.ts:12`

```
  const client = createPublicClient({ chain, transport: http(chain.rpcUrls.default.http[0], { timeout: 8000, retryCount: 0 }) });
```

verifySafeUserOperation decides whether a 1-of-1 Safe's UserOperation may be forwarded by reading owners, threshold, isModuleEnabled, fallback handler slot, module code and proxy code from chain.rpcUrls.default.http[0] (the public RPC baked into viem's chain registry, e.g. https://mainnet.base.org) with retryCount 0 and an 8 s timeout (line 12). Any failure inside readSafeProxyAuthority is swallowed by the catch at line 37 and reported by route.ts:143 as HTTP 401 'UserOperation signature is invalid'. This is a trust-gap note (a third-party public RPC is the sole authority for a server-side permission decision) and a misleading failure mode, not a bypass: the EntryPoint still validates the owner signature on-chain. Fix: use a configured RPC with retries and return a 502/503 naming the upstream failure.

**Reproduction**

State: the public RPC for the chain is unreachable or returning 429 to the proxy's egress IP. Input: a correctly signed Safe4337 UserOperation from a 1-of-1 Safe with the module enabled, signature = 0x + 12 zero hex + validUntil(6 bytes) + owner ECDSA, posted as eth_sendUserOperation. Expected: forwarded, or a 502/503 that names the upstream read failure. Actual: readSafeProxyAuthority throws, verifySafeUserOperation returns false via the catch at line 37, route returns 401 'UserOperation signature is invalid'. Verified by reading safePolicy.ts:9-38 and route.ts:142-144.

### 22. Info: Shipped simulator runtime bytecode has no Solidity source in the repository; provenance is reproducible only from git history (commit 1372daf, solc 0.8.33, optimizer off, cancun)

`apps/extension/src/chrome/simulation/simulatorContract.ts:10`

```
export const SIMULATOR_BYTECODE: `0x${string}` =
```

The pinned tree contains no Solidity sources: apps/contracts (TxSimulator, vault, hooks, deploy scripts, foundry.toml) was removed in commit 8279639 'Remove extracted apps from the wallet monorepo (#12)'. The only contract artifact that ships to users is the 13,616-byte SIMULATOR_BYTECODE constant, executed on every transaction preview and batch gas estimate via eth_call state override at the user's address (simulatorOverride.ts:16-26). Reviewers and CI cannot verify it from the tree, and a changed constant could ship without a matching source diff. This review recompiled `git show 1372daf:apps/contracts/src/utils/TxSimulator.sol` with solc 0.8.33, optimizer=false, evm_version=cancun and obtained a runtime identical to the constant after stripping the CBOR metadata trailer (both 13,616 bytes; the trailer's solc bytes 0x000821 match). All contract-level findings here were made against that source. Recommend vendoring TxSimulator.sol plus its exact build settings next to simulatorContract.ts (or in a packages/ workspace with a foundry.toml) and adding a CI step that recompiles and asserts the metadata-stripped runtime equals the constant.

**Reproduction**

`find . -name '*.sol' -not -path './.imd/*'` on the tree at b54334a returns nothing, while simulatorContract.ts line 11 embeds 27,232 hex characters of runtime code whose CBOR trailer ends in 0x64736f6c63430008210033 (solc 0.8.33). `git log --oneline -- apps/contracts/src/utils/TxSimulator.sol` shows the last change in 1372daf and the removal in 8279639. Expected: the source and build configuration that produce this constant are in the repository and checked by CI. Actual: source exists only in history and nothing checks the constant against it.

---

Judge's submission `843952c434994d970978830fd02094d018af69eb25ad931bba2d5afe54111dd1`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
