From 76095c28e471b346eecc2149c30669ca0ba76c19 Mon Sep 17 00:00:00 2001 From: Adefokun Adeoluwa Israel Date: Thu, 6 Aug 2026 18:00:17 +0100 Subject: [PATCH] feat(api): add Blend accrual keeper --- .env.example | 8 + api/__tests__/handlers.test.ts | 97 +++- api/v1/keepers/accrue.ts | 42 ++ apps/docs/.vitepress/config.ts | 4 + apps/docs/operations/accrual-keeper.md | 60 +++ apps/docs/operations/environment-variables.md | 19 +- .../src/accrual-keeper.test.ts | 233 +++++++++ .../stellar-sdk-helpers/src/accrual-keeper.ts | 474 ++++++++++++++++++ packages/stellar-sdk-helpers/src/index.ts | 1 + vercel.json | 1 + 10 files changed, 931 insertions(+), 8 deletions(-) create mode 100644 api/v1/keepers/accrue.ts create mode 100644 apps/docs/operations/accrual-keeper.md create mode 100644 packages/stellar-sdk-helpers/src/accrual-keeper.test.ts create mode 100644 packages/stellar-sdk-helpers/src/accrual-keeper.ts diff --git a/.env.example b/.env.example index 23def223..296c1da5 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,14 @@ UPSTASH_REDIS_REST_TOKEN= # Fastify API server uses the Redis protocol (ioredis). Format: rediss://default:TOKEN@HOST:PORT REDIS_URL= +# Scheduled Blend accrue keeper. Store real values in Vercel/project secrets, +# not in source control. Vercel Cron calls include Authorization: Bearer CRON_SECRET. +CRON_SECRET= +MERIDIAN_KEEPER_SECRET_KEY= +MERIDIAN_KEEPER_MAX_ATTEMPTS=3 +MERIDIAN_KEEPER_RETRY_BASE_DELAY_MS=1000 +MERIDIAN_KEEPER_RPC_TIMEOUT_MS=12000 + # Web VITE_API_URL=http://localhost:3001 # Optional override for Blend's testnet faucet endpoint. Leave empty to use the default. diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index 847da3ad..793a1fc9 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -9,6 +9,35 @@ vi.mock("@meridian/stellar-sdk-helpers", () => ({ buildWithdrawTx: vi.fn(async () => ({ xdr: "WITHDRAW_XDR", fee: "100" })), buildAddTrustlineTx: vi.fn(async () => ({ xdr: "TRUST_XDR" })), submitTx: vi.fn(async () => ({ hash: "HASH" })), + loadBlendAccrualKeeperConfig: vi.fn(() => ({ + network: { + network: "testnet", + rpcUrl: "https://rpc.example", + passphrase: "Test SDF Network ; September 2015", + }, + secretKey: "SECRET", + maxAttempts: 3, + baseDelayMs: 1, + rpcTimeoutMs: 100, + })), + runBlendAccrualKeeper: vi.fn(async () => ({ + network: "testnet", + startedAt: "2026-08-06T00:00:00.000Z", + finishedAt: "2026-08-06T00:00:01.000Z", + discoveredAdapters: 1, + blendAdapters: 1, + successes: [ + { + vaultId: "meridian-usdc", + adapterId: "CADAPTER", + hash: "HASH", + ledger: 123, + attempts: 1, + }, + ], + skipped: [], + failures: [], + })), fetchAllVaults: vi.fn(async () => [ { id: "blend-usdc-fixed", protocol: "blend" }, ]), @@ -31,8 +60,10 @@ import trustlineHandler from "../v1/tx/add-trustline"; import submitHandler from "../v1/tx/submit"; import vaultsHandler from "../v1/vaults/index"; import positionsHandler from "../v1/positions/[publicKey]"; +import keeperHandler from "../v1/keepers/accrue"; import { buildDepositTx, + runBlendAccrualKeeper, resolvePositions, } from "@meridian/stellar-sdk-helpers"; @@ -71,7 +102,10 @@ function makeRes(): FakeRes & VercelResponse { return r as unknown as FakeRes & VercelResponse; } -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + process.env.CRON_SECRET = "cron-secret"; +}); describe("POST /api/v1/tx/deposit", () => { it("rejects non-POST methods with 405", async () => { @@ -255,3 +289,64 @@ describe("GET /api/v1/positions/:publicKey", () => { expect(res.body).toEqual({ error: "Failed to read positions" }); }); }); + +describe("GET /api/v1/keepers/accrue", () => { + it("rejects requests without the cron bearer token", async () => { + const res = makeRes(); + await keeperHandler(fakeReq({ method: "GET", headers: {} }), res); + + expect(res.statusCode).toBe(401); + expect(runBlendAccrualKeeper).not.toHaveBeenCalled(); + }); + + it("runs the accrual keeper for authorized cron calls", async () => { + const res = makeRes(); + await keeperHandler( + fakeReq({ + method: "GET", + headers: { authorization: "Bearer cron-secret" }, + }), + res + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ successes: [{ hash: "HASH" }] }); + expect(runBlendAccrualKeeper).toHaveBeenCalledOnce(); + }); + + it("returns 500 when a submission fails so the cron run is observable", async () => { + vi.mocked(runBlendAccrualKeeper).mockResolvedValueOnce({ + network: "testnet", + startedAt: "2026-08-06T00:00:00.000Z", + finishedAt: "2026-08-06T00:00:01.000Z", + discoveredAdapters: 1, + blendAdapters: 1, + successes: [], + skipped: [], + failures: [ + { + vaultId: "meridian-usdc", + adapterId: "CADAPTER", + stage: "submit", + attempts: 3, + transient: true, + error: "try again later", + }, + ], + }); + + const res = makeRes(); + await keeperHandler( + fakeReq({ + method: "GET", + headers: { authorization: "Bearer cron-secret" }, + }), + res + ); + + expect(res.statusCode).toBe(500); + expect(res.body).toMatchObject({ + failures: [{ vaultId: "meridian-usdc", error: "try again later" }], + }); + }); +}); diff --git a/api/v1/keepers/accrue.ts b/api/v1/keepers/accrue.ts new file mode 100644 index 00000000..fce5946d --- /dev/null +++ b/api/v1/keepers/accrue.ts @@ -0,0 +1,42 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { + loadBlendAccrualKeeperConfig, + runBlendAccrualKeeper, +} from "@meridian/stellar-sdk-helpers"; + +function authorizationHeader(req: VercelRequest): string | undefined { + const raw = req.headers.authorization; + return Array.isArray(raw) ? raw[0] : raw; +} + +function isCronAuthorized(req: VercelRequest): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) return process.env.NODE_ENV !== "production"; + return authorizationHeader(req) === `Bearer ${secret}`; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== "GET" && req.method !== "POST") { + res.setHeader("Allow", "GET, POST"); + return res.status(405).json({ error: "Method not allowed" }); + } + + if (!process.env.CRON_SECRET && process.env.NODE_ENV === "production") { + return res.status(503).json({ error: "CRON_SECRET is not configured" }); + } + + if (!isCronAuthorized(req)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + try { + const config = loadBlendAccrualKeeperConfig(process.env); + const result = await runBlendAccrualKeeper(config); + const status = result.failures.length > 0 ? 500 : 200; + return res.status(status).json(result); + } catch (err) { + console.error("[accrual-keeper] run failed:", err); + const message = err instanceof Error ? err.message : "Keeper failed"; + return res.status(500).json({ error: message }); + } +} diff --git a/apps/docs/.vitepress/config.ts b/apps/docs/.vitepress/config.ts index bddee1c9..9cd8f0d6 100644 --- a/apps/docs/.vitepress/config.ts +++ b/apps/docs/.vitepress/config.ts @@ -48,6 +48,10 @@ export default defineConfig({ text: "Environment Variables", link: "/operations/environment-variables", }, + { + text: "Blend Accrual Keeper", + link: "/operations/accrual-keeper", + }, ], }, ], diff --git a/apps/docs/operations/accrual-keeper.md b/apps/docs/operations/accrual-keeper.md new file mode 100644 index 00000000..b3c7906b --- /dev/null +++ b/apps/docs/operations/accrual-keeper.md @@ -0,0 +1,60 @@ +# Blend Accrual Keeper + +Meridian Blend adapters cache `total_assets()`. Deposits and withdrawals update +that cache, but passive Blend interest is only reflected after a real +`accrue()` transaction lands on-chain. Read-only simulations do not persist +state, so the production deployment runs a scheduled keeper. + +## Schedule + +Vercel Cron calls `GET /api/v1/keepers/accrue` every 15 minutes, as configured +in `vercel.json`. + +With successful runs, the expected maximum TVL/APY staleness window for +Blend-backed Meridian vaults is one keeper interval: 15 minutes. Dashboard HTTP +caching may add up to another 60 seconds on mainnet responses. If a keeper run +fails, values can remain stale until the next successful run; failed runs return +a non-2xx status so hosting alerts and cron logs can detect them. + +## Signing Key + +Set `MERIDIAN_KEEPER_SECRET_KEY` in the deployment secret store. It must be the +Stellar secret seed for a funded keeper account that can pay Soroban fees. The +key is read from environment variables injected by the platform; never commit +it to source control. + +The legacy fallback name `KEEPER_SECRET_KEY` is also accepted, but new +deployments should use `MERIDIAN_KEEPER_SECRET_KEY`. + +Set `CRON_SECRET` as a separate secret. Scheduled calls must include +`Authorization: Bearer $CRON_SECRET`; production deployments fail closed when +`CRON_SECRET` is missing. + +## Discovery + +The keeper discovers adapters from live Meridian coordinator vault entries in +`KNOWN_POOLS`: + +1. Call `vault.get_adapter()`. +2. Call `adapter.get_protocol()`. +3. Submit `adapter.accrue()` only when the protocol is exactly `blend`. + +DeFindex-backed adapters are skipped because their `total_assets()` value is +computed live and does not require a separate accrue transaction. + +## Retry And Failure Handling + +Each Blend accrue submission is built from a freshly loaded source account, then +simulated, assembled, signed, submitted, and confirmed before the keeper moves +to the next adapter. Rebuilding on retry avoids reusing stale sequence numbers. + +Transient submission failures retry with exponential backoff. Configure: + +- `MERIDIAN_KEEPER_MAX_ATTEMPTS` default `3` +- `MERIDIAN_KEEPER_RETRY_BASE_DELAY_MS` default `1000` +- `MERIDIAN_KEEPER_RPC_TIMEOUT_MS` default `12000` + +Failures are logged with the vault id, adapter id, protocol, stage, attempt +count, and error summary. Any discovery or submission failure is also included +in the endpoint response. If at least one failure occurs, the endpoint returns +HTTP 500 so the scheduled run is observable instead of silently passing. diff --git a/apps/docs/operations/environment-variables.md b/apps/docs/operations/environment-variables.md index 256878b8..96e96f33 100644 --- a/apps/docs/operations/environment-variables.md +++ b/apps/docs/operations/environment-variables.md @@ -8,13 +8,18 @@ ## API: serverless (`api/v1/`) and Fastify (`apps/api-local`) -| Variable | Required | Default | Description | -| ------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STELLAR_NETWORK` | No | `"testnet"` | Selects the network the API talks to. Any value other than `"mainnet"` resolves to testnet. Controls which `CONTRACT_ADDRESSES`/`STELLAR_NETWORKS` entry (`packages/shared/src/constants.ts`) is used for every contract call the API makes. | -| `DEFINDEX_VAULT_ID` | No | `""` | Overrides the DeFindex vault contract address at runtime. When empty, the address from `CONTRACT_ADDRESSES.testnet.defindex.vault` in `packages/shared/src/constants.ts` is used. Blend and vault contract addresses are always sourced from constants. | -| `PORT` | No | `3001` | Fastify server port (local dev only). | -| `ALLOWED_ORIGIN` | No | `"https://usemeridian.vercel.app"` | CORS allowed origin for the Fastify server. Set to your frontend domain in production if running Fastify as a standalone server. | -| `REDIS_URL` | No | `""` | Redis URL for `@fastify/rate-limit` in `apps/api-local` (ioredis). Unset: in-memory store (single process); production: set for distributed rate limits. | +| Variable | Required | Default | Description | +| ------------------------------------- | ------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STELLAR_NETWORK` | No | `"testnet"` | Selects the network the API talks to. Any value other than `"mainnet"` resolves to testnet. Controls which `CONTRACT_ADDRESSES`/`STELLAR_NETWORKS` entry (`packages/shared/src/constants.ts`) is used for every contract call the API makes. | +| `DEFINDEX_VAULT_ID` | No | `""` | Overrides the DeFindex vault contract address at runtime. When empty, the address from `CONTRACT_ADDRESSES.testnet.defindex.vault` in `packages/shared/src/constants.ts` is used. Blend and vault contract addresses are always sourced from constants. | +| `PORT` | No | `3001` | Fastify server port (local dev only). | +| `ALLOWED_ORIGIN` | No | `"https://usemeridian.vercel.app"` | CORS allowed origin for the Fastify server. Set to your frontend domain in production if running Fastify as a standalone server. | +| `REDIS_URL` | No | `""` | Redis URL for `@fastify/rate-limit` in `apps/api-local` (ioredis). Unset: in-memory store (single process); production: set for distributed rate limits. | +| `CRON_SECRET` | Yes | `""` | Bearer token required by scheduled keeper endpoints in production. | +| `MERIDIAN_KEEPER_SECRET_KEY` | Yes (keeper) | `""` | Stellar secret seed for the funded account that submits Blend `accrue()` transactions. Store in a secrets manager or deployment environment variables; never commit it. | +| `MERIDIAN_KEEPER_MAX_ATTEMPTS` | No | `3` | Maximum attempts per Blend adapter accrue submission. | +| `MERIDIAN_KEEPER_RETRY_BASE_DELAY_MS` | No | `1000` | Initial exponential-backoff delay for transient keeper failures. | +| `MERIDIAN_KEEPER_RPC_TIMEOUT_MS` | No | `12000` | Timeout for keeper RPC calls, in milliseconds. | ## Deploy scripts (`scripts/`) diff --git a/packages/stellar-sdk-helpers/src/accrual-keeper.test.ts b/packages/stellar-sdk-helpers/src/accrual-keeper.test.ts new file mode 100644 index 00000000..7fb4085a --- /dev/null +++ b/packages/stellar-sdk-helpers/src/accrual-keeper.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi } from "vitest"; +import { + discoverLiveAdapters, + loadBlendAccrualKeeperConfig, + runBlendAccrualKeeper, + type BlendAccrualKeeperConfig, + type DiscoveredAdapter, + type KeeperLogger, +} from "./accrual-keeper"; +import type { KnownPoolMeta } from "./known-pools"; + +const NETWORK = { + network: "testnet" as const, + rpcUrl: "https://rpc.example", + passphrase: "Test SDF Network ; September 2015", +}; + +const CONFIG: BlendAccrualKeeperConfig = { + network: NETWORK, + secretKey: "S".repeat(56), + maxAttempts: 3, + baseDelayMs: 1, + rpcTimeoutMs: 100, +}; + +const VAULT: KnownPoolMeta = { + id: "meridian-usdc", + name: "Meridian", + protocol: "meridian", + label: "USDC Vault", + contractId: "CVAULT", +}; + +const DIRECT_BLEND: KnownPoolMeta = { + id: "blend-usdc-fixed", + name: "Blend", + protocol: "blend", + label: "Fixed Pool", + contractId: "CBLENDPOOL", +}; + +const BLEND_ADAPTER: DiscoveredAdapter = { + vaultId: "meridian-usdc", + vaultContractId: "CVAULT", + adapterId: "CADAPTERBLEND", + protocol: "blend", +}; + +const DEFINDEX_ADAPTER: DiscoveredAdapter = { + vaultId: "meridian-eurc", + vaultContractId: "CVAULT2", + adapterId: "CADAPTERDFX", + protocol: "defindex", +}; + +function logger(): KeeperLogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("loadBlendAccrualKeeperConfig", () => { + it("requires the signing key from the environment", () => { + expect(() => loadBlendAccrualKeeperConfig({})).toThrow( + "MERIDIAN_KEEPER_SECRET_KEY is required" + ); + }); + + it("loads retry tuning from environment variables", () => { + const config = loadBlendAccrualKeeperConfig({ + MERIDIAN_KEEPER_SECRET_KEY: "SECRET", + MERIDIAN_KEEPER_MAX_ATTEMPTS: "5", + MERIDIAN_KEEPER_RETRY_BASE_DELAY_MS: "250", + MERIDIAN_KEEPER_RPC_TIMEOUT_MS: "9000", + }); + + expect(config.secretKey).toBe("SECRET"); + expect(config.maxAttempts).toBe(5); + expect(config.baseDelayMs).toBe(250); + expect(config.rpcTimeoutMs).toBe(9000); + }); +}); + +describe("discoverLiveAdapters", () => { + it("discovers adapters from Meridian vaults without using direct Blend pool entries", async () => { + const simulate = vi.fn(async (_server, contractId, _passphrase, method) => { + if (contractId === "CVAULT" && method === "get_adapter") + return "CADAPTER"; + if (contractId === "CADAPTER" && method === "get_protocol") + return "blend"; + throw new Error(`unexpected call ${contractId}.${String(method)}`); + }); + + const result = await discoverLiveAdapters({ + network: NETWORK, + server: {} as never, + simulate: simulate as never, + pools: { + "meridian-usdc": VAULT, + "blend-usdc-fixed": DIRECT_BLEND, + }, + }); + + expect(result.failures).toEqual([]); + expect(result.adapters).toEqual([ + { + vaultId: "meridian-usdc", + vaultContractId: "CVAULT", + adapterId: "CADAPTER", + protocol: "blend", + }, + ]); + expect(simulate).toHaveBeenCalledTimes(2); + }); + + it("records discovery failures instead of dropping them", async () => { + const simulate = vi.fn(async () => { + throw new Error("rpc timed out"); + }); + const result = await discoverLiveAdapters({ + network: NETWORK, + server: {} as never, + simulate: simulate as never, + maxAttempts: 2, + baseDelayMs: 1, + sleep: vi.fn(), + pools: { "meridian-usdc": VAULT }, + }); + + expect(simulate).toHaveBeenCalledTimes(2); + expect(result.adapters).toEqual([]); + expect(result.failures).toMatchObject([ + { + vaultId: "meridian-usdc", + vaultContractId: "CVAULT", + stage: "discover", + attempts: 2, + transient: true, + error: "rpc timed out", + }, + ]); + }); +}); + +describe("runBlendAccrualKeeper", () => { + it("submits accrue only for Blend-backed adapters", async () => { + const submitAccrual = vi.fn(async () => ({ hash: "HASH", ledger: 123 })); + const result = await runBlendAccrualKeeper(CONFIG, { + logger: logger(), + sleep: vi.fn(), + discoverAdapters: async () => ({ + adapters: [BLEND_ADAPTER, DEFINDEX_ADAPTER], + failures: [], + }), + submitAccrual, + }); + + expect(submitAccrual).toHaveBeenCalledOnce(); + expect(submitAccrual).toHaveBeenCalledWith(BLEND_ADAPTER, 1); + expect(result.successes).toEqual([ + { + vaultId: "meridian-usdc", + adapterId: "CADAPTERBLEND", + hash: "HASH", + ledger: 123, + attempts: 1, + }, + ]); + expect(result.skipped).toEqual([ + { ...DEFINDEX_ADAPTER, reason: "non-blend" }, + ]); + }); + + it("retries transient submission failures and reports the successful attempt", async () => { + const submitAccrual = vi + .fn() + .mockRejectedValueOnce(new Error("try again later")) + .mockResolvedValueOnce({ hash: "HASH2", ledger: 456 }); + const log = logger(); + + const result = await runBlendAccrualKeeper(CONFIG, { + logger: log, + sleep: vi.fn(), + discoverAdapters: async () => ({ + adapters: [BLEND_ADAPTER], + failures: [], + }), + submitAccrual, + }); + + expect(submitAccrual).toHaveBeenCalledTimes(2); + expect(log.warn).toHaveBeenCalledOnce(); + expect(result.failures).toEqual([]); + expect(result.successes[0]).toMatchObject({ hash: "HASH2", attempts: 2 }); + }); + + it("makes failed submissions observable in the run result and logs context", async () => { + const log = logger(); + const result = await runBlendAccrualKeeper(CONFIG, { + logger: log, + sleep: vi.fn(), + discoverAdapters: async () => ({ + adapters: [BLEND_ADAPTER], + failures: [], + }), + submitAccrual: vi.fn(async () => { + throw new Error("contract trapped"); + }), + }); + + expect(result.successes).toEqual([]); + expect(result.failures).toMatchObject([ + { + vaultId: "meridian-usdc", + adapterId: "CADAPTERBLEND", + protocol: "blend", + stage: "submit", + attempts: 1, + transient: false, + error: "contract trapped", + }, + ]); + expect(log.error).toHaveBeenCalledWith( + "[accrual-keeper] accrue failed", + expect.objectContaining({ + vaultId: "meridian-usdc", + adapterId: "CADAPTERBLEND", + }) + ); + }); +}); diff --git a/packages/stellar-sdk-helpers/src/accrual-keeper.ts b/packages/stellar-sdk-helpers/src/accrual-keeper.ts new file mode 100644 index 00000000..94148f3c --- /dev/null +++ b/packages/stellar-sdk-helpers/src/accrual-keeper.ts @@ -0,0 +1,474 @@ +import { + Account, + Contract, + Keypair, + Transaction, + TransactionBuilder, + rpc, +} from "@stellar/stellar-sdk"; +import { APP_NETWORK, withRaceTimeout } from "@meridian/shared"; +import { KNOWN_POOLS, type KnownPoolMeta } from "./known-pools"; +import { BASE_FEE, getRpcServer } from "./internal"; +import { simulateView, simErrorMessage, waitForTransaction } from "./tx"; +import type { StellarNetwork } from "./types"; + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BASE_DELAY_MS = 1_000; +const DEFAULT_RPC_TIMEOUT_MS = 12_000; + +export interface BlendAccrualKeeperConfig { + network: StellarNetwork; + secretKey: string; + maxAttempts: number; + baseDelayMs: number; + rpcTimeoutMs: number; +} + +export interface DiscoveredAdapter { + vaultId: string; + vaultContractId: string; + adapterId: string; + protocol: string; +} + +export interface AccrualSuccess { + vaultId: string; + adapterId: string; + hash: string; + ledger: number; + attempts: number; +} + +export interface KeeperFailure { + vaultId?: string; + vaultContractId?: string; + adapterId?: string; + protocol?: string; + stage: "discover" | "submit"; + attempts: number; + transient: boolean; + error: string; +} + +export interface SkippedAdapter { + vaultId: string; + vaultContractId: string; + adapterId: string; + protocol: string; + reason: "non-blend"; +} + +export interface BlendAccrualKeeperResult { + network: StellarNetwork["network"]; + startedAt: string; + finishedAt: string; + discoveredAdapters: number; + blendAdapters: number; + successes: AccrualSuccess[]; + skipped: SkippedAdapter[]; + failures: KeeperFailure[]; +} + +export interface KeeperLogger { + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; +} + +interface RetryConfig { + maxAttempts: number; + baseDelayMs: number; +} + +interface KeeperRpcServer { + getAccount(publicKey: string): Promise; + simulateTransaction( + tx: Transaction + ): Promise; + sendTransaction(tx: Transaction): Promise; + getTransaction(hash: string): Promise; +} + +type SimulateFn = typeof simulateView; + +export interface DiscoverAdaptersOptions { + network?: StellarNetwork; + pools?: Record; + server?: KeeperRpcServer; + simulate?: SimulateFn; + maxAttempts?: number; + baseDelayMs?: number; + logger?: KeeperLogger; + sleep?: (ms: number) => Promise; +} + +export interface BlendAccrualKeeperDeps { + discoverAdapters?: () => Promise<{ + adapters: DiscoveredAdapter[]; + failures: KeeperFailure[]; + }>; + submitAccrual?: ( + adapter: DiscoveredAdapter, + attempt: number + ) => Promise>; + logger?: KeeperLogger; + sleep?: (ms: number) => Promise; +} + +const consoleLogger: KeeperLogger = { + info(message, context) { + console.info(message, context ?? {}); + }, + warn(message, context) { + console.warn(message, context ?? {}); + }, + error(message, context) { + console.error(message, context ?? {}); + }, +}; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +class KeeperRetryError extends Error { + readonly attempts: number; + readonly transient: boolean; + + constructor(err: unknown, attempts: number, transient: boolean) { + super(errorMessage(err)); + this.name = "KeeperRetryError"; + this.attempts = attempts; + this.transient = transient; + } +} + +function parsePositiveInt( + value: string | undefined, + fallback: number, + name: string +): number { + if (value === undefined || value.trim() === "") return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +export function loadBlendAccrualKeeperConfig( + env: Record +): BlendAccrualKeeperConfig { + const secretKey = + env.MERIDIAN_KEEPER_SECRET_KEY?.trim() || env.KEEPER_SECRET_KEY?.trim(); + if (!secretKey) { + throw new Error("MERIDIAN_KEEPER_SECRET_KEY is required"); + } + + return { + network: APP_NETWORK, + secretKey, + maxAttempts: parsePositiveInt( + env.MERIDIAN_KEEPER_MAX_ATTEMPTS, + DEFAULT_MAX_ATTEMPTS, + "MERIDIAN_KEEPER_MAX_ATTEMPTS" + ), + baseDelayMs: parsePositiveInt( + env.MERIDIAN_KEEPER_RETRY_BASE_DELAY_MS, + DEFAULT_BASE_DELAY_MS, + "MERIDIAN_KEEPER_RETRY_BASE_DELAY_MS" + ), + rpcTimeoutMs: parsePositiveInt( + env.MERIDIAN_KEEPER_RPC_TIMEOUT_MS, + DEFAULT_RPC_TIMEOUT_MS, + "MERIDIAN_KEEPER_RPC_TIMEOUT_MS" + ), + }; +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) + return err.message.split("\n")[0]?.trim() || err.message; + return String(err); +} + +function describeSendError(res: rpc.Api.SendTransactionResponse): string { + try { + return res.errorResult?.result().switch().name ?? "unknown error"; + } catch { + return "unknown error"; + } +} + +function isTransientKeeperError(err: unknown): boolean { + const message = errorMessage(err).toLowerCase(); + return ( + message.includes("try again") || + message.includes("timeout") || + message.includes("timed out") || + message.includes("rate limit") || + message.includes("429") || + message.includes("500") || + message.includes("502") || + message.includes("503") || + message.includes("504") || + message.includes("temporarily") || + message.includes("not_found") + ); +} + +async function withKeeperRetry( + fn: (attempt: number) => Promise, + config: RetryConfig, + logger: KeeperLogger, + context: Record, + sleepFn: (ms: number) => Promise +): Promise<{ value: T; attempts: number }> { + let lastErr: unknown; + let attempts = 0; + let transient = false; + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + attempts = attempt; + try { + return { value: await fn(attempt), attempts: attempt }; + } catch (err) { + lastErr = err; + transient = isTransientKeeperError(err); + if (!transient || attempt >= config.maxAttempts) break; + const delayMs = config.baseDelayMs * 2 ** (attempt - 1); + logger.warn("[accrual-keeper] transient failure; retrying", { + ...context, + attempt, + nextAttempt: attempt + 1, + delayMs, + error: errorMessage(err), + }); + await sleepFn(delayMs); + } + } + throw new KeeperRetryError(lastErr, attempts, transient); +} + +export async function discoverLiveAdapters( + options: DiscoverAdaptersOptions = {} +): Promise<{ adapters: DiscoveredAdapter[]; failures: KeeperFailure[] }> { + const network = options.network ?? APP_NETWORK; + const networkKey = network.network === "mainnet" ? "mainnet" : "testnet"; + const pools = options.pools ?? KNOWN_POOLS[networkKey]; + const server = + options.server ?? getRpcServer(network.rpcUrl, DEFAULT_RPC_TIMEOUT_MS); + const simulate = options.simulate ?? simulateView; + const logger = options.logger ?? consoleLogger; + const sleepFn = options.sleep ?? sleep; + const retryConfig = { + maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + baseDelayMs: options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS, + }; + const adapters: DiscoveredAdapter[] = []; + const failures: KeeperFailure[] = []; + + for (const meta of Object.values(pools)) { + if (meta.protocol !== "meridian" || !meta.contractId) continue; + const vaultContractId = meta.contractId; + + try { + const result = await withKeeperRetry( + async () => { + const adapterId = (await simulate( + server as never, + vaultContractId, + network.passphrase, + "get_adapter" + )) as string; + const protocol = (await simulate( + server as never, + adapterId, + network.passphrase, + "get_protocol" + )) as string; + return { + vaultId: meta.id, + vaultContractId, + adapterId, + protocol, + }; + }, + retryConfig, + logger, + { + vaultId: meta.id, + vaultContractId, + stage: "discover", + }, + sleepFn + ); + adapters.push(result.value); + } catch (err) { + const attempts = err instanceof KeeperRetryError ? err.attempts : 1; + const transient = + err instanceof KeeperRetryError + ? err.transient + : isTransientKeeperError(err); + failures.push({ + vaultId: meta.id, + vaultContractId, + stage: "discover", + attempts, + transient, + error: errorMessage(err), + }); + } + } + + return { adapters, failures }; +} + +async function submitAccrualTransaction( + adapter: DiscoveredAdapter, + config: BlendAccrualKeeperConfig, + server: KeeperRpcServer +): Promise> { + const keypair = Keypair.fromSecret(config.secretKey); + const source = await withRaceTimeout( + () => server.getAccount(keypair.publicKey()), + config.rpcTimeoutMs, + "Soroban RPC" + ); + const contract = new Contract(adapter.adapterId); + const tx = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase: config.network.passphrase, + }) + .addOperation(contract.call("accrue")) + .setTimeout(300) + .build(); + + const sim = await withRaceTimeout( + () => server.simulateTransaction(tx), + config.rpcTimeoutMs, + "Soroban RPC" + ); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${simErrorMessage(sim.error)}`); + } + if (!rpc.Api.isSimulationSuccess(sim)) { + throw new Error("Simulation did not return a successful result"); + } + + const prepared = rpc.assembleTransaction(tx, sim).build(); + prepared.sign(keypair); + + const sent = await withRaceTimeout( + () => server.sendTransaction(prepared), + config.rpcTimeoutMs, + "Soroban RPC" + ); + if (sent.status === "ERROR") { + throw new Error( + `Transaction rejected at submission: ${describeSendError(sent)}` + ); + } + if (sent.status === "TRY_AGAIN_LATER") { + throw new Error("Transaction could not be submitted yet (try again later)"); + } + + const confirmed = await waitForTransaction(server, sent.hash); + return { hash: sent.hash, ledger: confirmed.ledger }; +} + +export async function runBlendAccrualKeeper( + config: BlendAccrualKeeperConfig, + deps: BlendAccrualKeeperDeps = {} +): Promise { + const logger = deps.logger ?? consoleLogger; + const sleepFn = deps.sleep ?? sleep; + const startedAt = new Date().toISOString(); + const server = getRpcServer(config.network.rpcUrl, config.rpcTimeoutMs); + const discovery = deps.discoverAdapters + ? await deps.discoverAdapters() + : await discoverLiveAdapters({ + network: config.network, + server, + maxAttempts: config.maxAttempts, + baseDelayMs: config.baseDelayMs, + logger, + sleep: sleepFn, + }); + const successes: AccrualSuccess[] = []; + const failures: KeeperFailure[] = [...discovery.failures]; + const skipped: SkippedAdapter[] = []; + const blendAdapters = discovery.adapters.filter((adapter) => { + if (adapter.protocol === "blend") return true; + skipped.push({ ...adapter, reason: "non-blend" }); + return false; + }); + + logger.info("[accrual-keeper] discovered adapters", { + network: config.network.network, + discoveredAdapters: discovery.adapters.length, + blendAdapters: blendAdapters.length, + skippedAdapters: skipped.length, + discoveryFailures: discovery.failures.length, + }); + + for (const adapter of blendAdapters) { + try { + const result = await withKeeperRetry( + (attempt) => + deps.submitAccrual + ? deps.submitAccrual(adapter, attempt) + : submitAccrualTransaction(adapter, config, server), + config, + logger, + { + vaultId: adapter.vaultId, + adapterId: adapter.adapterId, + protocol: adapter.protocol, + }, + sleepFn + ); + successes.push({ + vaultId: adapter.vaultId, + adapterId: adapter.adapterId, + hash: result.value.hash, + ledger: result.value.ledger, + attempts: result.attempts, + }); + logger.info("[accrual-keeper] accrue submitted", { + vaultId: adapter.vaultId, + adapterId: adapter.adapterId, + hash: result.value.hash, + ledger: result.value.ledger, + attempts: result.attempts, + }); + } catch (err) { + const attempts = err instanceof KeeperRetryError ? err.attempts : 1; + const transient = + err instanceof KeeperRetryError + ? err.transient + : isTransientKeeperError(err); + const failure: KeeperFailure = { + vaultId: adapter.vaultId, + vaultContractId: adapter.vaultContractId, + adapterId: adapter.adapterId, + protocol: adapter.protocol, + stage: "submit", + attempts, + transient, + error: errorMessage(err), + }; + failures.push(failure); + logger.error("[accrual-keeper] accrue failed", { ...failure }); + } + } + + return { + network: config.network.network, + startedAt, + finishedAt: new Date().toISOString(), + discoveredAdapters: discovery.adapters.length, + blendAdapters: blendAdapters.length, + successes, + skipped, + failures, + }; +} diff --git a/packages/stellar-sdk-helpers/src/index.ts b/packages/stellar-sdk-helpers/src/index.ts index a04252af..86adbd51 100644 --- a/packages/stellar-sdk-helpers/src/index.ts +++ b/packages/stellar-sdk-helpers/src/index.ts @@ -1,4 +1,5 @@ export * from "./blend"; +export * from "./accrual-keeper"; export * from "./coordinator"; export * from "./defilamma"; export * from "./defindex"; diff --git a/vercel.json b/vercel.json index 9badadd2..d2e3acee 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,7 @@ { "buildCommand": "bash scripts/build-vercel.sh", "outputDirectory": "dist", + "crons": [{ "path": "/api/v1/keepers/accrue", "schedule": "*/15 * * * *" }], "rewrites": [ { "source": "/app/:path*", "destination": "/app/index.html" }, { "source": "/docs/:path*", "destination": "/docs/:path*" }