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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/__tests__/drepStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) {
return { get: jest.fn(impl) };
}
Expand Down Expand Up @@ -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 });
Expand Down
117 changes: 117 additions & 0 deletions src/__tests__/stakeAccountInfo.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>>();
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<unknown>;

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();
});
});
63 changes: 63 additions & 0 deletions src/__tests__/stakeAccountStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) {
return { get: jest.fn(impl) };
}
Expand Down Expand Up @@ -67,10 +77,63 @@ 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 });
});
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<void>((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));
}
});
});
34 changes: 4 additions & 30 deletions src/lib/governance/drep-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 };
6 changes: 5 additions & 1 deletion src/lib/governance/drep-status.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"))
);
}
6 changes: 5 additions & 1 deletion src/lib/staking/stake-account-status.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"))
);
}
21 changes: 18 additions & 3 deletions src/pages/api/v1/stakeAccountInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions src/utils/blockfrost-not-found.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading