diff --git a/.github/workflows/verify-contract-addresses.yml b/.github/workflows/verify-contract-addresses.yml index f066b745..a8979e05 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,38 @@ 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: | + # 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 + 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 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 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/apps/docs/operations/testnet-deployment.md b/apps/docs/operations/testnet-deployment.md index 577a1852..ed29d21c 100644 --- a/apps/docs/operations/testnet-deployment.md +++ b/apps/docs/operations/testnet-deployment.md @@ -180,6 +180,62 @@ 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. + +### 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/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/shared/src/constants.ts b/packages/shared/src/constants.ts index a9dfff08..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,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: "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 1b6d05b6..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: "CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL", + contractId: "CBOQTI3C7UHTBRHSF3AJEQYXDINJ354XRWIZKSEV6PFIEUSJF2YWZPME", assetId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU", asset: "USDC", }, 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", () => { 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" },