> ## Documentation Index
> Fetch the complete documentation index at: https://docs.walletwall.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Dune query guardrails

# Dune query guardrails

Dune is WalletWall's highest credit-risk provider: a single query *execution* can
consume thousands of credits, and an earlier incident saw daily usage spike from a low
baseline into the thousands. This document is the contract for how WalletWall is
allowed to touch Dune. See also [provider-usage-governance.md](./provider-usage-governance.md)
and the [Dune product dependency map](../audits/dune-product-dependency-map.md).

## Golden rule: read-only, never execute

WalletWall treats Dune as a **read-only auto-run snapshot source**. Public runtime
routes read whatever the last scheduled / auto-run produced. They **never** POST to
`/execute`, so no execution credits are consumed on the request path.

`api/_dune.js` enforces this:

| Function                            | Credits? | Allowed from public routes? | Notes                                                                                                               |
| ----------------------------------- | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `readLatestResults(queryId)`        | No       | ✅                           | Reads the last auto-run result via `GET /query/:id/results`.                                                        |
| `readOrCache(queryId, opts)`        | No       | ✅                           | Read-only cache-aside for global/non-parameterized snapshots.                                                       |
| `getOrCache(queryId, params, opts)` | No       | ✅                           | Same, namespaced; ignores `params` for execution — only reads latest. Caller filters rows by wallet.                |
| `executeAndPoll(queryId, params)`   | **Yes**  | ❌                           | POSTs `/execute` and polls. Fail-closed behind the multi-factor gate below. **Deliberate one-off human runs only.** |

### Corrected root cause: agent/CLI spend, not scheduled refreshes

The earlier assumption — that scheduled auto-refreshes drove the credit spike —
was wrong. Schedules are now off; the spend path was **coding agents and dev/CI
sessions** running Dune execute/refresh/write commands during implementation and
"quick validation". The containment for that lives in
[dune-agent-safety.md](./dune-agent-safety.md) and is summarized here.

`executeAndPoll` now routes through `assertDuneExecutionAllowed`
(`api/_dune-execution-guard.js`) and throws unless **all four** hold:

```text theme={null}
1. ALLOW_DUNE_EXECUTION=true              (legacy alias DUNE_ALLOW_EXECUTION)
2. DUNE_EXECUTION_ACK=I_UNDERSTAND_THIS_COSTS_CREDITS
3. DUNE_WRITE_API_KEY is set              (read keys can never authorize a write)
4. NOT running in CI / tests              (CI / GITHUB_ACTIONS / NODE_ENV=test)
```

All four are blank/absent in `.env.example`, normal dev, and CI, and must never
be committed or set in CI. The read path uses `DUNE_READONLY_API_KEY` /
`DUNE_API_KEY`; execution uses `DUNE_WRITE_API_KEY` only. Never call the `dune`
CLI directly — vet commands with `npm run dune:guard -- <command>`.

## Query ID configuration

All Dune query IDs come from environment variables (never hardcoded), declared in
`.env.example`. Quantum query metadata is centralized in `api/_quantum-facts.js`
(`QUERY_CONFIGS` / `DUNE_QUANTUM_SOURCES`).

| Env var                                                                             | Module                                                            | Mode                               | Cadence   |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------- | --------- |
| `DUNE_QUERY_WALLET_SIGNATURE_EXPOSURE`                                              | `quantum-exposure.js`, `_quantum-facts.js`                        | wallet (address-filtered snapshot) | scheduled |
| `DUNE_QUERY_QUANTUM_VALUE_AT_RISK` (legacy alias `DUNE_QUERY_VALUE_AT_RISK`)        | `quantum-exposure.js`                                             | wallet                             | scheduled |
| `DUNE_QUERY_WALLET_MIGRATION_READINESS`                                             | `quantum-exposure.js`, `quantum-readiness.js`                     | wallet                             | weekly    |
| `DUNE_QUERY_WALLET_CHAIN_AFFINITY`                                                  | `wallet-chain-affinity.js` (Quantum Exposure Clock bridge driver) | scheduled (global, batch snapshot) | weekly    |
| `DUNE_QUERY_DORMANT_QUANTUM_EXPOSURE`                                               | `_quantum-facts.js`                                               | scheduled (global)                 | weekly    |
| `DUNE_QUERY_QUANTUM_COUNTERPARTY_CONTEXT`                                           | `_quantum-facts.js`                                               | wallet                             | scheduled |
| `DUNE_QUERY_QUANTUM_ADVERSARIAL_HEURISTICS`                                         | `_quantum-facts.js`                                               | wallet                             | scheduled |
| `DUNE_QUERY_PROTOCOL_VOLUME` / `_TOKEN_FLOWS` / `_CHAIN_ACTIVITY` / `_WHALE_TRADES` | `insights.js`                                                     | scheduled (global)                 | scheduled |
| `DUNE_QUERY_48H_ACTIVE_WALLETS` (legacy `DUNE_QUERY_ID`)                            | `wallet-activity.js` (fallback)                                   | scheduled (global)                 | scheduled |
| `DUNE_QUERY_WALLET_PORTFOLIO`                                                       | `wallet-portfolio.js`                                             | wallet                             | scheduled |
| `DUNE_QUERY_12WK_ACTIVE_WALLETS`                                                    | `whale-watcher.js`                                                | scheduled (global)                 | scheduled |
| `DUNE_QUERY_DEX_ID`                                                                 | `_wallet-live-provider.js`                                        | wallet                             | scheduled |

## Runtime route contract

Public user routes must stay on the read-only Dune path:

* Use `readOrCache`, `getOrCache`, or `readLatestResults` from `api/_dune.js`.
* Filter shared scheduled snapshots by wallet or query locally after the read.
* Return user-safe stale/unavailable warnings when a scheduled result is missing.
* Do not import or call `executeAndPoll` from public API routes.

PR #949 standardized API method guards across the runtime. Dune-backed public
routes now return `405 Method Not Allowed` with an `Allow` header when called
with an unsupported method; the guard does not introduce any Dune execution path.

## Public routes are cache-only (the contract)

This is the explicit, testable contract every public request path upholds:

* **Public routes are cache-only.** They read the last scheduled / auto-run
  snapshot via the `api/_dune.js` read layer (`readOrCache` / `getOrCache` /
  `readLatestResults`, or the wrappers in `_dune-feature-route.js` and
  `_quantum-facts.js`). They never `POST /execute`, never refresh, and never call
  `executeAndPoll`.
* **Refresh / execution stays out of the request flow.** Producing fresh Dune
  results is done **manually or by an internal, scheduled, out-of-band process**
  (Dune's own scheduler / auto-run, or a reviewed admin priming script with
  `DUNE_ALLOW_EXECUTION=true`). It must never be reachable from a public HTTP
  request.
* **No write-capable key on the request path.** Public routes require, at most, a
  **read-only** Dune key (used for `GET /query/:id/results`, which costs zero
  credits). A write-capable or execution-enabled key is never needed for normal
  public route behavior, and `DUNE_ALLOW_EXECUTION` must remain unset in runtime.
* **The key is not a hard dependency for serving a request.** When a cached
  snapshot exists (Redis or the in-process fallback), routes serve it with no Dune
  call at all. When `DUNE_API_KEY` is absent or Dune is unreachable, routes degrade
  to partial-state / "Dune auto-run data unavailable" fallbacks rather than
  failing — see the per-route warnings.

This contract is enforced statically by:

* `npm run dune:verify` (`scripts/verify-dune-usage.mjs`) — only `api/_dune.js`
  may reference `executeAndPoll` or the `/execute` endpoint.
* `test/dune-cache-compliance.test.mjs` — discovers **every** Dune-touching public
  route and asserts the cache-only contract for each (no `executeAndPoll`, no
  `/execute` POST, no refresh/cancel, no direct `api.dune.com` fetch), plus that
  the shared read-layer funnels never execute.
* `test/dune-usage-guardrails.test.mjs` — structural guards on docs, env template,
  and the execution gate.

## Dune cost / credit risk summary

| Concern                                                    | Status                                                                                                                                                      |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Execution mode**                                         | Read-only `GET /results`. No `/execute` on the request path.                                                                                                |
| **Are results cached server-side?**                        | Yes — Redis (`dune:v1:<queryId>:default`) with in-memory fallback.                                                                                          |
| **Cache key scoping**                                      | One shared snapshot per `queryId`; per-wallet routes filter rows downstream, so concurrent wallets reuse one cached read.                                   |
| **TTLs**                                                   | 6h quantum facts, 12h movement/breakdown, 24h leaderboards/migration — all encoded as named constants and asserted in `dune-cache-compliance.test.mjs`.     |
| **Can repeated refreshes burn credits?**                   | No. Reads are free; cache + in-flight coalescing collapse bursts into one read.                                                                             |
| **Can multiple users trigger duplicate executions?**       | No execution at all; concurrent reads coalesce via `coalesceRead`.                                                                                          |
| **Can failed / queued / stopped executions cost credits?** | Not reachable — `executeAndPoll` is gated off. (Reads never queue executions.)                                                                              |
| **Are errors/fallbacks visible?**                          | Yes — `recordProviderCall('dune', …)` in `readLatestResults`; routes emit `dune:<source>: Dune auto-run data unavailable` warnings and serve partial state. |
| **Are secrets redacted?**                                  | Key is sent as the `x-dune-api-key` header, never logged or placed in a URL.                                                                                |

## Did the quantum scoring changes add a Dune execution path?

**No.** Audited `_quantum-facts.js` and `quantum-exposure.js`: the new
counterparty-context and adversarial-heuristics sources build wallet query *parameters*
but pass them to `getOrCache`, which **ignores params for execution** and only reads the
latest snapshot. The new sources widen the read surface, not the execution surface.
`dune-cache-compliance.test.mjs` and `quantum-facts.test.mjs` assert no
`executeAndPoll` import and no `/execute` POST from these paths.

## Safe usage pattern

```js theme={null}
// ✅ Read-only, cached, address-filtered.
const { rows, fromCache, queryRunAt } = await getOrCache(
  process.env.DUNE_QUERY_WALLET_SIGNATURE_EXPOSURE,
  { wallet_address: address.toLowerCase() },   // ignored for execution; documents intent
  { ttlSeconds: 21_600 },                       // 6h
);
const walletRows = rows.filter(r => String(r.wallet_address).toLowerCase() === address.toLowerCase());
```

## Anti-patterns (do not do)

* ❌ Importing or calling `executeAndPoll` from any `api/*` route handler.
* ❌ Setting any execution-gate var (`ALLOW_DUNE_EXECUTION`/`DUNE_ALLOW_EXECUTION=true`, `DUNE_EXECUTION_ACK`, `DUNE_WRITE_API_KEY`) in a deployed/runtime, dev, or CI environment.
* ❌ Running `dune query execute/refresh/create/update/delete` from an agent, script, test, or CI job.
* ❌ Hardcoding a Dune query ID instead of reading it from an env var.
* ❌ Per-wallet cache keys for a shared snapshot (multiplies Redis writes; reads are already shared).
* ❌ A TTL under 6h for quantum facts, or per-render reads inside a component.
* ❌ Caching empty/error results (the next request must be able to re-read).
* ❌ Logging the Dune key or putting it in a URL query string.

## Open risks

* Telemetry counters are per-instance and reset on cold start, so they are a
  directional signal, not a billing-grade total. A persistent aggregate (e.g. a Redis
  counter or provider-side budget alert) remains a recommended follow-up for true
  spike alerting. **(Open — not fixed in this PR.)**
* `DUNE_ALLOW_EXECUTION` protects the runtime, but an operator running a priming
  script still spends credits. Keep priming scripts manual and reviewed.
