Skip to content

Latest commit

 

History

History
596 lines (436 loc) · 36 KB

api.md

File metadata and controls

596 lines (436 loc) · 36 KB

HTTP API

Full endpoint reference for TuCOPWallet-Backend. See ../README.md for the project overview and operations.md for deploy / env vars / ops runbooks.

Endpoints

GET /health

Liveness probe. Returns 200 with static service info as long as the process is responsive. Does NOT check dependencies; use /ready for that.

{ "ok": true, "service": "tucopwallet-backend", "version": "0.1.0" }

GET /ready

Readiness probe. Checks Postgres, Redis, and Celo RPC. Returns 200 when all healthy or when the optional deps (DB, Redis) are unconfigured; returns 503 with a per-dependency status when any required check fails. Response may include a neeruWarmup field with values 'ok' or 'warming' on deploys where the Neeru indexer is running; during the first few seconds after a container restart the readiness gate returns 503 with neeruWarmup: 'warming' so external load balancers do not route traffic before the warmup completes.

{ "ok": true, "checks": { "db": "ok", "redis": "ok", "rpc": "ok" } }
{ "ok": false, "checks": { "db": "fail: connection refused", "redis": "ok", "rpc": "ok" } }

Operator alerts should page on /ready 503s, not /health.

GET /health/relay

Relay hot-wallet health surface. Returns the relay address and current CELO balance (private key never exposed). Lets external monitors alert on low balance without an Sentry / Grafana integration.

{
  "ok": true,
  "address": "0x...",
  "balanceWei": "10000000000000000000",
  "balanceCelo": "10"
}

Returns 503 { "ok": false, "error": "relay not configured" } when WRI_RELAY_PK is missing or invalid, or 502 { "ok": false, "error": "rpc unavailable", "address": "0x..." } on RPC failure.

GET /metrics

Prometheus scrape endpoint (text format). Includes default Node/process metrics, an http_request_duration_seconds histogram labeled by method/route/status, custom wri_relay_* counters/gauges, pg_pool_* gauges, and Neeru indexer gauges. Scrape interval recommendation: 30s.

GET /api/prices/xaut

Proxies a XAUt0 price quote (in USD) from CoinMarketCap. Cached in Redis for 60 seconds when REDIS_URL is configured; serves direct otherwise.

Query params:

Name Required Description
vs no Quote currency. Only usd is supported. Defaults to usd.

Success response:

{ "symbol": "XAUT", "vs": "usd", "priceUsd": 3421.5, "asOf": "2026-06-16T12:00:00.000Z" }

Error responses:

  • 400 { "error": "only vs=usd supported" }
  • 502 { "error": "upstream price feed unavailable" }

GET /events

Proxies a contract event-log query to Etherscan V2 API on Celo mainnet (chainid 42220). Only whitelisted contract addresses are accepted (see ALLOWED_CONTRACTS in src/app.ts).

Query params:

Name Required Description
address yes Contract address (0x + 40 hex). Must be in ALLOWED_CONTRACTS.
topic0 no Event signature topic. 0x + 64 hex.
topic1 no First indexed argument. 0x + 64 hex.
fromBlock no Default 0.
toBlock no Default latest.

Success response:

{ "events": [ { "address": "...", "topics": [...], "data": "0x...", ... } ] }

Error responses:

  • 400 { "error": "invalid address" } / invalid topic0 / invalid topic1
  • 403 { "error": "contract not allowed" }
  • 502 { "error": "etherscan error" } / etherscan unreachable (upstream message is logged server-side, never returned)
  • 503 { "error": "etherscan key not configured" }

Blockscout proxy

Passthrough proxy for Celo's Blockscout V2 API, injecting the API key on the server side so the mobile app never sees it. Responses are returned exactly as Blockscout returns them.

Endpoint Cache TTL
GET /api/v2/transactions/:hash 30 s
GET /api/v2/addresses/:address/transactions 30 s
GET /api/v2/addresses/:address/token-transfers 300 s

Query params are strictly whitelisted per route. Unknown keys (including the reserved apikey / api_key) return 400 { "error": "unknown param" }. Cache keys are normalised (sorted, reserved params dropped, capped at 512 chars) so callers cannot blow up the Redis keyspace.

Route Allowed query params
GET /api/v2/transactions/:hash (none)
GET /api/v2/addresses/:address/transactions filter, block_number, index, items_count
GET /api/v2/addresses/:address/token-transfers filter, type, token, block_number, index, items_count

Adding a new param means a deliberate edit to ALLOWED_*_PARAMS in src/routes/blockscout.ts, not silent passthrough.

Host allowlist: BLOCKSCOUT_BASE_URL must use https:// AND its hostname must appear in the static allowlist in src/server.ts (default: celo.blockscout.com). Operators can add hosts via the comma-separated BLOCKSCOUT_ALLOWED_HOSTS env. A misconfigured deploy now fails at boot with a clear error rather than turning the proxy into a generic SSRF gateway.

Validation: :hash must match 0x + 64 hex; :address must match 0x + 40 hex. Otherwise 400 { "error": "invalid ..." }. Upstream failures return 502 { "error": "blockscout upstream unavailable" }.

GET /api/swap/quote

Drop-in replacement for Valora's getSwapQuote cloud function. Backend POSTs to Squid Router v2 with TuCop's x-integrator-id so swap volume attribution flows to TuCop. The response shape matches the wallet's FetchQuoteResponse (src/swap/types.ts in TuCopWallet) so the mobile-side change is a single URL flip.

Query params (strict allowlist; any other key returns 400 { "error": "unknown param" }):

Name Required Validation Notes
buyToken yes 0x + 40 lowercase hex destination token
buyIsNative yes 'true' or 'false' substitutes the EVM native sentinel upstream
buyNetworkId yes matches /^[a-z0-9-]+$/ e.g. celo-mainnet, ethereum-mainnet, arbitrum-one, op-mainnet, polygon-pos-mainnet, base-mainnet
sellToken yes 0x + 40 lowercase hex source token
sellIsNative yes 'true' or 'false'
sellNetworkId yes same set as buyNetworkId
sellAmount yes decimal integer (smallest unit / wei)
userAddress yes 0x + 40 lowercase hex EOA used for fromAddress and toAddress upstream
slippagePercentage no decimal in [0, 100] defaults to 0.5
quoteOnly no 'true' or 'false' defaults to 'false'. Set to 'true' for planning quotes (multi-step dollarsSpend flows that fan out 3-5 parallel quotes for the same user); Squid skips the transactionRequest build, and per their team this path does NOT charge the wallet-based 10 RPS bucket. Refetch with quoteOnly=false (or omit it) on commit once the user picks a route; that single call IS the one that counts against the bucket.

Success response (shape):

{
  "unvalidatedSwapTransaction": {
    "swapType": "same-chain",
    "chainId": 42220,
    "buyAmount": "998000",
    "sellAmount": "1000000",
    "buyTokenAddress": "0x...",
    "sellTokenAddress": "0x...",
    "price": "0.998",
    "guaranteedPrice": "0.993",
    "estimatedPriceImpact": "0.2",
    "gas": "300000",
    "estimatedGasUse": "200000",
    "to": "0x...",
    "value": "0",
    "data": "0x...",
    "from": "0x...",
    "allowanceTarget": "0x...",
    "appFeePercentageIncludedInPrice": "0.5"
  },
  "details": { "swapProvider": "squid" }
}

When sellNetworkId !== buyNetworkId, the unvalidatedSwapTransaction object additionally has swapType: "cross-chain" plus estimatedDuration (seconds), maxCrossChainFee and estimatedCrossChainFee (wei strings, sum of upstream feeCosts).

The optional appFeePercentageIncludedInPrice field (added 2026-08-05) is a decimal string like "0.5" representing the percentage the upstream aggregator already deducted from buyAmount. Field is present only when the integrator-fee feature is active; consumers should render it as a fee line but MUST NOT subtract it from buyAmount a second time (the amount is already net).

Error responses:

  • 400 { "error": "invalid <field>" } / { "error": "unknown param" } / { "error": "unsupported sellNetworkId" } / { "error": "unsupported buyNetworkId" }
  • 429 { "error": "rate limited by squid, retry" } (pass-through when Squid throttles us; the upstream Retry-After header is forwarded). Squid throttles per-wallet at 10 RPS, so the safe pattern for parallel planning quotes is quoteOnly=true on the planner and quoteOnly=false only on commit.
  • 502 { "error": "squid upstream unavailable" } (timeout or non-429 non-2xx from Squid; the upstream message is never echoed)
  • 503 { "error": "squid integrator id not configured" } if SQUID_INTEGRATOR_ID is not set on the backend

Cached in Redis for 30 s (quotes go stale fast). Cache key includes userAddress so we never serve another user's prepared transaction.

POST /api/wri/delegate-relay

One-time, sponsored EIP-7702 delegation setup for TuCop's Wallet Relay Infrastructure (WRI). Most TuCop users hold only stables (USDT, USDC, USDm) and no CELO; this endpoint pays the gas for the single type 0x04 transaction that delegates a user's EOA to TuCop's hardened BatchExecutor at 0xaE6a87E88b55644Eda54C3AA55B11944eE5E1DFe. After delegation, every Dolares to Pesos conversion is a normal CIP-64 (type 0x7b) transaction paying gas in stables; CIP-64 and 0x04 are mutually exclusive at the Celo protocol level, hence this dedicated setup tx.

Request body (application/json):

{
  "userAddress": "0x...",
  "signedAuthorization": {
    "chainId": "0xa4ec",
    "address": "0xaE6a87E88b55644Eda54C3AA55B11944eE5E1DFe",
    "nonce": "0x...",
    "yParity": "0x0",
    "r": "0x...",
    "s": "0x..."
  }
}

signedAuthorization is the JSON shape viem's walletClient.signAuthorization(...) emits.

Security invariants (any failure -> 400, no tx submitted):

  • userAddress must match 0x + 40 hex.
  • signedAuthorization.chainId must be 42220 (Celo mainnet only).
  • signedAuthorization.address must equal 0xaE6a87E88b55644Eda54C3AA55B11944eE5E1DFe (case-insensitive). The relay refuses to delegate to any other contract, period. Hardcoded.
  • signedAuthorization.nonce must equal the on-chain nonce of userAddress exactly. A stale or future nonce is rejected up front; the already_delegated short-circuit + post-mining getCode poll handle the propagation-lag case.
  • The signature must recover to userAddress via recoverAuthorizationAddress.

Operational invariants:

  • If the user's EOA code already starts with 0xef0100 followed by the BatchExecutor address, the endpoint short-circuits with { "status": "already_delegated" } and submits no tx.
  • Server-side rate limiting is enforced across three tiers (per-IP, global, per-address). Callers should honor Retry-After headers returned on 429.
  • Relay hot-wallet health check gates the endpoint; when the relay balance falls below the configured minimum the endpoint returns 503.

Success response (delegation submitted and confirmed):

{
  "status": "delegated",
  "txHash": "0x...",
  "userAddress": "0x...",
  "delegatedTo": "0xaE6a87E88b55644Eda54C3AA55B11944eE5E1DFe"
}

Error responses:

  • 400 { "error": "invalid userAddress" } / invalid signedAuthorization / invalid chainId / invalid delegation target / invalid signature / nonce mismatch
  • 429 { "error": "ip rate limited" } (per-IP tier) / relay globally rate limited (global token bucket) / address rate limited (per-address tier) - each with Retry-After header
  • 502 { "error": "rpc unavailable" } / relay tx submission failed / relay tx reverted / relay tx unconfirmed / relay tx unverified
  • 503 { "error": "relay temporarily unavailable" } (relay private key missing/invalid or balance below threshold) / rate limiter unavailable (global tier requires Redis and Redis is down)

Out of scope: this endpoint ONLY handles the one-time delegation setup. The actual execute(calls) payload that uses the delegated EOA must be sent by the wallet as a regular CIP-64 transaction; the backend does not relay batch payloads.

POST /api/wri/fee-adapter-bootstrap

One-shot, sponsored approve(adapter, MAX_UINT256) on each adapter-only stable the user holds. Solves the WRI Track C chicken-and-egg case: a user whose only dollar balance is in USDC or USDT (both adapter-only fee currencies on Celo) cannot pay gas on any tx because the adapter contract needs allowance, allowance setting needs an approve() tx, and approve() needs gas. Without a sponsored bootstrap such a user is stuck. The wallet UI groups USDC + USDT + USDm as "Dolares" so users do not see the distinction; reactively calling this endpoint on first action unblocks them transparently.

Precondition: the user EOA must already be delegated to TuCop's BatchExecutor (via /api/wri/delegate-relay). The endpoint sends a tx to the user EOA calling BatchExecutor.execute([(token, 0, approve(adapter, MAX_UINT256))]), so msg.sender inside approve() is the user EOA, not the relay. The relay only pays gas. Calls without a prior delegation return 412.

Request body (application/json):

{ "address": "0x..." }

Idempotency: for each token, if allowance(user, adapter) >= 2**200 the endpoint short-circuits with status: "already_approved" and submits no tx. Re-calling after a successful bootstrap is therefore a free read-path no-op.

Success response (200):

{
  "ok": true,
  "relayAddress": "0x...",
  "results": [
    {
      "tokenSymbol": "USDC",
      "tokenAddress": "0xceba9300f2b948710d2653dd7b07f33a8b32118c",
      "adapterAddress": "0x...",
      "status": "approved",
      "txHash": "0x...",
      "alreadyApproved": false
    },
    {
      "tokenSymbol": "USDT",
      "tokenAddress": "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e",
      "adapterAddress": "0x...",
      "status": "skipped_no_balance",
      "txHash": null,
      "alreadyApproved": false
    }
  ]
}

Per-token status values: approved, already_approved, skipped_no_balance, skipped_no_adapter, relay_failed. The endpoint never partial-fails the whole response - a tx failure on one token is reported in that token's row and other tokens still get processed.

Error responses:

  • 400 { "error": "invalid address" }
  • 412 { "error": "precondition failed: user not delegated to BatchExecutor" }
  • 500 { "error": "internal" }
  • 503 { "error": "fee bootstrap disabled" } (kill switch) / relay temporarily unavailable (relay unavailable) / no adapter tokens configured

Transaction feed (WRI Track C)

Backend-owned replacement for Valora's getWalletTransactions. Indexes Celo blocks for opted-in addresses and classifies into the same TokenTransaction shape the wallet already consumes, with an extension for EIP-7702 atomic batches (which Valora omits).

Registers an address for indexing on first POST /watch and returns 503 when the feature is not enabled on the deployment.

Historical backfill: the first POST /watch triggers an asynchronous historical scan (resumable across restarts). Progress is checkpointed per chunk so a redeploy mid-scan resumes where it left off.

Response shape (TokenTransaction) - important post-2026-07-06:

  • TokenAmount.value is a decimalised human-readable string (e.g. "3500.000000000000000000"), NOT raw wei. The wallet consumes it via new BigNumber(value) without dividing by 10^decimals.
  • TokenAmount.decimals is the number of decimals used to scale value. null when the token is outside the canonical CIP-64 / Mento registry - in that case value is the raw wei fallback so no precision is silently lost.
  • TokenAmount.timestamp mirrors the parent tx's top-level timestamp (ms). Populated for every amount to match Valora's shape.
  • TokenAmount.localAmount is always populated - either the peg-matched conversion or explicit null when the token has no peg or the requested localCurrencyCode does not match the peg.
  • TokenTransaction.status is "Complete" for successful txs and "Failed" for reverted txs. Reverted txs surface with status: "Failed" so the wallet timeline can show attempted actions.
  • Swap classifier: "swap intent" (Valora-compatible) convention. inAmount / outAmount / fromTokenAmounts[] include only the swap-leg movements (user -> counterparty and counterparty -> user in DIFFERENT tokens). Fee sink outbounds, mirror mint+burn refunds, and aggregator fees are excluded from these fields and appear only in fees[]. Aligns byte-exact with the numbers users have seen in Valora historically. See filterToSwapLegs in src/transactions-indexer/classifier.ts for the exact rule.
  • Multi-hop aggregator detection (KNOWN_AGGREGATOR_TARGETS). classifyAggregatorSwap originally required the outbound and inbound Transfer legs to share a common counterparty (Uniswap-style single pool). Squid Router's fundAndRunMulticall routes through DIFFERENT Mento pools for the outbound and inbound legs, so the counterparty sets do not intersect. The classifier now recognises tx.to against a small registry of known aggregator entry points (currently Squid Router 0xce16f69375520ab01377ce7b88f5ba8c48f8d666) and skips the intersection check for those, treating the tx as a swap. Unknown routers still require intersection to avoid false-positives on random contracts that happen to move tokens both ways.
  • APPROVAL rule accepts approve and increaseAllowance. classifyApprove matches both approve(address,uint256) (0x095ea7b3) and OpenZeppelin's non-standard increaseAllowance(address,uint256) (0x39509351); both have the same calldata layout (spender at word 0) and emit only an Approval event with no Transfer log. CELO ERC20 users hit increaseAllowance when raising an existing allowance without resetting it. decreaseAllowance is intentionally NOT matched: emitting it as APPROVAL would misrepresent a revocation as a fresh grant.
  • fees[].amount respects tx.feeCurrency (CIP-64). For Mento native fee currencies (USDm / COPm) the fee row is emitted with the token address directly. For adapter-only fee currencies (USDC / USDT via their Mento adapter contracts) the fee row is surfaced as the underlying token with underlying decimals (6 for USDC / USDT), downshifted from the adapter's 18-decimal-normalised units so BigNumber(value) yields the user-visible amount. Native-CELO-fee txs fall back to the CELO ERC20 contract id with 18 decimals.

POST /api/transactions/watch

Registers an address for indexing and triggers a one-shot historical backfill in the background. Called by the wallet at boot after walletAddressInitialized. Idempotent, safe to retry, the wallet should not block on its result.

{ "address": "0x...", "walletCreatedAt": "2026-01-15T10:30:00.000Z" }

walletCreatedAt is optional; when the wallet supplies it, the backfill window's fromBlock is extended to cover activity going back to that timestamp instead of the default TX_INDEXER_BACKFILL_BLOCKS tail. Rules:

  • ISO 8601 timestamp (UTC). Any Date.parse-compatible string works but .toISOString() is the recommended shape.
  • Rejected if in the future, unparseable, or earlier than 2020-04-01T00:00:00Z (Celo mainnet floor).
  • Never truncates the default window - it only extends it further into the past if the derived fromBlock is older than the default.
  • Hard cap: never scans more than 5 000 000 blocks even if the timestamp implies deeper. This protects the RPC budget for other watched wallets from an accidentally-huge backfill.
  • The formula segments across the Celo L2 migration (block 31 056 500 at 2025-03-26T00:00:00Z): ~1 s/block post-L2, ~5 s/block pre-L2.
  • Adds a 300 000-block safety buffer (~4 days at 1 s/block post-L2) to the derived from-block. Covers wallets that /watch weeks or months after their real creation, and absorbs the ~5% slippage between the flat 1 s/block estimate and real Celo L2 block time (~1.04 s/block over sustained windows). Still bounded by the 5M hard cap.

When walletCreatedAt is omitted, the backfill uses the existing TX_INDEXER_BACKFILL_BLOCKS default (10 000 blocks ~ 2.8 hours on Celo L2). Passing it is safe on repeat /watch calls.

Legacy re-open path. When a repeat /watch call carries walletCreatedAt on an already-completed row and the derived fromBlock is DEEPER than what was originally scanned, the backfill window re-opens one-shot to scan [new fromBlock, previous fromBlock - 1] (or [new fromBlock, previous end_block] on rows backfilled before 2026-07-07, which do not record their original floor). The new floor is recorded in backfill_initial_from_block so subsequent /watch calls with the same or shallower walletCreatedAt no-op. persistTx is upsert-idempotent so any legacy-path overlap is harmless. Introduced when the WRI feed rollout at 100% (2026-07-06) surfaced coverage gaps on wallets that had been watched with the pre-walletCreatedAt default depth.

Response 200:

{
  "ok": true,
  "backfillStartedAt": "2026-06-29T20:00:00.000Z",
  "backfillCompleted": false
}

backfillStartedAt is the timestamp of the first INSERT for this address (set once, preserved across re-watch calls). backfillCompleted flips to true after the background job finishes writing historical rows. The HTTP response is sent immediately - the wallet should not await backfillCompleted becoming true, just poll /feed and rows will appear as the backfill makes progress.

The backfill scans the derived block window for ERC20 Transfer events touching the address as from or to, fetches tx + receipt + block timestamp via JSON-RPC, and writes through the same persistence layer the live worker uses. Native CELO sends are not discovered by this method (acceptable for an MVP since real users pay gas in stables); the live worker still catches them going forward.

Errors: 400 invalid address, 400 invalid walletCreatedAt, 503 database not configured, 500 database error.

GET /api/transactions/indexer/health

Operator probe for the indexer worker. Reports the last indexed block, current Celo tip, lag (tip minus last), and how many addresses are being watched.

{
  "networkId": "celo-mainnet",
  "lastIndexedBlock": 70513283,
  "celoTipBlock": 70513290,
  "lagBlocks": 7,
  "watchedAddressCount": 42
}

celoTipBlock and lagBlocks are null when the RPC tip probe fails so the route stays responsive during upstream blips. lastIndexedBlock is null before the worker has written its first cursor.

