Full endpoint reference for TuCOPWallet-Backend. See
../README.mdfor the project overview andoperations.mdfor deploy / env vars / ops runbooks.
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" }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.
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.
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.
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" }
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 topic1403{ "error": "contract not allowed" }502{ "error": "etherscan error" }/etherscan unreachable(upstream message is logged server-side, never returned)503{ "error": "etherscan key not configured" }
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" }.
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 upstreamRetry-Afterheader is forwarded). Squid throttles per-wallet at 10 RPS, so the safe pattern for parallel planning quotes isquoteOnly=trueon the planner andquoteOnly=falseonly 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" }ifSQUID_INTEGRATOR_IDis 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.
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):
userAddressmust match0x+ 40 hex.signedAuthorization.chainIdmust be42220(Celo mainnet only).signedAuthorization.addressmust equal0xaE6a87E88b55644Eda54C3AA55B11944eE5E1DFe(case-insensitive). The relay refuses to delegate to any other contract, period. Hardcoded.signedAuthorization.noncemust equal the on-chain nonce ofuserAddressexactly. A stale or future nonce is rejected up front; thealready_delegatedshort-circuit + post-mininggetCodepoll handle the propagation-lag case.- The signature must recover to
userAddressviarecoverAuthorizationAddress.
Operational invariants:
- If the user's EOA code already starts with
0xef0100followed 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-Afterheaders returned on429. - 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 mismatch429{ "error": "ip rate limited" }(per-IP tier) /relay globally rate limited(global token bucket) /address rate limited(per-address tier) - each withRetry-Afterheader502{ "error": "rpc unavailable" }/relay tx submission failed/relay tx reverted/relay tx unconfirmed/relay tx unverified503{ "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.
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
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.valueis a decimalised human-readable string (e.g."3500.000000000000000000"), NOT raw wei. The wallet consumes it vianew BigNumber(value)without dividing by10^decimals.TokenAmount.decimalsis the number of decimals used to scalevalue.nullwhen the token is outside the canonical CIP-64 / Mento registry - in that casevalueis the raw wei fallback so no precision is silently lost.TokenAmount.timestampmirrors the parent tx's top-level timestamp (ms). Populated for every amount to match Valora's shape.TokenAmount.localAmountis always populated - either the peg-matched conversion or explicitnullwhen the token has no peg or the requestedlocalCurrencyCodedoes not match the peg.TokenTransaction.statusis"Complete"for successful txs and"Failed"for reverted txs. Reverted txs surface withstatus: "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 infees[]. Aligns byte-exact with the numbers users have seen in Valora historically. SeefilterToSwapLegsinsrc/transactions-indexer/classifier.tsfor the exact rule. - Multi-hop aggregator detection (
KNOWN_AGGREGATOR_TARGETS).classifyAggregatorSwaporiginally required the outbound and inbound Transfer legs to share a common counterparty (Uniswap-style single pool). Squid Router'sfundAndRunMulticallroutes through DIFFERENT Mento pools for the outbound and inbound legs, so the counterparty sets do not intersect. The classifier now recognisestx.toagainst a small registry of known aggregator entry points (currently Squid Router0xce16f69375520ab01377ce7b88f5ba8c48f8d666) 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
approveandincreaseAllowance.classifyApprovematches bothapprove(address,uint256)(0x095ea7b3) and OpenZeppelin's non-standardincreaseAllowance(address,uint256)(0x39509351); both have the same calldata layout (spender at word 0) and emit only anApprovalevent with noTransferlog. CELO ERC20 users hitincreaseAllowancewhen raising an existing allowance without resetting it.decreaseAllowanceis intentionally NOT matched: emitting it asAPPROVALwould misrepresent a revocation as a fresh grant. fees[].amountrespectstx.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 soBigNumber(value)yields the user-visible amount. Native-CELO-fee txs fall back to the CELO ERC20 contract id with 18 decimals.
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
fromBlockis 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.
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.
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:
appNameis the human-readable protocol label the wallet renderer reads directly; missing it falls back to an i18n placeholder ("Depósito" without a protocol name).inAmountandoutAmountare BOTH populated with the sameTokenAmountreference (single decimalise, cheap dup). The DEPOSIT renderer readsoutAmount; the WITHDRAW / CLAIM_REWARD renderer readsinAmount. Duplicating both guards against any renderer branch hittingundefinedand yieldingNaNin the display.appId,positionId, andamountare TuCop extensions kept as extras. The current Valora renderer ignores unknown fields; the wallet team plans to deep-link topositionIdin 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.
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=trueto start the worker. No-op without it.NEERU_INDEXER_INTERVAL_MSoptional, defaults to30000.- 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.
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.
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.
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 |
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 |
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:
categoryLabelisFlexiblefor the flexible category; otherwise a<days> diaslabel derived from the on-chain per-category metadata.monthlyRatePercentageis 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.isEarlyistrueonly when the position is locked ANDnow < endTs. The early-claim penalty math runs in wei (bigint floor division) before the value is formatted.renewedFromPositionIdis alwaysnull; the indexer schema does not track renewal chains.lastSyncedBlock/lastSyncedAtcome fromneeru_indexer_stateso the wallet can warn if the partner indexer is stale.
Error responses:
400{ "error": "invalid address" }ifaddressis missing, not lowercase, or not 40 hex.400{ "error": "unknown param" }for any query key other thanaddress.503{ "error": "database not configured" }whenDATABASE_URLis unset.503{ "error": "neeru not configured" }whenNEERU_DEPOSIT_TOKEN_ADDRESSis unset.502{ "error": "detail fetch failed" }on any infra/RPC failure. Underlying message is logged server-side and never echoed.
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 }] }.categoryIdis0..3,amountis a decimal integer in whole units (the backend reads the deposit-token decimals from chain and scales to wei).shortcutId: "withdraw":{ positionId }.positionIdis 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.) andunknown 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 insrc/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 withoutDATABASE_URL.
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 indisplayProps.manageUrlanddataProps.manageUrl. Empty string when unset.NEERU_TERMS_URL(optional). Surfaced indataProps.termsUrl. Empty string when unset.NEERU_CONTRACT_CREATED_AT_ISO(optional). ISO 8601 string. Surfaced indataProps.contractCreatedAt.nullwhen unset.
-
Generate a throwaway key. Example with foundry:
cast wallet new # Address: 0x... # Private key: 0x...
-
Fund the address with around 10 CELO (this covers thousands of delegation setups). Top up when balance approaches
WRI_RELAY_MIN_CELO_BALANCE. -
On Railway, set
WRI_RELAY_PKto the private key (with the0xprefix). The backend logs the derived address at startup so you can confirm the right key was loaded.