Skip to content

Add rate limits and wallet-ownership sessions to blockchain read APIs - #69

Open
sirpy wants to merge 2 commits into
mainfrom
feat-rate-limits-tatum
Open

sirpy wants to merge 2 commits into
mainfrom
feat-rate-limits-tatum

Conversation

@sirpy

@sirpy sirpy commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Protects /api/chains/* (the Tatum / RPC-backed blockchain read endpoints) from abuse by non-app clients.

  • Rate limiting in src/proxy.ts: per-IP, per-endpoint, 60s fixed window (in-memory, per instance). Over-limit returns 429 with Retry-After.
  • Cross-origin blocking: Sec-Fetch-Site: cross-site or an Origin that doesn't match the host returns 403.
  • Wallet-ownership sessions (POST /api/chains/auth/address, new): the client requests a stateless HMAC-signed 5-minute challenge for (family, address), solves a SHA-256 proof of work (0000 prefix) and signs the challenge with the wallet key. The server verifies both and sets an HttpOnly cookie valid only for that family+address on /api/chains (30 min, anchored to challenge issue time, so replays cannot extend it). No shared nonce storage is needed.
  • Guarded reads: /api/chains/<family>/addresses/<addr>/(balance|history|utxos) require the matching cookie (401 otherwise). Non-verbose GET /transactions/<hash> requires ?address= plus the cookie. Verbose tx reads, fee and broadcast stay unauthenticated (rate-limited, except broadcast).
  • Signatures for every family: EVM (signMessage), Solana (Ed25519), BTC/DOGE (auth-only SHA256 + compact ECDSA, not BIP322), XRP (ripple-keypairs). Added optional signAuthMessage to BTC/XRP signers.
  • Client: providers (EVM, Solana, XRP, Bitcoin, Tatum handler) now call fetchWithAddressSession, which authenticates lazily, caches expiry, and retries once on 401.
  • Tests: ~200 vitest tests under src/app/api/chains. vitest.config.ts alias order fixed so @/config resolves. Biome allows ! in *.test.ts.

Limits per IP per 60s: auth challenge (GET) 12, auth verify (POST) 24, balance 30, history 12, utxos 18, tx lookup 30, fee 60.

Required deploy config (all environments)

Env var Requirement
API_POW_SESSION_SECRET Random, at least 32 chars. Missing/short makes auth return 503 and guarded reads 401.
API_OWNERSHIP_ORIGIN Exact canonical origin, no trailing slash or path, e.g. https://wallet.example. HTTPS required in production. Defaults to http://localhost:3000 in dev.

The secret must be identical across instances (no shared state needed otherwise). Details in src/app/api/chains/OWNERSHIP.md.

Known limitations

  • Rate limiter is per-instance memory; multi-instance deployments multiply the effective limit.
  • Raw tx lookup does not verify the hash belongs to the authenticated address.
  • fee and verbose tx reads are unauthenticated.
  • ENS/SNS lookups and the gas-price RPC call bypass these APIs.

QA guide

Setup: set the two env vars, run yarn dev, open the app at API_OWNERSHIP_ORIGIN (e.g. http://localhost:3000). Use browser DevTools > Network (filter api/chains) and Application > Cookies. Test with accounts for each chain family where available (EVM, Solana, BTC, DOGE, XRP). Use a fresh browser profile or clear cookies/sessionStorage between scenarios.

A. Regression: normal wallet use (must not break)

  1. Log in and open the main screen. Balances load for every chain/network the account supports.
  2. Open activity history and the Send > Recent list. Both load.
  3. In DevTools, confirm the first read per address is preceded by GET /api/chains/auth/address?... (200) and POST /api/chains/auth/address (200, Set-Cookie: gw_owner_v2_...; HttpOnly; Path=/api/chains), then reads return 200.
  4. Reload the page: no new auth request for the same address while the session is valid (sessionStorage key gw-ownership-v2:<family>:<addr>), reads still 200.
  5. Switch between multiple accounts/addresses: each address gets its own cookie, and each wallet's data loads.
  6. Bitcoin send flow: UTXO fetch, fee estimate, broadcast and post-send confirmation work. DOGE as well if supported.
  7. Send tokens on EVM/Solana/XRP: balance refresh after sending works.

B. Ownership enforcement

Copy a real cookie from DevTools (name gw_owner_v2_<hash>, value is the token).

  1. Valid cookie, matching address: curl -i -H "Cookie: <name>=<value>" "$ORIGIN/api/chains/EVM/addresses/<own-address>/balance" returns 200.
  2. Same cookie, different address: change <address> in the URL. Expect 401 {"message":"Address session required"}.
  3. Same cookie, different family (e.g. swap EVM for BTC): 401.
  4. No cookie: 401 for /balance, /history, /utxos.
  5. Tamper with the cookie value (change one character): 401.
  6. Raw tx lookup: GET /api/chains/BTC/transactions/<hash> without address gives 400 "Wallet address is required for raw transaction reads". With ?address=<own> and the cookie gives 200. With another address and the cookie gives 401.
  7. GET /api/chains/BTC/transactions/<hash>?verbose=true and GET /api/chains/BTC/fee work without a cookie.
  8. Session expiry: delete the cookie in DevTools and use the app. Next read re-authenticates transparently (one retry after a 401), the UI shows no error.
  9. Cannot obtain a session without owning the key: POST /api/chains/auth/address with a made-up body returns 400/401 and no Set-Cookie. A POST with a wrong Origin header returns 403.

C. Cross-origin protection

  1. curl -i -H "Sec-Fetch-Site: cross-site" "$ORIGIN/api/chains/BTC/fee" returns 403 {"message":"Forbidden"}.
  2. curl -i -H "Origin: https://evil.example" "$ORIGIN/api/chains/BTC/fee" returns 403.
  3. Same requests with Sec-Fetch-Site: same-origin or no Origin header return 200.
  4. Browser check: from another origin's DevTools console, fetch("<ORIGIN>/api/chains/BTC/fee") is blocked or returns 403.

D. Rate limits

Use a loop with a fixed IP; local dev falls under one unknown/same IP bucket, or send -H "x-real-ip: qa-1". Windows are 60s, so wait 60s between scenarios or change the IP value.

for i in $(seq 1 65); do curl -s -o /dev/null -w "%{http_code} " -H "x-real-ip: qa-fee" "$ORIGIN/api/chains/BTC/fee"; done

Expected: first 60 return 200, then 429.

Endpoint Allowed per 60s
GET /api/chains/auth/address 12
POST /api/chains/auth/address 24
/addresses/<a>/balance 30
/addresses/<a>/history 12
/addresses/<a>/utxos 18
/transactions/<hash> 30
/fee 60

For each, verify:

  1. The request after the limit returns 429 with body {"message":"Too many requests"} and a numeric Retry-After header (at most 60).
  2. A different x-real-ip is unaffected.
  3. Another endpoint for the same IP is unaffected (e.g. exhaust history, then fee still works).
  4. After Retry-After seconds, requests succeed again.
  5. Guarded endpoints without a cookie count toward the limit (401 responses become 429 after the limit).
  6. POST /api/chains/<family>/transactions/broadcast and non-chain routes (e.g. /api/tokens) are never rate limited.

E. Normal-usage sanity under limits

  1. Open the app with an account holding many tokens/chains, scroll the activity feed and refresh balances repeatedly for a minute. No 429s should appear in normal use (report any you see, with endpoint and count).
  2. After intentionally triggering a 429, confirm the UI degrades gracefully (loading/empty state, no crash) and recovers within a minute.

F. Config failures

  1. Unset API_POW_SESSION_SECRET (or set under 32 chars) and restart: GET /api/chains/auth/address?family=EVM&address=0x... returns 503, and address-scoped reads return 401.
  2. Set API_OWNERSHIP_ORIGIN with a trailing slash (e.g. http://localhost:3000/): auth returns 503.
  3. In a production build, a non-HTTPS origin is rejected (503).

Automated checks

yarn test (all pass) and yarn biome check src/app/api/chains src/proxy.ts.

@sirpy
sirpy requested a balanced review from Copilot October 8, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Session duration, rate-limit security, capacity, and normal-use fan-out issues remain unresolved.

5 open findings
What changed in this PR

Adds abuse protection and wallet-ownership authentication to blockchain read APIs.

Changes:

  • Adds per-IP rate limits and ownership-cookie guards.
  • Implements multi-chain challenge signing and client-side session management.
  • Adds extensive protocol, proxy, and cryptographic tests.
File Description
vitest.config.ts Orders test aliases correctly.
src/​proxy.ts Adds rate limits and ownership guards.
src/​login/​types.ts Extends BTC/XRP signer interfaces.
src/​login/​adapters/​privatekey.ts Implements BTC, DOGE, and XRP auth signing.
src/​hooks/​useLedger/​tatumBitcoinHandler.ts Authenticates Bitcoin history reads.
src/​chain/​provider/​XRP/​xrpProvider.ts Authenticates XRP history reads.
src/​chain/​provider/​Solana/​solanaProvider.ts Authenticates Solana history reads.
src/​chain/​provider/​EVM/​evmProvider.ts Authenticates EVM history reads.
src/​chain/​provider/​Bitcoin/​bitcoinProvider.ts Authenticates Bitcoin balance, UTXO, and raw transaction reads.
src/​app/​api/​chains/​powSession.ts Implements signed challenges and sessions.
src/​app/​api/​chains/​ownershipSignature.vitest.config.ts Configures isolated protocol tests.
src/​app/​api/​chains/​ownershipSignature.ts Verifies ownership signatures by family.
src/​app/​api/​chains/​ownershipSignature.test.ts Tests multi-chain signature verification.
src/​app/​api/​chains/​ownershipSession.test.ts Tests challenge and session behavior.
src/​app/​api/​chains/​ownershipRoutes.test.ts Tests routes, origins, and validation.
src/​app/​api/​chains/​ownershipProtocol.ts Defines ownership scopes and messages.
src/​app/​api/​chains/​OWNERSHIP.md Documents deployment and protocol behavior.
src/​app/​api/​chains/​clientOwnershipSignature.ts Selects and invokes client signers.
src/​app/​api/​chains/​clientAddressSession.ts Creates, caches, and refreshes sessions.
src/​app/​api/​chains/​clientAddressSession.test.ts Tests client session orchestration.
src/​app/​api/​chains/​chainsProxy.test.ts Tests proxy limits and guards.
src/​app/​api/​chains/​auth/​address/​route.ts Exposes challenge and verification endpoints.
biome.json Allows non-null assertions in tests.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

import { normalizeOwnershipAddress, OwnershipScope } from "./ownershipProtocol"

const POW_CHALLENGE_TTL_MS = 5 * 60 * 1000
const POW_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
Comment thread src/proxy.ts
Comment on lines +60 to +63
const forwardedFor = request.headers.get("x-forwarded-for")
if (forwardedFor) {
return forwardedFor.split(",")[0]?.trim() ?? "unknown"
}
Comment thread src/proxy.ts
addressChallenge: { limit: 12, windowMs: 60 * 1000 },
addressChallengeVerify: { limit: 24, windowMs: 60 * 1000 },
balance: { limit: 30, windowMs: 60 * 1000 },
history: { limit: 12, windowMs: 60 * 1000 },
Comment thread src/proxy.ts
Comment on lines +82 to +86
if (!current || current.resetAt <= now) {
rateLimitStore.set(compositeKey, {
hits: 1,
resetAt: now + policy.windowMs,
})
Comment thread src/proxy.ts
Comment on lines +197 to +201
if (
request.method === "GET" &&
isTxLookupPath(pathname) &&
request.nextUrl.searchParams.get("verbose") !== "true"
) {

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants