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
2 changes: 1 addition & 1 deletion src/common/logging.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function makeContext(method = "GET", originalUrl = "/api/v1/intents", statusCode
} as any;
}

function makeHandler(observable = of({ data: "ok" })) {
function makeHandler(observable: import("rxjs").Observable<unknown> = of({ data: "ok" })) {
return { handle: () => observable } as any;
}

Expand Down
3 changes: 3 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface AppConfig {
// format-checks it in production so it can never silently fall back to
// a placeholder. Never log this value.
signingKey: string;
/** Fee percentile to use when estimating Soroban inclusion fees. */
feePercentile: FeePercentile;
};
onchainIntentsEnabled: boolean;
corsOrigin: string;
Expand All @@ -64,6 +66,7 @@ export default (): AppConfig => ({
settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "",
solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "",
signingKey: process.env.SOROBAN_SIGNING_KEY ?? "",
feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile,
},
onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true",
corsOrigin: process.env.CORS_ORIGIN ?? "*",
Expand Down
37 changes: 1 addition & 36 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, ServiceUnavailableException } from "@nestjs/common";
import { Controller, Get } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags } from "@nestjs/swagger";
import { AppConfig } from "../config/configuration";
Expand All @@ -24,40 +24,5 @@ export class HealthController {
uptime: process.uptime(),
db,
};

let sorobanStatus: string;
try {
const health = await this.withTimeout(
this.sorobanService.getHealth(),
SOROBAN_TIMEOUT_MS,
);
sorobanStatus = health.status;
} catch {
sorobanStatus = "unreachable";
}

const degraded = sorobanStatus !== "healthy";

if (degraded) {
throw new ServiceUnavailableException({
...base,
status: "degraded",
soroban: { status: sorobanStatus },
});
}

return {
...base,
status: "ok",
soroban: { status: sorobanStatus },
};
}

private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
const timer = setTimeout(() => reject(new Error("Timeout")), ms);
timer.unref();
});
return Promise.race([promise, timeout]);
}
}
1 change: 1 addition & 0 deletions src/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
import { DatabaseHealthService } from "./database-health.service";
import { SorobanModule } from "../soroban/soroban.module";

// PrismaModule is registered as @Global() in AppModule so PrismaService is
// available here without an explicit import.
Expand Down
2 changes: 1 addition & 1 deletion src/intents/dto/create-intent.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min } from "class-validator";
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MinLength } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { SupportedChain } from "../intents.types";
import { IsValidAddress } from "../../common/validators/is-valid-address.validator";
Expand Down
4 changes: 2 additions & 2 deletions src/intents/in-memory-intents.repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { IIntentsRepository } from "./intents.repository";
import { IntentsRepository } from "./intents.repository";
import { Intent, IntentState } from "./intents.types";
import { buildSeedIntents } from "./intents.seed";

