Skip to main content

Portfolio Assessment — cost model & safe supported limit

Portfolio Assessment (/portfolio-assessment) is a controlled-pilot workspace that lets someone paste or upload 10-100 public Ethereum wallets and get back an aggregated view built entirely from each wallet’s existing, canonical Wallet Risk Report. This document records the actual provider/rate-limit evidence the feature’s concurrency and pacing decisions are based on, and states plainly where the honest ceiling is lower than the 100-wallet submission cap.

What this feature adds, and what it deliberately does not add

  • No new server endpoint. The workspace calls the existing, already-shipped GET /api/wallet-risk-report?address= route (src/lib/wallet-risk-report-client.js’s fetchWalletRiskReport) once per wallet, sequentially. It does not call composeWalletRiskReport() in-process and does not add a batch API route.
  • No new provider call path. Every upstream call this feature triggers already exists and is already governed by docs/operations/provider-usage-governance.md and docs/operations/dune-spend-path-controls.md. Nothing here bypasses the wallet-risk-report route’s own rate limiter — the workspace simply calls it and honors whatever it reports back (including 429s).
  • No Dune execution or result-read code added. The Quantum/Dormancy dimensions a Wallet Risk Report already includes are Dune-sourced through the existing gated getOrCache/readOrCache path (api/_dune.js); Portfolio Assessment does not touch that path directly and adds zero new DUNE_QUERY_* references.

Why concurrency is 1, not 2 or more

api/_wallet-live-provider.js documents two provider-specific self-imposed ceilings for a single wallet’s own internal fan-out:
  • Etherscan: “cap peak concurrency at 2 to reduce self-inflicted pressure on the rate limit” (balance + txlist run concurrently, tokentx after).
  • Alchemy: “Two Alchemy calls only” (getTokenBalances + eth_getCode), reduced specifically to ease 429 throttling.
Those numbers are the codebase’s own definition of “the edge of safe” for one wallet. If Portfolio Assessment ran two wallets concurrently, each with its own internal 2-way fan-out, the batch would put roughly double that provider pressure on Etherscan/Alchemy system-wide — silently exceeding a ceiling the code’s own authors already judged as the limit. Running one wallet at a time keeps total in-flight pressure at, not above, that existing ceiling. This is a correctness constraint, not a tunable — it does not change if the submission cap changes.

The real per-hour ceiling: the existing route’s own rate limit

api/wallet-risk-report.js already rate-limits itself to:
RATE_LIMIT_OPTIONS = { limit: 30, windowSeconds: 3600 }  // 30 requests / hour, per client IP
This budget is shared with every other caller of that route from the same IP (Whale Watcher, Quantum, Wallet Profile entry points, and any other open tab). It is also not cache-aware at the route level — like every other rate-limited route in this codebase (api/wallet.js’s 30/300s, api/wallet-expand.js’s 20/300s), a request counts against the budget regardless of whether the wallet turns out to be a warm cache hit downstream. Portfolio Assessment inherits this behavior unmodified rather than inventing a cache-aware credit scheme, to stay consistent with how every other route in this app already works and to avoid adding a new rate-limiting design that would need its own security review. Consequence: in one hour, from one browser/IP, at most ~30 fresh GET /api/wallet-risk-report calls can succeed — regardless of how many wallets were submitted. A 100-wallet portfolio (assuming no cache warmth and no other concurrent usage of the route from the same IP) will complete roughly 30 wallets before the route starts returning 429s.

What the workspace actually does about it

  • Submission cap: up to 100 distinct (post-dedup) wallets may be submitted, per the product requirement — this cap governs UX and never implies all 100 can finish in one sitting.
  • De-duplication first: identical addresses (case-insensitive) collapse to one entry before any network work is scheduled — the single highest-leverage cost lever, per the provider-caching audit.
  • Sequential dispatch, honest stop: the runner (src/lib/portfolio-assessment/run-assessment.js) processes one wallet at a time. The moment fetchWalletRiskReport reports status: 'rate-limited', the run stops immediately — it does not skip ahead, and does not retry. The remaining, not-yet-assessed wallets are kept queued.
  • Manual resume, never background polling: the UI shows the server-reported retryAfterSeconds as a countdown and only enables a “Resume” button once it elapses — actually resuming still requires the person to click it. No timer ever fires a network request on its own.
  • Per-wallet failures never fail the batch: a network/server error for one wallet (status: 'unavailable') is recorded against that wallet only; the run continues to the next one.

Documented blocker

Safely supporting a full 100-wallet run in a single sitting is not possible under the current per-IP rate-limit contract on /api/wallet-risk-report — that is an existing, deliberate product decision on a shared route, not something this feature is positioned to change unilaterally. Rather than pretending otherwise, Portfolio Assessment:
  1. Accepts up to 100 submitted wallets (satisfying the UX requirement).
  2. Processes them sequentially at concurrency 1 (satisfying the provider-safety evidence above).
  3. Honestly pauses at the real, server-reported rate-limit boundary rather than a guessed static number — because the 30/hour budget is shared with other app usage on the same IP, the number that will actually complete before a pause varies by session, and reacting to the live 429 is more honest than hard-coding “stop after 30.”
  4. Requires an explicit human click to continue past a pause — never auto-retries.
If a future revision wants all 100 wallets to complete faster than the existing per-IP budget allows, that requires a deliberate, separately-reviewed decision (e.g. a dedicated rate-limit bucket sized and approved for this route), not a client-side workaround.