diff --git a/package.json b/package.json index 9e356c56..8cad0d2c 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,8 @@ "@commitlint/config-conventional": "^21.2.3", "@nestjs/cli": "^11.0.24", "@nestjs/schematics": "^11.1.0", + "@msgpack/msgpack": "^3.0.0", + "joi": "^18.2.9" "@nestjs/testing": "^12.1.0", "@stryker-mutator/core": "^8.0.0", "@stryker-mutator/jest-runner": "^8.0.0", @@ -96,7 +98,7 @@ "@types/uuid": "^10.0.0", "@types/ws": "^8.5.12", "@msgpack/msgpack": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/eslint-plugin": "^8.70.1", "@typescript-eslint/parser": "^7.13.0", "eslint": "^8.57.0", "fast-check": "^4.10.2", diff --git a/packages/solver-sdk/src/signing.ts b/packages/solver-sdk/src/signing.ts index 9838681c..3aec6ad3 100644 --- a/packages/solver-sdk/src/signing.ts +++ b/packages/solver-sdk/src/signing.ts @@ -51,6 +51,8 @@ export const messages = { accept: (intentId: string, solver: string) => `accept:${intentId}:${solver}`, fill: (intentId: string, solver: string) => `fill:${intentId}:${solver}`, cancel: (intentId: string) => `cancel:${intentId}`, + amend: (intentId: string, user: string, minDstAmount: string, deadline: number) => + `amend:${intentId}:${user}:${minDstAmount}:${deadline}`, wsAuth: (solver: string, timestamp: number | string) => `solver-auth:${solver}:${String(timestamp)}`, register: (address: string) => `register:${address}`, }; @@ -110,6 +112,22 @@ export function signCancel(keypair: Keypair, intentId: string, options?: IntentS return { user, ...context, signature: signMessage(keypair, messagesV2.cancel(intentId, user, context)) }; } +/** Body for POST /api/v1/intents/{id}/amend (signed by the intent's user). */ +export function signAmend( + keypair: Keypair, + intentId: string, + minDstAmount: string, + deadline: number, +) { + const user = keypair.publicKey(); + return { + user, + minDstAmount, + deadline, + signature: signMessage(keypair, messages.amend(intentId, user, minDstAmount, deadline)), + }; +} + /** WS `{ type: "auth" }` frame. */ export function signWsAuth(keypair: Keypair, timestamp = Math.floor(Date.now() / 1000)) { const solver = keypair.publicKey(); diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index d84fbb36..63ab1c84 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -105,6 +105,18 @@ export function buildCancelMessage(intentId: string, context?: IntentSignatureCo return `cancel:${intentId}`; } +/** + * Build the canonical message that an intent owner must sign to amend it. + */ +export function buildAmendMessage( + intentId: string, + user: string, + minDstAmount: string, + deadline: number, +): string { + return `amend:${intentId}:${user}:${minDstAmount}:${deadline}`; +} + /** * Build the canonical message that a solver must sign to authenticate its WS connection. */ diff --git a/src/intents/dto/amend-intent.dto.ts b/src/intents/dto/amend-intent.dto.ts new file mode 100644 index 00000000..9fbed10c --- /dev/null +++ b/src/intents/dto/amend-intent.dto.ts @@ -0,0 +1,33 @@ +import { IsInt, IsString, Matches, MaxLength, Min, MinLength } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsValidDeadline } from "../../common/validators/deadline.validator"; + +const ED25519_SIGNATURE_MAX_LENGTH = 88; + +export class AmendIntentDto { + @ApiProperty({ description: "Stellar address of the intent's original creator (must match)", maxLength: 56 }) + @IsString() + @MinLength(10) + @MaxLength(56) + user!: string; + + @ApiProperty({ description: "Replacement minimum destination amount in base units" }) + @IsString() + @Matches(/^\d+$/) + minDstAmount!: string; + + @ApiProperty({ description: "Replacement Unix timestamp deadline" }) + @IsInt() + @Min(1) + @IsValidDeadline() + deadline!: number; + + @ApiProperty({ + description: 'Base64 Ed25519 signature of "amend::::"', + maxLength: ED25519_SIGNATURE_MAX_LENGTH, + }) + @IsString() + @MinLength(10) + @MaxLength(ED25519_SIGNATURE_MAX_LENGTH) + signature!: string; +} \ No newline at end of file diff --git a/src/intents/dual-write-intents.repository.ts b/src/intents/dual-write-intents.repository.ts index a5445adf..e12e7225 100644 --- a/src/intents/dual-write-intents.repository.ts +++ b/src/intents/dual-write-intents.repository.ts @@ -120,6 +120,16 @@ export class DualWriteIntentsRepository implements IIntentsRepository { return this.mirrored("update", this.primary.update(id, patch, expectedVersion)); } + async amendIfOpen( + id: string, + patch: Pick, + now?: number, + ): Promise { + const amended = this.primary.amendIfOpen(id, patch, now); + if (amended) await this.mirror("amendIfOpen", amended); + return amended; + } + /** * Deletes from memory only. Retention eviction exists to bound process * memory; Postgres keeps the durable history. diff --git a/src/intents/in-memory-intents.repository.spec.ts b/src/intents/in-memory-intents.repository.spec.ts index 5d5482c2..78870234 100644 --- a/src/intents/in-memory-intents.repository.spec.ts +++ b/src/intents/in-memory-intents.repository.spec.ts @@ -131,4 +131,32 @@ describe("InMemoryIntentsRepository", () => { it("update returns null for a missing id", () => { expect(repo.update("nope", { state: "cancelled" })).toBeNull(); }); + + it("amendIfOpen updates both terms without changing intent identity or creation history", () => { + const now = Math.floor(Date.now() / 1000); + const original = makeIntent({ intentId: "amend-1", createdAt: now - 10, deadline: now + 100 }); + repo.save(original); + + const amended = repo.amendIfOpen("amend-1", { minDstAmount: "980000", deadline: now + 200 }, now); + + expect(amended).toMatchObject({ + intentId: original.intentId, + createdAt: original.createdAt, + state: "open", + minDstAmount: "980000", + deadline: now + 200, + }); + }); + + it("amendIfOpen refuses a non-open or expired intent", () => { + const now = Math.floor(Date.now() / 1000); + repo.save(makeIntent({ intentId: "accepted", state: "accepted", deadline: now + 100 })); + repo.save(makeIntent({ intentId: "expired", deadline: now - 1 })); + + expect(repo.amendIfOpen("accepted", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull(); + expect(repo.amendIfOpen("expired", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull(); + expect(repo.amendIfOpen("missing", { minDstAmount: "1", deadline: now + 200 }, now)).toBeNull(); + }); +}); + }); }); diff --git a/src/intents/intents.controller.amend.spec.ts b/src/intents/intents.controller.amend.spec.ts new file mode 100644 index 00000000..dd61e41f --- /dev/null +++ b/src/intents/intents.controller.amend.spec.ts @@ -0,0 +1,96 @@ +import { ConfigService } from "@nestjs/config"; +import { ForbiddenException, UnauthorizedException } from "@nestjs/common"; +import { Keypair } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { buildAmendMessage } from "../common/stellar-signature"; +import { IntentsController } from "./intents.controller"; +import { IntentsService } from "./intents.service"; +import { Intent } from "./intents.types"; +import { SolversService } from "../solvers/solvers.service"; +import { IntentsGateway } from "./intents.gateway"; +import { TokensService } from "../tokens/tokens.service"; +import { RoutingService } from "../routing/routing.service"; +import { KillSwitchService } from "../killswitch/killswitch.service"; + +describe("IntentsController.amend", () => { + const keypair = Keypair.random(); + const now = Math.floor(Date.now() / 1000); + const intent: Intent = { + intentId: "amend-test-id", + user: keypair.publicKey(), + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "900", + state: "open", + createdAt: now - 60, + deadline: now + 600, + }; + + function setup(current = intent) { + const amended: Intent = { ...current, minDstAmount: "850", deadline: now + 900 }; + const service = { + get: jest.fn().mockResolvedValue(current), + amendIfOpen: jest.fn().mockResolvedValue(amended), + appendAuditEntry: jest.fn(), + } as unknown as jest.Mocked; + const config = { get: jest.fn().mockReturnValue([]) } as unknown as ConfigService; + const controller = new IntentsController( + service, + {} as SolversService, + {} as IntentsGateway, + {} as TokensService, + {} as RoutingService, + {} as KillSwitchService, + config, + ); + const dto = { + user: keypair.publicKey(), + minDstAmount: "850", + deadline: now + 900, + signature: keypair + .sign(Buffer.from(buildAmendMessage(intent.intentId, keypair.publicKey(), "850", now + 900))) + .toString("base64"), + }; + return { controller, service, amended, dto }; + } + + it("applies both signed replacement terms and appends amendment history", async () => { + const { controller, service, amended, dto } = setup(); + + await expect(controller.amend(intent.intentId, dto)).resolves.toBe(amended); + expect(service.amendIfOpen).toHaveBeenCalledWith(intent.intentId, { + minDstAmount: "850", + deadline: now + 900, + }); + expect(service.appendAuditEntry).toHaveBeenCalledWith( + intent.intentId, + "open", + keypair.publicKey(), + "user amended", + { + previousMinDstAmount: "900", + minDstAmount: "850", + previousDeadline: intent.deadline, + deadline: now + 900, + }, + ); + }); + + it("rejects a request signed by an address other than the intent owner", async () => { + const { controller, service, dto } = setup(); + + await expect(controller.amend(intent.intentId, { ...dto, user: Keypair.random().publicKey() })) + .rejects.toBeInstanceOf(ForbiddenException); + expect(service.amendIfOpen).not.toHaveBeenCalled(); + }); + + it("rejects a signature that does not cover the replacement values", async () => { + const { controller, service, dto } = setup(); + + await expect(controller.amend(intent.intentId, { ...dto, minDstAmount: "851" })) + .rejects.toBeInstanceOf(UnauthorizedException); + expect(service.amendIfOpen).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index e361a781..ce8e8f3b 100644 --- a/src/intents/intents.repository.ts +++ b/src/intents/intents.repository.ts @@ -55,6 +55,17 @@ export interface IIntentsRepository { */ update(id: string, patch: Partial): Intent | null | Promise; + /** + * Atomically replace an open intent's minimum output and deadline while its + * current deadline is still in the future. Returns null when the intent is + * missing, no longer open, or already expired. + */ + amendIfOpen( + id: string, + patch: Pick, + now?: number, + ): Intent | null | Promise; + /** * Remove a stored intent. Used only for in-memory retention sweeps for stale * terminal-state records; Prisma-backed stores ignore this call by design. @@ -196,6 +207,20 @@ export class InMemoryIntentsRepository implements IIntentsRepository { return updated; } + amendIfOpen( + id: string, + patch: Pick, + now = Math.floor(Date.now() / 1000), + ): Intent | null { + const existing = this.store.get(id); + if (!existing || existing.state !== "open" || existing.deadline <= now || patch.deadline <= now) { + return null; + } + const updated: Intent = { ...existing, ...patch }; + this.store.set(id, updated); + return updated; + } + delete(id: string): boolean { return this.store.delete(id); } diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index be5aaea6..e69de29b 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -1,543 +0,0 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { ConfigService } from "@nestjs/config"; -import { Keypair } from "@stellar/stellar-sdk"; -import { AppConfig, CHAIN_FILL_WINDOW_DEFAULTS, DEFAULT_FILL_WINDOW_SECONDS } from "../config/configuration"; -import { StellarTxService } from "../soroban/stellar-tx.service"; -import { IntentsService } from "./intents.service"; -import { INTENTS_REPOSITORY, InMemoryIntentsRepository } from "./intents.repository"; -import { PrismaService } from "../prisma/prisma.service"; -import { ProtocolParamsService } from "../governance/params.service"; - -const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; - -function fakeConfig(overrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}) { - const values: Record = { - onchainIntentsEnabled: overrides.onchainIntentsEnabled ?? false, - "stellar.settlementContractId": overrides.settlementContractId ?? "", - }; - return { get: (path: string) => values[path] } as ConfigService; -} - -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 fakeProtocolParamsService(): ProtocolParamsService { - return { - snapshotForChain: jest.fn().mockReturnValue({ - version: 0, - feeBps: 30, - deadlineSeconds: 1800, - fillWindowSeconds: 600, - capturedAt: new Date().toISOString(), - }), - getCurrent: jest.fn().mockReturnValue({ version: 0, feeBps: 30, chains: {}, maxExposureRatio: 0.05, slashAmount: "100000000", activeSinceLedger: 0, adoptedAt: new Date().toISOString() }), - getPending: jest.fn().mockReturnValue(null), - getHistory: jest.fn().mockReturnValue([]), - } as unknown as ProtocolParamsService; -} - -function makeService( - configOverrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}, - stellarTx?: jest.Mocked, -) { - return new IntentsService( - new InMemoryIntentsRepository(), - fakeConfig(configOverrides), - stellarTx ?? fakeStellarTxService(), - fakePrismaService(), - fakeProtocolParamsService(), - ); -} - -function validCreateData() { - return { - user: Keypair.random().publicKey(), - srcChain: "ethereum" as const, - srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" as const }, - srcAmount: "1000000", - dstToken: { contract: VALID_CONTRACT_ID, symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: Math.floor(Date.now() / 1000) + 1800, - }; -} - -async function buildService( - configOverrides: { onchainIntentsEnabled?: boolean; settlementContractId?: string } = {}, - stellarTxService?: jest.Mocked, -): Promise { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: INTENTS_REPOSITORY, - useClass: InMemoryIntentsRepository, - }, - { - provide: ConfigService, - useValue: fakeConfig(configOverrides), - }, - { - provide: StellarTxService, - useValue: stellarTxService ?? fakeStellarTxService(), - }, - { - provide: PrismaService, - useValue: fakePrismaService(), - }, - { - provide: ProtocolParamsService, - useValue: fakeProtocolParamsService(), - }, - IntentsService, - ], - }).compile(); - - return module.get(IntentsService); -} - -describe("IntentsService", () => { - let service: IntentsService; - - beforeEach(() => { - service = makeService(); - }); - - it("seeds 5 intents on construction", async () => { - expect(await service.getAll()).toHaveLength(5); - }); - - it("getAll returns intents sorted by createdAt descending", async () => { - const all = await service.getAll(); - for (let i = 1; i < all.length; i++) { - expect(all[i - 1].createdAt).toBeGreaterThanOrEqual(all[i].createdAt); - } - }); - - it("create adds an open intent with a generated id", async () => { - const before = (await service.getAll()).length; - const deadline = Math.floor(Date.now() / 1000) + 1800; - const intent = await service.create({ - user: "GTEST...0000", - srcChain: "ethereum", - srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline, - }); - - expect(intent.state).toBe("open"); - expect(intent.intentId).toBeTruthy(); - expect(intent.deadline).toBe(deadline); - expect(await service.getAll()).toHaveLength(before + 1); - }); - - it("create defaults deadline to now + 1800 when omitted", async () => { - const before = Math.floor(Date.now() / 1000); - const intent = await service.create({ - user: "GTEST...0000", - srcChain: "ethereum", - srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: undefined as unknown as number, - }); - - expect(intent.deadline).toBeGreaterThanOrEqual(before + 1800); - }); - - it("get returns undefined for an unknown id", async () => { - expect(await service.get("does-not-exist")).toBeUndefined(); - }); - - it("update mutates and returns the patched intent", async () => { - const [existing] = await service.getByState("open"); - const updated = await service.update(existing.intentId, { state: "accepted", solver: "SOLVER_X" }); - - expect(updated?.state).toBe("accepted"); - expect(updated?.solver).toBe("SOLVER_X"); - expect((await service.get(existing.intentId))?.state).toBe("accepted"); - }); - - it("update returns null for an unknown id", async () => { - expect(await service.update("does-not-exist", { state: "cancelled" })).toBeNull(); - }); - - it("getByUser is case-insensitive", async () => { - const [existing] = await service.getAll(); - const found = await service.getByUser(existing.user.toLowerCase()); - expect(found.some((i) => i.intentId === existing.intentId)).toBe(true); - }); - - it("getByState only returns intents in that state", async () => { - for (const intent of await service.getByState("filled")) { - expect(intent.state).toBe("filled"); - } - }); - - describe("acceptIfOpen", () => { - it("transitions an open intent to accepted and returns it", async () => { - const [open] = await service.getByState("open"); - const result = await service.acceptIfOpen(open.intentId, "SOLVER_X"); - - expect(result).not.toBeNull(); - expect(result!.state).toBe("accepted"); - expect(result!.solver).toBe("SOLVER_X"); - expect((await service.get(open.intentId))!.state).toBe("accepted"); - }); - - it("returns null for a non-existent intent", async () => { - expect(await service.acceptIfOpen("does-not-exist", "SOLVER_X")).toBeNull(); - }); - - it("returns null when the intent is already accepted", async () => { - const [accepted] = await service.getByState("accepted"); - expect(await service.acceptIfOpen(accepted.intentId, "SOLVER_X")).toBeNull(); - }); - - it("only the first caller wins under simulated concurrency", async () => { - const [open] = await service.getByState("open"); - const results = await Promise.all( - Array.from({ length: 10 }, (_, i) => - service.acceptIfOpen(open.intentId, `SOLVER_${i}`), - ), - ); - - const successes = results.filter((r) => r !== null); - expect(successes).toHaveLength(1); - expect(successes[0]!.state).toBe("accepted"); - }); - - // ----------------------------------------------------------------------- - // Per-chain fill-window tests (issue: chain-aware fill window) - // ----------------------------------------------------------------------- - - it("sets deadline to now + stellar fill window (120 s) for a stellar intent", async () => { - const now = Math.floor(Date.now() / 1000); - const intent = await service.create({ - user: "GTEST_STELLAR_CHAIN1", - srcChain: "stellar", - srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: now + 900, - }); - - const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); - - expect(result).not.toBeNull(); - const expectedWindow = CHAIN_FILL_WINDOW_DEFAULTS["stellar"] ?? DEFAULT_FILL_WINDOW_SECONDS; - // Allow a 2-second tolerance for test execution time - expect(result!.deadline).toBeGreaterThanOrEqual(now + expectedWindow - 2); - expect(result!.deadline).toBeLessThanOrEqual(now + expectedWindow + 2); - }); - - it("sets deadline to now + ethereum fill window (1800 s) for an ethereum intent", async () => { - const now = Math.floor(Date.now() / 1000); - const intent = await service.create({ - user: "GTEST_ETHEREUM_CHAIN1", - srcChain: "ethereum", - srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: now + 3600, - }); - - const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); - - expect(result).not.toBeNull(); - const expectedWindow = CHAIN_FILL_WINDOW_DEFAULTS["ethereum"] ?? DEFAULT_FILL_WINDOW_SECONDS; - // Allow a 2-second tolerance for test execution time - expect(result!.deadline).toBeGreaterThanOrEqual(now + expectedWindow - 2); - expect(result!.deadline).toBeLessThanOrEqual(now + expectedWindow + 2); - }); - - it("stellar and ethereum accepted intents get distinct (non-equal) fill deadlines", async () => { - const now = Math.floor(Date.now() / 1000); - - const stellarIntent = await service.create({ - user: "GTEST_STELLAR_DIFF1", - srcChain: "stellar", - srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: now + 900, - }); - const ethIntent = await service.create({ - user: "GTEST_ETHEREUM_DIFF1", - srcChain: "ethereum", - srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: now + 3600, - }); - - const stellarResult = await service.acceptIfOpen(stellarIntent.intentId, "SOLVER_STELLAR"); - const ethResult = await service.acceptIfOpen(ethIntent.intentId, "SOLVER_ETH"); - - expect(stellarResult).not.toBeNull(); - expect(ethResult).not.toBeNull(); - - // Ethereum solver gets a materially larger fill window than Stellar - expect(ethResult!.deadline).toBeGreaterThan(stellarResult!.deadline); - - // Confirm the windows match the config constants exactly (allowing 2 s clock drift) - const stellarWindow = CHAIN_FILL_WINDOW_DEFAULTS["stellar"] ?? DEFAULT_FILL_WINDOW_SECONDS; - const ethWindow = CHAIN_FILL_WINDOW_DEFAULTS["ethereum"] ?? DEFAULT_FILL_WINDOW_SECONDS; - expect(ethWindow).toBeGreaterThan(stellarWindow); // sanity-check on config - }); - - it("falls back to DEFAULT_FILL_WINDOW_SECONDS for an unknown chain", async () => { - const now = Math.floor(Date.now() / 1000); - const intent = await service.create({ - user: "GTEST_UNKNOWN_CHAIN01", - srcChain: "stellar", // create as valid chain, then patch for test - srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, - srcAmount: "1000000", - dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, - minDstAmount: "990000", - deadline: now + 3600, - }); - // Manually patch to an unknown chain to exercise the fallback - await service.update(intent.intentId, { srcChain: "unknown_chain" as never }); - - const result = await service.acceptIfOpen(intent.intentId, "SOLVER_X"); - - expect(result).not.toBeNull(); - expect(result!.deadline).toBeGreaterThanOrEqual(now + DEFAULT_FILL_WINDOW_SECONDS - 2); - expect(result!.deadline).toBeLessThanOrEqual(now + DEFAULT_FILL_WINDOW_SECONDS + 2); - }); - }); // end describe("acceptIfOpen") - - describe("fillIfAccepted", () => { - it("transitions an accepted intent to filled when solver matches", async () => { - const [accepted] = await service.getByState("accepted"); - const result = await service.fillIfAccepted(accepted.intentId, accepted.solver!, { - fillAmount: "100", - txHash: "test-hash", - filledAt: Math.floor(Date.now() / 1000), - }); - - expect(result).not.toBeNull(); - expect(result!.state).toBe("filled"); - expect(result!.fillAmount).toBe("100"); - }); - - it("returns null when solver does not match", async () => { - const [accepted] = await service.getByState("accepted"); - const result = await service.fillIfAccepted(accepted.intentId, "WRONG_SOLVER", { - fillAmount: "100", - }); - expect(result).toBeNull(); - }); - - it("returns null for a non-existent intent", async () => { - expect(await service.fillIfAccepted("nope", "SOLVER_X", {})).toBeNull(); - }); - - it("only the first caller wins under simulated concurrency", async () => { - const [accepted] = await service.getByState("accepted"); - const results = await Promise.all( - Array.from({ length: 10 }, () => - service.fillIfAccepted(accepted.intentId, accepted.solver!, { - fillAmount: "100", - txHash: "race-hash", - filledAt: Math.floor(Date.now() / 1000), - }), - ), - ); - - const successes = results.filter((r) => r !== null); - expect(successes).toHaveLength(1); - expect(successes[0]!.state).toBe("filled"); - }); - }); - - describe("on-chain registration (ONCHAIN_INTENTS_ENABLED)", () => { - it("stays fully in the repository when the flag is off, never touching StellarTxService", async () => { - const stellarTxService = fakeStellarTxService(); - const svc = makeService({ onchainIntentsEnabled: false }, stellarTxService); - - const intent = await svc.create(validCreateData()); - - expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); - expect(await svc.get(intent.intentId)).toEqual(intent); - }); - - 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 svc = makeService( - { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, - stellarTxService, - ); - - const data = validCreateData(); - const intent = await svc.create(data); - - expect(stellarTxService.invokeContract).toHaveBeenCalledTimes(1); - const call = stellarTxService.invokeContract.mock.calls[0][0]; - expect(call.contractId).toBe(VALID_CONTRACT_ID); - expect(call.method).toBe("create_intent"); - - // response shape is unchanged relative to the in-memory path - expect(Object.keys(intent).sort()).toEqual( - Object.keys({ - intentId: "", - user: "", - srcChain: "", - srcToken: "", - srcAmount: "", - dstToken: "", - minDstAmount: "", - state: "", - createdAt: 0, - deadline: 0, - }).sort(), - ); - expect(await svc.get(intent.intentId)).toBeDefined(); - }); - - it("rejects with a clear error and does not create the intent when SETTLEMENT_CONTRACT_ID is unset", async () => { - const stellarTxService = fakeStellarTxService(); - const service = makeService({ onchainIntentsEnabled: true }, stellarTxService); - const before = (await service.getAll()).length; - - await expect(service.create(validCreateData())).rejects.toMatchObject({ - message: expect.stringContaining("SETTLEMENT_CONTRACT_ID"), - }); - expect(stellarTxService.invokeContract).not.toHaveBeenCalled(); - expect(await service.getAll()).toHaveLength(before); - }); - - 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 svc = makeService( - { onchainIntentsEnabled: true, settlementContractId: VALID_CONTRACT_ID }, - stellarTxService, - ); - const before = (await svc.getAll()).length; - - await expect(svc.create(validCreateData())).rejects.toThrow(/settlement contract/i); - expect(await svc.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(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService, fakeProtocolParamsService()); - - 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(new InMemoryIntentsRepository(), fakeConfig(), fakeStellarTxService(), prismaService, fakeProtocolParamsService()); - - // 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 e69de29b..fd81a2af 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -0,0 +1,833 @@ +import { + Inject, + Injectable, + Logger, + Optional, + ServiceUnavailableException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { v4 as uuidv4 } from "uuid"; +import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; +import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; +import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; +import { AppConfig } from "../config/configuration"; +import { + CHAIN_DEADLINE_DEFAULTS, + DEFAULT_DEADLINE_SECONDS, + CHAIN_FILL_WINDOW_DEFAULTS, + DEFAULT_FILL_WINDOW_SECONDS, +} from "../config/configuration"; +import { StellarTxService } from "../soroban/stellar-tx.service"; +import { ShadowService, type ShadowObservationRequest } from "../soroban/shadow.service"; +import { SHADOW_TRANSITIONS, type ShadowTransition } from "../soroban/shadow.types"; +import { MetricsService } from "../metrics/metrics.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { ProtocolParamsService } from "../governance/params.service"; +import { FeatureFlagService } from "../flags/feature-flag.service"; + +const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slashed"]; + +/** + * Sentinel `from_state` for the transition into "open". + * + * Not an {@link IntentState}: creation has no prior state, and inventing one + * would put a value in the `from_state` label that no lifecycle edge can + * produce. Bounded (one extra series), and it keeps the funnel's denominator + * honest. + */ +const NONE_STATE = "none"; + +/** + * Runtime check that `transition` is one of the five the shadow monitor models. + * + * A mis-wired call site is logged and dropped rather than thrown on, so a shadow + * bug can never become a 500 on the intent path, and so an unknown label can + * never create a new Prometheus series. + */ +function isKnownShadowTransition(transition: ShadowTransition): boolean { + return (SHADOW_TRANSITIONS as readonly string[]).includes(transition); +} + +/** How long a completed idempotency-key result stays replayable. */ +const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours + +/** + * Maximum number of simultaneously open (state = "open" | "accepted") intents + * allowed per user address. + * + * Rationale: the per-user rate limit (UserThrottlerGuard) bounds the *rate* of + * creation but not the standing *count* — a user could steadily accumulate + * thousands of open intents over time, which is exactly the scenario the + * on-call runbook flags as a sweeper-performance risk. This constant is the + * authoritative cap; it is enforced in IntentsController.create() before the + * intent is persisted. + * + * Kept as a named constant (rather than a config value) so the cap is visible + * at the call site and testable without ConfigService. Raise or lower it with + * a code change + review rather than a silent env-var override. + */ +export const MAX_OPEN_INTENTS_PER_USER = 50; + +/** Payload for creating a new intent. */ +export type NewIntentData = Omit; + +/** + * Orchestration layer for intents. + * + * Business logic (ID generation, default state, deadline defaulting, + * idempotency cache, audit log) lives here. All persistence is delegated + * to the injected IIntentsRepository so the storage adapter can be swapped + * (in-memory ↔ Prisma) without touching this service or anything above it. + */ +@Injectable() +export class IntentsService { + private readonly logger = new Logger(IntentsService.name); + + /** + * Idempotency cache: maps caller-supplied keys → { intentId, expiresAt }. + * Kept in-service (not in the repository) because it is a short-lived + * request deduplication concern, not a durable persistence concern. + */ + private readonly idempotencyCache = new Map(); + + /** + * Keys whose creation is currently in flight → the in-flight creation + * promise. Claimed synchronously in {@link create} so that concurrent + * requests carrying the same idempotency key collapse onto a single created + * intent instead of racing the check-then-set window (issue #274). + */ + private readonly idempotencyInFlight = new Map>(); + + /** + * 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(); + + constructor( + @Inject(INTENTS_REPOSITORY) + private readonly repo: IIntentsRepository, + private readonly configService: ConfigService, + private readonly stellarTxService: StellarTxService, + private readonly prisma: PrismaService, + private readonly protocolParamsService: ProtocolParamsService, + /** + * Shadow-mode divergence monitor (issue #401). + * + * Injected `@Optional()` on purpose: the monitor is observability, not a + * correctness dependency, and the intent path must keep working — including + * in the unit-test harnesses that construct this service directly — when + * the soroban module is not in the graph. + */ + @Optional() private readonly shadowService?: ShadowService, + /** + * SLO counters for the intent funnel (issue #481). + * + * `@Optional()` for the same reason as the shadow monitor: the dashboards + * are observability, and a unit harness that constructs this service + * directly must not have to provide a metrics registry. `MetricsModule` is + * `@Global()` and registered in `AppModule`, so in the running application + * this is always present. + */ + @Optional() private readonly metricsService?: MetricsService, + @Optional() private readonly flags?: FeatureFlagService, + ) {} + + /** + * Logs the store size and evicts stale terminal intents from the in-memory + * adapter when it is the active backend. This keeps the memory footprint + * bounded without affecting on-chain or durable storage paths. + * + * Runs as the `intents.store-size` background job (see + * intents-maintenance.jobs.ts, issue #494) rather than a local timer. + */ + async logStoreSize(): Promise { + const evicted = await this.evictTerminalIntents(); + const remaining = await this.repo.findAll(); + this.logger.log(`[store-monitor] intents store size: ${remaining.length} (evicted=${evicted})`); + } + + private async evictTerminalIntents(): Promise { + const persistence = process.env.INTENTS_PERSISTENCE ?? "memory"; + const onchainEnabled = this.configService.get("onchainIntentsEnabled", { infer: true }); + if (persistence !== "memory" || onchainEnabled) { + return 0; + } + + const retentionDays = Number(this.configService.get("intentRetentionDays", { infer: true }) ?? 30); + const retentionSeconds = Math.max(0, Number.isFinite(retentionDays) ? retentionDays * 86400 : 30 * 86400); + const cutoff = Math.floor(Date.now() / 1000) - retentionSeconds; + + const all = await this.repo.findAll(); + const stale = all.filter((intent) => { + if (!TERMINAL_STATES.includes(intent.state)) return false; + const lastTerminalTs = intent.filledAt ?? intent.createdAt; + return lastTerminalTs <= cutoff; + }); + + let evicted = 0; + for (const intent of stale) { + const removed = await this.repo.delete(intent.intentId); + if (removed) evicted += 1; + this.logger.warn( + `[retention] evicted terminal intent ${intent.intentId} from in-memory store (state=${intent.state}, createdAt=${intent.createdAt})`, + ); + } + + return evicted; + } + + async create( + data: Omit, + idempotencyKey?: string, + ): Promise { + if (!idempotencyKey) { + return this.persistNewIntent(data); + } + + const now = Math.floor(Date.now() / 1000); + + // 1. Fast path — a previous request with this key already completed. + const cached = this.idempotencyCache.get(idempotencyKey); + if (cached && cached.expiresAt > now) { + const cachedIntent = await this.repo.findById(cached.intentId); + if (cachedIntent) { + return cachedIntent; + } + // Cache entry outlived its intent — drop it and fall through. + this.idempotencyCache.delete(idempotencyKey); + } + + // 2. Race-safe claim. The check-and-set on `idempotencyInFlight` runs + // synchronously — there is no `await` between the `get` and the `set` — + // so two concurrent callers carrying the same key can never both proceed + // to create. The loser awaits the winner's in-flight promise and returns + // its result. The claim is taken *before* the conditional + // `registerOnChain()` await inside persistNewIntent(), so the race window + // is closed rather than merely shifted past the on-chain call. + // + // The future Prisma-backed adapter (issue #1) must preserve the same + // guarantee at the storage layer: an atomic + // `INSERT ... ON CONFLICT (idempotency_key) DO NOTHING` followed by a + // read-back of the winning row, rather than a read-then-write. + const inFlight = this.idempotencyInFlight.get(idempotencyKey); + if (inFlight) { + return inFlight; + } + + const creation = this.persistNewIntent(data) + .then((intent) => { + this.idempotencyCache.set(idempotencyKey, { + intentId: intent.intentId, + expiresAt: now + IDEMPOTENCY_TTL_SECONDS, + }); + return intent; + }) + .finally(() => { + this.idempotencyInFlight.delete(idempotencyKey); + }); + + this.idempotencyInFlight.set(idempotencyKey, creation); + return creation; + } + + /** + * Issue #429 — Atomically create up to N intents (all-or-nothing). + * If any intent fails validation or user open-intent limits, NO intents are created + * and per-item validation errors are returned. + */ + async createBatch( + items: NewIntentData[], + ): Promise<{ created: Intent[]; errors: { index: number; field?: string; message: string }[] }> { + const errors: { index: number; field?: string; message: string }[] = []; + const userOpenCounts = new Map(); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const user = item.user?.toLowerCase(); + + if (!user) { + errors.push({ index: i, field: "user", message: "User address is required" }); + continue; + } + + if (!userOpenCounts.has(user)) { + const standingCount = await this.countOpenByUser(item.user); + userOpenCounts.set(user, standingCount); + } + + const currentCount = userOpenCounts.get(user)!; + if (currentCount + 1 > MAX_OPEN_INTENTS_PER_USER) { + errors.push({ + index: i, + field: "user", + message: `Open-intent cap reached — max ${MAX_OPEN_INTENTS_PER_USER} open/accepted intents per user`, + }); + } else { + userOpenCounts.set(user, currentCount + 1); + } + } + + if (errors.length > 0) { + return { created: [], errors }; + } + + const created: Intent[] = []; + for (const item of items) { + const intent = await this.persistNewIntent(item); + created.push(intent); + } + + return { created, errors: [] }; + } + + /** + * Build, optionally register on-chain, and persist a brand-new intent. + * Contains no idempotency logic — deduplication is the caller's concern. + */ + private async persistNewIntent( + data: Omit, + ): Promise { + const now = Math.floor(Date.now() / 1000); + + // Snapshot governance-controlled parameters at creation time so in-flight + // intents are evaluated against the rules that were active when the user + // submitted (issue #500). + const paramsSnapshot = this.protocolParamsService.snapshotForChain(data.srcChain); + const defaultDeadline = data.deadline ?? now + paramsSnapshot.deadlineSeconds; + + const intent: Intent = { + ...data, + intentId: uuidv4(), + state: "open", + createdAt: now, + deadline: defaultDeadline, + paramsVersion: paramsSnapshot.version, + }; + + // ONCHAIN_INTENTS_ENABLED is the default; the `onchain-intents-enabled` + // runtime flag (issue #495) can roll it out per chain / percentage. + const onchain = this.flags + ? await this.flags.getBooleanValue("onchain-intents-enabled", { + targetingKey: intent.intentId, + chain: intent.srcChain, + }) + : this.configService.get("onchainIntentsEnabled", { infer: true }); + if (onchain) { + await this.registerOnChain(intent); + } + + await this.repo.save(intent); + // Creation is the entry edge of the funnel: the `vortex:intent:*` recording + // rules count transitions *into* each state, so without this the intent + // dashboard would start every conversion ratio from zero. `from_state` is + // the sentinel "none" — an intent that does not exist yet has no state. + this.countTransition(NONE_STATE, "open"); + return intent; + } + + /** + * 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). + */ + private async registerOnChain(intent: Intent): Promise { + const contractId = this.configService.get("stellar.settlementContractId", { infer: true }); + if (!contractId) { + throw new ServiceUnavailableException( + "On-chain intent registration is enabled but SETTLEMENT_CONTRACT_ID is not configured", + ); + } + + try { + const result = await this.stellarTxService.invokeContract({ + contractId, + method: "create_intent", + args: this.buildCreateIntentArgs(intent), + }); + this.logger.log(`Registered intent ${intent.intentId} on-chain (tx ${result.hash})`); + } catch (err) { + this.logger.error( + `Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`, + ); + throw new ServiceUnavailableException( + "Failed to register intent with the settlement contract", + ); + } + } + + private buildCreateIntentArgs(intent: Intent): xdr.ScVal[] { + return [ + nativeToScVal(intent.intentId, { type: "string" }), + new Address(intent.user).toScVal(), + nativeToScVal(intent.srcChain, { type: "symbol" }), + nativeToScVal(intent.srcToken.address, { type: "string" }), + nativeToScVal(BigInt(intent.srcAmount), { type: "i128" }), + new Address(intent.dstToken.contract).toScVal(), + nativeToScVal(BigInt(intent.minDstAmount), { type: "i128" }), + nativeToScVal(intent.deadline, { type: "u64" }), + ]; + } + + // --------------------------------------------------------------------------- + // Shadow-mode divergence monitoring (issue #401) + // --------------------------------------------------------------------------- + // + // Every state transition the off-chain path commits is handed to + // ShadowService, which simulates the equivalent contract call on a background + // queue and records the (expected, simulated) pair. The call here is + // synchronous, allocation-light and never awaited — see the latency + // guarantee on ShadowService.observe. + // + // Both outcomes are reported, not just successes: a transition the off-chain + // path *refused* is the interesting negative case, because a contract that + // would have accepted it is a real divergence. + + /** + * Report one off-chain transition to the shadow monitor. + * + * Callers MUST gate on {@link beginShadowObservation} first: that is where + * the disabled check and the sampling draw happen, so a sampled-out + * transition costs one `Math.random()` and no repository I/O, no XDR encoding + * and no timer work. + * + * The whole body is wrapped: the monitor is observability, so a bug in it can + * never surface as a failed intent transition. + */ + private reportShadow( + transition: ShadowTransition, + intentId: string, + committed: boolean, + method: string, + args: xdr.ScVal[], + ): void { + try { + if (!this.shadowService) return; + if (!isKnownShadowTransition(transition)) { + // A mis-wired call site must be visible but must not throw into the + // request path, and must not create an unbounded Prometheus label. + this.logger.error(`[shadow] dropping observation with unknown transition "${transition}"`); + return; + } + const request: ShadowObservationRequest = { transition, intentId, committed, method, args }; + this.shadowService.observe(request); + } catch (err) { + this.logger.error(`[shadow] reportShadow failed, discarding: ${(err as Error).message}`); + } + } + + /** + * Ask the shadow monitor whether it wants to observe the transition that is + * about to happen, before any shadow-only work is done. + * + * Returns false when the monitor is absent, disabled, or has sampled this + * transition out. Sampling happens here rather than inside `observe()` so + * the extra repository read and XDR encoding the cancel/expire/slash hooks + * need are only paid for transitions that will actually be simulated. + */ + private beginShadowObservation(): boolean { + try { + return this.shadowService?.shouldObserve() === true; + } catch (err) { + this.logger.error(`[shadow] shouldObserve failed: ${(err as Error).message}`); + return false; + } + } + + /** + * Build the contract arguments for a transition, tolerating a record that + * cannot be encoded. + * + * A malformed intent (a non-integer amount, an unparseable address) must not + * be able to break the shadow path — the whole point of the monitor is to + * gather evidence, and an encoding failure is evidence in itself. It is + * therefore reported as an "empty" argument list, which simulates against the + * contract's arity check and surfaces as an `outcome_mismatch`. + */ + private safeArgs(build: () => xdr.ScVal[]): xdr.ScVal[] { + try { + return build(); + } catch (err) { + this.logger.warn( + `[shadow] could not encode contract args for simulation: ${(err as Error).message}`, + ); + return []; + } + } + + /** + * Count one committed lifecycle transition (issue #481). + * + * `vortex_intent_state_transitions_total{from_state,to_state}` is the only + * input to the `vortex:intent:*` recording rules, i.e. to the intent-funnel + * dashboard and to the `VortexIntentsNotTerminating` / + * `VortexSolverFillRateLow` alerts. It is counted here, once, immediately + * after the conditional write won — the same place the state actually moves, + * so a lost race is never counted. + */ + private countTransition(from: string, to: string): void { + try { + this.metricsService?.incIntentStateTransition(from, to); + } catch (err) { + this.logger.error(`[metrics] could not record transition ${from}->${to}: ${(err as Error).message}`); + } + } + + async get(id: string): Promise { + return this.repo.findById(id); + } + + async getAll(): Promise { + return this.repo.findAll(); + } + + async getByState(state: IntentState): Promise { + return this.repo.findByState(state); + } + + async getByUser(user: string): Promise { + return this.repo.findByUser(user); + } + + /** + * Batch-fetch the current record for each of `ids` (issue #275). + * + * IDs are de-duplicated; IDs with no matching record are simply omitted from + * the result (callers get "missing" by comparing lengths, not a 404 per ID). + * + * This reuses `get()` per ID rather than adding a storage-layer method — fine + * for the in-memory adapter. Issue #1's Prisma adapter should implement this + * as a single `WHERE intent_id IN (...)` query for efficiency. + */ + async getMany(ids: string[]): Promise { + const unique = [...new Set(ids)]; + const found = await Promise.all(unique.map((id) => this.get(id))); + return found.filter((intent): intent is Intent => intent !== undefined); + } + + async getAcceptedCountBySolver(solver: string): Promise { + const all = await this.repo.findAll(); + return all.filter((i) => i.state === "accepted" && i.solver === solver).length; + } + + /** + * Count the number of intents in "open" or "accepted" state for a user. + * + * Used by IntentsController.create() to enforce MAX_OPEN_INTENTS_PER_USER. + * The query is a simple filter over findByUser so it works identically + * against the in-memory adapter and — once the repo is swapped — can be + * replaced with an efficient Prisma COUNT query without touching the service + * interface (issue #1). + */ + async countOpenByUser(user: string): Promise { + const userIntents = await this.repo.findByUser(user); + return userIntents.filter( + (i) => i.state === "open" || i.state === "accepted", + ).length; + } + + /** + * Patch an intent without going through a lifecycle edge. + * + * Production callers only patch non-state fields (`quotedDstAmount`), which is + * why this stays a plain repository call. A `state` in the patch is an + * unconditional write that bypasses the guarded `*If*` methods, and therefore + * also bypasses the funnel counters, the audit trail and the shadow monitor — + * it is used by test setup only. It is logged so that a future production + * caller is caught in review rather than silently skewing the dashboards. + */ + async update(id: string, patch: Partial): Promise { + if (patch.state !== undefined) { + this.logger.warn( + `[state-machine] update(${id}) carries a state patch ("${patch.state}"); ` + + `this bypasses the guarded transitions and their observers`, + ); + } + return this.repo.update(id, patch); + } + + /** Amend an open intent without changing its ID or creation history. */ + async amendIfOpen( + id: string, + patch: Pick, + now = Math.floor(Date.now() / 1000), + ): Promise { + return this.repo.amendIfOpen(id, patch, now); + } + + /** + * Atomically accept an intent only if it is currently "open" with a future + * deadline (issue #473). Delegates to the repository so both in-memory and + * Prisma adapters apply the conditional write atomically. + * + * The new deadline is set to now + fill window from governance params (or + * CHAIN_FILL_WINDOW_DEFAULTS[srcChain] as fallback) so solvers on + * slower-settling chains get a proportionally longer window and are not + * unfairly slashed for a deadline that was never realistic. + * Returns null when the intent is not found, not open, or past deadline. + */ + async acceptIfOpen(id: string, solver: string, now?: number): Promise { + const intent = await this.repo.findById(id); + if (!intent) return null; + const nowSec = now ?? Math.floor(Date.now() / 1000); + const fillWindow = + CHAIN_FILL_WINDOW_DEFAULTS[intent.srcChain] ?? DEFAULT_FILL_WINDOW_SECONDS; + const updated = await this.repo.acceptIfOpen(id, solver, nowSec + fillWindow, nowSec); + if (updated !== null) this.countTransition("open", "accepted"); + if (this.beginShadowObservation()) { + this.observeAccept(updated ?? intent, solver, updated !== null); + } + return updated; + } + + /** Shadow hook for `accept` — reported whether or not the conditional write won. */ + private observeAccept(intent: Intent, solver: string, committed: boolean): void { + this.reportShadow( + "accept", + intent.intentId, + committed, + "accept_intent", + this.safeArgs(() => [ + nativeToScVal(intent.intentId, { type: "string" }), + new Address(solver).toScVal(), + nativeToScVal(intent.deadline, { type: "u64" }), + ]), + ); + } + + /** + * Atomically fill an intent only if it is currently "accepted" by the given + * solver with a future deadline (issue #473). + * Returns null when the intent is not found, not accepted, assigned to a + * different solver, or past the fill window (sweeper wins). + */ + async fillIfAccepted( + id: string, + solver: string, + patch: Omit, "state" | "solver">, + now?: number, + ): Promise { + const nowSec = now ?? Math.floor(Date.now() / 1000); + const updated = await this.repo.fillIfAccepted(id, solver, patch, nowSec); + if (updated !== null) this.countTransition("accepted", "filled"); + if (this.beginShadowObservation()) { + // Report from `patch` rather than re-reading: on a lost race the stored + // record belongs to whoever won, so its fill amount is not the amount + // this call was asked to settle. The submitted values are the ones the + // contract would have been handed if the off-chain guard had not + // pre-empted it. + this.observeFill(id, solver, patch.fillAmount, patch.txHash, updated !== null); + } + return updated; + } + + /** Shadow hook for `fill` — reported whether or not the conditional write won. */ + private observeFill( + intentId: string, + solver: string, + fillAmount: string | undefined, + txHash: string | undefined, + committed: boolean, + ): void { + this.reportShadow( + "fill", + intentId, + committed, + "fill_intent", + this.safeArgs(() => [ + nativeToScVal(intentId, { type: "string" }), + new Address(solver).toScVal(), + nativeToScVal(BigInt(fillAmount ?? "0"), { type: "i128" }), + nativeToScVal(txHash ?? "", { type: "string" }), + ]), + ); + } + + /** + * Atomically cancel an intent only if it is currently "open". + * Returns null when the intent is not found or is not in the "open" state + * (e.g. a concurrent accept() or sweeper expiry already transitioned it). + */ + async cancelIfOpen(id: string): Promise { + const updated = await this.repo.cancelIfOpen(id); + if (updated !== null) this.countTransition("open", "cancelled"); + if (this.beginShadowObservation()) { + const subject = updated ?? (await this.repo.findById(id)); + if (subject) { + this.reportShadow( + "cancel", + subject.intentId, + updated !== null, + "cancel_intent", + this.safeArgs(() => [ + nativeToScVal(subject.intentId, { type: "string" }), + new Address(subject.user).toScVal(), + ]), + ); + } + } + return updated; + } + + /** + * Atomically expire an intent only if it is currently "open". + * Used by the sweeper so a concurrent user cancel() or solver accept() + * always wins the race. + */ + async expireIfOpen(id: string): Promise { + const updated = await this.repo.expireIfOpen(id); + if (updated !== null) this.countTransition("open", "expired"); + if (this.beginShadowObservation()) { + const subject = updated ?? (await this.repo.findById(id)); + if (subject) { + this.reportShadow( + "expire", + subject.intentId, + updated !== null, + "expire_intent", + this.safeArgs(() => [ + nativeToScVal(subject.intentId, { type: "string" }), + nativeToScVal(subject.deadline, { type: "u64" }), + ]), + ); + } + } + return updated; + } + + /** + * Atomically slash an intent only if it is currently "accepted". + * Used by the sweeper so a concurrent solver fill() always wins the race. + */ + async slashIfAccepted( + id: string, + patch: { slashedAt: number; slashReason: string }, + ): Promise { + const updated = await this.repo.slashIfAccepted(id, patch); + if (updated !== null) this.countTransition("accepted", "slashed"); + if (this.beginShadowObservation()) { + const subject = updated ?? (await this.repo.findById(id)); + // An "accepted" intent always carries a solver. A record without one is + // corrupt, so skip the simulation rather than encoding a null address — + // the sweep loop already logs that case loudly. + const slashedSolver = subject?.solver; + if (subject && slashedSolver) { + this.reportShadow( + "slash", + subject.intentId, + updated !== null, + "slash_intent", + this.safeArgs(() => [ + nativeToScVal(subject.intentId, { type: "string" }), + new Address(slashedSolver).toScVal(), + nativeToScVal(patch.slashReason, { type: "string" }), + nativeToScVal(patch.slashedAt, { type: "u64" }), + ]), + ); + } + } + return updated; + } + + /** + * Issue #477 — extend an accepted intent's fill window, used by the sweeper + * while an emergency pause blocks fills so the solver is not slashed for a + * pause it did not cause. Returns null when the intent is no longer accepted + * or already has a later deadline. + */ + async extendDeadlineIfAccepted(id: string, newDeadline: number): Promise { + return this.repo.extendDeadlineIfAccepted(id, newDeadline); + } + + // --------------------------------------------------------------------------- + // Audit trail (issue #217 / #62) + // --------------------------------------------------------------------------- + + /** + * Append a new audit entry for the given intent. + * + * 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, + toState: IntentState, + actor: string, + reason: string, + metadata?: Record, + ): void { + const entry: IntentAuditEntry = { + timestamp: new Date().toISOString(), + toState, + actor, + reason, + ...(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, limit?: number, offset?: number): IntentAuditEntry[] { + const entries = this.auditLog.get(intentId) ?? []; + if (limit === undefined && offset === undefined) return entries; + + const safeLimit = Math.min(limit ?? 20, 100); + const safeOffset = Math.max(0, offset ?? 0); + return entries.slice(safeOffset, safeOffset + safeLimit); + } +} diff --git a/src/intents/prisma-intents.repository.spec.ts b/src/intents/prisma-intents.repository.spec.ts index 8f076a8f..2f24e06d 100644 --- a/src/intents/prisma-intents.repository.spec.ts +++ b/src/intents/prisma-intents.repository.spec.ts @@ -15,6 +15,25 @@ import { Intent } from "./intents.types"; const url = process.env.TEST_DATABASE_URL; const describeDb = url ? describe : describe.skip; +describe("PrismaIntentsRepository.amendIfOpen", () => { + it("guards the update by open state and the existing future deadline", async () => { + const updateMany = jest.fn().mockResolvedValue({ count: 0 }); + const findUnique = jest.fn(); + const repo = new PrismaIntentsRepository({ + intent: { updateMany, findUnique }, + } as unknown as PrismaService); + + expect( + await repo.amendIfOpen("intent-1", { minDstAmount: "900", deadline: 2_000 }, 1_000), + ).toBeNull(); + expect(updateMany).toHaveBeenCalledWith({ + where: { intentId: "intent-1", state: "open", deadline: { gt: 1_000 } }, + data: { minDstAmount: "900", deadline: 2_000 }, + }); + expect(findUnique).not.toHaveBeenCalled(); + }); +}); + describeDb("PrismaIntentsRepository (Postgres)", () => { // Built lazily: describe.skip still evaluates this body, and PrismaClient // rejects an undefined URL at construction time. diff --git a/src/intents/prisma-intents.repository.ts b/src/intents/prisma-intents.repository.ts index 4a99461e..e69de29b 100644 --- a/src/intents/prisma-intents.repository.ts +++ b/src/intents/prisma-intents.repository.ts @@ -1,350 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { PrismaService } from "../prisma/prisma.service"; -import { IIntentsRepository } from "./intents.repository"; -import { Intent, IntentState, StellarToken, TokenInfo } from "./intents.types"; -import { IntentState as PrismaIntentState, Prisma } from "@prisma/client"; - -/** - * Prisma-backed implementation of IIntentsRepository. - * - * All mutating operations that must be race-free (`acceptIfOpen`, - * `fillIfAccepted`) use a single conditional `updateMany` call so the - * database enforces the state guard atomically — no separate read-then-write. - * - * Bigint amounts (srcAmount, minDstAmount, fillAmount, quotedDstAmount) are - * stored and returned as strings per the project's bigint-as-string convention - * (see CONTRIBUTING.md). JSON columns (srcToken, dstToken) are cast back to - * their TypeScript types on the way out. - */ -@Injectable() -export class PrismaIntentsRepository implements IIntentsRepository { - constructor(private readonly prisma: PrismaService) {} - - async save(intent: Intent): Promise { - const data = this.toDbData(intent); - await this.prisma.intent.upsert({ - where: { intentId: intent.intentId }, - create: { ...data, intentId: intent.intentId }, - update: data, - }); - return intent; - } - - async findById(id: string): Promise { - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : undefined; - } - - async findAll(): Promise { - const rows = await this.prisma.intent.findMany({ - orderBy: { createdAt: "desc" }, - }); - return rows.map((r) => this.fromRow(r)); - } - - async findByState(state: IntentState): Promise { - const rows = await this.prisma.intent.findMany({ - where: { state: this.toPrismaState(state) }, - orderBy: { createdAt: "desc" }, - }); - return rows.map((r) => this.fromRow(r)); - } - - async findByUser(user: string): Promise { - // Postgres is case-sensitive; normalise the address comparison in-query. - const rows = await this.prisma.intent.findMany({ - where: { user: { equals: user, mode: "insensitive" } }, - orderBy: { createdAt: "desc" }, - }); - return rows.map((r) => this.fromRow(r)); - } - - async update(id: string, patch: Partial): Promise { - try { - const row = await this.prisma.intent.update({ - where: { intentId: id }, - data: this.toDbPatch(patch), - }); - return this.fromRow(row); - } catch (err) { - // P2025 = Record to update not found - if ((err as Prisma.PrismaClientKnownRequestError).code === "P2025") return null; - throw err; - } - } - - async delete(id: string): Promise { - try { - await this.prisma.intent.delete({ where: { intentId: id } }); - return true; - } catch (err) { - if ((err as Prisma.PrismaClientKnownRequestError).code === "P2025") return false; - throw err; - } - } - - /** - * Atomically accept an intent only when it is currently `open` AND its - * deadline is still in the future (issue #473). - * - * Uses a single `updateMany` with a compound WHERE clause so the database - * enforces the state + deadline guards — zero rows updated means another - * solver already won the race or the sweeper already expired the intent. - * - * Lock ordering: callers enforcing per-solver caps must hold the solver - * advisory lock (`pg_advisory_xact_lock`) BEFORE calling this method. - */ - async acceptIfOpen( - id: string, - solver: string, - newDeadline: number, - now?: number, - ): Promise { - const nowSec = now ?? Math.floor(Date.now() / 1000); - const result = await this.prisma.intent.updateMany({ - where: { intentId: id, state: PrismaIntentState.open, deadline: { gt: nowSec } }, - data: { - state: PrismaIntentState.accepted, - solver, - deadline: newDeadline, - }, - }); - - if (result.count === 0) return null; // not found, already taken, or expired - - // Fetch the updated row to return the full intent shape. - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : null; - } - - /** - * Acquire a transaction-scoped advisory lock for a solver key (issue #473). - * - * Must be called inside a `$transaction` callback to serialize per-solver - * cap checks across replicas. Lock ordering: solver lock BEFORE any intent - * row write, released automatically at transaction end. No-op fallback when - * the Prisma client does not expose `$executeRaw` (e.g. unit tests). - */ - async acquireSolverLock(solver: string): Promise { - const client = this.prisma as unknown as { - $executeRaw?: (q: TemplateStringsArray, ...v: unknown[]) => Promise; - }; - if (typeof client.$executeRaw !== "function") return; - await client.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${solver}))`; - } - - /** Count open/accepted intents for a user with a single COUNT query. */ - async countOpenByUser(user: string): Promise { - return this.prisma.intent.count({ - where: { - user: { equals: user, mode: "insensitive" }, - state: { in: [PrismaIntentState.open, PrismaIntentState.accepted] }, - }, - }); - } - - /** - * Atomically fill an intent only when it is currently `accepted` by the - * specified solver AND the fill window has not elapsed (issue #473). - * - * Uses a single `updateMany` with a compound WHERE clause — zero rows - * updated means the intent was not in the expected state, is assigned to a - * different solver, or the deadline passed (sweeper wins). - */ - async fillIfAccepted( - id: string, - solver: string, - patch: Omit, "state" | "solver">, - now?: number, - ): Promise { - const nowSec = now ?? Math.floor(Date.now() / 1000); - const result = await this.prisma.intent.updateMany({ - where: { - intentId: id, - state: PrismaIntentState.accepted, - solver, - deadline: { gt: nowSec }, - }, - data: { - state: PrismaIntentState.filled, - ...(patch.filledAt !== undefined ? { filledAt: patch.filledAt } : {}), - ...(patch.fillAmount !== undefined ? { fillAmount: patch.fillAmount } : {}), - ...(patch.feeAmount !== undefined - ? { feeAmount: patch.feeAmount as string } - : {}), - ...(patch.txHash !== undefined ? { txHash: patch.txHash } : {}), - }, - }); - - if (result.count === 0) return null; // guard failed - - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : null; - } - - /** - * Atomically cancel an intent only when it is currently `open`. Guards - * against a concurrent solver accept() or sweeper expiry on the same intent. - */ - async cancelIfOpen(id: string): Promise { - const result = await this.prisma.intent.updateMany({ - where: { intentId: id, state: PrismaIntentState.open }, - data: { state: PrismaIntentState.cancelled }, - }); - - if (result.count === 0) return null; - - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : null; - } - - /** - * Atomically expire an intent only when it is currently `open`. Used by the - * sweeper so a concurrent user cancel() or solver accept() always wins the race. - */ - async expireIfOpen(id: string): Promise { - const result = await this.prisma.intent.updateMany({ - where: { intentId: id, state: PrismaIntentState.open }, - data: { state: PrismaIntentState.expired }, - }); - - if (result.count === 0) return null; - - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : null; - } - - /** - * Atomically slash an intent only when it is currently `accepted`. Used by - * the sweeper so a concurrent solver fill() always wins the race. - */ - async slashIfAccepted( - id: string, - patch: { slashedAt: number; slashReason: string }, - ): Promise { - const result = await this.prisma.intent.updateMany({ - where: { intentId: id, state: PrismaIntentState.accepted }, - data: { state: PrismaIntentState.slashed }, - }); - - if (result.count === 0) return null; - - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : null; - } - - /** - * Issue #477 — push an accepted intent's deadline out during a fill pause. - * The `deadline < newDeadline` predicate makes this a no-op once the window - * is already long enough, so repeated sweep cycles cannot creep the deadline - * forward indefinitely. - */ - async extendDeadlineIfAccepted(id: string, newDeadline: number): Promise { - const result = await this.prisma.intent.updateMany({ - where: { - intentId: id, - state: PrismaIntentState.accepted, - deadline: { lt: newDeadline }, - }, - data: { deadline: newDeadline }, - }); - - if (result.count === 0) return null; - - const row = await this.prisma.intent.findUnique({ where: { intentId: id } }); - return row ? this.fromRow(row) : null; - } - - // ── Private helpers ──────────────────────────────────────────────────────── - - /** Map Intent → Prisma create/update data (omits intentId which is the key). */ - private toDbData( - intent: Intent, - ): Omit { - const data: Omit & { feeAmount?: string | null } = { - user: intent.user, - srcChain: intent.srcChain as Prisma.IntentCreateInput["srcChain"], - srcToken: intent.srcToken as unknown as Prisma.InputJsonValue, - srcAmount: intent.srcAmount, - dstToken: intent.dstToken as unknown as Prisma.InputJsonValue, - minDstAmount: intent.minDstAmount, - quotedDstAmount: intent.quotedDstAmount ?? null, - solver: intent.solver ?? null, - state: this.toPrismaState(intent.state), - createdAt: intent.createdAt, - deadline: intent.deadline, - filledAt: intent.filledAt ?? null, - fillAmount: intent.fillAmount ?? null, - txHash: intent.txHash ?? null, - }; - - if (intent.feeAmount !== undefined) { - (data as { feeAmount?: string | null }).feeAmount = intent.feeAmount ?? null; - } - - return data; - } - - /** Build an `updateMany`-compatible data object from a partial Intent patch. */ - private toDbPatch(patch: Partial): Prisma.IntentUpdateInput { - const data = {} as Prisma.IntentUpdateInput & { feeAmount?: string | null }; - if (patch.state !== undefined) data.state = this.toPrismaState(patch.state); - if (patch.solver !== undefined) data.solver = patch.solver; - if (patch.deadline !== undefined) data.deadline = patch.deadline; - if (patch.filledAt !== undefined) data.filledAt = patch.filledAt; - if (patch.fillAmount !== undefined) data.fillAmount = patch.fillAmount; - if (patch.feeAmount !== undefined) (data as { feeAmount?: string | null }).feeAmount = patch.feeAmount ?? null; - if (patch.txHash !== undefined) data.txHash = patch.txHash; - if (patch.quotedDstAmount !== undefined) data.quotedDstAmount = patch.quotedDstAmount; - if (patch.srcAmount !== undefined) data.srcAmount = patch.srcAmount; - if (patch.minDstAmount !== undefined) data.minDstAmount = patch.minDstAmount; - if ("slashedAt" in patch && patch.slashedAt !== undefined) { - // slashedAt / slashReason are not Prisma schema columns yet; ignore silently - // until the schema migration lands (issue #62). - } - return data; - } - - /** Map a Prisma Intent row → domain Intent. */ - private fromRow(row: { - intentId: string; - user: string; - srcChain: string; - srcToken: Prisma.JsonValue; - srcAmount: string; - dstToken: Prisma.JsonValue; - minDstAmount: string; - quotedDstAmount: string | null; - solver: string | null; - state: PrismaIntentState; - createdAt: number; - deadline: number; - filledAt: number | null; - fillAmount: string | null; - feeAmount?: string | null; - txHash: string | null; - }): Intent { - return { - intentId: row.intentId, - user: row.user, - srcChain: row.srcChain as Intent["srcChain"], - srcToken: row.srcToken as unknown as TokenInfo, - srcAmount: row.srcAmount, - dstToken: row.dstToken as unknown as StellarToken, - minDstAmount: row.minDstAmount, - ...(row.quotedDstAmount !== null ? { quotedDstAmount: row.quotedDstAmount } : {}), - ...(row.solver !== null ? { solver: row.solver } : {}), - state: row.state as IntentState, - createdAt: row.createdAt, - deadline: row.deadline, - ...(row.filledAt !== null ? { filledAt: row.filledAt } : {}), - ...(row.fillAmount !== null ? { fillAmount: row.fillAmount } : {}), - ...(row.feeAmount !== undefined && row.feeAmount !== null ? { feeAmount: row.feeAmount } : {}), - ...(row.txHash !== null ? { txHash: row.txHash } : {}), - }; - } - - private toPrismaState(state: IntentState): PrismaIntentState { - return state as PrismaIntentState; - } -}