Expand All @@ -13,7 +13,7 @@ import { buildSeedIntents } from "./intents.seed";
* lands, without any changes to IntentsService.
*/
@Injectable()
export class InMemoryIntentsRepository implements IIntentsRepository {
export class InMemoryIntentsRepository implements IntentsRepository {
private readonly store = new Map<string, Intent>();

constructor() {
Expand Down
34 changes: 25 additions & 9 deletions src/intents/intents-sweeper.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { ConfigService } from "@nestjs/config";
import { IntentsSweeperService } from "./intents-sweeper.service";
import { IntentsService } from "./intents.service";
import { IntentsGateway } from "./intents.gateway";
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 { 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);
}

function fakeSolversService(): SolversService {
return new SolversService(new InMemorySolversRepository());
}

describe("IntentsSweeperService", () => {
let intentsService: IntentsService;
Expand All @@ -12,9 +28,9 @@ describe("IntentsSweeperService", () => {
let sweeper: IntentsSweeperService;

beforeEach(() => {
intentsService = new IntentsService();
intentsService = fakeIntentsService();
gateway = { broadcast: jest.fn() } as unknown as IntentsGateway;
solversService = new SolversService();
solversService = fakeSolversService();
solverRegistryService = {
slashSolver: jest.fn().mockResolvedValue({
submitted: false,
Expand All @@ -31,8 +47,8 @@ describe("IntentsSweeperService", () => {
);
});

function makeAcceptedIntent(deadline: number, solver = "SOLVER_ALPHA") {
const intent = intentsService.create({
async function makeAcceptedIntent(deadline: number, solver = "SOLVER_ALPHA") {
const intent = await intentsService.create({
user: "GTEST...0000",
srcChain: "ethereum",
srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" },
Expand All @@ -47,7 +63,7 @@ describe("IntentsSweeperService", () => {

it("expires open intents past their deadline (existing behavior preserved)", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
const intent = intentsService.create({
const intent = await intentsService.create({
user: "GTEST...0000",
srcChain: "stellar",
srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" },
Expand All @@ -67,7 +83,7 @@ describe("IntentsSweeperService", () => {

it("slashes an accepted intent whose fill deadline has passed", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
const intentId = makeAcceptedIntent(past, "SOLVER_ALPHA");
const intentId = await makeAcceptedIntent(past, "SOLVER_ALPHA");

await sweeper.sweep();

Expand All @@ -87,7 +103,7 @@ describe("IntentsSweeperService", () => {
it("bumps the solver's fillsFailed counter on a slash", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
const before = solversService.get("SOLVER_ALPHA")?.fillsFailed ?? 0;
const intentId = makeAcceptedIntent(past, "SOLVER_ALPHA");
const intentId = await makeAcceptedIntent(past, "SOLVER_ALPHA");

await sweeper.sweep();

Expand All @@ -97,7 +113,7 @@ describe("IntentsSweeperService", () => {

it("does not touch accepted intents still within their fill window", async () => {
const future = Math.floor(Date.now() / 1000) + 300;
const intentId = makeAcceptedIntent(future, "SOLVER_ALPHA");
const intentId = await makeAcceptedIntent(future, "SOLVER_ALPHA");

await sweeper.sweep();

Expand All @@ -107,7 +123,7 @@ describe("IntentsSweeperService", () => {

it("does not throw if an accepted intent somehow has no solver on record", async () => {
const past = Math.floor(Date.now() / 1000) - 10;
const intent = intentsService.create({
const intent = await intentsService.create({
user: "GTEST...0000",
srcChain: "stellar",
srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" },
Expand Down
21 changes: 8 additions & 13 deletions src/intents/intents-sweeper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {
}

async sweep() {
const now = Math.floor(Date.now() / 1000);
const startMs = Date.now();
const now = Math.floor(startMs / 1000);
let expiredCount = 0;

for (const intent of this.intentsService.getByState("open")) {
Expand All @@ -52,17 +53,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {

const durationMs = Date.now() - startMs;

// ── metrics ──────────────────────────────────────────────────────────────
MetricsRegistry.sweeper.sweepDurationMs.observe(durationMs);
if (expiredCount > 0) {
MetricsRegistry.sweeper.expiredTotal.inc(expiredCount);
}
// ─────────────────────────────────────────────────────────────────────────

this.logger.debug(
`sweep complete: expired=${expiredCount} duration=${durationMs}ms ` +
`totalExpired=${MetricsRegistry.sweeper.expiredTotal.get()}`,
);
this.logger.debug(`sweep complete: expired=${expiredCount} duration=${durationMs}ms`);

if (expiredCount > 0) {
this.logger.log(`[sweeper] Expired ${expiredCount} intent(s) in ${durationMs}ms`);
Expand All @@ -77,7 +68,11 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {
}
}

private async slashMissedFill(intentId: string, solver: string | undefined, now: number) {
private async slashMissedFill(
intentId: string,
solver: string | undefined,
now: number,
) {
const reason = "accepted intent not filled before deadline";

this.intentsService.update(intentId, {
Expand Down
Loading