Errors: 503 database not configured, 500 internal.

GET /api/transactions/feed

Byte-compatible replacement for Valora. Same response envelope ({ transactions, pageInfo: { hasNextPage, endCursor } }) and same TokenTransaction discriminated union (SENT / RECEIVED / SWAP_TRANSACTION / APPROVAL / DEPOSIT / WITHDRAW / CLAIM_REWARD).

Query params:

Name Required Notes
address yes 0x + 40 hex (case-insensitive)
networkIds no csv, defaults to celo-mainnet
includeTypes no csv of TokenTransaction types, filter applied post-classification
localCurrencyCode no ISO 4217 alpha-3 (e.g. USD, COP). Default USD. Populates localAmount on each TokenAmount whose token has a hard fiat peg matching this code (Mento stables + USDC/USDT). Unknown / volatile tokens (CELO) are left without localAmount and the wallet keeps its own renderer
afterCursor no opaque cursor returned by a previous page
pageSize no 1 to 100, default 20

7702 atomic-batch extension: when one tx atomically sells more than one token, the wallet receives a single SwapTransaction whose fromTokenAmounts[] lists every sold token; outAmount is the highest-value leg so existing single-leg renderers keep working unchanged. inAmount is the bought token. The selector keyed off is 0x3f707e6b (execute((address,uint256,bytes)[]) on the BatchExecutor at 0xaE6a87E88b55644Eda54C3AA55B11944eE5E1DFe).

Token IDs: ERC20s are emitted as celo-mainnet:0x<contract>. CELO native is emitted as its ERC20 contract id celo-mainnet:0x471ece3750da237f93b8e339c536989b8978a438, not a :native sentinel, so the wallet's token registry resolves it the same way as any other ERC20.

Earn extension (DEPOSIT / WITHDRAW / CLAIM_REWARD): when a tx emits an event from a configured Earn contract (currently Neeru Vaults, via NEERU_CONTRACT_ADDRESS + NEERU_EVENT_A/B/C_TOPIC0), the classifier emits an EarnTransaction instead of folding the deposit token movement into a bogus swap. The wire-shape aligns with the Valora feed the wallet already renders in production (DepositOrWithdrawFeedItem.tsx / DepositOrWithdrawContent.tsx):

{
  "type": "DEPOSIT",
  "appName": "Neeru Vaults",
  "outAmount": { "tokenId": "celo-mainnet:0x8a56...41ea", "value": "100.000000000000000000", "decimals": 18, "localAmount": { ... } },
  "inAmount":  { "tokenId": "celo-mainnet:0x8a56...41ea", "value": "100.000000000000000000", "decimals": 18, "localAmount": { ... } },
  "appId": "neeru-vaults",
  "positionId": "42",
  "amount": { ... same TokenAmount ... },
  ... base fields (status, fees, timestamp, block, transactionHash, ...)
}

Wire-shape notes:

  • appName is the human-readable protocol label the wallet renderer reads directly; missing it falls back to an i18n placeholder ("Depósito" without a protocol name).
  • inAmount and outAmount are BOTH populated with the same TokenAmount reference (single decimalise, cheap dup). The DEPOSIT renderer reads outAmount; the WITHDRAW / CLAIM_REWARD renderer reads inAmount. Duplicating both guards against any renderer branch hitting undefined and yielding NaN in the display.
  • appId, positionId, and amount are TuCop extensions kept as extras. The current Valora renderer ignores unknown fields; the wallet team plans to deep-link to positionId in a future release.
  • Kind A = new position -> DEPOSIT; Kind B = early withdrawal -> WITHDRAW; Kind C = matured claim -> CLAIM_REWARD. The Earn rule runs BEFORE the swap rules so a Neeru deposit tx (which moves COPm user -> contract) is never misclassified as a swap.

Errors: 400 invalid address / invalid afterCursor, 503 database not configured, 500 database error.

Neeru indexer

Postgres-backed indexer for a partner integration on Celo. Stores per-position state used by the Earn endpoints (forthcoming PRs).

Env to enable on Railway:

  • NEERU_INDEXER_ENABLED=true to start the worker. No-op without it.
  • NEERU_INDEXER_INTERVAL_MS optional, defaults to 30000.
  • Reuses DATABASE_URL.

Tables created by the migration: neeru_positions, neeru_indexer_state.

The indexer uses a shared Celo RPC fallback chain with per-endpoint skip windows on transient failures. Endpoint list and skip semantics are internal implementation detail.

