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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions DATABASE_INDEXES.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand Down Expand Up @@ -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`:
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
17 changes: 17 additions & 0 deletions prisma/migrations/20260828000002_intent_audit_log/migration.sql
Original file line number Diff line number Diff line change
@@ -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);
23 changes: 23 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/intents/dto/quote-request.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
47 changes: 47 additions & 0 deletions src/intents/dto/quote-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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" })
Expand All @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion src/intents/intents-sweeper.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ 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 {
const configService = {
get: jest.fn().mockReturnValue(false),
} as unknown as ConfigService<AppConfig, true>;
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 {
Expand Down
Loading