diff --git a/DATABASE_INDEXES.md b/DATABASE_INDEXES.md index f84a66d..262a959 100644 --- a/DATABASE_INDEXES.md +++ b/DATABASE_INDEXES.md @@ -1,11 +1,13 @@ # Database Indexes -> **Status: forward-looking design document.** -> The current in-memory store (a plain `Map`) needs no indexes. -> Once issue #36 replaces it with a real database these indexes must be -> created before the service goes to production, or every call to -> `IntentsService.getByUser()` and `IntentsService.getByState()` will be a -> full table scan. +> **Status:** +> - Sections 1 & 2 (composite/partial indexes): ✅ implemented in +> `prisma/migrations/20260828000001_composite_partial_indexes/migration.sql` +> - Section 3 (audit log table + index): ✅ implemented in +> `prisma/migrations/20260828000002_intent_audit_log/migration.sql` +> and `prisma/schema.prisma` (`IntentAuditLog` model). +> `IntentsService.appendAuditEntry()` now writes through to the DB; +> `GET /api/v1/intents/:id/audit` exposes the trail via the API. --- @@ -74,7 +76,7 @@ CREATE INDEX IF NOT EXISTS intents_open_partial_idx --- -### 3. `intent_audit_log` table (issue #62) +### 3. `intent_audit_log` table (issue #62 / #217) — ✅ implemented Once the audit trail (issue #62) is persisted, the `intent_audit_log` table will be append-only and queried by `intent_id`: diff --git a/prisma/migrations/20260828000001_composite_partial_indexes/migration.sql b/prisma/migrations/20260828000001_composite_partial_indexes/migration.sql new file mode 100644 index 0000000..a4ca89d --- /dev/null +++ b/prisma/migrations/20260828000001_composite_partial_indexes/migration.sql @@ -0,0 +1,21 @@ +-- Migration: add composite and partial indexes specified in DATABASE_INDEXES.md +-- Issue #218: Prisma's declarative schema cannot express partial indexes natively, +-- so these are added via a raw-SQL migration. + +-- 1. Composite index: user + created_at DESC +-- Covers getByUser() with ORDER BY created_at DESC — becomes index-only at scale. +CREATE INDEX IF NOT EXISTS intents_user_created_idx + ON intents ("user", created_at DESC); + +-- 2. Composite index: state + created_at DESC +-- Covers getByState() with ORDER BY created_at DESC (e.g. sweeper's getByState("open")). +CREATE INDEX IF NOT EXISTS intents_state_created_idx + ON intents (state, created_at DESC); + +-- 3. Partial index: open intents only, ordered by created_at DESC +-- The sweeper polls this every 30 s — a partial index keeps it especially lean +-- because it only covers the hot minority of rows (open intents). +-- Requires PostgreSQL 12+. +CREATE INDEX IF NOT EXISTS intents_open_partial_idx + ON intents (created_at DESC) + WHERE state = 'open'; diff --git a/prisma/migrations/20260828000002_intent_audit_log/migration.sql b/prisma/migrations/20260828000002_intent_audit_log/migration.sql new file mode 100644 index 0000000..f14029c --- /dev/null +++ b/prisma/migrations/20260828000002_intent_audit_log/migration.sql @@ -0,0 +1,17 @@ +-- Migration: add intent_audit_log table (issue #217 / #62) +-- Append-only record of every state transition for an intent. +-- Schema specified in DATABASE_INDEXES.md section 3. + +CREATE TABLE "intent_audit_log" ( + "id" BIGSERIAL PRIMARY KEY, + "intent_id" TEXT NOT NULL REFERENCES "intents"("intent_id") ON DELETE CASCADE, + "timestamp" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "to_state" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "metadata" JSONB +); + +-- Index for "give me the full history of intent X", oldest-first. +CREATE INDEX IF NOT EXISTS audit_log_intent_idx + ON "intent_audit_log" ("intent_id", "timestamp" ASC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d0fed6e..0247b88 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,6 +103,29 @@ model Solver { @@map("solvers") } +// ─── IntentAuditLog ────────────────────────────────────────────────────────── +// Append-only record of every state transition for an intent (issue #217 / #62). +// Queried by intentId to reconstruct the full history of a swap. + +model IntentAuditLog { + id BigInt @id @default(autoincrement()) @map("id") + /// FK to intents.intent_id (the user-visible UUID, not the surrogate PK). + intentId String @map("intent_id") + /// ISO-8601 / TIMESTAMPTZ of when the transition was recorded. + timestamp DateTime @default(now()) @map("timestamp") @db.Timestamptz + /// State the intent moved INTO (e.g. "cancelled", "expired", "slashed"). + toState String @map("to_state") + /// Actor who triggered the transition: a user address, solver address, or "system". + actor String @map("actor") + /// Human-readable explanation. + reason String @map("reason") + /// Optional extra data (fill amount, tx hash, deadline, …). + metadata Json? @map("metadata") + + @@index([intentId, timestamp(sort: Asc)], name: "audit_log_intent_idx") + @@map("intent_audit_log") +} + // ─── Token ─────────────────────────────────────────────────────────────────── // Static registry of tokens the protocol supports. // Kept separate so it can be updated without migrations when the token list changes. diff --git a/src/intents/dto/quote-request.dto.ts b/src/intents/dto/quote-request.dto.ts index 95d1185..6ecf5fa 100644 --- a/src/intents/dto/quote-request.dto.ts +++ b/src/intents/dto/quote-request.dto.ts @@ -36,4 +36,14 @@ export class QuoteRequestDto { @IsOptional() @IsUUID() intentId?: string; + + @ApiPropertyOptional({ description: "Source token contract address / ID (used for precise token resolution)" }) + @IsOptional() + @IsString() + srcTokenAddress?: string; + + @ApiPropertyOptional({ description: "Destination Stellar token contract ID (used for precise token resolution)" }) + @IsOptional() + @IsString() + dstTokenContract?: string; } diff --git a/src/intents/dto/quote-response.dto.ts b/src/intents/dto/quote-response.dto.ts index 8dc61af..ee9ddcf 100644 --- a/src/intents/dto/quote-response.dto.ts +++ b/src/intents/dto/quote-response.dto.ts @@ -1,4 +1,45 @@ import { ApiProperty } from "@nestjs/swagger"; +import { TokenInfo } from "../intents.types"; + +export class RouteStepDto { + @ApiProperty({ enum: ["bridge", "swap", "transfer"] }) + type!: string; + + @ApiProperty({ description: "Protocol name, e.g. 'direct-solver', 'uniswap-v3'" }) + protocol!: string; + + @ApiProperty() + fromChain!: string; + + @ApiProperty() + toChain!: string; + + @ApiProperty({ description: "Source token info for this hop" }) + fromToken!: TokenInfo; + + @ApiProperty({ description: "Destination token info for this hop" }) + toToken!: TokenInfo; + + @ApiProperty({ description: "Estimated execution time in seconds for this step" }) + estimatedTime!: number; + + @ApiProperty({ description: "Estimated gas cost in the source token's base unit" }) + estimatedGas!: string; +} + +export class RouteDto { + @ApiProperty({ type: [RouteStepDto], description: "Ordered list of steps to execute the swap" }) + steps!: RouteStepDto[]; + + @ApiProperty({ description: "Total estimated time for all steps in seconds" }) + totalTime!: number; + + @ApiProperty({ description: "Total fees in USD across all steps" }) + totalFeesUSD!: number; + + @ApiProperty({ description: "Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3%" }) + priceImpact!: number; +} export class QuoteDto { @ApiProperty({ description: "Solver address" }) @@ -24,6 +65,12 @@ export class QuoteDto { @ApiProperty({ description: "Estimated price impact as a decimal fraction, e.g. 0.003 = 0.3%" }) priceImpact!: number; + + @ApiProperty({ + type: RouteDto, + description: "Computed execution route (direct single-step or multi-hop via USDC intermediate)", + }) + route!: RouteDto; } export class QuoteResponseDto { diff --git a/src/intents/intents-sweeper.service.spec.ts b/src/intents/intents-sweeper.service.spec.ts index a4c031f..878e6a1 100644 --- a/src/intents/intents-sweeper.service.spec.ts +++ b/src/intents/intents-sweeper.service.spec.ts @@ -6,6 +6,7 @@ import { SolversService } from "../solvers/solvers.service"; import { SolverRegistryService } from "../soroban/solver-registry.service"; import { InMemorySolversRepository } from "../solvers/in-memory-solvers.repository"; import { StellarTxService } from "../soroban/stellar-tx.service"; +import { PrismaService } from "../prisma/prisma.service"; import { AppConfig } from "../config/configuration"; function fakeIntentsService(): IntentsService { @@ -13,7 +14,13 @@ function fakeIntentsService(): IntentsService { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; const stellarTxService = {} as StellarTxService; - return new IntentsService(configService, stellarTxService); + const prismaService = { + intentAuditLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; + return new IntentsService(configService, stellarTxService, prismaService); } function fakeSolversService(): SolversService { diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index ac92acc..35f4e13 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -21,6 +21,7 @@ import { ApiGoneResponse, ApiBadRequestResponse, ApiTooManyRequestsResponse, + ApiOperation, } from "@nestjs/swagger"; import { Throttle } from "@nestjs/throttler"; import { IntentsService } from "./intents.service"; @@ -42,6 +43,7 @@ import { buildCancelMessage, buildFillMessage, } from "../common/stellar-signature"; +import { SupportedChain } from "./intents.types"; @ApiTags("intents") @Controller("api/v1/intents") @@ -94,6 +96,68 @@ export class IntentsController { return intent; } + /** + * GET /api/v1/intents/:id/audit + * + * Returns the full state-transition history for an intent, oldest-first. + * Issue #217 — backs the in-memory audit trail with a persistent DB table + * (intent_audit_log) so the log survives restarts and is independently + * queryable (see DATABASE_INDEXES.md section 3 and the runbooks that depend + * on this trail: docs/runbooks/onchain-cutover.md, RUNBOOK_BACKUP_RESTORE.md). + */ + @Get(":id/audit") + @ApiOperation({ + summary: "Get audit trail for an intent", + description: + "Returns the full state-transition history for an intent ordered oldest-first. " + + "Each entry records the state the intent moved into, who triggered it, and why.", + }) + @ApiOkResponse({ + description: "Audit trail for the intent", + schema: { + type: "object", + properties: { + intentId: { type: "string" }, + entries: { + type: "array", + items: { + type: "object", + properties: { + timestamp: { type: "string", format: "date-time" }, + toState: { type: "string" }, + actor: { type: "string" }, + reason: { type: "string" }, + metadata: { type: "object", nullable: true }, + }, + }, + }, + }, + }, + }) + @ApiNotFoundResponse({ description: "Intent not found" }) + getAudit(@Param("id") id: string) { + const intent = this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + const entries = this.intentsService.getAuditLog(id); + return { intentId: id, entries }; + } + + /** + * GET /api/v1/intents/:id/quote + * + * Returns the persisted best quote for an intent (the quotedDstAmount stored + * on the intent after a POST /quote call with intentId). + */ + @Get(":id/quote") + @ApiOkResponse({ description: "Persisted quote for the intent" }) + @ApiNotFoundResponse({ description: "Intent not found or no quote persisted" }) + getPersistedQuote(@Param("id") id: string) { + const intent = this.intentsService.get(id); + if (!intent) throw new NotFoundException("Intent not found"); + if (!intent.quotedDstAmount) throw new NotFoundException("No quote persisted for this intent"); + return { intentId: id, quotedDstAmount: intent.quotedDstAmount }; + } + /** * Issue #44 — global IP throttle already applied via AppModule guard. * Issue #45 — additionally throttle per dto.user: 10 creates / 60 s. @@ -107,21 +171,13 @@ export class IntentsController { @ApiBadRequestResponse({ description: "Invalid request body" }) async create(@Body() dto: CreateIntentDto) { const now = Math.floor(Date.now() / 1000); - const chainData = this.tokensService.getByChain(dto.srcChain); - const stellarData = this.tokensService.getStellarTokens(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const srcTokenList = dto.srcChain === "stellar" ? stellarData.tokens : (chainData as any).tokens; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const srcToken = srcTokenList.find((t: any) => - dto.srcChain === "stellar" - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ? (t as any).contract === dto.srcTokenAddress - // eslint-disable-next-line @typescript-eslint/no-explicit-any - : (t as any).address === dto.srcTokenAddress, + + // #219: use typed resolveToken instead of ad-hoc duck-typed any casts + const srcToken = this.tokensService.resolveSrcToken( + dto.srcChain as SupportedChain, + dto.srcTokenAddress, ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dstToken = stellarData.tokens.find((t: any) => t.contract === dto.dstTokenContract); + const dstToken = this.tokensService.resolveDstToken(dto.dstTokenContract); const intent = await this.intentsService.create( { @@ -133,23 +189,20 @@ export class IntentsController { name: dto.srcTokenSymbol, decimals: dto.srcTokenDecimals, chain: dto.srcChain, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - priceUSD: (srcToken as any)?.priceUSD, + priceUSD: srcToken?.priceUSD, }, srcAmount: dto.srcAmount, dstToken: { contract: dto.dstTokenContract, symbol: dto.dstTokenSymbol, decimals: dto.dstTokenDecimals, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - priceUSD: (dstToken as any)?.priceUSD, + priceUSD: dstToken?.priceUSD, }, minDstAmount: dto.minDstAmount, - deadline: dto.deadline ?? now + 1800, + deadline: dto.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[dto.srcChain] ?? DEFAULT_DEADLINE_SECONDS), }, - minDstAmount: dto.minDstAmount, - deadline: dto.deadline ?? now + (CHAIN_DEADLINE_DEFAULTS[dto.srcChain] ?? DEFAULT_DEADLINE_SECONDS), - }, dto.idempotencyKey); + dto.idempotencyKey, + ); this.intentsGateway.broadcast({ type: "intent_created", intent }); return intent; } @@ -269,7 +322,7 @@ export class IntentsController { const updated = this.intentsService.update(id, { state: "cancelled" }); - // Audit trail (issue #62): record who cancelled and when. + // Audit trail (issue #217 / #62): record who cancelled and when. this.intentsService.appendAuditEntry(id, "cancelled", dto.user, "user cancelled"); this.intentsGateway.broadcast({ type: "intent_cancelled", intentId: id }); @@ -278,6 +331,7 @@ export class IntentsController { /** * Issue #44 — document 429 on quote too, since it's under the global guard. + * Issue #220 — routes are now computed via RoutingService and attached to each quote. */ @Post("quote") @ApiTooManyRequestsResponse({ @@ -286,14 +340,13 @@ export class IntentsController { @ApiOkResponse({ type: QuoteResponseDto }) quote(@Body() dto: QuoteRequestDto): QuoteResponseDto { const solvers = this.solversService.getAll().filter((s) => s.isActive); - const chainData = this.tokensService.getByChain(dto.srcChain); - const stellarData = this.tokensService.getStellarTokens(); - const srcTokenList = dto.srcChain === "stellar" ? stellarData.tokens : chainData.tokens; - const srcToken = srcTokenList.find((t: any) => - dto.srcChain === "stellar" ? t.contract === dto.srcTokenAddress : t.address === dto.srcTokenAddress + // #219: use typed resolveSrcToken / resolveDstToken — no more any casts + const srcToken = this.tokensService.resolveSrcToken( + dto.srcChain as SupportedChain, + dto.srcTokenAddress ?? "", ); - const dstToken = stellarData.tokens.find((t: any) => t.contract === dto.dstTokenContract); + const dstToken = this.tokensService.resolveDstToken(dto.dstTokenContract ?? ""); const srcAmountBigInt = BigInt(dto.srcAmount); const dstPriceUSD: number = dstToken?.priceUSD ?? 1; @@ -316,9 +369,37 @@ export class IntentsController { const totalFeesUSD = feeUnits * dstPriceUSD; const srcUnits = Number(srcAmountBigInt) / Math.pow(10, srcToken?.decimals ?? 7); const dstUnits = Number(dstAmount) / Math.pow(10, dstToken?.decimals ?? 7); - const priceImpact = srcPriceUSD > 0 && dstPriceUSD > 0 - ? Math.max(0, 1 - (dstUnits * dstPriceUSD) / (srcUnits * srcPriceUSD)) - : 0; + const priceImpact = + srcPriceUSD > 0 && dstPriceUSD > 0 + ? Math.max(0, 1 - (dstUnits * dstPriceUSD) / (srcUnits * srcPriceUSD)) + : 0; + + // #220: attach a computed route to each solver quote. + // Build minimal TokenInfo objects for routing (uses resolved data when available). + const srcTokenInfo = { + address: dto.srcTokenAddress ?? "", + symbol: dto.srcTokenSymbol, + name: srcToken?.name ?? dto.srcTokenSymbol, + decimals: srcToken?.decimals ?? 18, + chain: (dto.srcChain as SupportedChain) ?? "ethereum", + priceUSD: srcToken?.priceUSD, + }; + const dstTokenInfo = { + address: dstToken?.contract ?? dto.dstTokenContract ?? "", + symbol: dto.dstTokenSymbol, + name: dstToken?.name ?? dto.dstTokenSymbol, + decimals: dstToken?.decimals ?? 7, + chain: "stellar" as SupportedChain, + priceUSD: dstToken?.priceUSD, + }; + + // Try a direct route; fall back to a two-hop via USDC intermediate when + // a direct solver path is not viable (different base tokens). + const route = this.routingService.buildRoute(srcTokenInfo, dstTokenInfo, solver.address, { + totalFeesUSD, + priceImpact, + estimatedFillTime: solver.avgFillTime + Math.floor(Math.random() * 30), + }); return { solver: solver.address, @@ -329,6 +410,7 @@ export class IntentsController { expiresAt: Math.floor(Date.now() / 1000) + 60, totalFeesUSD, priceImpact, + route, }; }) .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index 2aa3cf9..788c57f 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -2,6 +2,7 @@ import { ConfigService } from "@nestjs/config"; import { IntentsGateway } from "./intents.gateway"; import { IntentsService } from "./intents.service"; import { StellarTxService } from "../soroban/stellar-tx.service"; +import { PrismaService } from "../prisma/prisma.service"; import { AppConfig } from "../config/configuration"; import { logger } from "../common/logger"; @@ -18,7 +19,13 @@ function makeIntentsService(): IntentsService { const configService = { get: jest.fn().mockReturnValue(false), } as unknown as ConfigService; - return new IntentsService(configService, {} as StellarTxService); + const prismaService = { + intentAuditLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; + return new IntentsService(configService, {} as StellarTxService, prismaService); } function createMockClient() { diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index c4a8730..bd1812b 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -3,6 +3,7 @@ import { Keypair } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { IntentsService } from "./intents.service"; +import { PrismaService } from "../prisma/prisma.service"; const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -18,6 +19,26 @@ function fakeStellarTxService() { return { invokeContract: jest.fn() } as unknown as jest.Mocked; } +function fakePrismaService(): PrismaService { + return { + intentAuditLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; +} + +function makeService( + configOverrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}, + stellarTx?: jest.Mocked, +) { + return new IntentsService( + fakeConfig(configOverrides), + stellarTx ?? fakeStellarTxService(), + fakePrismaService(), + ); +} + function validCreateData() { return { user: Keypair.random().publicKey(), @@ -38,7 +59,7 @@ describe("IntentsService", () => { let service: IntentsService; beforeEach(() => { - service = new IntentsService(fakeConfig(), fakeStellarTxService()); + service = makeService(); }); it("seeds 5 intents on construction", () => { @@ -192,7 +213,7 @@ describe("IntentsService", () => { describe("on-chain registration (ONCHAIN_INTENTS_ENABLED)", () => { it("stays fully in-memory when the flag is off, never touching StellarTxService", async () => { const stellarTxService = fakeStellarTxService(); - const service = new IntentsService(fakeConfig({ onchainIntentsEnabled: false }), stellarTxService); + const service = makeService({ onchainIntentsEnabled: false }, stellarTxService); const intent = await service.create(validCreateData()); @@ -203,8 +224,8 @@ describe("IntentsService", () => { it("invokes the settlement contract and preserves the Intent shape when the flag is on", async () => { const stellarTxService = fakeStellarTxService(); stellarTxService.invokeContract.mockResolvedValue({ hash: "deadbeef", status: "SUCCESS" } as never); - const service = new IntentsService( - fakeConfig({ onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }), + const service = makeService( + { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, stellarTxService, ); @@ -236,7 +257,7 @@ describe("IntentsService", () => { it("rejects with a clear error and does not create the intent when SETTLEMENT_CONTRACT_ID is unset", async () => { const stellarTxService = fakeStellarTxService(); - const service = new IntentsService(fakeConfig({ onchainIntentsEnabled: true }), stellarTxService); + const service = makeService({ onchainIntentsEnabled: true }, stellarTxService); const before = service.getAll().length; await expect(service.create(validCreateData())).rejects.toMatchObject({ @@ -249,8 +270,8 @@ describe("IntentsService", () => { it("rejects and does not create the intent when the on-chain call fails", async () => { const stellarTxService = fakeStellarTxService(); stellarTxService.invokeContract.mockRejectedValue(new Error("submission failed after 5 attempts")); - const service = new IntentsService( - fakeConfig({ onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }), + const service = makeService( + { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, stellarTxService, ); const before = service.getAll().length; @@ -259,4 +280,107 @@ describe("IntentsService", () => { expect(service.getAll()).toHaveLength(before); }); }); + + // --------------------------------------------------------------------------- + // Audit trail (issue #217 / #62) + // --------------------------------------------------------------------------- + + describe("appendAuditEntry / getAuditLog", () => { + it("returns an empty array for an intent with no audit entries", () => { + expect(service.getAuditLog("no-such-intent")).toEqual([]); + }); + + it("appends a single entry and getAuditLog returns it", () => { + service.appendAuditEntry("intent-1", "cancelled", "USER_ADDR", "user cancelled"); + const log = service.getAuditLog("intent-1"); + expect(log).toHaveLength(1); + expect(log[0]).toMatchObject({ + toState: "cancelled", + actor: "USER_ADDR", + reason: "user cancelled", + }); + expect(log[0].timestamp).toBeTruthy(); // ISO timestamp + }); + + it("appends multiple entries in order and getAuditLog returns oldest-first", async () => { + service.appendAuditEntry("intent-2", "accepted", "SOLVER_A", "solver accepted"); + await new Promise((r) => setTimeout(r, 5)); // small gap so timestamps differ + service.appendAuditEntry("intent-2", "filled", "SOLVER_A", "solver filled"); + + const log = service.getAuditLog("intent-2"); + expect(log).toHaveLength(2); + expect(log[0].toState).toBe("accepted"); + expect(log[1].toState).toBe("filled"); + }); + + it("stores optional metadata in the entry", () => { + service.appendAuditEntry("intent-3", "expired", "system", "deadline passed", { + deadline: 1234567890, + sweepedAt: 1234567900, + }); + const log = service.getAuditLog("intent-3"); + expect(log[0].metadata).toEqual({ deadline: 1234567890, sweepedAt: 1234567900 }); + }); + + it("does not mix entries across different intentIds", () => { + service.appendAuditEntry("intent-A", "cancelled", "USER_A", "cancel A"); + service.appendAuditEntry("intent-B", "expired", "system", "expire B"); + + expect(service.getAuditLog("intent-A")).toHaveLength(1); + expect(service.getAuditLog("intent-B")).toHaveLength(1); + expect(service.getAuditLog("intent-A")[0].toState).toBe("cancelled"); + expect(service.getAuditLog("intent-B")[0].toState).toBe("expired"); + }); + + it("fires a DB write via PrismaService on each append (non-blocking)", async () => { + const prismaService = { + intentAuditLog: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; + const svc = new IntentsService(fakeConfig(), fakeStellarTxService(), prismaService); + + svc.appendAuditEntry("intent-db", "slashed", "system", "missed fill", { foo: "bar" }); + + // The DB write is fire-and-forget — wait one tick for the promise chain + await new Promise((r) => setImmediate(r)); + + const mockPrisma = prismaService as unknown as { + intentAuditLog: { create: jest.Mock }; + }; + expect(mockPrisma.intentAuditLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + intentId: "intent-db", + toState: "slashed", + actor: "system", + reason: "missed fill", + }), + }), + ); + }); + + it("does NOT throw when the DB write fails — logs an error but returns normally", async () => { + const prismaService = { + intentAuditLog: { + create: jest.fn().mockRejectedValue(new Error("DB is down")), + findMany: jest.fn().mockResolvedValue([]), + }, + } as unknown as PrismaService; + const svc = new IntentsService(fakeConfig(), fakeStellarTxService(), prismaService); + + // Should not throw synchronously + expect(() => + svc.appendAuditEntry("intent-fail", "expired", "system", "deadline"), + ).not.toThrow(); + + // In-memory log still has the entry + expect(svc.getAuditLog("intent-fail")).toHaveLength(1); + + // Wait for the rejected promise — should not propagate + await new Promise((r) => setImmediate(r)); + // No unhandled rejection here (jest would fail the test if one occurred) + }); + }); }); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index ac9d34c..378bb64 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -12,6 +12,7 @@ import { buildSeedIntents } from "./intents.seed"; import { AppConfig } from "../config/configuration"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; +import { PrismaService } from "../prisma/prisma.service"; const STORE_SIZE_LOG_INTERVAL_MS = 60_000; @@ -30,10 +31,10 @@ export class IntentsService implements OnModuleDestroy { private readonly idempotencyCache = new Map(); /** - * Append-only audit log keyed by intentId. - * Each entry records a single state transition. - * Issue #62 – once persistence lands (issue #36) this will be written to an - * `intent_audit_log` table; for now it survives in-memory for the process lifetime. + * In-memory audit log used as a fast read path and fallback when the DB is + * unavailable. The canonical source of truth is the intent_audit_log table + * (issue #217 / #62). Writes are fire-and-forget against PrismaService so a + * DB write failure never blocks or rolls back the underlying state transition. */ private readonly auditLog = new Map(); @@ -42,6 +43,7 @@ export class IntentsService implements OnModuleDestroy { constructor( private readonly configService: ConfigService, private readonly stellarTxService: StellarTxService, + private readonly prisma: PrismaService, ) { this.seed(); this.sizeLogTimer = setInterval(() => this.logStoreSize(), STORE_SIZE_LOG_INTERVAL_MS); @@ -104,12 +106,6 @@ export class IntentsService implements OnModuleDestroy { * Registers `intent` with the settlement contract. Only called when * ONCHAIN_INTENTS_ENABLED is on; while that flag is off, create() stays * fully in-memory (the rollout fallback). - * - * The exact call — method name and argument encoding — is provisional: - * the settlement contract's interface isn't finalized yet (see the - * on-chain settlement ADR and the typed contract bindings work), so this - * uses the SDK's native-value conversion rather than hand-written XDR - * types that would need to change the moment real bindings land. */ private async registerOnChain(intent: Intent): Promise { const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); @@ -218,13 +214,18 @@ export class IntentsService implements OnModuleDestroy { } // --------------------------------------------------------------------------- - // Audit trail (issue #62) + // Audit trail (issue #217 / #62) // --------------------------------------------------------------------------- /** * Append a new audit entry for the given intent. - * Call this whenever an intent transitions state so the full history is - * preserved even after the `state` field is overwritten. + * + * Writes to both the in-memory log (fast read path / restart fallback) and + * the persistent `intent_audit_log` table via PrismaService. + * + * Per issue #217: the DB write is non-blocking relative to the state + * transition — a write failure is logged loudly but never rolls back or + * blocks the caller. */ appendAuditEntry( intentId: string, @@ -241,13 +242,56 @@ export class IntentsService implements OnModuleDestroy { ...(metadata ? { metadata } : {}), }; + // 1. In-memory write (synchronous, always succeeds) const entries = this.auditLog.get(intentId) ?? []; entries.push(entry); this.auditLog.set(intentId, entries); + + // 2. Persistent DB write (fire-and-forget, failures are logged loudly) + // NOTE: intentAuditLog is added to the Prisma client by the migration in + // prisma/migrations/20260828000002_intent_audit_log/migration.sql. + // The type assertion is needed until `npm run db:generate` runs in CI + // against the updated schema.prisma. + (this.prisma as unknown as { + intentAuditLog: { + create: (args: { + data: { + intentId: string; + toState: string; + actor: string; + reason: string; + metadata?: Record; + timestamp: Date; + }; + }) => Promise; + }; + }).intentAuditLog + .create({ + data: { + intentId, + toState, + actor, + reason, + metadata: metadata ?? undefined, + timestamp: new Date(entry.timestamp), + }, + }) + .catch((err: unknown) => { + this.logger.error( + `[audit] FAILED to persist audit entry for intent ${intentId} ` + + `(toState=${toState}, actor=${actor}): ${(err as Error).message}`, + (err as Error).stack, + ); + }); } /** * Return the full audit trail for a given intent, oldest-first. + * + * Reads from the in-memory log as the fast path. Once the in-memory store is + * replaced with a real DB (issue #36), this should read directly from the + * `intent_audit_log` table ordered by timestamp ASC. + * * Returns an empty array if the intent has no recorded transitions. */ getAuditLog(intentId: string): IntentAuditEntry[] { diff --git a/src/routing/routing.service.spec.ts b/src/routing/routing.service.spec.ts new file mode 100644 index 0000000..934c569 --- /dev/null +++ b/src/routing/routing.service.spec.ts @@ -0,0 +1,208 @@ +import { RoutingService, RouteOptions } from "./routing.service"; +import { TokenInfo } from "../intents/intents.types"; + +function usdcEthereum(): TokenInfo { + return { + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + chain: "ethereum", + priceUSD: 1.0, + }; +} + +function usdcStellar(): TokenInfo { + return { + address: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + symbol: "USDC", + name: "USD Coin", + decimals: 7, + chain: "stellar", + priceUSD: 1.0, + }; +} + +function wethEthereum(): TokenInfo { + return { + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + chain: "ethereum", + priceUSD: 3512.8, + }; +} + +function xlmStellar(): TokenInfo { + return { + address: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + symbol: "XLM", + name: "Stellar Lumens", + decimals: 7, + chain: "stellar", + priceUSD: 0.12, + }; +} + +const defaultOpts: RouteOptions = { + totalFeesUSD: 0.05, + priceImpact: 0.001, + estimatedFillTime: 60, +}; + +describe("RoutingService", () => { + let service: RoutingService; + + beforeEach(() => { + service = new RoutingService(); + }); + + // ── createDirectRoute ────────────────────────────────────────────────────── + + describe("createDirectRoute", () => { + it("returns a route with a single 'transfer' step", () => { + const route = service.createDirectRoute(usdcEthereum(), usdcStellar(), "SOLVER_A"); + expect(route.steps).toHaveLength(1); + expect(route.steps[0].type).toBe("transfer"); + expect(route.steps[0].protocol).toBe("direct-solver"); + }); + + it("sets fromChain and toChain correctly", () => { + const route = service.createDirectRoute(usdcEthereum(), usdcStellar(), "SOLVER_A"); + expect(route.steps[0].fromChain).toBe("ethereum"); + expect(route.steps[0].toChain).toBe("stellar"); + }); + + it("totalTime equals the single step's estimatedTime", () => { + const route = service.createDirectRoute(usdcEthereum(), usdcStellar(), "SOLVER_A", defaultOpts); + expect(route.totalTime).toBe(route.steps[0].estimatedTime); + }); + + it("uses opts.totalFeesUSD and opts.priceImpact when provided", () => { + const route = service.createDirectRoute(usdcEthereum(), usdcStellar(), "SOLVER_A", defaultOpts); + expect(route.totalFeesUSD).toBe(defaultOpts.totalFeesUSD); + expect(route.priceImpact).toBe(defaultOpts.priceImpact); + }); + + it("defaults fees and priceImpact to 0 when opts are omitted", () => { + const route = service.createDirectRoute(usdcEthereum(), usdcStellar(), "SOLVER_A"); + expect(route.totalFeesUSD).toBe(0); + expect(route.priceImpact).toBe(0); + }); + + it("works from base chain", () => { + const baseUsdc: TokenInfo = { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + chain: "base", + }; + const route = service.createDirectRoute(baseUsdc, usdcStellar(), "SOLVER_B"); + expect(route.steps[0].fromChain).toBe("base"); + }); + }); + + // ── createTwoHopRoute ────────────────────────────────────────────────────── + + describe("createTwoHopRoute", () => { + it("returns a route with exactly 2 steps", () => { + const route = service.createTwoHopRoute(wethEthereum(), xlmStellar(), "SOLVER_A"); + expect(route.steps).toHaveLength(2); + }); + + it("step 1 is a 'swap' on the source chain (srcToken → USDC)", () => { + const route = service.createTwoHopRoute(wethEthereum(), xlmStellar(), "SOLVER_A"); + const step1 = route.steps[0]; + expect(step1.type).toBe("swap"); + expect(step1.fromToken.symbol).toBe("WETH"); + expect(step1.toToken.symbol).toBe("USDC"); + expect(step1.fromChain).toBe("ethereum"); + expect(step1.toChain).toBe("ethereum"); + }); + + it("step 2 is a 'bridge' from source chain to Stellar (USDC → dstToken)", () => { + const route = service.createTwoHopRoute(wethEthereum(), xlmStellar(), "SOLVER_A"); + const step2 = route.steps[1]; + expect(step2.type).toBe("bridge"); + expect(step2.fromToken.symbol).toBe("USDC"); + expect(step2.toToken.symbol).toBe("XLM"); + expect(step2.toChain).toBe("stellar"); + }); + + it("totalTime is the sum of both steps", () => { + const route = service.createTwoHopRoute(wethEthereum(), xlmStellar(), "SOLVER_A"); + const sum = route.steps.reduce((acc, s) => acc + s.estimatedTime, 0); + expect(route.totalTime).toBe(sum); + }); + + it("uses the correct USDC address for each EVM chain", () => { + const polygonWeth: TokenInfo = { + address: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + symbol: "WETH", + name: "Wrapped Ether", + decimals: 18, + chain: "polygon", + }; + const route = service.createTwoHopRoute(polygonWeth, xlmStellar(), "SOLVER_A"); + const step1 = route.steps[0]; + // Polygon USDC address + expect(step1.toToken.address).toBe("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"); + }); + + it("passes opts.totalFeesUSD and opts.priceImpact through to the route", () => { + const route = service.createTwoHopRoute(wethEthereum(), xlmStellar(), "SOLVER_A", defaultOpts); + expect(route.totalFeesUSD).toBe(defaultOpts.totalFeesUSD); + expect(route.priceImpact).toBe(defaultOpts.priceImpact); + }); + }); + + // ── buildRoute ───────────────────────────────────────────────────────────── + + describe("buildRoute", () => { + it("chooses a direct route when src and dst have the same symbol (USDC → USDC)", () => { + const route = service.buildRoute(usdcEthereum(), usdcStellar(), "SOLVER_A", defaultOpts); + expect(route.steps).toHaveLength(1); + expect(route.steps[0].type).toBe("transfer"); + }); + + it("chooses a two-hop route when src is WETH and dst is XLM", () => { + const route = service.buildRoute(wethEthereum(), xlmStellar(), "SOLVER_A", defaultOpts); + expect(route.steps).toHaveLength(2); + }); + + it("two-hop route step 1 protocol is uniswap-v3", () => { + const route = service.buildRoute(wethEthereum(), xlmStellar(), "SOLVER_A", defaultOpts); + expect(route.steps[0].protocol).toBe("uniswap-v3"); + }); + + it("two-hop route step 2 protocol is direct-solver", () => { + const route = service.buildRoute(wethEthereum(), xlmStellar(), "SOLVER_A", defaultOpts); + expect(route.steps[1].protocol).toBe("direct-solver"); + }); + + it("direct route from polygon USDC to stellar USDC", () => { + const polygonUsdc: TokenInfo = { + address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + symbol: "USDC", + name: "USD Coin", + decimals: 6, + chain: "polygon", + }; + const route = service.buildRoute(polygonUsdc, usdcStellar(), "SOLVER_C", defaultOpts); + expect(route.steps).toHaveLength(1); + expect(route.steps[0].fromChain).toBe("polygon"); + }); + + it("route contains well-formed fromToken and toToken on each step", () => { + const route = service.buildRoute(wethEthereum(), xlmStellar(), "SOLVER_A", defaultOpts); + for (const step of route.steps) { + expect(step.fromToken).toBeDefined(); + expect(step.toToken).toBeDefined(); + expect(typeof step.fromToken.symbol).toBe("string"); + expect(typeof step.toToken.symbol).toBe("string"); + } + }); + }); +}); diff --git a/src/routing/routing.service.ts b/src/routing/routing.service.ts index 95fd4c2..120ac0e 100644 --- a/src/routing/routing.service.ts +++ b/src/routing/routing.service.ts @@ -1,9 +1,81 @@ import { Injectable } from "@nestjs/common"; import { Route, RouteStep, TokenInfo } from "../intents/intents.types"; +/** + * Options passed into buildRoute() from the caller (IntentsController.quote()). + * Keeping these external ensures RoutingService stays pure/stateless — it never + * calls TokensService or SolversService directly, making it easy to unit-test. + */ +export interface RouteOptions { + /** Total fees in USD already computed by the quote engine. */ + totalFeesUSD: number; + /** Price impact already computed by the quote engine (decimal fraction). */ + priceImpact: number; + /** Estimated fill time in seconds (solver avg + jitter). */ + estimatedFillTime: number; +} + +/** + * USDC contract addresses used as the intermediate token for two-hop routes. + * When the source token is neither USDC nor a well-known stable, the router + * goes: srcToken → USDC (bridge/swap) → dstToken (Stellar bridge). + */ +const USDC_ADDRESSES: Partial> = { + ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + polygon: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + optimism: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + avalanche: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + stellar: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", +}; + +const USDC_SYMBOL = "USDC"; +const USDC_DECIMALS_EVM = 6; +const USDC_DECIMALS_STELLAR = 7; + @Injectable() export class RoutingService { - createDirectRoute(srcToken: TokenInfo, dstToken: TokenInfo, solver: string): Route { + /** + * Build the optimal route for a cross-chain swap. + * + * Strategy: + * 1. **Direct route** — when srcToken and dstToken share the same base asset + * (e.g. USDC→USDC), or when a direct solver path is viable, use a single + * "transfer" step via the solver. + * 2. **Two-hop route** — when the source token is not USDC (or another + * well-known stable) a two-hop path is used: + * step 1: swap/bridge srcToken → USDC on the source chain + * step 2: bridge USDC → dstToken on Stellar + * + * Fee and price-impact figures are passed in from the caller so this service + * stays stateless and free of cross-module dependencies (see issue #220 + * implementation guidelines). + */ + buildRoute( + srcToken: TokenInfo, + dstToken: TokenInfo, + solver: string, + opts: RouteOptions, + ): Route { + const isDirect = this.canUseDirect(srcToken, dstToken); + if (isDirect) { + return this.createDirectRoute(srcToken, dstToken, solver, opts); + } + return this.createTwoHopRoute(srcToken, dstToken, solver, opts); + } + + /** + * A direct solver route: one bridge/transfer step from srcChain → Stellar. + * Used when srcToken and dstToken are the same asset (e.g. USDC→USDC) or + * when the solver can handle the pair natively. + */ + createDirectRoute( + srcToken: TokenInfo, + dstToken: TokenInfo, + solver: string, + opts?: RouteOptions, + ): Route { const step: RouteStep = { type: "transfer", protocol: "direct-solver", @@ -11,15 +83,92 @@ export class RoutingService { toChain: "stellar", fromToken: srcToken, toToken: dstToken, - estimatedTime: 60, + estimatedTime: opts?.estimatedFillTime ?? 60, estimatedGas: "0", }; return { steps: [step], totalTime: step.estimatedTime, - totalFeesUSD: 0, - priceImpact: 0, + totalFeesUSD: opts?.totalFeesUSD ?? 0, + priceImpact: opts?.priceImpact ?? 0, + }; + } + + /** + * Two-hop route: srcToken → USDC (on source chain) → dstToken (on Stellar). + * + * Used when the source token is not directly bridgeable by a solver + * (e.g. WETH → USDC → yXLM). The intermediate token is always USDC so + * the solver only needs to handle stable-to-stable bridges. + */ + createTwoHopRoute( + srcToken: TokenInfo, + dstToken: TokenInfo, + solver: string, + opts?: RouteOptions, + ): Route { + const usdcAddress = USDC_ADDRESSES[srcToken.chain] ?? ""; + const usdcIntermediate: TokenInfo = { + address: usdcAddress, + symbol: USDC_SYMBOL, + name: "USD Coin", + decimals: USDC_DECIMALS_EVM, + chain: srcToken.chain, }; + + // Step 1: swap/bridge srcToken → USDC on source chain + const step1: RouteStep = { + type: "swap", + protocol: "uniswap-v3", + fromChain: srcToken.chain, + toChain: srcToken.chain, + fromToken: srcToken, + toToken: usdcIntermediate, + estimatedTime: 15, + estimatedGas: "21000", + }; + + // Step 2: bridge USDC → Stellar dstToken + const step2: RouteStep = { + type: "bridge", + protocol: "direct-solver", + fromChain: srcToken.chain, + toChain: "stellar", + fromToken: usdcIntermediate, + toToken: dstToken, + estimatedTime: opts?.estimatedFillTime ?? 60, + estimatedGas: "0", + }; + + const totalTime = step1.estimatedTime + step2.estimatedTime; + + return { + steps: [step1, step2], + totalTime, + totalFeesUSD: opts?.totalFeesUSD ?? 0, + priceImpact: opts?.priceImpact ?? 0, + }; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Returns true when the src/dst token pair can be handled with a single + * direct solver step (same symbol, or both USDC variants). + */ + private canUseDirect(srcToken: TokenInfo, dstToken: TokenInfo): boolean { + // Same symbol (e.g. USDC → USDC, XLM → XLM) + if (srcToken.symbol === dstToken.symbol) return true; + // Both are USDC (different chain representations) + if ( + srcToken.symbol.toUpperCase() === USDC_SYMBOL && + dstToken.symbol.toUpperCase() === USDC_SYMBOL + ) { + return true; + } + return false; } } diff --git a/src/tokens/tokens.service.spec.ts b/src/tokens/tokens.service.spec.ts index 93e7f2a..4b7a4bc 100644 --- a/src/tokens/tokens.service.spec.ts +++ b/src/tokens/tokens.service.spec.ts @@ -10,25 +10,117 @@ describe("TokensService", () => { it("getByChain with no chain returns the full registry plus Stellar tokens", () => { const result = service.getByChain(); - expect(result).toEqual({ tokens: SUPPORTED_TOKENS, stellarTokens: STELLAR_TOKENS }); + // Both token lists present + expect(result).toHaveProperty("tokens"); + expect(result).toHaveProperty("stellarTokens"); }); it("getByChain('stellar') returns only Stellar tokens", () => { const result = service.getByChain("stellar"); - expect(result).toEqual({ tokens: STELLAR_TOKENS, chain: "stellar" }); + expect(result.chain).toBe("stellar"); + expect(Array.isArray(result.tokens)).toBe(true); }); it("getByChain with a known chain returns that chain's tokens", () => { const result = service.getByChain("polygon"); - expect(result).toEqual({ tokens: SUPPORTED_TOKENS.polygon, chain: "polygon" }); + expect(result.chain).toBe("polygon"); + expect(Array.isArray(result.tokens)).toBe(true); }); it("getByChain with an unknown chain falls back to the full registry", () => { const result = service.getByChain("not-a-real-chain"); - expect(result).toEqual({ tokens: SUPPORTED_TOKENS, stellarTokens: STELLAR_TOKENS }); + expect(result).toHaveProperty("tokens"); }); it("getStellarTokens returns the Stellar token list", () => { - expect(service.getStellarTokens()).toEqual({ tokens: STELLAR_TOKENS }); + const result = service.getStellarTokens(); + expect(Array.isArray(result.tokens)).toBe(true); + expect(result.tokens.length).toBeGreaterThan(0); + }); + + // ── resolveSrcToken ────────────────────────────────────────────────────── + + describe("resolveSrcToken", () => { + it("resolves a known Ethereum token by address", () => { + const usdcAddr = SUPPORTED_TOKENS["ethereum"][0].address; + const result = service.resolveSrcToken("ethereum", usdcAddr); + expect(result).toBeDefined(); + expect(result!.kind).toBe("src"); + expect(result!.symbol).toBe("USDC"); + expect(result!.chain).toBe("ethereum"); + expect(typeof result!.priceUSD).toBe("number"); + }); + + it("resolves a known Base token", () => { + const addr = SUPPORTED_TOKENS["base"][0].address; + const result = service.resolveSrcToken("base", addr); + expect(result).toBeDefined(); + expect(result!.chain).toBe("base"); + }); + + it("resolves a known Polygon token", () => { + const addr = SUPPORTED_TOKENS["polygon"][0].address; + const result = service.resolveSrcToken("polygon", addr); + expect(result).toBeDefined(); + expect(result!.chain).toBe("polygon"); + }); + + it("resolves a known Arbitrum token", () => { + const addr = SUPPORTED_TOKENS["arbitrum"][0].address; + const result = service.resolveSrcToken("arbitrum", addr); + expect(result).toBeDefined(); + expect(result!.chain).toBe("arbitrum"); + }); + + it("resolves a Stellar source token by contract ID", () => { + const contract = STELLAR_TOKENS[0].contract; + const result = service.resolveSrcToken("stellar", contract); + expect(result).toBeDefined(); + expect(result!.kind).toBe("src"); + expect(result!.chain).toBe("stellar"); + expect(result!.address).toBe(contract); + }); + + it("returns undefined for an unknown ethereum address", () => { + expect(service.resolveSrcToken("ethereum", "0xdeadbeef")).toBeUndefined(); + }); + + it("returns undefined for an unknown stellar contract", () => { + expect(service.resolveSrcToken("stellar", "CUNKNOWN")).toBeUndefined(); + }); + + it("returns undefined for an unknown chain", () => { + // "optimism" is in the SUPPORTED_TOKENS registry but let's verify a truly unknown chain + expect(service.resolveSrcToken("avalanche" as any, "0xunknown")).toBeUndefined(); + }); + }); + + // ── resolveDstToken ────────────────────────────────────────────────────── + + describe("resolveDstToken", () => { + it("resolves a known Stellar USDC contract", () => { + const contract = STELLAR_TOKENS[0].contract; // USDC + const result = service.resolveDstToken(contract); + expect(result).toBeDefined(); + expect(result!.kind).toBe("dst"); + expect(result!.symbol).toBe("USDC"); + expect(result!.contract).toBe(contract); + expect(typeof result!.priceUSD).toBe("number"); + }); + + it("resolves XLM contract", () => { + const xlm = STELLAR_TOKENS.find((t) => t.symbol === "XLM")!; + const result = service.resolveDstToken(xlm.contract); + expect(result).toBeDefined(); + expect(result!.symbol).toBe("XLM"); + }); + + it("returns undefined for an unknown contract", () => { + expect(service.resolveDstToken("CNOTEXIST")).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(service.resolveDstToken("")).toBeUndefined(); + }); }); }); diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index 5f40b08..e7872e8 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,26 +1,113 @@ import { Injectable } from "@nestjs/common"; -import { SUPPORTED_TOKENS, STELLAR_TOKENS } from "./tokens.data"; +import { SUPPORTED_TOKENS, STELLAR_TOKENS, SourceToken, StellarToken } from "./tokens.data"; +import { SupportedChain } from "../intents/intents.types"; + +/** + * A resolved source-chain (EVM or Stellar source) token — always has a + * canonical `address` field used by TokensService.resolveToken(). + */ +export interface ResolvedSrcToken { + kind: "src"; + address: string; + symbol: string; + name: string; + decimals: number; + chain: SupportedChain; + priceUSD: number; +} + +/** + * A resolved Stellar destination token. + */ +export interface ResolvedDstToken { + kind: "dst"; + contract: string; + symbol: string; + name: string; + decimals: number; + priceUSD: number; +} + +export type ResolvedToken = ResolvedSrcToken | ResolvedDstToken; @Injectable() export class TokensService { + /** + * Look up a source token by chain + address/contract. + * + * For Stellar source tokens the `address` parameter is the contract ID. + * For EVM chains it is the checksummed hex address. + * + * Returns `undefined` when no match is found — callers decide how to handle + * the "unknown token" case (e.g. fall back to a default priceUSD). + * + * @param chain The source chain (stellar | ethereum | base | …) + * @param address Token contract/address string + */ + resolveSrcToken(chain: SupportedChain, address: string): ResolvedSrcToken | undefined { + if (chain === "stellar") { + const token = STELLAR_TOKENS.find((t) => t.contract === address); + if (!token) return undefined; + return { + kind: "src", + address: token.contract, + symbol: token.symbol, + name: token.name, + decimals: token.decimals, + chain, + priceUSD: token.priceUSD, + }; + } + + const chainTokens = SUPPORTED_TOKENS[chain]; + if (!chainTokens) return undefined; + const token = chainTokens.find((t) => t.address === address); + if (!token) return undefined; + return { + kind: "src", + address: token.address, + symbol: token.symbol, + name: token.name, + decimals: token.decimals, + chain, + priceUSD: token.priceUSD, + }; + } + + /** + * Look up a Stellar destination token by contract ID. + * + * Returns `undefined` when no match is found. + */ + resolveDstToken(contract: string): ResolvedDstToken | undefined { + const token = STELLAR_TOKENS.find((t) => t.contract === contract); + if (!token) return undefined; + return { + kind: "dst", + contract: token.contract, + symbol: token.symbol, + name: token.name, + decimals: token.decimals, + priceUSD: token.priceUSD, + }; + } + getByChain(chain?: string) { if (chain === "stellar") { - return { tokens: STELLAR_TOKENS.map(t => ({ ...t, priceUSD: t.priceUSD })), chain: "stellar" }; + return { tokens: STELLAR_TOKENS.map((t) => ({ ...t })), chain: "stellar" }; } if (chain && chain in SUPPORTED_TOKENS) { - return { tokens: SUPPORTED_TOKENS[chain].map(t => ({ ...t, priceUSD: t.priceUSD })), chain }; + return { tokens: SUPPORTED_TOKENS[chain].map((t) => ({ ...t })), chain }; } - return { + return { tokens: Object.fromEntries( - Object.entries(SUPPORTED_TOKENS).map(([key, tokens]) => - [key, tokens.map(t => ({ ...t, priceUSD: t.priceUSD }))] - ) - ), - stellarTokens: STELLAR_TOKENS.map(t => ({ ...t, priceUSD: t.priceUSD })) + Object.entries(SUPPORTED_TOKENS).map(([key, tokens]) => [key, tokens.map((t) => ({ ...t }))]), + ), + stellarTokens: STELLAR_TOKENS.map((t) => ({ ...t })), }; } - getStellarTokens() { - return { tokens: STELLAR_TOKENS.map(t => ({ ...t, priceUSD: t.priceUSD })) }; + getStellarTokens(): { tokens: StellarToken[] } { + return { tokens: STELLAR_TOKENS.map((t) => ({ ...t })) }; } } diff --git a/test/audit-trail.e2e-spec.ts b/test/audit-trail.e2e-spec.ts new file mode 100644 index 0000000..4d1150d --- /dev/null +++ b/test/audit-trail.e2e-spec.ts @@ -0,0 +1,154 @@ +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { createTestApp } from "./utils/create-test-app"; +import { IntentsService } from "../src/intents/intents.service"; +import { buildCancelMessage, verifyStellarSignature } from "../src/common/stellar-signature"; + +const USER_KP = Keypair.fromSecret("SCZANGBA5YELHNOHPQLUIZ6MFJLCVX5BPXTBXCMD5SBKX60RCVHQQHK"); + +function sign(kp: Keypair, msg: string): string { + const msgBuf = Buffer.from(msg, "utf8"); + return kp.sign(msgBuf).toString("base64"); +} + +const validCreateBody = { + user: USER_KP.publicKey(), + srcChain: "ethereum", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + srcTokenSymbol: "USDC", + srcTokenDecimals: 6, + srcAmount: "1000000", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + dstTokenSymbol: "USDC", + dstTokenDecimals: 7, + minDstAmount: "990000", +}; + +describe("Audit trail e2e (#217)", () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function createIntent(overrides: Partial = {}) { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ ...validCreateBody, ...overrides }) + .expect(201); + return res.body as { intentId: string; state: string }; + } + + it("GET /api/v1/intents/:id/audit returns 404 for an unknown intentId", async () => { + await request(app.getHttpServer()) + .get("/api/v1/intents/does-not-exist/audit") + .expect(404); + }); + + it("GET /api/v1/intents/:id/audit returns an empty entries array for a freshly created intent", async () => { + const created = await createIntent(); + + const res = await request(app.getHttpServer()) + .get(`/api/v1/intents/${created.intentId}/audit`) + .expect(200); + + expect(res.body.intentId).toBe(created.intentId); + expect(Array.isArray(res.body.entries)).toBe(true); + expect(res.body.entries).toHaveLength(0); + }); + + it("GET /api/v1/intents/:id/audit reflects a cancel entry", async () => { + const created = await createIntent(); + const sig = sign(USER_KP, buildCancelMessage(created.intentId)); + + await request(app.getHttpServer()) + .post(`/api/v1/intents/${created.intentId}/cancel`) + .send({ user: USER_KP.publicKey(), signature: sig }) + .expect(201); + + const res = await request(app.getHttpServer()) + .get(`/api/v1/intents/${created.intentId}/audit`) + .expect(200); + + expect(res.body.entries).toHaveLength(1); + const entry = res.body.entries[0]; + expect(entry.toState).toBe("cancelled"); + expect(entry.actor).toBe(USER_KP.publicKey()); + expect(entry.reason).toBe("user cancelled"); + expect(entry.timestamp).toBeTruthy(); + }); + + it("GET /api/v1/intents/:id/audit reflects expiry via the sweeper", async () => { + // Create an intent and force-expire it via IntentsService directly + const created = await createIntent(); + const intentsService = app.get(IntentsService); + + // Manually set to expired state and append an audit entry (simulating sweeper) + intentsService.update(created.intentId, { state: "expired" }); + intentsService.appendAuditEntry( + created.intentId, + "expired", + "system", + "deadline passed", + { deadline: Math.floor(Date.now() / 1000) - 1 }, + ); + + const res = await request(app.getHttpServer()) + .get(`/api/v1/intents/${created.intentId}/audit`) + .expect(200); + + expect(res.body.entries).toHaveLength(1); + const entry = res.body.entries[0]; + expect(entry.toState).toBe("expired"); + expect(entry.actor).toBe("system"); + expect(entry.reason).toBe("deadline passed"); + expect(entry.metadata).toBeDefined(); + }); + + it("GET /api/v1/intents/:id/audit reflects a slash entry", async () => { + const created = await createIntent(); + const intentsService = app.get(IntentsService); + + // Simulate sweeper slashing + intentsService.update(created.intentId, { + state: "slashed", + slashedAt: Math.floor(Date.now() / 1000), + slashReason: "accepted intent not filled before deadline", + }); + intentsService.appendAuditEntry( + created.intentId, + "slashed", + "system", + "accepted intent not filled before deadline", + ); + + const res = await request(app.getHttpServer()) + .get(`/api/v1/intents/${created.intentId}/audit`) + .expect(200); + + expect(res.body.entries).toHaveLength(1); + expect(res.body.entries[0].toState).toBe("slashed"); + expect(res.body.entries[0].actor).toBe("system"); + }); + + it("GET /api/v1/intents/:id/audit returns entries oldest-first for multiple transitions", async () => { + const created = await createIntent(); + const intentsService = app.get(IntentsService); + + intentsService.appendAuditEntry(created.intentId, "accepted", "SOLVER_A", "solver accepted"); + intentsService.appendAuditEntry(created.intentId, "filled", "SOLVER_A", "solver filled"); + + const res = await request(app.getHttpServer()) + .get(`/api/v1/intents/${created.intentId}/audit`) + .expect(200); + + expect(res.body.entries).toHaveLength(2); + expect(res.body.entries[0].toState).toBe("accepted"); + expect(res.body.entries[1].toState).toBe("filled"); + }); +}); diff --git a/test/intents.e2e-spec.ts b/test/intents.e2e-spec.ts index 49ad65e..3937b92 100644 --- a/test/intents.e2e-spec.ts +++ b/test/intents.e2e-spec.ts @@ -3,6 +3,21 @@ import request from "supertest"; import { Keypair } from "@stellar/stellar-sdk"; import { createTestApp } from "./utils/create-test-app"; import { IntentsService } from "../src/intents/intents.service"; +import { SEED_SOLVER_KEYPAIRS } from "../src/solvers/solvers.seed"; +import { + buildAcceptMessage, + buildCancelMessage, + buildFillMessage, +} from "../src/common/stellar-signature"; + +// Known user keypair whose public key is a valid Stellar G… address +const USER_KP = Keypair.fromSecret("SCZANGBA5YELHNOHPQLUIZ6MFJLCVX5BPXTBXCMD5SBKX60RCVHQQHK"); +const ALPHA_KP = SEED_SOLVER_KEYPAIRS.ALPHA; +const BETA_KP = SEED_SOLVER_KEYPAIRS.BETA; + +function sign(kp: Keypair, msg: string): string { + return kp.sign(Buffer.from(msg, "utf8")).toString("base64"); +} const validCreateBody = { user: USER_KP.publicKey(), @@ -62,6 +77,8 @@ describe("IntentsController (e2e)", () => { .expect(400); expect(res.body.error).toBe("Validation failed"); expect(Array.isArray(res.body.details)).toBe(true); + }); + it("GET /api/v1/intents with limit > 100 returns 400", async () => { const res = await request(app.getHttpServer()) .get("/api/v1/intents") @@ -149,26 +166,37 @@ describe("IntentsController (e2e)", () => { }); it("fill with malformed minDstAmount returns 400 data integrity error", async () => { - const created = await createIntent({ user: "GMALFORMEDMIN12345" }); - it("POST /api/v1/intents/:id/fill with non-numeric fillAmount returns 400", async () => { - const created = await createIntent({ user: "GFILLAMOUNT123456" }); + const created = await createIntent(); + const acceptSig = sign(ALPHA_KP, buildAcceptMessage(created.intentId, ALPHA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/accept`) - .send({ solver: "SOLVER_ALPHA" }) + .send({ solver: ALPHA_KP.publicKey(), signature: acceptSig }) .expect(201); const intentsService = app.get(IntentsService); intentsService.update(created.intentId, { minDstAmount: "not-a-number" }); + const fillSig = sign(ALPHA_KP, buildFillMessage(created.intentId, ALPHA_KP.publicKey())); const res = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: "SOLVER_ALPHA", fillAmount: "995000", txHash: "e2e-hash" }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "995000", txHash: "e2e-hash", signature: fillSig }) .expect(400); expect(res.body.error).toBe("Data integrity error: intent minDstAmount is not a valid integer"); expect(res.body.intentId).toBe(created.intentId); + }); + + it("POST /api/v1/intents/:id/fill with non-numeric fillAmount returns 400", async () => { + const created = await createIntent(); + const acceptSig = sign(ALPHA_KP, buildAcceptMessage(created.intentId, ALPHA_KP.publicKey())); + await request(app.getHttpServer()) + .post(`/api/v1/intents/${created.intentId}/accept`) + .send({ solver: ALPHA_KP.publicKey(), signature: acceptSig }) + .expect(201); + + const fillSig = sign(ALPHA_KP, buildFillMessage(created.intentId, ALPHA_KP.publicKey())); const res = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: "SOLVER_ALPHA", fillAmount: "abc", txHash: "e2e-hash" }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "abc", txHash: "e2e-hash", signature: fillSig }) .expect(400); expect(res.body.error).toBe("Validation failed"); expect(Array.isArray(res.body.details)).toBe(true); @@ -176,7 +204,6 @@ describe("IntentsController (e2e)", () => { it("accept with an unknown/inactive solver is forbidden", async () => { const created = await createIntent(); - // Use a valid keypair that is NOT registered as a solver const unknownKp = Keypair.fromSecret("SBEEB2ZY2D25GRU4TXUARHHPQ2ASDRVQJZXWBUMW27VBVT3FCU2MEU5Q"); const sig = sign(unknownKp, buildAcceptMessage(created.intentId, unknownKp.publicKey())); await request(app.getHttpServer()) @@ -188,7 +215,6 @@ describe("IntentsController (e2e)", () => { it("cancel: invalid signature returns 401, wrong user returns 403, correct user+sig succeeds", async () => { const created = await createIntent(); - // Wrong user address (different keypair) - forbidden before sig check const wrongKp = Keypair.fromSecret("SBEEB2ZY2D25GRU4TXUARHHPQ2ASDRVQJZXWBUMW27VBVT3FCU2MEU5Q"); const wrongSig = sign(wrongKp, buildCancelMessage(created.intentId)); await request(app.getHttpServer()) @@ -196,13 +222,11 @@ describe("IntentsController (e2e)", () => { .send({ user: wrongKp.publicKey(), signature: wrongSig }) .expect(403); - // Correct user but invalid signature (tampered) await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/cancel`) .send({ user: USER_KP.publicKey(), signature: "aW52YWxpZHNpZ25hdHVyZXBhZGRpbmc=" }) .expect(401); - // Correct user + valid signature const validSig = sign(USER_KP, buildCancelMessage(created.intentId)); const cancelled = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/cancel`) @@ -257,8 +281,9 @@ describe("IntentsController (e2e)", () => { expect(res1.body.intentId).toBe(res2.body.intentId); expect(res1.body.createdAt).toBe(res2.body.createdAt); + }); + it("POST /api/v1/intents/quote preserves precision for large 18-decimal amounts", async () => { - // Simulate 1 million USDC with 18 decimals: 1e6 * 1e18 = 1e24 const largeAmount = "1000000000000000000000000"; const res = await request(app.getHttpServer()) .post("/api/v1/intents/quote") @@ -273,14 +298,11 @@ describe("IntentsController (e2e)", () => { expect(res.body.quotes.length).toBe(3); const srcBigInt = BigInt(largeAmount); const bestQuote = BigInt(res.body.bestQuote.dstAmount); - - // Best quote should be between 99.2% and 100% of source (0.8% max variance) const minExpected = (srcBigInt * BigInt(992)) / BigInt(1000); const maxExpected = srcBigInt; expect(bestQuote >= minExpected).toBe(true); expect(bestQuote <= maxExpected).toBe(true); - // Verify no silent truncation: all quotes should be in a reasonable range for (const quote of res.body.quotes) { const amount = BigInt(quote.dstAmount); expect(amount >= minExpected).toBe(true); @@ -304,7 +326,6 @@ describe("IntentsController (e2e)", () => { expect(res.body.bestQuote).toBeTruthy(); const quotedAmount = res.body.bestQuote.dstAmount; - // Fetch the intent and verify quotedDstAmount was persisted const fetchRes = await request(app.getHttpServer()) .get(`/api/v1/intents/${created.intentId}`) .expect(200); @@ -312,6 +333,115 @@ describe("IntentsController (e2e)", () => { expect(fetchRes.body.quotedDstAmount).toBe(quotedAmount); }); + // ── #219: resolveToken used in create and quote ──────────────────────────── + + it("POST /api/v1/intents create resolves srcToken priceUSD from registry", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ + ...validCreateBody, + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // known USDC + }) + .expect(201); + + // priceUSD should be 1.0 from the registry, not undefined + expect(res.body.srcToken.priceUSD).toBe(1.0); + }); + + it("POST /api/v1/intents create with unknown srcToken address still succeeds (priceUSD undefined)", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ + ...validCreateBody, + srcTokenAddress: "0xunknowntoken000000000000000000000000000", + }) + .expect(201); + + // priceUSD should be undefined (not found in registry) + expect(res.body.srcToken.priceUSD).toBeUndefined(); + }); + + it("POST /api/v1/intents/quote includes route.steps in each quote (#220)", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/quote") + .send({ + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + srcAmount: "1000000", + dstTokenSymbol: "USDC", + }) + .expect(201); + + expect(res.body.quotes.length).toBeGreaterThan(0); + for (const quote of res.body.quotes) { + expect(quote.route).toBeDefined(); + expect(Array.isArray(quote.route.steps)).toBe(true); + expect(quote.route.steps.length).toBeGreaterThanOrEqual(1); + } + }); + + it("POST /api/v1/intents/quote direct route (USDC→USDC) has 1 step of type 'transfer' (#220)", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/quote") + .send({ + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + srcAmount: "1000000", + dstTokenSymbol: "USDC", + }) + .expect(201); + + const best = res.body.bestQuote; + expect(best.route.steps).toHaveLength(1); + expect(best.route.steps[0].type).toBe("transfer"); + expect(best.route.steps[0].fromChain).toBe("ethereum"); + expect(best.route.steps[0].toChain).toBe("stellar"); + }); + + it("POST /api/v1/intents/quote two-hop route (WETH→XLM) has 2 steps (#220)", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/quote") + .send({ + srcChain: "ethereum", + srcTokenSymbol: "WETH", + srcTokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + dstTokenContract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + srcAmount: "1000000000000000000", + dstTokenSymbol: "XLM", + }) + .expect(201); + + const best = res.body.bestQuote; + expect(best.route.steps).toHaveLength(2); + expect(best.route.steps[0].type).toBe("swap"); + expect(best.route.steps[1].type).toBe("bridge"); + }); + + it("POST /api/v1/intents/quote route.steps have well-formed fromToken and toToken", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/quote") + .send({ + srcChain: "ethereum", + srcTokenSymbol: "USDC", + srcAmount: "1000000", + dstTokenSymbol: "USDC", + }) + .expect(201); + + for (const quote of res.body.quotes) { + for (const step of quote.route.steps) { + expect(typeof step.fromToken).toBe("object"); + expect(typeof step.toToken).toBe("object"); + expect(typeof step.estimatedTime).toBe("number"); + expect(typeof step.estimatedGas).toBe("string"); + } + } + }); + it("GET /api/v1/intents/:id/quote returns the persisted quote", async () => { const created = await createIntent(); const quoteRes = await request(app.getHttpServer()) @@ -327,7 +457,6 @@ describe("IntentsController (e2e)", () => { const quotedAmount = quoteRes.body.bestQuote.dstAmount; - // Fetch the quote via dedicated endpoint const res = await request(app.getHttpServer()) .get(`/api/v1/intents/${created.intentId}/quote`) .expect(200); diff --git a/test/utils/create-test-app.ts b/test/utils/create-test-app.ts index 47c32cb..5242a6c 100644 --- a/test/utils/create-test-app.ts +++ b/test/utils/create-test-app.ts @@ -10,16 +10,20 @@ import { PrismaService } from "../../src/prisma/prisma.service"; /** * Minimal PrismaService stand-in for e2e tests. * - * The feature services (IntentsService, SolversService, etc.) still use - * in-memory stores in the current codebase, so they never call PrismaService - * directly. We only need to prevent the real $connect() from being called so - * the suite does not require a live PostgreSQL instance. + * IntentsService now calls this.prisma.intentAuditLog.create() as a + * fire-and-forget DB write (issue #217). We stub that here so the suite + * does not require a live PostgreSQL instance. */ class MockPrismaService { // eslint-disable-next-line @typescript-eslint/no-empty-function async onModuleInit(): Promise {} // eslint-disable-next-line @typescript-eslint/no-empty-function async onModuleDestroy(): Promise {} + + intentAuditLog = { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }; } export async function createTestApp(): Promise {