Runs a daily reconciliation job at 03:00 UTC.

Hooks API

Drop-in replacement for Valora's hooks-api. Surfaces the catalogue of Earn products the wallet renders in the Earn tab. Two apps are wired today: the Allbridge native port (LP positions + reward claims) and the Neeru Vaults partner integration (4 categories keyed off the indexer above). The contract address and per-category metadata are read from env + on-chain at request time; no Neeru-specific constants are baked into source.

Each endpoint returns { "data": [...] } with a discriminated union of app-token / contract-position entries. Per-category metadata (TVL, per-category rate, per-category secs, deposit-token decimals/symbol) is fetched via one Multicall3 call and cached in-process for 30 s; per-user balances are read from neeru_positions (Postgres) plus a per-batch Multicall3 read. Allbridge calls are wrapped in try/catch and never fail the whole response - if upstream times out the wallet still sees the Neeru side.

GET /hooks-api/getPositions

Returns positions the user already holds (Allbridge LPs with non-zero balance + Neeru categories with non-zero amount-plus-accrued).

Param Required Notes
address yes 0x + 40 hex (case-insensitive)
networkIds no repeatable; defaults to celo-mainnet

Returns { "data": Position[] }. 400 on invalid address or unsupported networkIds.

GET /hooks-api/getEarnPositions

Returns the full catalogue (4 Neeru categories + Allbridge LPs) regardless of holdings. When address is omitted, every entry has balance: "0".

Param Required Notes
address no 0x + 40 hex; when set, balances are populated
networkIds no repeatable; restricts to listed networks
supportedAppIds no repeatable; restricts to listed app ids (allbridge, neeru-vaults)
supportedPools no repeatable; restricts to specific positionId values

GET /hooks-api/v2/getShortcuts

Returns the merged shortcut catalogue (Allbridge deposit / withdraw / claim-rewards / swap-deposit, Neeru deposit / withdraw / withdraw-amount-only).

Param Required Notes
address no reserved; ignored for now
networkIds no repeatable; restricts the shortcut list

GET /api/earn/neeru/positions

Per-position detail surface for the wallet's "your positions" screen. Returns one entry per OPEN row in neeru_positions, enriched with live on-chain reads from the partner contract via a single batched Multicall3 request. Read responses (per-category metadata, deposit-token decimals, and any global view fields) are cached in-process for 30 s.

Param Required Notes
address yes 0x + 40 lowercase hex

Any other query key returns 400 { "error": "unknown param" } (strict allowlist).

Response shape (placeholder values):

{
  "data": {
    "address": "0x...",
    "positions": [
      {
        "positionId": "<opaque>",
        "category": 1,
        "categoryLabel": "<derived from on-chain per-category metadata>",
        "amount": "<decimal-formatted>",
        "accruedInterest": "<decimal-formatted>",
        "monthlyRatePercentage": "<numeric>",
        "startTs": 1700000000,
        "endTs": 1702592000,
        "depositBlock": "<opaque>",
        "depositTxHash": "0x...",
        "renewedFromPositionId": null,
        "currentPayoutIfClosed": {
          "amount": "<decimal-formatted>",
          "interest": "<decimal-formatted>",
          "penaltyBps": "<numeric>",
          "interestAfterPenalty": "<decimal-formatted>",
          "total": "<decimal-formatted>",
          "isEarly": true
        }
      }
    ],
    "lastSyncedBlock": "<opaque>",
    "lastSyncedAt": "<iso-8601>"
  }
}

Notes:

  • categoryLabel is Flexible for the flexible category; otherwise a <days> dias label derived from the on-chain per-category metadata.
  • monthlyRatePercentage is computed from the per-position frozen-rate field stored on chain at deposit time, not from the live per-category rate, so quotes do not drift after a per-category rate update.
  • currentPayoutIfClosed.isEarly is true only when the position is locked AND now < endTs. The early-claim penalty math runs in wei (bigint floor division) before the value is formatted.
  • renewedFromPositionId is always null; the indexer schema does not track renewal chains.
  • lastSyncedBlock / lastSyncedAt come from neeru_indexer_state so the wallet can warn if the partner indexer is stale.

