From 024e6f89faa20e0a191c218b5e6111151f130fb9 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Tue, 1 Sep 2026 11:44:44 +0100 Subject: [PATCH 01/10] fix(build): shamefully-hoist deps for Vercel's function typecheck --- .npmrc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.npmrc b/.npmrc index a4792e47..9f3c0eee 100644 --- a/.npmrc +++ b/.npmrc @@ -7,3 +7,17 @@ # Node's normal upward directory walk regardless of whether that specific # nested link is correctly wired. public-hoist-pattern[]=tslib + +# Vercel's Serverless Functions builder (@vercel/node) runs its own +# per-file TypeScript type-check against api/**/*.ts, separate from +# `pnpm typecheck`/`pnpm typecheck:api` (which always pass locally, even +# from a clean install). That check has been failing in production to +# resolve exports of @meridian/shared, @meridian/api-core, and +# @meridian/stellar-sdk-helpers that genuinely exist in source, on +# deploys confirmed to be cache-free. pnpm's default strict, symlinked +# node_modules layout is a known source of resolution issues for tools +# that aren't written with it in mind; shamefully-hoist flattens +# dependencies into root node_modules (in addition to, not instead of, +# the normal symlink tree), which is the standard first fix for that +# class of problem. +shamefully-hoist=true From b068e50497b7f97bd1a53dbb270f86b542807623 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Tue, 1 Sep 2026 16:44:11 +0100 Subject: [PATCH 02/10] fix(api): consolidate serverless functions under Vercel's Hobby cap --- api/__tests__/handlers.test.ts | 200 ++++++++++++++++++++++++++------- api/v1/admin/[resource].ts | 44 ++++++++ api/v1/admin/history.ts | 25 ----- api/v1/admin/vault-state.ts | 19 ---- api/v1/keepers/[action].ts | 131 +++++++++++++++++++++ api/v1/keepers/accrue.ts | 80 ------------- api/v1/keepers/health.ts | 19 ---- api/v1/keepers/rebalance.ts | 90 --------------- api/v1/tx/[action].ts | 56 +++++++++ api/v1/tx/add-trustline.ts | 23 ---- api/v1/tx/deposit.ts | 28 ----- api/v1/tx/submit.ts | 23 ---- api/v1/tx/withdraw.ts | 28 ----- vercel.json | 3 +- 14 files changed, 389 insertions(+), 380 deletions(-) create mode 100644 api/v1/admin/[resource].ts delete mode 100644 api/v1/admin/history.ts delete mode 100644 api/v1/admin/vault-state.ts create mode 100644 api/v1/keepers/[action].ts delete mode 100644 api/v1/keepers/accrue.ts delete mode 100644 api/v1/keepers/health.ts delete mode 100644 api/v1/keepers/rebalance.ts create mode 100644 api/v1/tx/[action].ts delete mode 100644 api/v1/tx/add-trustline.ts delete mode 100644 api/v1/tx/deposit.ts delete mode 100644 api/v1/tx/submit.ts delete mode 100644 api/v1/tx/withdraw.ts diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index 367f50ef..c46fd811 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -131,16 +131,11 @@ vi.mock("@meridian/stellar-sdk-helpers", () => ({ })), })); -import depositHandler from "../v1/tx/deposit"; -import withdrawHandler from "../v1/tx/withdraw"; -import trustlineHandler from "../v1/tx/add-trustline"; -import submitHandler from "../v1/tx/submit"; +import txHandler from "../v1/tx/[action]"; import vaultsHandler from "../v1/vaults/index"; import positionsHandler from "../v1/positions/[publicKey]"; -import keeperHandler from "../v1/keepers/accrue"; -import rebalanceHandler from "../v1/keepers/rebalance"; -import keeperHealthHandler from "../v1/keepers/health"; -import vaultStateHandler from "../v1/admin/vault-state"; +import keepersHandler from "../v1/keepers/[action]"; +import adminHandler from "../v1/admin/[resource]"; import { checkRateLimit, resetRateLimitForTesting, @@ -205,8 +200,9 @@ describe("POST /api/v1/tx/deposit", () => { ); const res = makeRes(); - await depositHandler( + await txHandler( fakeReq({ + query: { action: "deposit" }, method: "POST", body: { walletAddress: PUBKEY, @@ -225,14 +221,25 @@ describe("POST /api/v1/tx/deposit", () => { it("rejects non-POST methods with 405", async () => { const res = makeRes(); - await depositHandler(fakeReq({ method: "GET", body: {} }), res); + await txHandler( + fakeReq({ + query: { action: "deposit" }, + method: "GET", + body: {}, + }), + res + ); expect(res.statusCode).toBe(405); }); it("returns 400 listing the missing fields", async () => { const res = makeRes(); - await depositHandler( - fakeReq({ method: "POST", body: { walletAddress: PUBKEY } }), + await txHandler( + fakeReq({ + query: { action: "deposit" }, + method: "POST", + body: { walletAddress: PUBKEY }, + }), res ); expect(res.statusCode).toBe(400); @@ -244,8 +251,9 @@ describe("POST /api/v1/tx/deposit", () => { it("builds the deposit transaction and returns the XDR", async () => { const res = makeRes(); - await depositHandler( + await txHandler( fakeReq({ + query: { action: "deposit" }, method: "POST", body: { walletAddress: PUBKEY, @@ -262,8 +270,9 @@ describe("POST /api/v1/tx/deposit", () => { it("accepts and forwards min_shares_out in deposit request", async () => { const res = makeRes(); - await depositHandler( + await txHandler( fakeReq({ + query: { action: "deposit" }, method: "POST", body: { walletAddress: PUBKEY, @@ -289,8 +298,9 @@ describe("POST /api/v1/tx/deposit", () => { new Error("USDC trustline missing") ); const res = makeRes(); - await depositHandler( + await txHandler( fakeReq({ + query: { action: "deposit" }, method: "POST", body: { walletAddress: PUBKEY, @@ -308,8 +318,9 @@ describe("POST /api/v1/tx/deposit", () => { describe("POST /api/v1/tx/withdraw", () => { it("returns 400 when shares is missing", async () => { const res = makeRes(); - await withdrawHandler( + await txHandler( fakeReq({ + query: { action: "withdraw" }, method: "POST", body: { walletAddress: PUBKEY, vaultId: "v" }, }), @@ -323,8 +334,9 @@ describe("POST /api/v1/tx/withdraw", () => { it("builds the withdraw transaction", async () => { const res = makeRes(); - await withdrawHandler( + await txHandler( fakeReq({ + query: { action: "withdraw" }, method: "POST", body: { walletAddress: PUBKEY, @@ -339,8 +351,9 @@ describe("POST /api/v1/tx/withdraw", () => { it("accepts and forwards min_usdc_out in withdraw request", async () => { const res = makeRes(); - await withdrawHandler( + await txHandler( fakeReq({ + query: { action: "withdraw" }, method: "POST", body: { walletAddress: PUBKEY, @@ -365,14 +378,25 @@ describe("POST /api/v1/tx/withdraw", () => { describe("POST /api/v1/tx/add-trustline", () => { it("returns 400 without a wallet address", async () => { const res = makeRes(); - await trustlineHandler(fakeReq({ method: "POST", body: {} }), res); + await txHandler( + fakeReq({ + query: { action: "add-trustline" }, + method: "POST", + body: {}, + }), + res + ); expect(res.statusCode).toBe(400); }); it("returns the trustline XDR", async () => { const res = makeRes(); - await trustlineHandler( - fakeReq({ method: "POST", body: { walletAddress: PUBKEY } }), + await txHandler( + fakeReq({ + query: { action: "add-trustline" }, + method: "POST", + body: { walletAddress: PUBKEY }, + }), res ); expect(res.body).toEqual({ xdr: "TRUST_XDR" }); @@ -382,14 +406,25 @@ describe("POST /api/v1/tx/add-trustline", () => { describe("POST /api/v1/tx/submit", () => { it("returns 400 without an xdr", async () => { const res = makeRes(); - await submitHandler(fakeReq({ method: "POST", body: {} }), res); + await txHandler( + fakeReq({ + query: { action: "submit" }, + method: "POST", + body: {}, + }), + res + ); expect(res.statusCode).toBe(400); }); it("submits and returns the tx hash", async () => { const res = makeRes(); - await submitHandler( - fakeReq({ method: "POST", body: { xdr: "SIGNED" } }), + await txHandler( + fakeReq({ + query: { action: "submit" }, + method: "POST", + body: { xdr: "SIGNED" }, + }), res ); expect(res.body).toEqual({ hash: "HASH" }); @@ -456,7 +491,14 @@ describe("GET /api/v1/positions/:publicKey", () => { 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); + await keepersHandler( + fakeReq({ + query: { action: "accrue" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(401); expect(runBlendAccrualKeeper).not.toHaveBeenCalled(); @@ -472,8 +514,9 @@ describe("GET /api/v1/keepers/accrue", () => { let lastRes = makeRes(); for (let i = 0; i < 101; i++) { lastRes = makeRes(); - await keeperHandler( + await keepersHandler( fakeReq({ + query: { action: "accrue" }, method: "GET", headers: { "x-forwarded-for": ip }, }), @@ -487,8 +530,9 @@ describe("GET /api/v1/keepers/accrue", () => { it("runs the accrual keeper for authorized cron calls", async () => { const res = makeRes(); - await keeperHandler( + await keepersHandler( fakeReq({ + query: { action: "accrue" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -528,8 +572,9 @@ describe("GET /api/v1/keepers/accrue", () => { }); const res = makeRes(); - await keeperHandler( + await keepersHandler( fakeReq({ + query: { action: "accrue" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -549,8 +594,9 @@ describe("GET /api/v1/keepers/accrue", () => { ); const res = makeRes(); - await keeperHandler( + await keepersHandler( fakeReq({ + query: { action: "accrue" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -586,7 +632,14 @@ describe("GET /api/v1/keepers/accrue", () => { process.env.VERCEL_ENV = "production"; const res = makeRes(); - await keeperHandler(fakeReq({ method: "GET", headers: {} }), res); + await keepersHandler( + fakeReq({ + query: { action: "accrue" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(503); expect(runBlendAccrualKeeper).not.toHaveBeenCalled(); @@ -597,7 +650,14 @@ describe("GET /api/v1/keepers/accrue", () => { delete process.env.NODE_ENV; const res = makeRes(); - await keeperHandler(fakeReq({ method: "GET", headers: {} }), res); + await keepersHandler( + fakeReq({ + query: { action: "accrue" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(200); expect(runBlendAccrualKeeper).toHaveBeenCalledOnce(); @@ -612,7 +672,14 @@ describe("GET /api/v1/keepers/accrue", () => { process.env.VERCEL_ENV = "preview"; const res = makeRes(); - await keeperHandler(fakeReq({ method: "GET", headers: {} }), res); + await keepersHandler( + fakeReq({ + query: { action: "accrue" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(503); expect(runBlendAccrualKeeper).not.toHaveBeenCalled(); @@ -624,8 +691,9 @@ describe("GET /api/v1/keepers/rebalance", () => { it("reports disabled instead of a noisy 500 when the migration secret key isn't configured", async () => { delete process.env.MERIDIAN_MIGRATION_KEEPER_SECRET_KEY; const res = makeRes(); - await rebalanceHandler( + await keepersHandler( fakeReq({ + query: { action: "rebalance" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -639,7 +707,14 @@ describe("GET /api/v1/keepers/rebalance", () => { it("rejects requests without the cron bearer token", async () => { const res = makeRes(); - await rebalanceHandler(fakeReq({ method: "GET", headers: {} }), res); + await keepersHandler( + fakeReq({ + query: { action: "rebalance" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(401); expect(runMigrationKeeper).not.toHaveBeenCalled(); @@ -653,8 +728,9 @@ describe("GET /api/v1/keepers/rebalance", () => { let lastRes = makeRes(); for (let i = 0; i < 101; i++) { lastRes = makeRes(); - await rebalanceHandler( + await keepersHandler( fakeReq({ + query: { action: "rebalance" }, method: "GET", headers: { "x-forwarded-for": ip }, }), @@ -668,8 +744,9 @@ describe("GET /api/v1/keepers/rebalance", () => { it("runs the migration keeper for authorized cron calls", async () => { const res = makeRes(); - await rebalanceHandler( + await keepersHandler( fakeReq({ + query: { action: "rebalance" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -712,8 +789,9 @@ describe("GET /api/v1/keepers/rebalance", () => { }); const res = makeRes(); - await rebalanceHandler( + await keepersHandler( fakeReq({ + query: { action: "rebalance" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -733,8 +811,9 @@ describe("GET /api/v1/keepers/rebalance", () => { ); const res = makeRes(); - await rebalanceHandler( + await keepersHandler( fakeReq({ + query: { action: "rebalance" }, method: "GET", headers: { authorization: "Bearer cron-secret" }, }), @@ -749,7 +828,14 @@ describe("GET /api/v1/keepers/rebalance", () => { describe("GET /api/v1/keepers/health", () => { it("is public — no cron bearer token required", async () => { const res = makeRes(); - await keeperHealthHandler(fakeReq({ method: "GET", headers: {} }), res); + await keepersHandler( + fakeReq({ + query: { action: "health" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(200); }); @@ -761,7 +847,14 @@ describe("GET /api/v1/keepers/health", () => { vi.mocked(isKeeperHealthy).mockImplementation((id) => id === "accrual"); const res = makeRes(); - await keeperHealthHandler(fakeReq({ method: "GET", headers: {} }), res); + await keepersHandler( + fakeReq({ + query: { action: "health" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(200); const body = res.body as { keepers: Array> }; @@ -779,14 +872,28 @@ describe("GET /api/v1/keepers/health", () => { describe("GET /api/v1/admin/vault-state", () => { it("is public — no cron bearer token required", async () => { const res = makeRes(); - await vaultStateHandler(fakeReq({ method: "GET", headers: {} }), res); + await adminHandler( + fakeReq({ + query: { resource: "vault-state" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(200); }); it("returns the coordinator vault's on-chain state", async () => { const res = makeRes(); - await vaultStateHandler(fakeReq({ method: "GET", headers: {} }), res); + await adminHandler( + fakeReq({ + query: { resource: "vault-state" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ @@ -804,7 +911,14 @@ describe("GET /api/v1/admin/vault-state", () => { ); const res = makeRes(); - await vaultStateHandler(fakeReq({ method: "GET", headers: {} }), res); + await adminHandler( + fakeReq({ + query: { resource: "vault-state" }, + method: "GET", + headers: {}, + }), + res + ); expect(res.statusCode).toBe(503); }); diff --git a/api/v1/admin/[resource].ts b/api/v1/admin/[resource].ts new file mode 100644 index 00000000..8576d787 --- /dev/null +++ b/api/v1/admin/[resource].ts @@ -0,0 +1,44 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { handleGetAdminHistory, handleGetVaultState } from "@meridian/api-core"; +import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; + +const HISTORY_CACHE_CONTROL = "public, s-maxage=30, stale-while-revalidate=120"; + +// Consolidated from two separate files (history/vault-state) to stay under +// Vercel's per-deployment Serverless Functions cap. URL paths are +// unchanged: /api/v1/admin/history and /api/v1/admin/vault-state both +// route here via the [resource].ts dynamic segment, with +// req.query.resource set accordingly. Both are public read-only endpoints +// (same data as the public /api/v1/vaults and /api/v1/positions routes, +// just reshaped for the admin dashboard), so they share one auth model. +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (applyCors(req, res)) return; + if (!(await checkRateLimit(req, res))) return; + + const { resource } = req.query; + + if (resource === "vault-state") { + const result = await handleGetVaultState(); + if (result.error) console.error("[admin/vault-state] error:", result.error); + res.setHeader("Cache-Control", "no-store"); + return res.status(result.status).json(result.body); + } + + if (resource === "history") { + if (req.method !== "GET") { + res.setHeader("Allow", "GET"); + return res.status(405).json({ error: "Method not allowed" }); + } + const raw = req.query["vaultId"]; + const vaultId = typeof raw === "string" ? raw : undefined; + if (!vaultId) return res.status(400).json({ error: "vaultId is required" }); + + const result = await handleGetAdminHistory(vaultId); + if (result.status === 200) { + res.setHeader("Cache-Control", HISTORY_CACHE_CONTROL); + } + return res.status(result.status).json(result.body); + } + + return res.status(404).json({ error: "Unknown resource" }); +} diff --git a/api/v1/admin/history.ts b/api/v1/admin/history.ts deleted file mode 100644 index 375c5ce1..00000000 --- a/api/v1/admin/history.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleGetAdminHistory } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -const CACHE_CONTROL = "public, s-maxage=30, stale-while-revalidate=120"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res))) return; - - if (req.method !== "GET") { - res.setHeader("Allow", "GET"); - return res.status(405).json({ error: "Method not allowed" }); - } - - const raw = req.query["vaultId"]; - const vaultId = typeof raw === "string" ? raw : undefined; - if (!vaultId) return res.status(400).json({ error: "vaultId is required" }); - - const result = await handleGetAdminHistory(vaultId); - if (result.status === 200) { - res.setHeader("Cache-Control", CACHE_CONTROL); - } - res.status(result.status).json(result.body); -} diff --git a/api/v1/admin/vault-state.ts b/api/v1/admin/vault-state.ts deleted file mode 100644 index b0e839d5..00000000 --- a/api/v1/admin/vault-state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleGetVaultState } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -// Read-only vault state (adapter/protocol, total shares, total assets, -// paused) for the admin dashboard's Vault State card. Public the same way -// /api/v1/vaults is: nothing here is sensitive, it's the same on-chain data -// that page already shows, just reshaped for the admin view. -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res))) return; - - const result = await handleGetVaultState(); - if (result.error) { - console.error("[admin/vault-state] error:", result.error); - } - res.setHeader("Cache-Control", "no-store"); - res.status(result.status).json(result.body); -} diff --git a/api/v1/keepers/[action].ts b/api/v1/keepers/[action].ts new file mode 100644 index 00000000..cf4fe906 --- /dev/null +++ b/api/v1/keepers/[action].ts @@ -0,0 +1,131 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { + consoleLogger, + isMigrationKeeperConfigured, + loadBlendAccrualKeeperConfig, + loadKeeperHeartbeatStore, + loadMigrationKeeperConfig, + recordKeeperHeartbeat, + redactedErrorMessage, + runBlendAccrualKeeper, + runMigrationKeeper, +} from "@meridian/stellar-sdk-helpers"; +import { handleGetKeeperHealth } from "@meridian/api-core"; +import { APP_NETWORK } from "@meridian/shared"; +import { + applyCors, + checkRateLimit, + isCronAuthorized, + isCronSecretConfigured, +} from "../../_lib/middleware.js"; + +// Consolidated from three separate files (accrue/health/rebalance) to stay +// under Vercel's per-deployment Serverless Functions cap. URL paths are +// unchanged: /api/v1/keepers/accrue, /api/v1/keepers/health, and +// /api/v1/keepers/rebalance all route here via the [action].ts dynamic +// segment, with req.query.action set accordingly. +// +// health is read-only and public, same as before; accrue/rebalance still +// require the cron bearer token and are never CORS-gated, since they sign +// and submit real transactions off a funded/admin account. That auth split +// is preserved exactly as it was when these were separate files. +export default async function handler(req: VercelRequest, res: VercelResponse) { + const { action } = req.query; + + if (action === "health") { + if (applyCors(req, res)) return; + if (!(await checkRateLimit(req, res))) return; + const result = await handleGetKeeperHealth(); + if (result.error) console.error("[keepers/health] error:", result.error); + res.setHeader("Cache-Control", "no-store"); + return res.status(result.status).json(result.body); + } + + if (action !== "accrue" && action !== "rebalance") { + return res.status(404).json({ error: "Unknown action" }); + } + + if (req.method !== "GET" && req.method !== "POST") { + res.setHeader("Allow", "GET, POST"); + return res.status(405).json({ error: "Method not allowed" }); + } + + // No applyCors: cron-invoked, never browser-facing. But both sign and + // submit real transactions, unlike simple reads, so they still get a + // rate-limit backstop. Checked before auth, deliberately, even though + // that costs a Redis round trip on an unauthenticated probe: this is the + // volume-abuse backstop for *all* traffic, not just correctly + // authenticated traffic — if it only ran after a successful auth check, + // unauthenticated/wrong-token spam would be entirely unbounded. + try { + if (!(await checkRateLimit(req, res, { strict: true }))) return; + } catch (err) { + console.error(`[keepers/${action}] rate limit check failed:`, err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } + + if (!isCronSecretConfigured()) { + return res.status(503).json({ error: "CRON_SECRET is not configured" }); + } + if (!isCronAuthorized(req)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (action === "accrue") { + try { + const config = loadBlendAccrualKeeperConfig(process.env); + const result = await runBlendAccrualKeeper(config); + const status = result.failures.length > 0 ? 500 : 200; + if (result.failures.length === 0) { + const store = loadKeeperHeartbeatStore(process.env, { + logger: consoleLogger, + }); + await recordKeeperHeartbeat( + store, + "accrual", + APP_NETWORK.network, + consoleLogger + ); + } + return res.status(status).json(result); + } catch (err) { + console.error("[accrual-keeper] run failed:", err); + return res.status(500).json({ error: redactedErrorMessage(err) }); + } + } + + // The migration keeper is deliberately not fully wired up yet (#511, + // #514): ops may reasonably leave this unset until both land. Without + // this check, every hourly cron tick would throw inside + // loadMigrationKeeperConfig and report a 500, a permanent, noisy false + // alarm for an intentionally disabled feature, not an actual failure. + if (!isMigrationKeeperConfigured(process.env)) { + return res.status(200).json({ + status: "disabled", + message: "MERIDIAN_MIGRATION_KEEPER_SECRET_KEY is not configured", + }); + } + + try { + const config = loadMigrationKeeperConfig(process.env); + const result = await runMigrationKeeper(config); + const status = result.failures.length > 0 ? 500 : 200; + if (result.failures.length === 0) { + const store = loadKeeperHeartbeatStore(process.env, { + logger: consoleLogger, + }); + await recordKeeperHeartbeat( + store, + "migration", + APP_NETWORK.network, + consoleLogger + ); + } + return res.status(status).json(result); + } catch (err) { + console.error("[migration-keeper] run failed:", err); + return res.status(500).json({ error: redactedErrorMessage(err) }); + } +} diff --git a/api/v1/keepers/accrue.ts b/api/v1/keepers/accrue.ts deleted file mode 100644 index 42039122..00000000 --- a/api/v1/keepers/accrue.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { - consoleLogger, - loadBlendAccrualKeeperConfig, - loadKeeperHeartbeatStore, - recordKeeperHeartbeat, - redactedErrorMessage, - runBlendAccrualKeeper, -} from "@meridian/stellar-sdk-helpers"; -import { APP_NETWORK } from "@meridian/shared"; -import { - checkRateLimit, - isCronAuthorized, - isCronSecretConfigured, -} from "../../_lib/middleware.js"; - -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" }); - } - - // No applyCors: this endpoint is cron-invoked, never browser-facing. But - // it does sign and submit real transactions off the keeper's funded - // account, unlike simple reads, so it still gets a rate-limit backstop. - // Checked before auth, deliberately, even though that costs a Redis round - // trip on an unauthenticated probe: this is the volume-abuse backstop for - // *all* traffic, not just correctly-authenticated traffic, if it only ran - // after a successful auth check, unauthenticated/wrong-token spam would - // be entirely unbounded, since a 401 would return before this ever runs. - // Wrapped, unlike a plain `await`: checkRateLimit talks to Upstash, and an - // outage there would otherwise escape as a bare unhandled 500 with no - // [accrual-keeper] log line, before the run (and its own store-outage - // signalling) ever starts. Fails closed on purpose even though the run - // itself is more tolerant: this is the abuse backstop on an endpoint that - // signs real transactions, and the next scheduled tick retries anyway. - try { - if (!(await checkRateLimit(req, res, { strict: true }))) return; - } catch (err) { - console.error("[accrual-keeper] rate limit check failed:", err); - return res - .status(503) - .json({ error: "Rate limiter unavailable; refusing to run" }); - } - - if (!isCronSecretConfigured()) { - 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; - // Recorded on a clean run only (no failures), even if there was nothing - // to do (zero discovered adapters): a clean no-op run still proves the - // keeper itself is alive, which is what the admin dashboard's Keeper - // Health card is showing. Best-effort and after the response status is - // already decided — a heartbeat-store hiccup must never turn an - // otherwise-successful run into a reported failure. - if (result.failures.length === 0) { - const store = loadKeeperHeartbeatStore(process.env, { - logger: consoleLogger, - }); - await recordKeeperHeartbeat( - store, - "accrual", - APP_NETWORK.network, - consoleLogger - ); - } - return res.status(status).json(result); - } catch (err) { - console.error("[accrual-keeper] run failed:", err); - return res.status(500).json({ error: redactedErrorMessage(err) }); - } -} diff --git a/api/v1/keepers/health.ts b/api/v1/keepers/health.ts deleted file mode 100644 index f947be3e..00000000 --- a/api/v1/keepers/health.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleGetKeeperHealth } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -// Read-only: reports on keeper runs already recorded elsewhere (see -// keeper-heartbeat.ts), never triggers one. No cron auth needed — unlike -// accrue.ts/rebalance.ts this signs nothing and holds no funded-account -// authority, so it's public the same way /api/v1/vaults is. -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res))) return; - - const result = await handleGetKeeperHealth(); - if (result.error) { - console.error("[keepers/health] error:", result.error); - } - res.setHeader("Cache-Control", "no-store"); - res.status(result.status).json(result.body); -} diff --git a/api/v1/keepers/rebalance.ts b/api/v1/keepers/rebalance.ts deleted file mode 100644 index 780b4a5d..00000000 --- a/api/v1/keepers/rebalance.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { - consoleLogger, - isMigrationKeeperConfigured, - loadKeeperHeartbeatStore, - loadMigrationKeeperConfig, - recordKeeperHeartbeat, - redactedErrorMessage, - runMigrationKeeper, -} from "@meridian/stellar-sdk-helpers"; -import { APP_NETWORK } from "@meridian/shared"; -import { - checkRateLimit, - isCronAuthorized, - isCronSecretConfigured, -} from "../../_lib/middleware.js"; - -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" }); - } - - // No applyCors: this endpoint is cron-invoked, never browser-facing. But - // it does sign and submit real migrate_adapter transactions off the - // vault's admin key, unlike simple reads, so it still gets a rate-limit - // backstop. Checked before auth, deliberately, even though that costs a - // Redis round trip on an unauthenticated probe: this is the volume-abuse - // backstop for *all* traffic, not just correctly-authenticated traffic; - // if it only ran after a successful auth check, unauthenticated/wrong-token - // spam would be entirely unbounded against an endpoint holding full vault - // admin authority. - // Wrapped, unlike a plain `await`: checkRateLimit talks to Upstash, and an - // outage there would otherwise escape as a bare unhandled 500 with no - // [migration-keeper] log line, before the run (and its own store-outage - // signalling) ever starts. Fails closed on purpose even though the run - // itself is more tolerant: this is the abuse backstop on an endpoint that - // signs real transactions, and the next scheduled tick retries anyway. - try { - if (!(await checkRateLimit(req, res, { strict: true }))) return; - } catch (err) { - console.error("[migration-keeper] rate limit check failed:", err); - return res - .status(503) - .json({ error: "Rate limiter unavailable; refusing to run" }); - } - - if (!isCronSecretConfigured()) { - return res.status(503).json({ error: "CRON_SECRET is not configured" }); - } - - if (!isCronAuthorized(req)) { - return res.status(401).json({ error: "Unauthorized" }); - } - - // The migration keeper is deliberately not fully wired up yet (#511, #514): - // ops may reasonably leave this unset until both land. Without this check, - // every hourly cron tick would throw inside loadMigrationKeeperConfig and - // report a 500, a permanent, noisy false alarm for an intentionally - // disabled feature, not an actual failure. - if (!isMigrationKeeperConfigured(process.env)) { - return res.status(200).json({ - status: "disabled", - message: "MERIDIAN_MIGRATION_KEEPER_SECRET_KEY is not configured", - }); - } - - try { - const config = loadMigrationKeeperConfig(process.env); - const result = await runMigrationKeeper(config); - const status = result.failures.length > 0 ? 500 : 200; - // See accrue.ts's matching comment: recorded only on a clean run, - // best-effort, after the response status is already decided. - if (result.failures.length === 0) { - const store = loadKeeperHeartbeatStore(process.env, { - logger: consoleLogger, - }); - await recordKeeperHeartbeat( - store, - "migration", - APP_NETWORK.network, - consoleLogger - ); - } - return res.status(status).json(result); - } catch (err) { - console.error("[migration-keeper] run failed:", err); - return res.status(500).json({ error: redactedErrorMessage(err) }); - } -} diff --git a/api/v1/tx/[action].ts b/api/v1/tx/[action].ts new file mode 100644 index 00000000..c6bbaa8f --- /dev/null +++ b/api/v1/tx/[action].ts @@ -0,0 +1,56 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { + handleDepositRequest, + handleWithdrawRequest, + handleSubmitRequest, + handleAddTrustlineRequest, +} from "@meridian/api-core"; +import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; + +// Consolidated from four separate files (deposit/withdraw/submit/add-trustline) +// to stay under Vercel's per-deployment Serverless Functions cap. URL paths +// are unchanged: Vercel's [action].ts dynamic segment routes +// /api/v1/tx/deposit, /api/v1/tx/withdraw, etc. to this one file with +// req.query.action set accordingly. +const ACTIONS = { + deposit: handleDepositRequest, + withdraw: handleWithdrawRequest, + submit: handleSubmitRequest, + "add-trustline": handleAddTrustlineRequest, +} as const; + +type Action = keyof typeof ACTIONS; + +function isAction(value: unknown): value is Action { + return typeof value === "string" && value in ACTIONS; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (applyCors(req, res)) return; + try { + if (!(await checkRateLimit(req, res, { strict: true }))) return; + } catch (err) { + console.error("[tx] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } + if (req.method !== "POST") + return res.status(405).json({ error: "Method not allowed" }); + + const { action } = req.query; + if (!isAction(action)) { + return res.status(404).json({ error: "Unknown action" }); + } + + const result = await ACTIONS[action](req.body); + if (result.error) { + const cause = (result.error as { cause?: unknown } | undefined)?.cause; + console.error( + `[tx/${action}] failed:`, + result.error, + cause ? { cause } : "" + ); + } + res.status(result.status).json(result.body); +} diff --git a/api/v1/tx/add-trustline.ts b/api/v1/tx/add-trustline.ts deleted file mode 100644 index c1a96395..00000000 --- a/api/v1/tx/add-trustline.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleAddTrustlineRequest } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - try { - if (!(await checkRateLimit(req, res, { strict: true }))) return; - } catch (err) { - console.error("[tx/add-trustline] rate limit check failed:", err); - return res - .status(503) - .json({ error: "Rate limiter unavailable; refusing to run" }); - } - if (req.method !== "POST") - return res.status(405).json({ error: "Method not allowed" }); - - const result = await handleAddTrustlineRequest(req.body); - if (result.error) { - console.error("[tx/add-trustline] build failed:", result.error); - } - res.status(result.status).json(result.body); -} diff --git a/api/v1/tx/deposit.ts b/api/v1/tx/deposit.ts deleted file mode 100644 index 3e4e1e65..00000000 --- a/api/v1/tx/deposit.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleDepositRequest } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - try { - if (!(await checkRateLimit(req, res, { strict: true }))) return; - } catch (err) { - console.error("[tx/deposit] rate limit check failed:", err); - return res - .status(503) - .json({ error: "Rate limiter unavailable; refusing to run" }); - } - if (req.method !== "POST") - return res.status(405).json({ error: "Method not allowed" }); - - const result = await handleDepositRequest(req.body); - if (result.error) { - const cause = (result.error as { cause?: unknown } | undefined)?.cause; - console.error( - "[tx/deposit] build failed:", - result.error, - cause ? { cause } : "" - ); - } - res.status(result.status).json(result.body); -} diff --git a/api/v1/tx/submit.ts b/api/v1/tx/submit.ts deleted file mode 100644 index a4bd863a..00000000 --- a/api/v1/tx/submit.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleSubmitRequest } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - try { - if (!(await checkRateLimit(req, res, { strict: true }))) return; - } catch (err) { - console.error("[tx/submit] rate limit check failed:", err); - return res - .status(503) - .json({ error: "Rate limiter unavailable; refusing to run" }); - } - if (req.method !== "POST") - return res.status(405).json({ error: "Method not allowed" }); - - const result = await handleSubmitRequest(req.body); - if (result.error) { - console.error("[tx/submit] failed:", result.error); - } - res.status(result.status).json(result.body); -} diff --git a/api/v1/tx/withdraw.ts b/api/v1/tx/withdraw.ts deleted file mode 100644 index 66db5379..00000000 --- a/api/v1/tx/withdraw.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { VercelRequest, VercelResponse } from "@vercel/node"; -import { handleWithdrawRequest } from "@meridian/api-core"; -import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; - -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (applyCors(req, res)) return; - try { - if (!(await checkRateLimit(req, res, { strict: true }))) return; - } catch (err) { - console.error("[tx/withdraw] rate limit check failed:", err); - return res - .status(503) - .json({ error: "Rate limiter unavailable; refusing to run" }); - } - if (req.method !== "POST") - return res.status(405).json({ error: "Method not allowed" }); - - const result = await handleWithdrawRequest(req.body); - if (result.error) { - const cause = (result.error as { cause?: unknown } | undefined)?.cause; - console.error( - "[tx/withdraw] build failed:", - result.error, - cause ? { cause } : "" - ); - } - res.status(result.status).json(result.body); -} diff --git a/vercel.json b/vercel.json index 97c01143..e05fb884 100644 --- a/vercel.json +++ b/vercel.json @@ -2,8 +2,7 @@ "buildCommand": "bash scripts/build-vercel.sh", "outputDirectory": "dist", "functions": { - "api/v1/keepers/accrue.ts": { "maxDuration": 60 }, - "api/v1/keepers/rebalance.ts": { "maxDuration": 60 } + "api/v1/keepers/[action].ts": { "maxDuration": 60 } }, "rewrites": [ { "source": "/app/:path*", "destination": "/app/index.html" }, From 440e3c72ac75ce6d42a1a2a8174d1ed73322de90 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Tue, 1 Sep 2026 17:16:00 +0100 Subject: [PATCH 03/10] chore(contracts): redeploy testnet vault for deposit() and migration --- apps/docs/operations/testnet-deployment.md | 28 +++++++++++++++++++ packages/shared/src/constants.ts | 15 +++++----- .../stellar-sdk-helpers/src/known-pools.ts | 2 +- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/docs/operations/testnet-deployment.md b/apps/docs/operations/testnet-deployment.md index 93f04104..6fe576ca 100644 --- a/apps/docs/operations/testnet-deployment.md +++ b/apps/docs/operations/testnet-deployment.md @@ -165,6 +165,34 @@ Verified against #514's acceptance criteria before opening this PR: `migrate_ada **A note on reproducible builds:** `stellar contract build`'s WASM output is not guaranteed byte-identical across different `stellar-cli`/Rust toolchain versions, even from identical source — a newer `stellar-cli` can apply a different (or newly-default) optimization pass and pull in different `soroban-sdk` transitive versions, changing the compiled bytecode. `.github/workflows/verify-contract-addresses.yml` always rebuilds with whatever `stellar-cli` version `cargo install --locked stellar-cli` resolves to _at CI run time_, not a pinned version. If your local `stellar-cli` has drifted behind that (check with `stellar --version` against the [latest release](https://github.com/stellar/stellar-cli/releases)), a contract you deploy locally can genuinely mismatch what CI rebuilds and compares it against, independent of whether your source is correct. If in doubt, verify the WASM you're about to deploy was built with a `stellar-cli` at least as new as CI's, or build it in a CI job of your own (e.g. a throwaway `workflow_dispatch` job that uploads the built `.wasm` as an artifact) and deploy that exact artifact instead of a locally-built one. +### 2026-09-01 — redeployed for `deposit()`'s `min_shares_out` and two-phase migration (#604, #606) + +The live testnet vault predated both #604 (`deposit()` gained a required `min_shares_out` parameter) and #606 (`begin_migration`/two-phase migration cooldown), landing exactly the ABI mismatch #602 was filed to prevent — #602 covered #600's `withdraw()` change and closed before #604/#606 merged, so neither was ever redeployed. Callers built against current source (three-argument `deposit()`) were failing simulation against the old two-argument contract with `HostError: Error(WasmVm, UnexpectedSize)`. + +**Pre-cutover status:** the old vault below held `get_total_assets() = 0` — no outstanding testnet deposits, so no withdrawal-announcement window was needed. + +**Old vault (superseded):** + +| Field | Value | +| ------------------- | ---------------------------------------------------------- | +| Vault contract | `CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL` | +| Blend adapter | `CDFIDKNA2ZTB37I7RN32WH7VU5AP2PAOXLGFWMTW6T2RSUM23AJIV2YM` | +| mUSDC (share token) | `CCSYXC4SDCPTGENHM6CSQY4HMSZOPOY5TJW4QYYLE5RDBUBJX4N7ZHV5` | +| Admin | `GB74ZDVMBYMPKWBBVJ7TAN2QK2EAKQQ5OZO6ETYAMPN5VQVNLZSQUYHH` | + +This contract is not deleted or disabled — Soroban has no such operation, it keeps running exactly as deployed. There is no automatic migration or sweep of old positions into the new vault, but there were none to move at cutover time. + +**New vault (current):** + +| Field | Value | +| ------------------- | ---------------------------------------------------------- | +| Vault contract | `CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP` | +| Blend adapter | `CDHUA2PW62YTU4MS2KDBPQ3UKXSZORVTHM43PMIT2VDIMVGXTKQHANY5` | +| mUSDC (share token) | `CDMPSG5HRSSPADIR5JKZM5CWTZFN3AAJEJV5K5QXOXVOZHAWJ7EKZB7H` | +| Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` | + +Deployed and initialized via `scripts/deploy-testnet.sh`, `DEPLOYER` and `ADMIN` both defaulting to the same key (a pre-existing, already-funded testnet identity previously used as the #514 cutover's admin too). `begin_migration`, `migrate_adapter`, and the three-argument `deposit()` all confirmed present in the deployed vault's exported function list. + ## Run the signing flow end-to-end With the contracts deployed and `known-pools.ts`/`constants.ts` updated: diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a9dfff08..e01aac6f 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -53,13 +53,14 @@ export const CONTRACT_ADDRESSES = { usdc: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", // Stellar Asset Contract for Circle's testnet EURC (issuer: GB3Q6QDZYTHWT7...). eurc: "CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ", - musdc: "CCSYXC4SDCPTGENHM6CSQY4HMSZOPOY5TJW4QYYLE5RDBUBJX4N7ZHV5", - // Redeployed for #514: the previous vault (CBQYEHWIRJWIPWCJFQZAOP3VAZHRWFGAUS5GZHWFDDYKMFHJ5S3YS2Q5) - // predates `migrate_adapter` and was never redeployed since #464/#507 - // added it. See apps/docs/operations/testnet-deployment.md's "Vault - // migration history" for the old address, why it's stale, and the - // pre-cutover withdrawal window for anyone still holding a position there. - vault: "CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL", + musdc: "CDMPSG5HRSSPADIR5JKZM5CWTZFN3AAJEJV5K5QXOXVOZHAWJ7EKZB7H", + // Redeployed to sync #604's deposit() min_shares_out parameter and + // #606's begin_migration/two-phase migration additions onto a live + // contract; the previous vault (CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL) + // predated both. See apps/docs/operations/testnet-deployment.md's + // "Vault migration history" for the old address and its (empty) + // pre-cutover balance. + vault: "CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP", }, mainnet: { blend: { diff --git a/packages/stellar-sdk-helpers/src/known-pools.ts b/packages/stellar-sdk-helpers/src/known-pools.ts index 1b6d05b6..495c84bc 100644 --- a/packages/stellar-sdk-helpers/src/known-pools.ts +++ b/packages/stellar-sdk-helpers/src/known-pools.ts @@ -63,7 +63,7 @@ export const KNOWN_POOLS: { name: "Meridian", protocol: "meridian", label: "USDC Vault", - contractId: "CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL", + contractId: "CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP", assetId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", asset: "USDC", }, From abf9e97b212f4b40aad7a6898994f8d1d72bdf12 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Tue, 1 Sep 2026 18:50:56 +0100 Subject: [PATCH 04/10] ci(contracts): cache stellar-cli binary keyed on its published version --- .../workflows/verify-contract-addresses.yml | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-contract-addresses.yml b/.github/workflows/verify-contract-addresses.yml index f066b745..1b0a00e1 100644 --- a/.github/workflows/verify-contract-addresses.yml +++ b/.github/workflows/verify-contract-addresses.yml @@ -28,7 +28,7 @@ jobs: # continue-on-error defaults to false; stated here so the intent is clear # to anyone reading this file. continue-on-error: false - timeout-minutes: 15 + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6.0.10 @@ -43,12 +43,30 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: packages/contracts + - name: Resolve latest Stellar CLI version + # Cache key below is pinned to this version, not to time or branch, so + # a new stellar-cli release always forces a fresh install and rebuild + # on the next run. This is what actually catches toolchain drift, not + # skipping the cache entirely; a cache hit for an unchanged version + # restores the exact same bytes a from-scratch build would produce. + id: stellar-cli-version + run: | + VERSION=$(curl -sSf https://crates.io/api/v1/crates/stellar-cli | jq -r '.crate.max_stable_version') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Cache Stellar CLI binary + id: cache-stellar-cli + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/stellar + key: stellar-cli-${{ runner.os }}-${{ steps.stellar-cli-version.outputs.version }} - name: Install Stellar CLI build dependencies # stellar-cli pulls in libdbus-sys and hidapi (hardware wallet # support) transitively, which need the dbus-1 and libudev system # libraries at build time; not preinstalled on ubuntu-latest runners. + if: steps.cache-stellar-cli.outputs.cache-hit != 'true' run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libudev-dev pkg-config - name: Install Stellar CLI + if: steps.cache-stellar-cli.outputs.cache-hit != 'true' run: cargo install --locked stellar-cli - name: Verify contract addresses match on-chain bytecode # The script retries transient Soroban RPC failures internally From 11a55d50d929d635ae11b108542004dc0d7768da Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 2 Sep 2026 22:13:38 +0100 Subject: [PATCH 05/10] fix: redeploy testnet vault to resolve stellar-cli bytecode mismatch --- apps/docs/operations/testnet-deployment.md | 28 +++++++++++++++++++ packages/shared/src/constants.ts | 18 ++++++------ .../stellar-sdk-helpers/src/known-pools.ts | 2 +- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/docs/operations/testnet-deployment.md b/apps/docs/operations/testnet-deployment.md index 77df9aa2..ed29d21c 100644 --- a/apps/docs/operations/testnet-deployment.md +++ b/apps/docs/operations/testnet-deployment.md @@ -208,6 +208,34 @@ This contract is not deleted or disabled — Soroban has no such operation, it k Deployed and initialized via `scripts/deploy-testnet.sh`, `DEPLOYER` and `ADMIN` both defaulting to the same key (a pre-existing, already-funded testnet identity previously used as the #514 cutover's admin too). `begin_migration`, `migrate_adapter`, and the three-argument `deposit()` all confirmed present in the deployed vault's exported function list. +### 2026-09-02 — redeployed to fix a `stellar-cli` version drift (#701) + +`.github/workflows/verify-contract-addresses.yml`'s "Verify On-Chain Bytecode" job started failing: rebuilding the vault from current source on CI produced a WASM hash (`d46c31020b6eb369ba84a87cbdbd9b0972c3ac8fa732b08a4915f6b262d5f179`) that didn't match the on-chain hash of the live vault below. Source hadn't drifted — the vault had simply been deployed with an older `stellar-cli` than what CI's dynamic `cargo install --locked stellar-cli` now resolves to (v28.0.0), and `stellar contract build`'s output isn't guaranteed byte-identical across CLI versions (see "A note on reproducible builds" above). Confirmed independently: rebuilding locally after upgrading to `stellar-cli` v28.0.0 produced the identical `d46c31020b...` hash CI did, on a separate machine. + +**Pre-cutover status:** the old vault below held `get_total_assets() = 0` — no outstanding testnet deposits, so no withdrawal-announcement window was needed. + +**Old vault (superseded):** + +| Field | Value | +| ------------------- | ---------------------------------------------------------- | +| Vault contract | `CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP` | +| Blend adapter | `CDHUA2PW62YTU4MS2KDBPQ3UKXSZORVTHM43PMIT2VDIMVGXTKQHANY5` | +| mUSDC (share token) | `CDMPSG5HRSSPADIR5JKZM5CWTZFN3AAJEJV5K5QXOXVOZHAWJ7EKZB7H` | +| Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` | + +This contract is not deleted or disabled — Soroban has no such operation, it keeps running exactly as deployed. There is no automatic migration or sweep of old positions into the new vault, but there were none to move at cutover time. + +**New vault (current):** + +| Field | Value | +| ------------------- | ---------------------------------------------------------- | +| Vault contract | `CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME` | +| Blend adapter | `CCXB5BRVBFNPAN72PRODGFWKGGDHEHJMHJLC7G2OEQFF4PZNNO3C4XBH` | +| mUSDC (share token) | `CAJASVPQ365EYUQ62Z54SRSZWJ4C7WJNDYXIYVWKLSRWJTTWET35JPYE` | +| Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` | + +The admin is the same durable key as the two prior cutovers, kept across this one too. Deployed via `scripts/deploy-testnet.sh` with a fresh throwaway `DEPLOYER` and `ADMIN`/`ADMIN_KEY` set explicitly to that durable admin key, so the vault was signed and initialized in the same run rather than left briefly claimable. `get_total_assets()`, `get_pool()`, and `get_protocol()` confirmed resolving correctly on the new vault and its Blend adapter. + ## Run the signing flow end-to-end With the contracts deployed and `known-pools.ts`/`constants.ts` updated: diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e01aac6f..e62a725c 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -31,7 +31,7 @@ export const USDC_ISSUER: Record = { // not before — this file describes what's actually live, not what the code // supports. export const MUSDC_ISSUER: Record = { - testnet: "GBLYQ5EHXMMULOA7KA4KK2S5Q5GTTWYFVSC3FKLXRLH34EJX35BIAL35", + testnet: "", mainnet: "", }; @@ -53,14 +53,14 @@ export const CONTRACT_ADDRESSES = { usdc: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", // Stellar Asset Contract for Circle's testnet EURC (issuer: GB3Q6QDZYTHWT7...). eurc: "CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ", - musdc: "CDMPSG5HRSSPADIR5JKZM5CWTZFN3AAJEJV5K5QXOXVOZHAWJ7EKZB7H", - // Redeployed to sync #604's deposit() min_shares_out parameter and - // #606's begin_migration/two-phase migration additions onto a live - // contract; the previous vault (CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL) - // predated both. See apps/docs/operations/testnet-deployment.md's - // "Vault migration history" for the old address and its (empty) - // pre-cutover balance. - vault: "CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP", + musdc: "CAJASVPQ365EYUQ62Z54SRSZWJ4C7WJNDYXIYVWKLSRWJTTWET35JPYE", + // Redeployed with stellar-cli v28.0.0 to match what + // verify-contract-addresses.yml rebuilds with; the previous vault + // (CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP) was built + // with an older CLI and its bytecode no longer matched current source. + // See apps/docs/operations/testnet-deployment.md's "Vault migration + // history" for the old address and its (empty) pre-cutover balance. + vault: "CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME", }, mainnet: { blend: { diff --git a/packages/stellar-sdk-helpers/src/known-pools.ts b/packages/stellar-sdk-helpers/src/known-pools.ts index 495c84bc..d7a7e429 100644 --- a/packages/stellar-sdk-helpers/src/known-pools.ts +++ b/packages/stellar-sdk-helpers/src/known-pools.ts @@ -63,7 +63,7 @@ export const KNOWN_POOLS: { name: "Meridian", protocol: "meridian", label: "USDC Vault", - contractId: "CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP", + contractId: "CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME", assetId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", asset: "USDC", }, From df803ced3148fdde9ba8d102617300200b229b8b Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 2 Sep 2026 22:46:05 +0100 Subject: [PATCH 06/10] fix: update trustline tests for mUSDC's SEP-41 cutover --- .../src/__tests__/hooks/useTrustlines.test.ts | 15 +++++++- packages/stellar-sdk-helpers/src/tx.test.ts | 37 +++++++------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/apps/web/src/__tests__/hooks/useTrustlines.test.ts b/apps/web/src/__tests__/hooks/useTrustlines.test.ts index ed19dff4..e449d108 100644 --- a/apps/web/src/__tests__/hooks/useTrustlines.test.ts +++ b/apps/web/src/__tests__/hooks/useTrustlines.test.ts @@ -82,6 +82,10 @@ function missingMusdcTrustlineHorizonResponse() { ); } +function missingUsdcTrustlineHorizonResponse() { + return new Response(JSON.stringify({ balances: [] }), { status: 200 }); +} + beforeEach(() => { useWalletStore.setState({ publicKey: KEY, @@ -106,12 +110,21 @@ describe("useTrustlines", () => { expect(result).toBe(true); }); - it("reports trustlines missing when mUSDC is absent", async () => { + it("reports trustlines present even when mUSDC's balance is absent (SEP-41 contract token, no classic trustline)", async () => { vi.stubGlobal( "fetch", vi.fn(async () => missingMusdcTrustlineHorizonResponse()) ); const result = await hasRequiredTrustlines(KEY, "testnet"); + expect(result).toBe(true); + }); + + it("reports trustlines missing when USDC is absent", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => missingUsdcTrustlineHorizonResponse()) + ); + const result = await hasRequiredTrustlines(KEY, "testnet"); expect(result).toBe(false); }); diff --git a/packages/stellar-sdk-helpers/src/tx.test.ts b/packages/stellar-sdk-helpers/src/tx.test.ts index d90d2f1f..4b5dbece 100644 --- a/packages/stellar-sdk-helpers/src/tx.test.ts +++ b/packages/stellar-sdk-helpers/src/tx.test.ts @@ -21,11 +21,7 @@ import { assertSubmittable, } from "./tx"; import type { StellarNetwork } from "./types"; -import { - CONTRACT_ADDRESSES, - MUSDC_ISSUER, - USDC_ISSUER, -} from "@meridian/shared"; +import { CONTRACT_ADDRESSES, USDC_ISSUER } from "@meridian/shared"; const { SUCCESS, FAILED, NOT_FOUND } = rpc.Api.GetTransactionStatus; @@ -244,12 +240,10 @@ const TESTNET: StellarNetwork = { passphrase: "Test SDF Network ; September 2015", }; -// Both pulled from the source of truth rather than hardcoded, so these -// fixtures don't drift out of sync the next time the vault (and its mUSDC -// issuer) is redeployed, as happened with the previous hardcoded value in -// #514. +// Pulled from the source of truth rather than hardcoded, so this fixture +// doesn't drift out of sync the next time the vault is redeployed, as +// happened with the previous hardcoded value in #514. const USDC_ISSUER_TESTNET = USDC_ISSUER.testnet; -const MUSDC_ISSUER_TESTNET = MUSDC_ISSUER.testnet; function makeBalance( code: string, @@ -274,11 +268,10 @@ describe("buildAddTrustlineTx", () => { afterEach(() => vi.restoreAllMocks()); it("throws when all required trustlines already exist", async () => { + // mUSDC is a SEP-41 contract token post-#578 (MUSDC_ISSUER.testnet is + // ""), so it has no classic trustline to check — only USDC is required. vi.spyOn(Horizon.Server.prototype, "loadAccount").mockResolvedValue({ - balances: [ - makeBalance("USDC", USDC_ISSUER_TESTNET), - makeBalance("MUSDC", MUSDC_ISSUER_TESTNET), - ], + balances: [makeBalance("USDC", USDC_ISSUER_TESTNET)], } as unknown as Awaited>); await expect( @@ -436,12 +429,10 @@ describe("assertSubmittable", () => { // testnet allowlist. const UNKNOWN_CONTRACT = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; - // Both pulled from the source of truth rather than hardcoded, so these - // fixtures don't drift out of sync the next time the vault (and its mUSDC - // issuer) is redeployed, as happened with the previous hardcoded value in - // #514. + // Pulled from the source of truth rather than hardcoded, so this fixture + // doesn't drift out of sync the next time the vault is redeployed, as + // happened with the previous hardcoded value in #514. const USDC_ISSUER_TESTNET = USDC_ISSUER.testnet; - const MUSDC_ISSUER_TESTNET = MUSDC_ISSUER.testnet; // Circle's mainnet USDC issuer: a validly-formed address that is not on the // testnet allowlist. const UNKNOWN_ISSUER = @@ -482,13 +473,11 @@ describe("assertSubmittable", () => { expect(() => assertSubmittable(tx, network)).not.toThrow(); }); - it("allows a changeTrust to the known mUSDC issuer", () => { + it("rejects a changeTrust to mUSDC (SEP-41 contract token, no classic issuer)", () => { const tx = buildTx( - Operation.changeTrust({ - asset: new Asset("MUSDC", MUSDC_ISSUER_TESTNET), - }) + Operation.changeTrust({ asset: new Asset("MUSDC", UNKNOWN_ISSUER) }) ); - expect(() => assertSubmittable(tx, network)).not.toThrow(); + expect(() => assertSubmittable(tx, network)).toThrow(/unrecognised issuer/); }); it("rejects a changeTrust to an unrecognised issuer", () => { From 90c2fc78ebea272b04173346a26d315ef4479d00 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 2 Sep 2026 22:55:51 +0100 Subject: [PATCH 07/10] fix(ci): resolve stellar-cli cache key via crates.io User-Agent --- .github/workflows/verify-contract-addresses.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-contract-addresses.yml b/.github/workflows/verify-contract-addresses.yml index 1b0a00e1..a8979e05 100644 --- a/.github/workflows/verify-contract-addresses.yml +++ b/.github/workflows/verify-contract-addresses.yml @@ -51,7 +51,15 @@ jobs: # restores the exact same bytes a from-scratch build would produce. id: stellar-cli-version run: | - VERSION=$(curl -sSf https://crates.io/api/v1/crates/stellar-cli | jq -r '.crate.max_stable_version') + # crates.io's API rejects requests with no User-Agent (403), which + # silently empties VERSION inside this command substitution rather + # than failing the step - the check below turns that into a loud + # failure instead of a permanently-broken cache key. + VERSION=$(curl -sSf -H "User-Agent: meridian-ci (github.com/drydocs/meridian)" https://crates.io/api/v1/crates/stellar-cli | jq -r '.crate.max_stable_version') + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + echo "::error::Failed to resolve stellar-cli version from crates.io" + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Cache Stellar CLI binary id: cache-stellar-cli From be9afad78ddc73db7ff43befe4670772f076bcfb Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sun, 6 Sep 2026 19:19:47 +0100 Subject: [PATCH 08/10] fix(build): fold alert into keepers action, redeploy vault stack --- api/v1/keepers/[action].ts | 60 +++++++++++++------ apps/docs/operations/testnet-deployment.md | 28 +++++++++ packages/shared/src/constants.ts | 17 +++--- .../stellar-sdk-helpers/src/known-pools.ts | 2 +- 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/api/v1/keepers/[action].ts b/api/v1/keepers/[action].ts index cf4fe906..f0ec882e 100644 --- a/api/v1/keepers/[action].ts +++ b/api/v1/keepers/[action].ts @@ -2,11 +2,13 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import { consoleLogger, isMigrationKeeperConfigured, + loadAlertKeeperConfig, loadBlendAccrualKeeperConfig, loadKeeperHeartbeatStore, loadMigrationKeeperConfig, recordKeeperHeartbeat, redactedErrorMessage, + runAlertKeeper, runBlendAccrualKeeper, runMigrationKeeper, } from "@meridian/stellar-sdk-helpers"; @@ -19,16 +21,19 @@ import { isCronSecretConfigured, } from "../../_lib/middleware.js"; -// Consolidated from three separate files (accrue/health/rebalance) to stay -// under Vercel's per-deployment Serverless Functions cap. URL paths are -// unchanged: /api/v1/keepers/accrue, /api/v1/keepers/health, and -// /api/v1/keepers/rebalance all route here via the [action].ts dynamic -// segment, with req.query.action set accordingly. +// Consolidated from four separate files (accrue/health/rebalance/alert) to +// stay under Vercel's per-deployment Serverless Functions cap. URL paths are +// unchanged: /api/v1/keepers/accrue, /api/v1/keepers/health, +// /api/v1/keepers/rebalance, and /api/v1/keepers/alert all route here via +// the [action].ts dynamic segment, with req.query.action set accordingly. // -// health is read-only and public, same as before; accrue/rebalance still -// require the cron bearer token and are never CORS-gated, since they sign -// and submit real transactions off a funded/admin account. That auth split -// is preserved exactly as it was when these were separate files. +// health is read-only and public, same as before; accrue/rebalance/alert +// still require the cron bearer token and are never CORS-gated. accrue and +// rebalance sign and submit real transactions off a funded/admin account; +// alert signs nothing but still costs an RPC round trip per known vault plus +// a webhook POST per unauthenticated probe, so it gets the same rate-limit +// backstop. That auth split is preserved exactly as it was when these were +// separate files. export default async function handler(req: VercelRequest, res: VercelResponse) { const { action } = req.query; @@ -41,7 +46,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(result.status).json(result.body); } - if (action !== "accrue" && action !== "rebalance") { + if (action !== "accrue" && action !== "rebalance" && action !== "alert") { return res.status(404).json({ error: "Unknown action" }); } @@ -50,13 +55,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { return res.status(405).json({ error: "Method not allowed" }); } - // No applyCors: cron-invoked, never browser-facing. But both sign and - // submit real transactions, unlike simple reads, so they still get a - // rate-limit backstop. Checked before auth, deliberately, even though - // that costs a Redis round trip on an unauthenticated probe: this is the - // volume-abuse backstop for *all* traffic, not just correctly - // authenticated traffic — if it only ran after a successful auth check, - // unauthenticated/wrong-token spam would be entirely unbounded. + // No applyCors: cron-invoked, never browser-facing. But all three sign or + // submit real transactions, or read/post on their behalf, unlike simple + // reads, so they still get a rate-limit backstop. Checked before auth, + // deliberately, even though that costs a Redis round trip on an + // unauthenticated probe: this is the volume-abuse backstop for *all* + // traffic, not just correctly authenticated traffic — if it only ran + // after a successful auth check, unauthenticated/wrong-token spam would + // be entirely unbounded. try { if (!(await checkRateLimit(req, res, { strict: true }))) return; } catch (err) { @@ -96,6 +102,26 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } } + if (action === "alert") { + try { + const config = loadAlertKeeperConfig(process.env); + // Without a shared cursor store, a restart or a second concurrent + // invocation has no memory of what it already alerted on, + // reinstating exactly the replay this keeper exists to prevent. + // Reuses keeper-heartbeat.ts's generic numeric store rather than + // requiring a dedicated cursor store. + const cursorStore = loadKeeperHeartbeatStore(process.env, { + logger: consoleLogger, + }); + const result = await runAlertKeeper(config, { cursorStore }); + const status = result.failures.length > 0 ? 500 : 200; + return res.status(status).json(result); + } catch (err) { + console.error("[alert-keeper] run failed:", err); + return res.status(500).json({ error: redactedErrorMessage(err) }); + } + } + // The migration keeper is deliberately not fully wired up yet (#511, // #514): ops may reasonably leave this unset until both land. Without // this check, every hourly cron tick would throw inside diff --git a/apps/docs/operations/testnet-deployment.md b/apps/docs/operations/testnet-deployment.md index a4148c34..691b8b0f 100644 --- a/apps/docs/operations/testnet-deployment.md +++ b/apps/docs/operations/testnet-deployment.md @@ -238,6 +238,34 @@ This contract is not deleted or disabled — Soroban has no such operation, it k The admin is the same durable key as the two prior cutovers, kept across this one too. Deployed via `scripts/deploy-testnet.sh` with a fresh throwaway `DEPLOYER` and `ADMIN`/`ADMIN_KEY` set explicitly to that durable admin key, so the vault was signed and initialized in the same run rather than left briefly claimable. `get_total_assets()`, `get_pool()`, and `get_protocol()` confirmed resolving correctly on the new vault and its Blend adapter. +### 2026-09-06: redeployed for the TTL, event, and migration-timelock changes (#701) + +The previous vault predated four contract-touching PRs merged since the last cutover: #704 (instance/position TTL management), #711 (event emission on deposit/withdraw), #705 (an off-chain migration-keeper fix, no contract change but confirms the pairing), and #710 (`MAX_ADMIN_SLIPPAGE_BPS` slippage cap and the `MIN_LEDGER_GAP` timelock extension from ~1 minute to ~1 day). None of these changed argument counts the way #604/#606 did, so the vault kept simulating deposits successfully, but shipping the security-relevant #557 fix (the longer timelock) live only once #701 completed the build-pipeline fix made this the natural point to redeploy rather than leave the fix undeployed indefinitely. + +**Pre-cutover status:** the old vault below held `get_total_assets() = 0`, no outstanding testnet deposits, so no withdrawal-announcement window was needed. + +**Old vault (superseded):** + +| Field | Value | +| ------------------- | ------------------------------------------------------------ | +| Vault contract | `CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME` | +| Blend adapter | `CCXB5BRVBFNPAN72PRODGFWKGGDHEHJMHJLC7G2OEQFF4PZNNO3C4XBH` | +| mUSDC (share token) | `CAJASVPQ365EYUQ62Z54SRSZWJ4C7WJNDYXIYVWKLSRWJTTWET35JPYE` | +| Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` | + +This contract is not deleted or disabled: Soroban has no such operation, it keeps running exactly as deployed. There is no automatic migration or sweep of old positions into the new vault, but there were none to move at cutover time. + +**New vault (current):** + +| Field | Value | +| ------------------- | ------------------------------------------------------------ | +| Vault contract | `CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7` | +| Blend adapter | `CB2GNYVHJ6O2QX2ZEP5EIHRBC26W6VE3APVPU3PD6JVQR5KQIVBOLALC` | +| mUSDC (share token) | `CDJ6A3ISCVLZRHVUQC6SWVZDFMMXSK5I6XUUUO3FKJWCQSMXKOZK3YIO` | +| Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` | + +The admin is the same durable key as all three prior cutovers, kept across this one too. Deployed via `scripts/deploy-testnet.sh` with `DEPLOYER=cutover-deployer` (a pre-funded throwaway testnet identity) and `ADMIN`/`ADMIN_KEY` set explicitly to that durable admin key, built with `stellar-cli` v28.0.0, matching what `.github/workflows/verify-contract-addresses.yml` resolves at the time of this cutover. `extend_position_ttl`, `begin_migration`, `migrate_adapter`, and `on_transfer` all confirmed present in the deployed vault's exported function list, and `begin_migration`'s own doc text in that output already reflects the ~1-day timelock. `get_total_assets()`, `get_adapter()`, and the resulting Blend adapter's `get_pool()`/`get_protocol()` confirmed resolving correctly. + ## Run the signing flow end-to-end With the contracts deployed and `known-pools.ts`/`constants.ts` updated: diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e62a725c..27a69604 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -53,14 +53,15 @@ export const CONTRACT_ADDRESSES = { usdc: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", // Stellar Asset Contract for Circle's testnet EURC (issuer: GB3Q6QDZYTHWT7...). eurc: "CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ", - musdc: "CAJASVPQ365EYUQ62Z54SRSZWJ4C7WJNDYXIYVWKLSRWJTTWET35JPYE", - // Redeployed with stellar-cli v28.0.0 to match what - // verify-contract-addresses.yml rebuilds with; the previous vault - // (CC3WA7SSJOI7WJPLWEGHSK3GRD3PSQXAIOQTXQEHBXYIIVJFZR4ZVAYP) was built - // with an older CLI and its bytecode no longer matched current source. - // See apps/docs/operations/testnet-deployment.md's "Vault migration - // history" for the old address and its (empty) pre-cutover balance. - vault: "CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME", + musdc: "CDJ6A3ISCVLZRHVUQC6SWVZDFMMXSK5I6XUUUO3FKJWCQSMXKOZK3YIO", + // Redeployed for #701: the previous vault + // (CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME) predated + // #704/#705/#711/#710 (TTL management, event emission, migration-keeper + // fix, admin slippage cap and timelock), all landed after that vault was + // last deployed. See apps/docs/operations/testnet-deployment.md's "Vault + // migration history" for the old address and its (empty) pre-cutover + // balance. + vault: "CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7", }, mainnet: { blend: { diff --git a/packages/stellar-sdk-helpers/src/known-pools.ts b/packages/stellar-sdk-helpers/src/known-pools.ts index d7a7e429..77f14502 100644 --- a/packages/stellar-sdk-helpers/src/known-pools.ts +++ b/packages/stellar-sdk-helpers/src/known-pools.ts @@ -63,7 +63,7 @@ export const KNOWN_POOLS: { name: "Meridian", protocol: "meridian", label: "USDC Vault", - contractId: "CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME", + contractId: "CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7", assetId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", asset: "USDC", }, From 7f37e996eee6b8275af4cc880d2f7a2eac5f0491 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sun, 6 Sep 2026 19:34:12 +0100 Subject: [PATCH 09/10] fix(build): fix deposit test after tx consolidation, format doc --- api/__tests__/handlers.test.ts | 3 ++- apps/docs/operations/testnet-deployment.md | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index e36088a1..7a94603a 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -271,8 +271,9 @@ describe("POST /api/v1/tx/deposit", () => { it("rejects a deposit request missing risk acknowledgement", async () => { const res = makeRes(); - await depositHandler( + await txHandler( fakeReq({ + query: { action: "deposit" }, method: "POST", body: { walletAddress: PUBKEY, diff --git a/apps/docs/operations/testnet-deployment.md b/apps/docs/operations/testnet-deployment.md index 691b8b0f..520f1945 100644 --- a/apps/docs/operations/testnet-deployment.md +++ b/apps/docs/operations/testnet-deployment.md @@ -246,8 +246,8 @@ The previous vault predated four contract-touching PRs merged since the last cut **Old vault (superseded):** -| Field | Value | -| ------------------- | ------------------------------------------------------------ | +| Field | Value | +| ------------------- | ---------------------------------------------------------- | | Vault contract | `CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME` | | Blend adapter | `CCXB5BRVBFNPAN72PRODGFWKGGDHEHJMHJLC7G2OEQFF4PZNNO3C4XBH` | | mUSDC (share token) | `CAJASVPQ365EYUQ62Z54SRSZWJ4C7WJNDYXIYVWKLSRWJTTWET35JPYE` | @@ -257,8 +257,8 @@ This contract is not deleted or disabled: Soroban has no such operation, it keep **New vault (current):** -| Field | Value | -| ------------------- | ------------------------------------------------------------ | +| Field | Value | +| ------------------- | ---------------------------------------------------------- | | Vault contract | `CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7` | | Blend adapter | `CB2GNYVHJ6O2QX2ZEP5EIHRBC26W6VE3APVPU3PD6JVQR5KQIVBOLALC` | | mUSDC (share token) | `CDJ6A3ISCVLZRHVUQC6SWVZDFMMXSK5I6XUUUO3FKJWCQSMXKOZK3YIO` | From 5cdaa0589ee93a6b51fea013057128dafff7fe2b Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sun, 6 Sep 2026 21:36:58 +0100 Subject: [PATCH 10/10] fix(build): redeploy vault stack from a Linux-built WASM --- apps/docs/operations/testnet-deployment.md | 10 ++++++---- packages/shared/src/constants.ts | 14 +++++++++----- packages/stellar-sdk-helpers/src/known-pools.ts | 2 +- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/docs/operations/testnet-deployment.md b/apps/docs/operations/testnet-deployment.md index 520f1945..baa50e9d 100644 --- a/apps/docs/operations/testnet-deployment.md +++ b/apps/docs/operations/testnet-deployment.md @@ -259,12 +259,14 @@ This contract is not deleted or disabled: Soroban has no such operation, it keep | Field | Value | | ------------------- | ---------------------------------------------------------- | -| Vault contract | `CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7` | -| Blend adapter | `CB2GNYVHJ6O2QX2ZEP5EIHRBC26W6VE3APVPU3PD6JVQR5KQIVBOLALC` | -| mUSDC (share token) | `CDJ6A3ISCVLZRHVUQC6SWVZDFMMXSK5I6XUUUO3FKJWCQSMXKOZK3YIO` | +| Vault contract | `CAIQBVLBIUWQGE6DQUHDMZ2QWI7QP6KTCN7GP2BIZ6JZC4ES47JO4SSM` | +| Blend adapter | `CCKOKEMM7X6NQ6C6XXRRH6BPKLINDZBNHHCHY4YDR3VZNOLOQJCSZCEA` | +| mUSDC (share token) | `CCU7RWT246CODH2455WTSGUYKXRL3J4F5C2QXIEVCN7QHCRC4BAWGKV7` | | Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` | -The admin is the same durable key as all three prior cutovers, kept across this one too. Deployed via `scripts/deploy-testnet.sh` with `DEPLOYER=cutover-deployer` (a pre-funded throwaway testnet identity) and `ADMIN`/`ADMIN_KEY` set explicitly to that durable admin key, built with `stellar-cli` v28.0.0, matching what `.github/workflows/verify-contract-addresses.yml` resolves at the time of this cutover. `extend_position_ttl`, `begin_migration`, `migrate_adapter`, and `on_transfer` all confirmed present in the deployed vault's exported function list, and `begin_migration`'s own doc text in that output already reflects the ~1-day timelock. `get_total_assets()`, `get_adapter()`, and the resulting Blend adapter's `get_pool()`/`get_protocol()` confirmed resolving correctly. +The admin is the same durable key as all three prior cutovers, kept across this one too. `DEPLOYER=cutover-deployer` (a pre-funded throwaway testnet identity), `ADMIN`/`ADMIN_KEY` set explicitly to that durable admin key. + +The WASM itself was built in a Linux GitHub Actions job rather than locally, then uploaded and deployed with `stellar contract upload`/`stellar contract deploy` directly rather than through `scripts/deploy-testnet.sh` (which always builds locally). An earlier attempt at this same cutover, built locally on Windows, produced a vault whose bytecode hash did not match what `.github/workflows/verify-contract-addresses.yml` independently rebuilds on Linux and checks against `CONTRACT_ADDRESSES.testnet.vault`, since `stellar contract build`'s `--remap-path-prefix` only normalizes Linux-style registry paths and cannot make a Windows build byte-identical to a Linux one. That attempt (vault `CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7`, blend adapter `CB2GNYVHJ6O2QX2ZEP5EIHRBC26W6VE3APVPU3PD6JVQR5KQIVBOLALC`, mUSDC `CDJ6A3ISCVLZRHVUQC6SWVZDFMMXSK5I6XUUUO3FKJWCQSMXKOZK3YIO`) never had its address recorded in `CONTRACT_ADDRESSES`/`KNOWN_POOLS` and is not counted as a real cutover, so it is noted here rather than given its own history entry. `extend_position_ttl`, `begin_migration`, `migrate_adapter`, and `on_transfer` all confirmed present in the deployed vault's exported function list, and `begin_migration`'s own doc text in that output already reflects the ~1-day timelock. `get_total_assets()`, `get_adapter()`, and the resulting Blend adapter's `get_pool()`/`get_protocol()` confirmed resolving correctly. ## Run the signing flow end-to-end diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 27a69604..da964c8b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -53,15 +53,19 @@ export const CONTRACT_ADDRESSES = { usdc: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", // Stellar Asset Contract for Circle's testnet EURC (issuer: GB3Q6QDZYTHWT7...). eurc: "CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ", - musdc: "CDJ6A3ISCVLZRHVUQC6SWVZDFMMXSK5I6XUUUO3FKJWCQSMXKOZK3YIO", + musdc: "CCU7RWT246CODH2455WTSGUYKXRL3J4F5C2QXIEVCN7QHCRC4BAWGKV7", // Redeployed for #701: the previous vault // (CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME) predated // #704/#705/#711/#710 (TTL management, event emission, migration-keeper // fix, admin slippage cap and timelock), all landed after that vault was - // last deployed. See apps/docs/operations/testnet-deployment.md's "Vault - // migration history" for the old address and its (empty) pre-cutover - // balance. - vault: "CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7", + // last deployed. Built from a Linux CI job rather than locally, so its + // bytecode is guaranteed to match what + // .github/workflows/verify-contract-addresses.yml independently + // rebuilds and checks (a Windows-built WASM cannot be guaranteed + // byte-identical). See apps/docs/operations/testnet-deployment.md's + // "Vault migration history" for the old address and its (empty) + // pre-cutover balance. + vault: "CAIQBVLBIUWQGE6DQUHDMZ2QWI7QP6KTCN7GP2BIZ6JZC4ES47JO4SSM", }, mainnet: { blend: { diff --git a/packages/stellar-sdk-helpers/src/known-pools.ts b/packages/stellar-sdk-helpers/src/known-pools.ts index 77f14502..5351942c 100644 --- a/packages/stellar-sdk-helpers/src/known-pools.ts +++ b/packages/stellar-sdk-helpers/src/known-pools.ts @@ -63,7 +63,7 @@ export const KNOWN_POOLS: { name: "Meridian", protocol: "meridian", label: "USDC Vault", - contractId: "CBNXROTWUVHNRRI2LRKHEXJXIWPJTOZOMMMMX7KNQEJAY5ZOGSM7LYZ7", + contractId: "CAIQBVLBIUWQGE6DQUHDMZ2QWI7QP6KTCN7GP2BIZ6JZC4ES47JO4SSM", assetId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", asset: "USDC", },