diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f7d39..dc2ac81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,6 @@ jobs: env: # Public RPC MAINNET_RPC_URL: https://eth.api.pocket.network + + - name: Test + run: pnpm test diff --git a/src/application/decoders/erc1271Signature.ts b/src/application/decoders/erc1271Signature.ts index ed4eb86..7f54948 100644 --- a/src/application/decoders/erc1271Signature.ts +++ b/src/application/decoders/erc1271Signature.ts @@ -28,7 +28,7 @@ import { decodeAbiParameters, type Hex } from "viem"; // GPv2Order.Data: 12 fixed-size fields × 32 bytes = 384 bytes total (used for byte-offset math) const GPV2_ORDER_BYTES = 384; -const PAYLOAD_STRUCT_ABI = [ +export const PAYLOAD_STRUCT_ABI = [ { type: "tuple" as const, name: "payload", diff --git a/tests/__mocks__/ponder-api.ts b/tests/__mocks__/ponder-api.ts new file mode 100644 index 0000000..ab6ca87 --- /dev/null +++ b/tests/__mocks__/ponder-api.ts @@ -0,0 +1,14 @@ +import { vi } from "vitest"; + +// Chainable select stub — each .select() call gets its own independent chain so +// tests can use mockReturnValueOnce to return different rows per query. +export function makeSelectChain(rows: unknown[] = []) { + const where = vi.fn().mockResolvedValue(rows); + const from = vi.fn().mockReturnValue({ where }); + return { from }; +} + +export const db = { + execute: vi.fn().mockResolvedValue({ rows: [] }), + select: vi.fn().mockReturnValue(makeSelectChain()), +}; diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts new file mode 100644 index 0000000..1712208 --- /dev/null +++ b/tests/api/execution-summary.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { z } from "zod"; + +// Mock virtual modules before any ponder-importing source files are loaded. +vi.mock("ponder:api", () => ({ db: { execute: vi.fn() } })); +vi.mock("ponder", () => ({ + sql: Object.assign( + (_s: TemplateStringsArray, ..._v: unknown[]) => ({}), + { raw: (_s: string) => ({}) }, + ), +})); + +import { db } from "ponder:api"; +import { executionSummaryRoute } from "../../src/api/routes"; +import { executionSummaryHandler } from "../../src/api/endpoints/execution-summary"; +import { DiscreteOrderStatusQuery } from "../../src/api/schemas/common"; + +const Status = DiscreteOrderStatusQuery.enum; +type StatusRow = { status: z.infer; count: string }; + +function buildApp() { + const app = new OpenAPIHono(); + app.openapi(executionSummaryRoute, executionSummaryHandler); + return app; +} + +const EVENT_ID = "177991282000000000000001000000000046395020000000000000001750000000000000054"; + +function makeUrl(eventId = EVENT_ID, chainId = 1) { + return `http://localhost/generator/${eventId}/execution-summary?chainId=${chainId}`; +} + +beforeEach(() => { + vi.mocked(db.execute).mockReset(); +}); + +describe("GET /api/generator/:eventId/execution-summary", () => { + it("returns all-zero counts when no discrete orders exist", async () => { + vi.mocked(db.execute).mockResolvedValue({ rows: [] } as never); + + const res = await buildApp().request(makeUrl()); + expect(res.status).toBe(200); + + const body = await res.json() as Record; + expect(body["totalParts"]).toBe(0); + expect(body["filledParts"]).toBe(0); + expect(body["openParts"]).toBe(0); + expect(body["unfilledParts"]).toBe(0); + expect(body["expiredParts"]).toBe(0); + expect(body["cancelledParts"]).toBe(0); + }); + + it("maps fulfilled, expired, open, unfilled, cancelled to the right fields", async () => { + const rows: StatusRow[] = [ + { status: Status.fulfilled, count: "3" }, + { status: Status.expired, count: "7" }, + { status: Status.open, count: "2" }, + ]; + vi.mocked(db.execute).mockResolvedValue({ rows } as never); + + const body = await (await buildApp().request(makeUrl())).json() as Record; + + expect(body["filledParts"]).toBe(3); + expect(body["expiredParts"]).toBe(7); + expect(body["openParts"]).toBe(2); + expect(body["unfilledParts"]).toBe(0); + expect(body["cancelledParts"]).toBe(0); + expect(body["totalParts"]).toBe(12); + }); + + it("totalParts is the sum of all status counts", async () => { + const rows: StatusRow[] = [ + { status: Status.fulfilled, count: "10" }, + { status: Status.cancelled, count: "5" }, + { status: Status.unfilled, count: "3" }, + ]; + vi.mocked(db.execute).mockResolvedValue({ rows } as never); + + const body = await (await buildApp().request(makeUrl())).json() as Record; + expect(body["totalParts"]).toBe(18); + }); + + it("echoes back the generatorId and chainId", async () => { + vi.mocked(db.execute).mockResolvedValue({ rows: [] } as never); + + const body = await (await buildApp().request(makeUrl(EVENT_ID, 100))).json() as Record; + expect(body["generatorId"]).toBe(EVENT_ID); + expect(body["chainId"]).toBe(100); + }); + + it("returns 400 when chainId query param is missing", async () => { + const app = buildApp(); + const res = await app.request( + `http://localhost/generator/${EVENT_ID}/execution-summary`, + ); + expect(res.status).toBe(400); + }); + + it("returns 500 when the DB throws", async () => { + vi.mocked(db.execute).mockRejectedValueOnce(new Error("db error")); + + const res = await buildApp().request(makeUrl()); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts new file mode 100644 index 0000000..df6fd0a --- /dev/null +++ b/tests/api/orders-by-owner.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock virtual modules before any ponder-importing source files are loaded. +// ponder:api is resolved to tests/__mocks__/ponder-api.ts via vitest alias — no inline override needed. +vi.mock("ponder:schema", () => { + const ownerMapping = { owner: "owner", chainId: "chainId", address: "address" }; + const conditionalOrderGenerator = { + eventId: "eventId", chainId: "chainId", orderType: "orderType", + owner: "owner", resolvedOwner: "resolvedOwner", status: "status", + ownerAddressType: "ownerAddressType", + }; + const discreteOrder = { + conditionalOrderGeneratorId: "conditionalOrderGeneratorId", + orderUid: "orderUid", chainId: "chainId", status: "status", + sellAmount: "sellAmount", buyAmount: "buyAmount", feeAmount: "feeAmount", + validTo: "validTo", creationDate: "creationDate", + executedSellAmount: "executedSellAmount", executedBuyAmount: "executedBuyAmount", + }; + return { + default: { ownerMapping, conditionalOrderGenerator, discreteOrder }, + ownerMapping, + conditionalOrderGenerator, + discreteOrder, + }; +}); +vi.mock("ponder", () => ({ + and: (..._args: unknown[]) => ({}), + eq: (..._args: unknown[]) => ({}), + inArray: (..._args: unknown[]) => ({}), + or: (..._args: unknown[]) => ({}), +})); + +import { db } from "ponder:api"; +import { makeSelectChain } from "../__mocks__/ponder-api"; +import { ordersByOwnerHandler } from "../../src/api/endpoints/orders-by-owner"; + +const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const EVENT_ID = "abc123"; +const CHAIN_ID = 1; + +/** Minimal Hono context stub that satisfies orders-by-owner handler requirements. */ +function makeContext({ + owner = OWNER, + chainId = CHAIN_ID, + status, + ownerAddressType, +}: { + owner?: string; + chainId?: number; + status?: string; + ownerAddressType?: string; +} = {}) { + const responses: Array<{ body: unknown; status: number }> = []; + return { + req: { + valid: (type: "param" | "query") => { + if (type === "param") return { owner }; + return { chainId, status, ownerAddressType }; + }, + }, + json: (body: unknown, httpStatus = 200) => { + // Simulate JSON serialization to catch BigInt issues early. + const serialised = JSON.parse(JSON.stringify(body, (_k, v) => + typeof v === "bigint" ? v.toString() : v + )); + responses.push({ body: serialised, status: httpStatus }); + return { body: serialised, status: httpStatus }; + }, + _responses: responses, + }; +} + +const GENERATOR = { + eventId: EVENT_ID, + chainId: CHAIN_ID, + orderType: "TWAP", + owner: OWNER, + resolvedOwner: OWNER, + status: "Active", + ownerAddressType: null, + hash: "0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1", +}; + +const ORDER = { + orderUid: "0x" + "bb".repeat(56), + chainId: CHAIN_ID, + status: "fulfilled", + sellAmount: "1000000000000000000", + buyAmount: "2000000000000000000", + feeAmount: "1000000000000000", + validTo: 9_999_999_999, + creationDate: BigInt("1700000000"), + executedSellAmount: "1000000000000000000", + executedBuyAmount: "2000000000000000000", + generatorId: EVENT_ID, +}; + +beforeEach(() => { + vi.mocked(db.select).mockReset(); +}); + +describe("ordersByOwnerHandler", () => { + it("returns empty orders array when no generators are found", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) // ownerMapping → no proxies + .mockReturnValueOnce(makeSelectChain([]) as never); // generators → none + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: unknown[] }; + expect(result.orders).toEqual([]); + }); + + it("returns empty orders when generators exist but have no discrete orders", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: unknown[] }; + expect(result.orders).toEqual([]); + }); + + it("returns enriched orders with embedded generator data", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: Array> }; + + expect(result.orders).toHaveLength(1); + const order = result.orders[0]!; + expect(order["orderUid"]).toBe(ORDER.orderUid); + expect(order["status"]).toBe("fulfilled"); + const gen = order["generator"] as Record; + expect(gen["eventId"]).toBe(EVENT_ID); + expect(gen["orderType"]).toBe("TWAP"); + }); + + it("serialises creationDate as a decimal string (BigInt scalar)", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: Array> }; + expect(result.orders[0]!["creationDate"]).toBe("1700000000"); + }); + + it("includes proxy addresses from ownerMapping in the generator lookup", async () => { + const PROXY = "0xcccccccccccccccccccccccccccccccccccccccc"; + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([{ address: PROXY }]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: unknown[] }; + expect(result.orders).toHaveLength(1); + }); + + // Regression guard for COW-993 (F15): hash must be present in generator object. + // Enable this test after the F15 fix lands (add `hash` to GeneratorSummary schema + // and select it in ordersByOwnerHandler). + it.todo("includes hash in generator object (COW-993)"); +}); diff --git a/tests/application/decoders/erc1271Signature.test.ts b/tests/application/decoders/erc1271Signature.test.ts new file mode 100644 index 0000000..27053d1 --- /dev/null +++ b/tests/application/decoders/erc1271Signature.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { encodeAbiParameters, getAddress, type Hex } from "viem"; +import { decodeEip1271Signature, PAYLOAD_STRUCT_ABI } from "../../../src/application/decoders/erc1271Signature"; + +const HANDLER = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd" as Hex; +const SALT = ("0x" + "ab".repeat(32)) as Hex; +const STATIC_INPUT = "0xdeadbeef" as Hex; +const PROOF: Hex[] = [("0x" + "11".repeat(32)) as Hex]; +const OFFCHAIN_INPUT = "0x1234" as Hex; + +/** Build a Format B signature (ERC1271Forwarder / CoWShed path). */ +function buildFormatB({ + handler = HANDLER, + salt = SALT, + staticInput = STATIC_INPUT, + proof = [] as Hex[], + offchainInput = "0x" as Hex, +} = {}): Hex { + const payloadEncoded = encodeAbiParameters(PAYLOAD_STRUCT_ABI, [ + { proof, params: { handler, salt, staticInput }, offchainInput }, + ]); + // Format B: 384 zero bytes (GPv2Order.Data placeholder) + PayloadStruct ABI encoding + return ("0x" + "00".repeat(384) + payloadEncoded.slice(2)) as Hex; +} + +/** Build a Format A signature (ISafeSignatureVerifier / Safe path). */ +function buildFormatA({ + handler = HANDLER, + salt = SALT, + staticInput = STATIC_INPUT, + proof = [] as Hex[], + offchainInput = "0x" as Hex, +} = {}): Hex { + const payloadEncoded = encodeAbiParameters(PAYLOAD_STRUCT_ABI, [ + { proof, params: { handler, salt, staticInput }, offchainInput }, + ]); + const payloadBytes = payloadEncoded.slice(2); // strip "0x" + const payloadLen = payloadBytes.length / 2; + + // Format A byte layout (all offsets in bytes): + // 0–3 selector 4 bytes + // 4–35 domainSeparator 32 bytes + // 36–67 typeHash 32 bytes + // 68–99 ABI offset to encodeData (=0x80=128 from byte 68) 32 bytes + // 100–131 ABI offset to payload (=0x220=544 from byte 68) 32 bytes + // 132–163 encodeData length (= 384) 32 bytes + // 164–547 abi.encode(GPv2Order.Data) 384 bytes + // 548–579 payload length 32 bytes + // 580–N abi.encode(PayloadStruct) + const padHex = (n: number, bytes: number) => n.toString(16).padStart(bytes * 2, "0"); + const hex = [ + "5fd7e97d", // selector (no 0x prefix here) + "00".repeat(32), // domainSeparator + "00".repeat(32), // typeHash + padHex(0x80, 32), // offset to encodeData + padHex(0x220, 32), // offset to payload + padHex(384, 32), // encodeData length = 384 + "00".repeat(384), // GPv2Order.Data placeholder + padHex(payloadLen, 32), // payload length + payloadBytes, // abi.encode(PayloadStruct) + ].join(""); + return ("0x" + hex) as Hex; +} + +// ─── Format B (ERC1271Forwarder / CoWShed) ──────────────────────────────────── + +describe("decodeEip1271Signature — Format B (ERC1271Forwarder)", () => { + it("round-trips handler, salt, staticInput", () => { + const sig = buildFormatB(); + const result = decodeEip1271Signature(sig); + expect(result).not.toBeNull(); + expect(result!.handler).toBe(HANDLER.toLowerCase()); + expect(result!.salt).toBe(SALT); + expect(result!.staticInput).toBe(STATIC_INPUT); + }); + + it("normalises handler address to lowercase", () => { + // viem requires checksummed addresses for encoding; use getAddress() to checksum first, + // then verify the decoder lowercases the output regardless of the encoded casing. + const checksummed = getAddress(HANDLER); + const sig = buildFormatB({ handler: checksummed }); + const result = decodeEip1271Signature(sig); + expect(result!.handler).toBe(HANDLER.toLowerCase()); + }); + + it("round-trips a non-empty proof array", () => { + const sig = buildFormatB({ proof: PROOF }); + const result = decodeEip1271Signature(sig); + expect(result!.proof).toEqual(PROOF); + }); + + it("round-trips offchainInput", () => { + const sig = buildFormatB({ offchainInput: OFFCHAIN_INPUT }); + const result = decodeEip1271Signature(sig); + expect(result!.offchainInput).toBe(OFFCHAIN_INPUT); + }); + + it("round-trips a multi-byte staticInput", () => { + const longInput = ("0x" + "cc".repeat(64)) as Hex; + const sig = buildFormatB({ staticInput: longInput }); + const result = decodeEip1271Signature(sig); + expect(result!.staticInput).toBe(longInput); + }); +}); + +// ─── Format A (ISafeSignatureVerifier / Safe wallet) ───────────────────────── + +describe("decodeEip1271Signature — Format A (ISafeSignatureVerifier)", () => { + it("detects the 0x5fd7e97d selector and round-trips handler, salt, staticInput", () => { + const sig = buildFormatA(); + const result = decodeEip1271Signature(sig); + expect(result).not.toBeNull(); + expect(result!.handler).toBe(HANDLER.toLowerCase()); + expect(result!.salt).toBe(SALT); + expect(result!.staticInput).toBe(STATIC_INPUT); + }); + + it("round-trips a non-empty proof via Format A", () => { + const sig = buildFormatA({ proof: PROOF }); + const result = decodeEip1271Signature(sig); + expect(result!.proof).toEqual(PROOF); + }); +}); + +// ─── Error / edge cases ─────────────────────────────────────────────────────── + +describe("decodeEip1271Signature — invalid inputs", () => { + it("returns null for empty hex string", () => { + expect(decodeEip1271Signature("0x")).toBeNull(); + }); + + it("returns null for a signature that is too short to contain a payload", () => { + // Only 10 bytes — nothing to decode + expect(decodeEip1271Signature(("0x" + "aa".repeat(10)) as Hex)).toBeNull(); + }); + + it("returns null for random garbage bytes", () => { + const garbage = ("0x" + "ff".repeat(200)) as Hex; + expect(decodeEip1271Signature(garbage)).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9cda082..21d675b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ // ponder:schema must come before ponder to avoid prefix-match shadowing. { find: "ponder:schema", replacement: resolve(__dirname, "tests/__mocks__/ponder-schema.ts") }, { find: /^ponder$/, replacement: resolve(__dirname, "tests/__mocks__/ponder.ts") }, + { find: "ponder:api", replacement: resolve(__dirname, "tests/__mocks__/ponder-api.ts") }, ], }, });