Error responses:

  • 400 { "error": "invalid address" } if address is missing, not lowercase, or not 40 hex.
  • 400 { "error": "unknown param" } for any query key other than address.
  • 503 { "error": "database not configured" } when DATABASE_URL is unset.
  • 503 { "error": "neeru not configured" } when NEERU_DEPOSIT_TOKEN_ADDRESS is unset.
  • 502 { "error": "detail fetch failed" } on any infra/RPC failure. Underlying message is logged server-side and never echoed.

POST /hooks-api/triggerShortcut

Returns the ordered list of tx calldata the wallet signs and submits to execute one shortcut. The backend performs the preflight reads (allowance, pause flag, caps, ownership) so the wallet does not have to fan out and reason about per-shortcut invariants. No tx is submitted server-side.

Request body (application/json):

{
  "address": "0x...",
  "appId": "neeru-vaults",
  "networkId": "celo-mainnet",
  "shortcutId": "deposit",
  "...": "protocol-specific args"
}

Common fields:

Field Validation
address 0x + 40 hex (case-insensitive)
appId exact match: allbridge or neeru-vaults
networkId exact match: celo-mainnet
shortcutId string; valid set depends on appId

Per-app body shape:

  • appId: "allbridge":
    • shortcutId: "deposit": { positionAddress, tokenAddress, tokenDecimals, tokens: [{ amount }] }
    • shortcutId: "withdraw": { positionAddress, tokenDecimals, tokens: [{ amount }] }
    • shortcutId: "claim-rewards": { positionAddress }
  • appId: "neeru-vaults":
    • shortcutId: "deposit": { categoryId, tokens: [{ tokenId, amount }] }. categoryId is 0..3, amount is a decimal integer in whole units (the backend reads the deposit-token decimals from chain and scales to wei).
    • shortcutId: "withdraw": { positionId }. positionId is a decimal integer string.
    • shortcutId: "withdraw-amount-only": { positionId }.

Success response:

{
  "data": {
    "transactions": [
      { "to": "0x...", "data": "0x...", "value": "0", "networkId": "celo-mainnet" }
    ],
    "dataProps": {}
  }
}

Each transaction is JSON-safe: value is a string ("0" for non-payable calls), data is the encoded calldata, to is lowercase 40-hex. dataProps is reserved for upstream shapes that need to surface extra info (e.g. a future Squid-backed swap-deposit) and is currently always {}.

Error responses:

  • 400 { "error": "invalid <field>" } for body validation failures (invalid address, invalid tokens, invalid positionId, etc.) and unknown appId / unknown shortcut / unsupported networkId.
  • 400 { "error": "<code>" } when the preflight catches a recoverable wallet-side issue. The set of codes is the contract's own validation surface; the wallet handles each code as a user-facing message. Codes are stable but intentionally not enumerated in this public doc to keep the partner contract's behavior model private. The full list is in src/hooks-api/routes.ts (TRIGGER_USER_ERROR_CODES).
  • 502 { "error": "shortcut build failed" } for any other (infra / RPC) failure. The underlying message is logged server-side and never echoed.
  • 503 { "error": "neeru not configured" } when the Neeru env vars are not set.
  • 503 { "error": "database not configured" } when a Neeru withdraw is requested without DATABASE_URL.

Env vars

  • NEERU_DEPOSIT_TOKEN_ADDRESS (required for the Neeru catalogue; 0x + 40 hex). The Neeru side of every endpoint is a no-op when unset: requests still succeed but return only the Allbridge slice.
  • NEERU_CATEGORY_IMAGE_URL_TEMPLATE (optional). Template with {N} placeholder, e.g. https://cdn.tucop.xyz/neeru/category-{N}.png. Empty string when unset.
  • NEERU_MANAGE_URL (optional). Surfaced in displayProps.manageUrl and dataProps.manageUrl. Empty string when unset.
  • NEERU_TERMS_URL (optional). Surfaced in dataProps.termsUrl. Empty string when unset.
  • NEERU_CONTRACT_CREATED_AT_ISO (optional). ISO 8601 string. Surfaced in dataProps.contractCreatedAt. null when unset.

Provisioning the relay hot wallet (one-time, before enabling on Railway)

  1. Generate a throwaway key. Example with foundry:

    cast wallet new
    # Address: 0x...
    # Private key: 0x...
  2. Fund the address with around 10 CELO (this covers thousands of delegation setups). Top up when balance approaches WRI_RELAY_MIN_CELO_BALANCE.

  3. On Railway, set WRI_RELAY_PK to the private key (with the 0x prefix). The backend logs the derived address at startup so you can confirm the right key was loaded.