diff --git a/src/__tests__/drepStatus.test.ts b/src/__tests__/drepStatus.test.ts index 34dbd366..2eb18331 100644 --- a/src/__tests__/drepStatus.test.ts +++ b/src/__tests__/drepStatus.test.ts @@ -10,6 +10,16 @@ import { fetchDrepStatus } from "@/lib/governance/drep-status"; * inactive. */ +/** What Mesh's BlockfrostProvider.get rejects with (parseHttpError). */ +const meshRejection = (status: number) => + JSON.stringify({ + data: { status_code: status, error: "x", message: "x" }, + headers: { "content-type": "application/json" }, + status, + }); +const MESH_404 = meshRejection(404); +const MESH_500 = meshRejection(500); + function provider(impl: (url: string) => Promise) { return { get: jest.fn(impl) }; } @@ -45,6 +55,23 @@ describe("fetchDrepStatus", () => { await expect(fetchDrepStatus(byMessage, "drep1abc")).resolves.toEqual({ active: false }); }); + it("treats Mesh's real 404 rejection — a JSON string, not an Error — as inactive", async () => { + // Before this case was handled, an MCP vote draft for a never-registered + // DRep failed as DREP_LOOKUP_FAILED instead of telling the user to + // register first. + const p = provider(async () => { + throw MESH_404; + }); + await expect(fetchDrepStatus(p, "drep1abc")).resolves.toEqual({ active: false }); + }); + + it("rethrows Mesh's string rejection for any other status", async () => { + const p = provider(async () => { + throw MESH_500; + }); + await expect(fetchDrepStatus(p, "drep1abc")).rejects.toBe(MESH_500); + }); + it("rethrows any other provider failure", async () => { const p = provider(async () => { throw Object.assign(new Error("Blockfrost 500"), { status: 500 }); diff --git a/src/__tests__/stakeAccountInfo.test.ts b/src/__tests__/stakeAccountInfo.test.ts new file mode 100644 index 00000000..09b069ae --- /dev/null +++ b/src/__tests__/stakeAccountInfo.test.ts @@ -0,0 +1,117 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const providerGetMock = jest.fn<(path: string) => Promise>(); +const getProviderMock = jest.fn((_network: number) => ({ get: providerGetMock })); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: jest.fn(), + cors: jest.fn(async () => undefined), +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: () => true, + applyBotRateLimit: () => true, +})); + +jest.mock("@/lib/verifyJwt", () => ({ + __esModule: true, + verifyJwt: (token: string) => (token === "good" ? { address: "addr_test1x" } : null), + isBotJwt: () => false, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: (network: number) => getProviderMock(network), +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/stakeAccountInfo")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "error").mockImplementation(() => undefined); +}); + +// Well-formed bech32 reward addresses (header byte + 28-byte credential). +const MAINNET = "stake1uyrswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc4vqlc2"; +const TESTNET = "stake_test1uqrswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpcjx2auh"; +// Script-credential reward address (header 0xf1), as a multisig wallet has. +const MAINNET_SCRIPT = "stake17yrswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpcuyul02"; + +/** What Mesh's BlockfrostProvider.get rejects with (parseHttpError). */ +const meshRejection = (status: number) => + JSON.stringify({ data: { status_code: status }, headers: {}, status }); + +async function call(stakeAddress: unknown, token = "good") { + const req = { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + query: stakeAddress === undefined ? {} : { stakeAddress }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + return res; +} + +describe("GET /api/v1/stakeAccountInfo", () => { + it("requires a valid bearer token", async () => { + const res = await call(TESTNET, "bad"); + expect(res.statusCode).toBe(401); + expect(providerGetMock).not.toHaveBeenCalled(); + }); + + it("reads a registered account from the network its address names", async () => { + providerGetMock.mockResolvedValueOnce({ active: true, pool_id: "pool1abc" }); + const res = await call(` ${TESTNET} `); + expect(getProviderMock).toHaveBeenCalledWith(0); + expect(providerGetMock).toHaveBeenCalledWith(`/accounts/${TESTNET}`); + expect(res.statusCode).toBe(200); + expect(res.json).toHaveBeenCalledWith({ active: true, poolId: "pool1abc" }); + + for (const address of [MAINNET, MAINNET_SCRIPT]) { + providerGetMock.mockResolvedValueOnce({ active: false, pool_id: null }); + const mainnet = await call(address); + expect(mainnet.statusCode).toBe(200); + expect(getProviderMock).toHaveBeenLastCalledWith(1); + expect(providerGetMock).toHaveBeenLastCalledWith(`/accounts/${address}`); + } + }); + + it("answers 200 inactive for a never-registered account (Mesh's string 404)", async () => { + // A fresh multisig wallet's stake credential has never appeared on chain; + // that is the normal "not registered yet" answer, not a server error. + providerGetMock.mockRejectedValueOnce(meshRejection(404)); + const res = await call(TESTNET); + expect(res.statusCode).toBe(200); + expect(res.json).toHaveBeenCalledWith({ active: false, poolId: null }); + }); + + it("still answers 500 when the lookup itself fails", async () => { + providerGetMock.mockRejectedValueOnce(meshRejection(500)); + const res = await call(TESTNET); + expect(res.statusCode).toBe(500); + }); + + it.each([ + ["missing", undefined], + ["empty", " "], + ["repeated", [TESTNET, TESTNET]], + ["a path escape", `${TESTNET}/../../addresses/addr_test1x`], + ["another endpoint", `${TESTNET}/history`], + ["a query string", `${TESTNET}?page=2`], + ["a payment address", "addr_test1qrswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpcjx2auh"], + ["a truncated address", TESTNET.slice(0, -1)], + ["characters outside bech32", TESTNET.replace(/q/g, "b")], + ])("rejects %s with 400 before calling Blockfrost", async (_label, value) => { + const res = await call(value); + expect(res.statusCode).toBe(400); + expect(providerGetMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/stakeAccountStatus.test.ts b/src/__tests__/stakeAccountStatus.test.ts index c044b24d..be7803e8 100644 --- a/src/__tests__/stakeAccountStatus.test.ts +++ b/src/__tests__/stakeAccountStatus.test.ts @@ -10,6 +10,16 @@ import { fetchStakeAccountStatus } from "@/lib/staking/stake-account-status"; * surface rather than read as inactive. */ +/** What Mesh's BlockfrostProvider.get rejects with (parseHttpError). */ +const meshRejection = (status: number) => + JSON.stringify({ + data: { status_code: status, error: "x", message: "x" }, + headers: { "content-type": "application/json" }, + status, + }); +const MESH_404 = meshRejection(404); +const MESH_500 = meshRejection(500); + function provider(impl: (url: string) => Promise) { return { get: jest.fn(impl) }; } @@ -67,6 +77,27 @@ describe("fetchStakeAccountStatus", () => { }); }); + it("treats Mesh's real 404 rejection — a JSON string, not an Error — as inactive", async () => { + // BlockfrostProvider.get rejects with parseHttpError's JSON.stringify of + // the axios response. Before this case was handled, every never-registered + // stake account surfaced as a lookup failure (a 500 from stakeAccountInfo, + // a 502 STAKE_LOOKUP_FAILED from the MCP draft pipeline). + const p = provider(async () => { + throw MESH_404; + }); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).resolves.toEqual({ + active: false, + poolId: null, + }); + }); + + it("rethrows Mesh's string rejection for any other status", async () => { + const p = provider(async () => { + throw MESH_500; + }); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).rejects.toBe(MESH_500); + }); + it("rethrows any other provider failure", async () => { const p = provider(async () => { throw Object.assign(new Error("Blockfrost 500"), { status: 500 }); @@ -74,3 +105,35 @@ describe("fetchStakeAccountStatus", () => { await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).rejects.toThrow("Blockfrost 500"); }); }); + +describe("fetchStakeAccountStatus against Mesh's real BlockfrostProvider", () => { + // The mocks above copy a rejection shape; this pins it to the library, so a + // Mesh upgrade that changes how get() rejects shows up here first. + it("reads Blockfrost's 404 for an unseen account as inactive", async () => { + const http = await import("node:http"); + const { BlockfrostProvider } = await import("@meshsdk/core"); + const server = http.createServer((_req, res) => { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ status_code: 404, error: "Not Found" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + // axios honours HTTP(S)_PROXY even for 127.0.0.1; a proxied dev shell + // would otherwise send this request somewhere else. + const savedNoProxy = { upper: process.env.NO_PROXY, lower: process.env.no_proxy }; + process.env.NO_PROXY = process.env.no_proxy = "127.0.0.1,localhost"; + try { + const { port } = server.address() as { port: number }; + const p = new BlockfrostProvider(`http://127.0.0.1:${port}`); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).resolves.toEqual({ + active: false, + poolId: null, + }); + } finally { + for (const [key, value] of [["NO_PROXY", savedNoProxy.upper], ["no_proxy", savedNoProxy.lower]] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await new Promise((resolve) => server.close(resolve)); + } + }); +}); diff --git a/src/lib/governance/drep-profile.ts b/src/lib/governance/drep-profile.ts index 77da092e..24dc9bd9 100644 --- a/src/lib/governance/drep-profile.ts +++ b/src/lib/governance/drep-profile.ts @@ -2,6 +2,7 @@ import type { BlockfrostDrepInfo, BlockfrostDrepMetadata, } from "@/types/governance"; +import { isBlockfrostNotFound } from "@/utils/blockfrost-not-found"; /** Anything with Blockfrost's raw `get(path)`: the light reader or Mesh's provider. */ type DrepReader = { @@ -66,33 +67,6 @@ export async function fetchDrepProfile( }; } -/** - * Whether a Blockfrost read failed with 404. - * - * The light reader (`src/utils/blockfrost-get.ts`) rejects with a JSON - * *string* — `{"data":…,"status":404}` — copying Mesh's BlockfrostProvider, - * while other code paths reject with an object. Checking only - * `err.response.status` (what the explorer pages did) never matches the - * string, so every DRep without metadata was logged as an error. - */ -export function isBlockfrostNotFound(error: unknown): boolean { - let value: unknown = error; - if (typeof value === "string") { - try { - value = JSON.parse(value); - } catch { - return false; - } - } - if (!value || typeof value !== "object") return false; - const e = value as { - status?: unknown; - response?: { status?: unknown }; - data?: { status_code?: unknown }; - }; - return ( - e.status === 404 || - e.response?.status === 404 || - e.data?.status_code === 404 - ); -} +// Lives in a shared util so the registration probes use the same check; +// re-exported for existing importers of this module. +export { isBlockfrostNotFound }; diff --git a/src/lib/governance/drep-status.ts b/src/lib/governance/drep-status.ts index 71989fae..1ecff87c 100644 --- a/src/lib/governance/drep-status.ts +++ b/src/lib/governance/drep-status.ts @@ -1,3 +1,5 @@ +import { isBlockfrostNotFound } from "@/utils/blockfrost-not-found"; + export type DrepStatus = { /** Registered as a DRep on chain right now (a registration not since retired). */ active: boolean; @@ -44,8 +46,10 @@ export async function fetchDrepStatus( } function isNotFound(error: unknown): boolean { + // Mesh's BlockfrostProvider rejects with a JSON string, not an Error, so + // the string shape is the one production actually sees. return ( - (error as { status?: number })?.status === 404 || + isBlockfrostNotFound(error) || (error instanceof Error && error.message.includes("404")) ); } diff --git a/src/lib/staking/stake-account-status.ts b/src/lib/staking/stake-account-status.ts index 9488ad61..c783af0b 100644 --- a/src/lib/staking/stake-account-status.ts +++ b/src/lib/staking/stake-account-status.ts @@ -1,3 +1,5 @@ +import { isBlockfrostNotFound } from "@/utils/blockfrost-not-found"; + export type StakeAccountStatus = { /** Registered on chain right now (a registration not since undone). */ active: boolean; @@ -48,8 +50,10 @@ export async function fetchStakeAccountStatus( } function isNotFound(error: unknown): boolean { + // Mesh's BlockfrostProvider rejects with a JSON string, not an Error, so + // the string shape is the one production actually sees. return ( - (error as { status?: number })?.status === 404 || + isBlockfrostNotFound(error) || (error instanceof Error && error.message.includes("404")) ); } diff --git a/src/pages/api/v1/stakeAccountInfo.ts b/src/pages/api/v1/stakeAccountInfo.ts index 76f229f6..1d9d2627 100644 --- a/src/pages/api/v1/stakeAccountInfo.ts +++ b/src/pages/api/v1/stakeAccountInfo.ts @@ -5,6 +5,19 @@ import { applyRateLimit, applyBotRateLimit } from "@/lib/security/requestGuards" import { fetchStakeAccountStatus } from "@/lib/staking/stake-account-status"; import { getProvider } from "@/utils/get-provider"; +/** + * A lowercase bech32 reward address (key or script credential): `stake1` / + * `stake_test1` plus 53 characters of the bech32 alphabet (29 bytes of header + * + credential, then the checksum). The checksum itself is Blockfrost's to + * check; the shape is what keeps the value inside the path segment. + * + * The value is spliced into Blockfrost's `/accounts/{stake}` path with the + * server's project key, so anything else — `../…`, `?…`, a payment address — + * must not reach the request: it either reads another endpoint or comes + * back as a Blockfrost 400 that this route would report as a 500. + */ +const REWARD_ADDRESS = /^(stake|stake_test)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{53}$/; + export default async function handler(req: NextApiRequest, res: NextApiResponse) { addCorsCacheBustingHeaders(res); if (!applyRateLimit(req, res, { keySuffix: "v1/stakeAccountInfo" })) return; @@ -21,16 +34,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (isBotJwt(payload) && !applyBotRateLimit(req, res, payload.botId)) return; const { stakeAddress } = req.query; - if (typeof stakeAddress !== "string" || !stakeAddress.trim()) { + const parsed = + typeof stakeAddress === "string" ? REWARD_ADDRESS.exec(stakeAddress.trim()) : null; + if (!parsed) { return res.status(400).json({ error: "Missing or invalid stakeAddress parameter" }); } - const network = stakeAddress.startsWith("stake_test") ? 0 : 1; + const network = parsed[1] === "stake_test" ? 0 : 1; const provider = getProvider(network); try { // Never-registered accounts (Blockfrost 404) come back as inactive. - const info = await fetchStakeAccountStatus(provider, stakeAddress); + const info = await fetchStakeAccountStatus(provider, parsed[0]); return res.status(200).json(info); } catch (e) { console.error("stakeAccountInfo error:", e); diff --git a/src/utils/blockfrost-not-found.ts b/src/utils/blockfrost-not-found.ts new file mode 100644 index 00000000..c972fda5 --- /dev/null +++ b/src/utils/blockfrost-not-found.ts @@ -0,0 +1,31 @@ +/** + * Whether a Blockfrost read failed with 404. + * + * Mesh's `BlockfrostProvider.get` — and the light reader + * (`src/utils/blockfrost-get.ts`), which copies it — rejects with a JSON + * *string*: `{"data":{"status_code":404,…},"headers":…,"status":404}`, not an + * Error. Checking `err.status` or `err instanceof Error` never matches that + * string, so a "never seen on chain" answer was read as a lookup failure. + * Other code paths reject with an object, so both shapes are accepted. + */ +export function isBlockfrostNotFound(error: unknown): boolean { + let value: unknown = error; + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return false; + } + } + if (!value || typeof value !== "object") return false; + const e = value as { + status?: unknown; + response?: { status?: unknown }; + data?: { status_code?: unknown }; + }; + return ( + e.status === 404 || + e.response?.status === 404 || + e.data?.status_code === 404 + ); +}