Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
97 changes: 96 additions & 1 deletion api/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]),
Expand All @@ -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";

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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" }],
});
});
});
42 changes: 42 additions & 0 deletions api/v1/keepers/accrue.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
4 changes: 4 additions & 0 deletions apps/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export default defineConfig({
text: "Environment Variables",
link: "/operations/environment-variables",
},
{
text: "Blend Accrual Keeper",
link: "/operations/accrual-keeper",
},
],
},
],
Expand Down
60 changes: 60 additions & 0 deletions apps/docs/operations/accrual-keeper.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 12 additions & 7 deletions apps/docs/operations/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand Down
Loading
Loading